76 Commits
Author SHA1 Message Date
davotoulaandClaude Opus 5 592fd8f542 test: pin the amy argument surface the buzz-agent path depends on
Flag names and error strings are the contract between amy and the wrapper
scripts (and their operators), but nothing type-checks them: renaming --base-ref
or rewording an error breaks callers while compiling clean. This harness pins the
ones the buzz-agent path drives.

14 cases, all offline against an isolated ~/.amy via the `amy.home` seam the JVM
tests use: the shared `repo-naddr-or-coordinates` positional on git
browse/cat/log, `pass --channel GID` on the workflow verbs, --base-ref accepted
while --base-reff is rejected on both agent up and agent serve, and the
no_relays detail.

Confirmed to discriminate: 14/14 on this branch, 10/14 when built against
main-upstream's BuzzCommands, with the four failures printing the old misleading
"no relays: pass --relays ws://…" for input the user did pass.

Worth recording from writing it: a bare word like `garbage` is *accepted* as a
relay (the normalizer prepends wss://), so the realistic trigger for the empty
set is a pasted http:// url — which is what the cases use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
2026-07-29 15:42:48 +02:00
davotoula fcbd9d727b test: add a regression harness for workflow-ship.sh 2026-07-29 14:14:39 +02:00
davotoula 30c7dbc2f3 Code review:
1. `title="$(git log -1 … | cut …)"` aborts the whole script under
   `set -euo pipefail` when the worktree has no commits
2. `base_branch` only fell back to `main`
2026-07-29 14:14:28 +02:00
davotoula 4700e51d82 fix: add explicit default case to the workflow-ship branch guard 2026-07-29 14:13:51 +02:00
Claude 8e79abaa82 feat(cli): fewer steps to connect a Buzz agent — accept-from-channel, agent up, doctor
Collapses the operator setup from an 8-flag command + two hand-written scripts to
essentially two commands, without removing any of the safety.

- `buzz workflow run` gains `--accept-from-channel` (parity with the job
  scheduler): scope intake to the channel's kind-39002 roster instead of pasting
  every teammate key. `--worktree` now defaults to the current directory.
- Ship the gated reference wrappers (tools/buzz-agent/workflow-agent.sh →
  agent+commit; workflow-ship.sh → push+PR after the gate), split around the
  approval gate the way agent-exec.sh is the one-shot ungated version.
- `buzz agent up RELAY --repo DIR --approver NPUB` — one command: resolves the
  channel (the relay's only one, or --channel), defaults worktree/intake, extracts
  the bundled wrappers to ~/.amy/buzz-agent, and delegates to `workflow run`. The
  only thing it can't default is the human approver.
- `buzz agent doctor [--repo DIR]` — preflight that turns the security checklist
  into a green/red report: gh authenticated, token can write to the repo, default
  branch protected against force-push, worktree clean. Exits non-zero if not.
- cli build: set duplicatesStrategy on processResources (the explicit
  resources.srcDir re-adds the default root, which now doubles the bundled scripts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-27 15:40:50 +00:00
Claude a7d5fbfa8a feat(buzz): reference --exec wrapper that turns jobs into PRs
tools/buzz-agent/agent-exec.sh — the last mile from the scheduler to a live channel.

Honors the `amy buzz agent serve --exec` contract: reads the task on stdin, runs a
coding agent (Claude Code by default, or any $AGENT_CMD) inside the job's git worktree,
verifies a diff exists, commits, pushes the `claude/job-*` feature branch, opens (or
reuses) a PR, and prints the PR URL as the job result (kind-43004); any failure exits
non-zero → job error (kind-43006). It never touches the default branch and never
force-pushes — merge stays a human action on GitHub.

README documents the load-bearing guardrails, since Buzz enforces none of them: a
PR-only fine-grained token (Contents + Pull requests write, nothing else), branch
protection on the default branch (require PR + review + CI, block force-push/deletion),
and scoped intake (--accept-from) + agent tool allowlist.

Verified end-to-end against a throwaway repo with a stubbed gh + agent (happy path
pushes the branch and prints the PR URL; no-change path errors cleanly). Plan doc
follow-up #3 marked done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-25 20:53:21 +00:00
Claude 5001d4a27a test(notifications): cover Buzz reactions in the e2e harness + document Buzz DM
- run.sh: add a Buzz-style bare reaction check (kind-7 with an `e` tag but no `p`
  tag) asserting it still lands on the Reactions channel, and a note that Buzz DM
  isn't auto-triggered.
- README: coverage row for the bare reaction, plus a "Buzz DM (manual)" section
  with the channel-setup steps (kind-39000 `t=dm` + participant, then post
  kind-9/40002) since participant-routing needs cached channel metadata.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
2026-07-23 21:25:29 +00:00
Claude 853229a2c6 test(notifications): add Layer-3 end-to-end harness (amy + adb)
An agent-runnable script that drives the whole push-notification pipeline on a
real device/emulator: a second identity publishes each notification kind through
`amy` to the account on the phone, and the script reads back the notification
shade over `adb` to assert the right notification on the right channel — then
exercises cold-push metadata enrichment (title flips from raw pubkey to display
name in place) and dismiss-on-read (opening the note clears the tray entry).

Covers mention, reply, reaction, repost, picture, and DM; prints PASS/WARN/FAIL
and exits non-zero on any hard failure. tools/notification-e2e/{run.sh,README.md}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
2026-07-23 14:26:41 +00:00
Vitor PamplonaandClaude Opus 4.8 dd6e7e2852 fix(tor): map Arti errors to accurate SOCKS reply codes
The JNI wrapper answered every failed client.connect() with SOCKS reply 0x05,
so a domain that no longer exists, an exit that timed out, and a genuinely
refused port were indistinguishable. Java renders 0x05 as
SocketException("SOCKS: Connection refused"), so the whole failure taxonomy
collapsed into one opaque string and callers could only apply their most
generic retry policy. On a cold start with the default settings — torType
INTERNAL and newRelaysViaTor true, so the entire outbox fan-out is routed
through Tor — 639 of roughly 768 relay failures arrived this way.

This is why the DNS classification added earlier was effectively dead code for
default users: name resolution happens at the exit, so UnknownHostException is
never raised locally.

socks_reply_for() maps ErrorKind onto the codes Java surfaces with distinct
messages, so a caller can tell "this relay is gone" from "this circuit had a
bad minute". No new dependency — arti-client re-exports ErrorKind and HasKind.

RemoteHostResolutionFailed is mapped to 0x04 even though Arti documents it as
retryable, because an exit's resolver failing is not proof the name is dead.
The caller's response to 0x04 is a bounded backoff rather than permanent
condemnation, which is a retry, just a slower one; probing the relays that
produced this error found 17 of 21 to be NXDOMAIN from an ordinary resolver, so
the conservative reading costs far more than it saves. Changing that arm to
0x01 restores Arti's reading if that tradeoff is ever judged wrong.

Verified on device: Java now reports "SOCKS: TTL expired", "SOCKS: Host
unreachable", "SOCKS: Connection not allowed by ruleset" and friends where
everything was previously "SOCKS: Connection refused".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00
Claude 500de62841 fix(ui): explain pending OTS on tap, render the stamp glyph and pill icons larger
Tapping the pending OTS pill now shows a toast explaining that the
attestation is waiting to be stamped into the Bitcoin blockchain (new
ots_info_pending_description). The OpenTimestamps glyph gets a
near-full-em content box — its fine outline read much lighter than
Material's solid shapes at the standard 80..880 bounds — and HeaderPill
icons go from 11dp to 13dp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 14:15:56 +00:00
Claude 41ef1d89b3 feat(ui): add the OpenTimestamps logo as a custom glyph and use it in the OTS pill
Bake the official OpenTimestamps stamp logo (traced from
opentimestamps/logo vector.svg; monochrome outline, tinted at render
time like every other glyph) into the Material Symbols subset font at
U+F8F0. New tools/material-symbols-subset/add_custom_glyphs.py converts
the traced SVGs in custom/ into TrueType glyphs and is invoked by
subset.sh after pyftsubset, so font regenerations keep them.

With the logo identifying the pill, drop the verbose "OTS:" prefix:
the pill now reads icon + "2y" (or icon + "Pending", new
R.string.pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 12:59:30 +00:00
Claude 6bb2f8045d build: pre-merge audit fixes for reproducibility work
Bugs / inconsistencies found while reviewing the branch for merge:

- dependenciesInfo comment falsely claimed Play "still derives this data
  server-side, nothing is lost." Not true: includeInBundle=false means the
  .aab carries no dependency metadata, so Play Console's dependency-insights /
  SDK-vulnerability alerts go unpopulated (uploads still succeed). Corrected
  the comment and the BUILDING.md framing (it called the blob "the one
  remaining blocker" when the Arti .so was the bigger one).

- Version-bump workflow was broken: the README told you to run
  `build-arti.sh --clean` to refresh Cargo.lock, but the build is now --locked
  (fails on a stale lock) and the clone moved to the canonical /tmp path. Added
  a dedicated `--regen-lock` mode (clone + cargo generate-lockfile, no NDK
  needed) and pointed the docs at it. Verified it reproduces the committed lock
  byte-for-byte.

- verify-reproducible.sh: new helper that builds twice and diffs to prove
  byte-for-byte reproducibility; uses portable sha256 (sha256sum/shasum) and
  plain `sort` so it runs on macOS too.

- README verify recipe referenced paths that only resolved from the repo root
  while telling you to cd into tools/arti-build — replaced with the helper.

- rust-toolchain.toml listed four Android targets but only two ABIs ship a
  .so; trimmed to match (check_prerequisites adds any other on the fly).

- BUILDING.md: documented that the bundled Arti .so is reproducible-from-source
  and that secp256k1/webrtc are version-pinned Maven prebuilts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU
2026-06-27 00:19:26 +00:00
Claude ed3a893d9d build: build Arti at a canonical path for cross-environment reproducibility
Empirical finding: with the toolchain pin, locked deps, and
--remap-path-prefix all in place, two host builds of libarti_android.so at
the *same* path are byte-for-byte identical, but two builds at *different*
paths still differ — not in any embedded string (no path leaks into the
binary) but in the order rustc lays out functions/data, which it derives
from the real on-disk artifact paths. --remap-path-prefix only rewrites
embedded strings, not that internal ordering.

So compile in a fixed location (/tmp/amethyst-arti-build, overridable via
ARTI_REPRO_DIR) in both build-arti.sh and build-arti-host.sh. Any checkout
then produces matching bytes, which is what lets F-Droid / a verifier build
at the same canonical path and reproduce the shipped .so. This mirrors how
Rust libraries are reproduced elsewhere (F-Droid builds Rust at a fixed
path too).

Corrects the README, which previously implied path remapping alone gave
path-independent output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU
2026-06-26 23:40:18 +00:00
Claude f7a15a8407 build: make the Arti (Tor) native build reproducible
The libarti_android.so shipped in the APK is the one binary we compile
ourselves, and it was the remaining blocker to a verifiable build: a Rust
cdylib is only reproducible when the compiler, the dependency graph, and the
embedded build paths are all pinned. None were.

Pin all three:
- rust-toolchain.toml pins rustc (rustup auto-installs it + the Android
  targets), so codegen is stable across machines.
- Cargo.lock is now generated and committed (501 packages); both build
  scripts run `cargo --locked` so transitive versions can't drift.
- repro-env.sh (sourced by build-arti.sh and build-arti-host.sh) rewrites
  host-specific absolute paths with --remap-path-prefix, disables incremental
  compilation, and sets a fixed SOURCE_DATE_EPOCH derived from the Arti tag.

With these, an independent rebuild of the pinned tag reproduces the committed
.so bit-for-bit, which is what lets F-Droid / Zapstore verify it from source
instead of trusting a prebuilt blob. README documents the pins and a
two-path build-and-diff verification recipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU
2026-06-26 23:01:48 +00:00
Vitor PamplonaandClaude Opus 4.8 dec1e0bec1 feat(embed): selection loupe + native-parity fixes for embedded text selection
Builds out host-drawn text selection for embedded napplet/nsite/browser
surfaces toward native parity, and fixes the bugs found while exercising it.

- Magnifier loupe (#4): EmbeddedMagnifier + provider-side pixel capture
  (EmbeddedMagnifierProbe) shipped over IPC for both embed paths; the
  caret/selection handles drive it via OnMagnify.
- SelectionUiState: single source of truth for the overlay show/hide rules
  (insertion caret / in-field range / page-text range + dragging/scrolling).
- EmbeddedSelectionDrag: suspends the nav drawer's edge swipe while a handle
  is dragged (auto-scroll #9).

Bug fixes:
- No more overlay blink on word-select: the shim's selection-reveal scrolls
  (a textarea auto-scrolling to show a forming/re-asserted range) no longer
  trip the hide-on-scroll path, and the hide self-heals instead of being
  re-armed indefinitely.
- RemoteImeView debounces the range-lost signal so a transient collapse that
  gets re-asserted doesn't flicker the handles/toolbar.
- Focusing a field clears any page-text selection (shim + host), so the stale
  page handles/Copy bar no longer linger above — and stop stealing drags from —
  the field overlays; also cancels any in-flight scroll-hide on focus.
- Caret insertion-handle drag now actually moves the caret: read the pointer
  delta with positionChangeIgnoreConsumed() before consuming, so the value
  isn't zeroed by our own consume (or the sandbox surface consuming the move).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:55 -04:00
Vitor PamplonaandClaude Opus 4.8 8232cd50a3 test(embed): add IME/selection test harness + document how to run it
tools/ime-test/index.html is a single-page input+textarea harness with an
on-page log timestamping focus/selection/input/composition events, paint
latency, long-tasks, and main-thread blocks — the instrumentation used to pin
the erase / caret-jump / first-letter-freeze bugs and what we'll use to profile
the magnifier. README documents serving it (python http.server on 8765,
10.0.2.2 for emulator / adb reverse for USB) and opening it as an embedded tab
via the in-app browser address bar. Plan gets a matching "How to test" section.

Dev tool only — nothing under tools/ ships, so the [ImeDiag] strings stay out of src/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:55 -04:00
Claude a2eae42c07 feat(napplets): app-store-style card + icon manifest tag; demote capabilities
Redesign the shared StaticWebsiteCard (used by the feed AND the napplets browse
screen) to look like an app entry instead of a manifest dump: square app icon
(with a colored monogram fallback), name, a NAPPLET/WEBSITE type label, a short
description, and an Open button. The technical details users don't care about —
declared capabilities, Blossom servers, source URL — move behind a tap-to-expand
"What it can access" disclosure; capabilities are still re-confirmed at the
consent prompt when actually used and remain fully manageable in the permissions
screen.

Add an `icon` tag (NIP-5A/5D) end-to-end:
- quartz: IconTag + siteIcon() accessor/builder, NappletManifest.icon(), and an
  icon param on all four site/napplet build() factories (+ round-trip test).
- amy: `--icon URL` on `nsite/napplet publish`, surfaced in the publish output.
- card: renders the icon via Coil, monogram fallback when absent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 17:13:41 +00:00
Claude d60a618653 feat(amy): list an author's napplets / nsites
Add `amy napplet list <author>` and `amy nsite list <author>`: fetch the
author's root + named manifests (15129/35129 for napplets, 15128/35128 for
nsites), keep the latest per identifier, and emit a summary of each (kind, d,
title, description, path count, servers, requires/aggregate, event id,
created_at). Thin assembly over ctx.drain + the quartz manifest accessors.

Harness README notes `amy napplet list` for enumerating what you've published.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 15:38:21 +00:00
Claude 7bc95d836c feat(amy): nsite/napplet serve (local preview); drop standalone publish.sh
Add `amy nsite serve` and `amy napplet serve <author> [--d ID] [--port N]`:
fetch the manifest and serve its content over a local HTTP server, resolving
each request through quartz StaticSiteResolver (blob downloaded from Blossom
and sha256-verified per request, same as the device host) with SPA fallback to
index.html. Lets you open a published site/napplet in a browser to confirm it
loads and routes. (Static content only — a napplet's window.napplet.* runtime
still needs the Amethyst host; documented in the command + harness README.)

Implemented as thin cli glue (StaticSiteServe) over the resolver + commons
BlossomClient + the JDK HTTP server.

Also retire tools/napplet-test/publish.sh now that `amy napplet publish` is the
single source of truth; the harness README documents publish + serve via amy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 15:35:14 +00:00
Claude ece1f43975 feat(amy): publish napplets and nsites (ship a directory)
Add `amy nsite publish <dir>` and `amy napplet publish <dir>` so a static-site
or napplet directory can be shipped to Nostr in one command, building on the
new CLI/Blossom infrastructure.

- commons (jvmMain) StaticSitePublisher: the reusable "upload a tree" half —
  walks a directory (or single file), content-addresses each file, BUD-02
  signed-uploads it via BlossomClient, and maps it to an absolute web path
  (/index.html, /assets/app.js, …). Returns the NIP-5A path→sha256 tags.
- cli StaticSitePublish: thin shared flow — uploads via the commons publisher,
  hands the path tags to a kind-specific builder, signs with the account key,
  and broadcasts. nsite builds 15128/35128 (+ x aggregate); napplet builds
  15129/35129 (aggregate + requires already added by the quartz builder).
- nsite/napplet `publish` verbs wired into their routers.

Test harness README now recommends `amy napplet publish tools/napplet-test`,
keeping publish.sh as a no-amy fallback. Unit test covers the path mapping;
cli + commons build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 15:31:28 +00:00
Claude b0332c9ff3 test(napplet): add on-device test harness (napplet + publish script)
A self-contained napplet (tools/napplet-test/index.html) that calls every
window.napplet.* API and renders each result on screen, for verifying the
NIP-5D host end to end on a real device — including the new
identity.getList/getZaps/getBadges, identity.onChanged, keys.onAction, and
resource.bytes nostr: paths.

publish.sh uploads it to a Blossom server (BUD-02) and publishes the NIP-5D
named-napplet event (kind 35129) via nak; README documents the flow and a
per-feature verification checklist. Tooling only — no app code or deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 15:04:28 +00:00
Vitor PamplonaandClaude Opus 4.8 d9066572cd test(tor): add repeatable on-device networking scenario suite
The 60s bootstrap timeout this branch adds is invisible to every JVM test —
it only manifests against a real radio. Add a device-driven harness that drives
the network transitions that have historically wedged Tor and asserts the
lifecycle invariants from logcat, so the behavior can be re-verified whenever
Arti is bumped or the Tor management path changes.

tools/tor-network-tests/run.sh — adb-driven runner, one function per scenario,
PASS/FAIL per check, restores a clean network state on exit:
  - cold_start        Active reached + no pre-ready dial storm (the #3223 gate)
  - offline_bootstrap empty cache + airplane: asserts the bootstrap is bounded
                      ('bootstrap timed out' logged), then recovers on restore.
                      This is the regression test for this PR — it FAILS on a
                      build without the timeout (create_bootstrapped blocks 95s+).
  - wifi_cellular     WiFi->Cellular handover recovers to Active
  - airplane          offline pauses relays cleanly; restore recovers
  - pause_resume      backgrounding winds relays down (~30s); resume reconnects

README.md documents each scenario's rationale, device setup, when to re-run
(new Arti version, TorService/TorManager/dial-gate changes), and how the suite
relates to TorManagerTest / TorCircuitHealthTrackerTest / the instrumented test.

Verified on a Pixel emulator (WiFi + Cellular): all scenarios pass on this
branch; offline_bootstrap fails on main (no timeout), confirming the suite
discriminates fixed from broken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:44:06 -04:00
Vitor PamplonaandClaude Opus 4.8 69b8ba9f23 fix: bound Arti bootstrap with a 60s timeout so a hostile network can't wedge Tor
A self-heal reset()/resetWithCleanState() (and the lifecycle serialization added
in the previous commit) can only recover Tor if initialize() actually returns.
ArtiNative.initialize() calls TorClient::create_bootstrapped, which on a hostile
network (unreachable guards, wiped consensus) retries internally for many
minutes. While it blocks it holds lifecycleMutex, so the watchdog's reset can
never run — Tor stays wedged at Connecting.

Wrap create_bootstrapped in a 60s tokio::time::timeout. On timeout the future is
dropped (tearing down the half-built client) and initialize() returns -4; the
JNI ABI is unchanged (still one String arg), so the checked-in CI host .so and
TorArtiNativeIntegrationTest keep working without a rebuild. TorService treats
-4 specially: drop the init flag and leave status Connecting (don't wipe+retry
inline under the lock, don't go Off) so TorManager's self-heal watchdog resets
and re-inits on its own cadence, and connectionFailure can still surface the
"use regular connection" dialog.

Rebuilt libarti_android.so for arm64-v8a + x86_64.

Verified on device: a no-network cold-start bootstrap timed out at exactly 60s
(previously hung 7+ min), released the lock, and on network restore the watchdog
re-init'd and Tor reached Active. Addresses #3225.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:41:09 -04:00
Claude e39ea55fd6 test(tor): tier-3 integration — JVM host build of Arti, smoke + bootstrap tests
Closes the test gap below the tier-1 unit tests by running the real Arti
JNI shim end-to-end on JVM. Cheaper than an emulator + connectedAndroidTest,
and exercises the exact Rust + JNI code path the Android .so does.

Three tests in TorArtiNativeIntegrationTest:

1. `library loads and reports a version` — always-on smoke check. Loads
   libarti_android.so via System.loadLibrary and calls ArtiNative.getVersion.
   ~10ms. Catches build/link regressions (e.g. a stale .so after an ARTI
   bump, a missing JNI symbol export, a forgotten rebuild on this path).
   Skipped on non-Linux-x86_64 hosts with a clear message pointing at the
   build-arti-host.sh rebuild step.

2. `bootstraps and proxies an HTTPS request through Tor` — opt-in via
   -Pamethyst.arti.integration=true. ArtiNative.initialize → startSocksProxy
   → OkHttp-via-SOCKS → check.torproject.org/api/ip. Asserts "IsTor":true.
   Regression net for the rustls CryptoProvider install we added after the
   v2.3.0 bump and for the destroy/handler-abort fixes in the Rust shim.

3. `destroy then re-initialize releases the state file lock cleanly` — opt-in.
   The direct unit-test mirror of the self-heal path: bootstrap, destroy, hit
   the SAME data dir with initialize again, verify it succeeds without a
   "state file already locked" error and that traffic still flows.

Wiring:
- New tools/arti-build/build-arti-host.sh — companion to build-arti.sh.
  Cargo-builds the wrapper crate for the host target (x86_64-linux on most
  dev machines, but the script maps macOS / arm64-linux too) and copies to
  amethyst/src/test/native-libs/<host-tag>/libarti_android.so.
- amethyst/build.gradle.kts testOptions.unitTests.all configures
  -Djava.library.path so System.loadLibrary("arti_android") finds the
  checked-in host .so. Also forwards -Pamethyst.arti.integration so the
  opt-in gate works from a Gradle invocation.
- Checked-in src/test/native-libs/x86_64-linux/libarti_android.so for the
  most common dev/CI host (~6 MB).

Wrapper change to make the JVM path actually run:
- lib.rs: on #[cfg(not(target_os = "android"))], call
  builder.storage().permissions().dangerously_trust_everyone() so Arti's
  fs-mistrust check doesn't reject /tmp data dirs on hosts where parent
  directories have unusual UIDs (typical in containers). Android keeps its
  strict default — the app's private filesDir is already sandboxed by the OS.
  Compiled-out on Android, so the shipped Android .so is functionally
  unchanged.

Verified in this session:
- Smoke test passes without -P (3 tests, 1 ran, 2 skipped).
- Full unit test suite still passes.
- With -P the bootstrap tests get past Arti's permissions check; they hang
  on actual relay I/O in this container because outbound TCP egress is
  restricted to a CDN allow-list, not Tor relays. Tests succeed on hosts
  with unrestricted outbound — see the test kdoc.
2026-05-26 21:34:26 +00:00
Claude 9a2adf091d chore(tor): bump Arti to v2.3.0
Wins: reduced GeoIP memory usage (moved off heap), CircuitClosed→NotConnected
error change (affects our handler error paths), DATA-cells-on-closed-streams
fix, and a flow-control sidechannel mitigation bug fix. Nothing here directly
addresses the stuck-Tor recovery work in the prior commits, but it's a clean
overdue bump while we're in this code.

Wrapper changes required by the bump:
- arti-client + tor-rtcompat: 0.41 → 0.42 to match the new crate versions
  shipped with arti-v2.3.0.
- arti-v2.3.0's tor-rtcompat no longer installs a rustls CryptoProvider
  implicitly (changelog: "if the application fails to install a rustls
  CryptoProvider, tor-rtcompat no longer installs one itself"). Add a direct
  `rustls = "0.23"` dep with the `ring` feature and `install_default()` it
  inside INIT_ONCE before runtime creation — otherwise create_bootstrapped
  panics on the first TLS handshake. Keeping `ring` (same as 2.2.0
  effectively used) rather than 2.3.0's new default `aws-lc-rs`, which is
  heavier on Android and has known build.rs pain on aarch64-linux-android.

Heads-up for the next bump: arti-v2.4.0 will explicitly wrap TorClient in
Arc rather than implicitly having Arc-like semantics. We already wrap
explicitly so the migration is a no-op aside from potential Arc<Arc<...>>
cleanup.

Rebuilds: libarti_android.so for arm64-v8a + x86_64.
2026-05-26 20:06:15 +00:00
Claude c3ddd4e7be fix(tor): audit fixes — first-bootstrap grace + tighten destroy() race
Audit of db378a1 surfaced three issues; this commit addresses them.

1) First-bootstrap self-heal storm (TorManager). On a fresh install with a
   slow network the legitimate first bootstrap takes 30–60s. The 45s
   stuck-Connecting watchdog used to fire resetWithCleanState, wiping an
   empty state dir and adding a full bootstrap cycle of delay for no gain.
   Now: track hasEverBootstrapped (flipped when status reaches Active);
   pre-first-bootstrap self-heals use the gentler reset (drop client only,
   keep state), post-first-bootstrap use resetWithCleanState. Wiping stale
   on-disk guards only matters once we know Arti can actually work.

2) Rust destroy() race (lib.rs). The accept loop in startSocksProxy has no
   .await between accept() returning and HANDLER_TASKS.push(h), so an
   abort() alone is racy — a new handler can be spawned and pushed AFTER
   our drain runs, which then holds an Arc<TorClient> past destroy() and
   keeps the state file lock alive. Now: after abort(), await the SOCKS
   JoinHandle with a 1s timeout so the listener fully terminates before
   we drain HANDLER_TASKS. No new handlers can be added once the listener
   is gone.

3) TOKIO_RUNTIME mutex held during block_on(sleep). The previous
   `if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref()` kept the
   mutex held for the full sleep duration, blocking any other JNI caller
   that needs the runtime. Now: clone the runtime Handle and release the
   mutex immediately. Same fix applied to stopSocksProxy.

Rebuilds: libarti_android.so for arm64-v8a + x86_64.
2026-05-26 19:37:36 +00:00
Claude db378a105c feat(tor): self-heal — drop & rebuild Arti on network change and stuck Connecting
When Arti's in-memory TorClient gets into a broken state (bad guards from a
previous network, dead circuits, expired consensus held in memory), nothing
short of a process restart used to recover it: the JNI exposed initialize /
startSocksProxy / stopSocksProxy but no way to drop the TorClient, and the
Kotlin side gated initialize behind a one-shot AtomicBoolean. force-stop
preserved the on-disk arti/state/, toggle-off-then-on only re-bound the SOCKS
listener on the same broken client, and wiping app data was the only way out.

Rust side
- New JNI Java_..._ArtiNative_destroy: aborts the SOCKS listener task, aborts
  all in-flight per-connection handlers (each holds an Arc<TorClient> clone
  that would otherwise pin the state file lock), waits 500ms, drops the static
  ARTI_CLIENT. Next initialize() call creates a fresh client and re-bootstraps.
- Track handler JoinHandles in HANDLER_TASKS so destroy can abort them; cull
  finished ones on each accept to keep the Vec bounded.

Kotlin side
- TorService.reset() / resetWithCleanState() — drop the native client, flip
  initialized=false. The second variant also wipes arti/state/ on disk to
  rebuild guard selection from scratch.
- TorManager.resetEpoch StateFlow is now part of the status combine; bumping
  it re-fires the INTERNAL branch which calls service.start() and runs full
  Arti re-init.
- onNetworkChange (wired from ConnectivityManager.networkId distinctUntilChanged)
  now calls service.reset() + clears the persisted bypass approval + bumps the
  epoch. Replaces the previous clearSessionBypass() which only touched the
  in-memory bypass half.
- Self-heal watchdog: when status sits at Connecting for >45s (before the 60s
  connectionFailure dialog), calls resetWithCleanState. Rate-limited to one
  per 5 minutes so a permanently broken network doesn't loop us. onNetworkChange
  primes lastSelfHealAtMs so a slow legitimate post-network-change bootstrap
  doesn't get a second reset on top of itself.

Rebuilds: libarti_android.so for arm64-v8a + x86_64 (NDK 27, 16KB-page aligned).
2026-05-26 19:09:54 +00:00
Claude 19588be84b build(perf): trim build outputs for hooks, CI, and the shipped font
Headline numbers from a fresh `./gradlew assemble`:
- :commons material_symbols_outlined.ttf 11M -> 409K (subset to the 210
  codepoints actually referenced from MaterialSymbols.kt)
- :amethyst per-ABI APKs no longer built in CI (-PdisableAbiSplits=true);
  ~600M of stripped_native_libs intermediates skipped per run

Changes:
- .git-hooks/pre-push runs only :amethyst:testPlayDebugUnitTest plus jvmTest
  on the KMP modules instead of `./gradlew test`, which was compiling all six
  amethyst variants (play/fdroid x debug/release/benchmark)
- amethyst/build.gradle adds three opt-in fast-build flags:
    -PdisableAbiSplits=true       skip per-ABI APK splits
    -PdisableUniversalApk=true    skip the universal APK output
    -Pamethyst.skipMapping=true   disable R8 (release+benchmark)
  Defaults are unchanged; release pipelines must not set skipMapping.
- .github/workflows/build.yml uses gradle/actions/setup-gradle@v4 with
  cache-read-only on PRs / cache-write on main, drops --no-daemon, collapses
  the Android job to a single Gradle invocation (lint + focused debug unit
  tests + assembleBenchmark with -PdisableAbiSplits=true), and globs both
  APK naming patterns for the upload step.
- tools/material-symbols-subset/{subset.sh,README.md} regenerates the font
  from upstream + MaterialSymbols.kt; run after adding/removing icons.

Verified: ./gradlew :amethyst:help with all three new -P flags parses cleanly,
and ./gradlew :commons:jvmJar --rerun-tasks succeeds with the subsetted font
(commons-jvm jar shrinks from ~13M to 2.9M).

https://claude.ai/code/session_01YSmkagXXN5AwGcY4upiUh1
2026-05-05 22:49:43 +00:00
Claude b0698e0a66 test(cli): move tests/ out of tools/, separate marmot vs dm
`tools/` is for libraries (arti-build); shell-based interop harnesses
that drive the `amy` binary belong with the CLI module. New layout:

  cli/tests/
  ├── lib.sh                       # shared logging + result tracking
  ├── headless/helpers.sh          # shared amy_a / amy_json / assertions
  ├── marmot/                      # MLS group-messaging interop (vs whitenoise-rs)
  │   ├── marmot-interop.sh
  │   ├── marmot-interop-headless.sh
  │   ├── setup.sh
  │   ├── tests-create.sh
  │   ├── tests-manage.sh
  │   ├── tests-extras.sh
  │   └── patches/
  └── dm/                          # NIP-17 DM interop (amy ↔ amy)
      ├── dm-interop-headless.sh
      ├── setup.sh
      └── tests-dm.sh

Per-suite changes:
- Each suite owns its own setup.sh; previously the Marmot setup was at
  headless/setup.sh and the DM setup at headless/setup-dm.sh, which
  implied shared infra they don't actually share.
- `cli/tests/lib.sh` and `cli/tests/headless/helpers.sh` are the only
  shared bits across suites.
- The DM harness still reuses Marmot's `start_local_relay` /
  `stop_local_relay` (sourced via `../marmot/setup.sh`) — same relay
  binary, no duplication.
- All sourcing paths updated; REPO_ROOT now climbs three levels (was
  two when the harness lived under `tools/`); patches/ is now a sibling
  of marmot/setup.sh rather than under marmot/headless/.
- `.gitignore` updated to cover both suites' state dirs.

cli/ROADMAP.md and cli/tests/README.md updated to point at the new paths.
No behavioural change — every test runs the same code; only the file
layout and source paths moved.
2026-04-23 20:37:22 +00:00
Claude 112801ba82 test(cli): amy↔amy NIP-17 DM interop harness
Adds `tools/marmot-interop/dm-interop-headless.sh` — a zero-prompt
end-to-end harness that runs two independent `amy` processes against
a loopback nostr-rs-relay and verifies the NIP-17 DM surface.

Six scenarios:

  dm-01  text round-trip (kind:14) in both directions
  dm-02  `dm list` surfaces prior exchange with `type:text` discriminator
  dm-03  strict kind:10050 refuses sends to an inboxless recipient
         (surfaces the `no_dm_relays` error JSON)
  dm-04  `--allow-fallback` opts into NIP-65 read / bootstrap chain
         (asserts a `relay_source: "bootstrap"` or `"nip65_read"` row)
  dm-05  file message reference mode (kind:15) — send-file with
         --key / --nonce, verify recipient decodes url + key + nonce + mime
  dm-06  no-flag `dm list` advances `state.giftWrapSince`; second call
         returns an empty `messages` array

Shape matches the existing Marmot harness: reuses `lib.sh` for logging
+ result tracking, reuses `setup.sh` for the nostr-rs-relay lifecycle
(`start_local_relay` / `stop_local_relay`), adds a slim `setup-dm.sh`
preflight (amy + relay only, no whitenoise-rs / Marmot patches), and
defines tests in `tests-dm.sh`.

No new CI job yet — the relay build needs Rust + ~3 minutes on a
cold cache, same constraint as the Marmot harness.

Upload-mode (`dm send-file --file PATH --server URL`) is not scripted
here: it needs a local Blossom server, which is out of scope for this
pass. The shared upload classes are unit-tested on desktop at
`desktopApp/src/jvmTest/kotlin/.../service/upload/`.
2026-04-23 20:14:08 +00:00
Claude a4e031353a Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs 2026-04-22 23:41:40 +00:00
Claude 3279c2463a fix(mls): use filtered direct path in UpdatePath per RFC 9420 §7.9
Interop Test 06 (wn removes a member from a group Amethyst is in) was
failing with:

  GroupEventHandler.add: ERROR Failed to apply commit:
    UpdatePath node count (1) doesn't match direct path length (2)

Repro scenario: 3-member group [B=leaf 0, C=leaf 1, A=leaf 2]; B (admin,
committer) removes C. After the Remove proposal applies, leaf 1 is
blank but leaf 2 (A) is still occupied — so leaf_count stays at 3 per
RFC 9420 §7.8 and the sender's direct path has length 2 ([node 1,
root]).

Per RFC 9420 §4.1.2 and §7.9 the **filtered direct path** drops any
parent node whose child on the copath has an empty resolution:
encrypting to such a parent is equivalent to encrypting to its only
non-blank child, which is already on the direct path. In our scenario
the parent at level 1 (parent of leaves 0, 1) has a blank leaf 1 on
the copath — empty resolution — so it's filtered out. Filtered
direct path length is 1, and that's what openmls / whitenoise-rs emit
in `UpdatePath.nodes<V>`.

Quartz was always using the unfiltered direct path on both sides
(send + receive + parent-hash chain + path-secret decrypt). That
worked for quartz↔quartz (both sides agreed), but broke the moment a
spec-correct sender (wn) sent a filtered UpdatePath into a quartz
receiver. Parent-hash chain is further affected because §7.9.2 walks
the filtered path — even if the size check passed, the hashes would
not match.

Fix it throughout:

- RatchetTree.filteredDirectPath: new helper returning parallel
  (filteredDirectPath, filteredCopath) with the empty-resolution
  entries dropped. Uses the existing `resolution()` helper, which
  already handles unmerged_leaves per §4.1.2.

- RatchetTree.applyUpdatePath: size-check + target the FILTERED path.

- MlsGroup.commit (sender): emit one staged path node per filtered
  level; encrypt path secrets using the filtered copath; patch parent
  hashes into the filtered positions only.

- MlsGroup.processCommitInner (receiver): the UpdatePath node index
  aligns to the filtered direct path, so the common-ancestor lookup
  must find the filtered index to pick the right ciphertext + copath
  resolution. Use the unfiltered index only for counting KDF steps
  to the root (path_secret chain advances one step per unfiltered
  level regardless of filtering — the chain is continuous; filtering
  only decides which levels emit a UpdatePathNode, not how many KDF
  steps separate them).

- MlsGroup.computeSenderParentHashes: walk the filtered direct path.
  Map each filtered node back to its unfiltered level so the
  preUpdateSiblingHashes lookup (indexed by unfiltered level) still
  resolves. This makes the parent_hash chain agree with §7.9.2 and
  with what openmls computes on the other side.

- MlsGroup.verifyParentHash: short-circuit on empty filtered path
  rather than empty unfiltered path.

New regression test: testRemoveMiddleLeaf_ReceiverAcceptsCommit
reproduces the 3-member remove-middle scenario end-to-end within
quartz. It passed before this patch (because both sides were
unfiltered and agreed with each other), and it passes after this
patch (because both sides are now filtered and still agree) — the
compatibility win is that quartz now also agrees with
openmls/wn-produced commits of the same shape.

Also fix an unrelated script bug in marmot-interop.sh: the
discover_a_relays SQL probe was falsely reporting "wn has NO cached
relay entries for A" even when welcome delivery was working, because
the query assumed users.pubkey stored as BLOB but wn stores it as
TEXT (hex) in the current schema. Accept either encoding.
2026-04-22 22:58:21 +00:00
Claude c4e7e0d9b4 fix: don't render Marmot reactions/deletions as chat bubbles
WhiteNoise threads its kind:445 payloads across three inner kinds:
kind:9 for chat, kind:7 for emoji reactions, kind:5 for unreacts. The
Amethyst ingest pipeline was routing every inner event into the group
chatroom feed, so a kind:7 reaction rendered as a chat bubble whose
only content was the emoji, quoting the liked message via the target
`e` tag. That looked identical to a threaded reply. The actual kind:9
reply from `wn messages send --reply-to` never arrived, which made the
two look swapped in the UI.

Three changes to untangle this:

- MarmotGroupList.addMessage/restoreMessage now skip inner events with
  kind:5 and kind:7 before they enter `MarmotGroupChatroom.messages`.
  The reaction is still consumed by LocalCache so it attaches to the
  target note's reaction row, and the deletion still revokes that
  reaction — they just don't appear as standalone bubbles.
- LocalCache.computeReplyTo learned to derive thread parents for
  `ChatEvent` (kind:9) from plain NIP-10 `e` tags in addition to the
  existing NIP-18 `q` tag path. WhiteNoise emits `e`-tagged replies;
  without this the reply bubble had no quote context in the feed.
- tools/marmot-interop/marmot-interop.sh: `wn messages send` exposes
  its `reply_to` field through clap v4, which renames snake_case to
  kebab-case by default. The script was passing `--reply_to`, which
  clap rejected; the `|| true` + redirected stderr hid the error and
  no reply was ever published. Use `--reply-to`.

https://claude.ai/code/session_01K3g1uWLhByoEdBS77zdF32
2026-04-22 22:07:15 +00:00
Claude 6f3abac3b2 fix(marmot-interop): peel .result wrapper when reporting Test 10's B view
Post-v0.2 wn wraps `--json groups show` output in {"result": {...}},
so `jq '{name, epoch}'` was reading the top level where those keys
don't exist — Test 10 always logged "B state: {name: null, epoch: null}"
and the operator couldn't tell whether the race produced the expected
converged state on wn's side.

Peel the .result wrapper before extracting the fields; same pattern
already used everywhere else in the script that parses wn JSON.
2026-04-22 21:54:18 +00:00
Claude 2cdd89558b fix(marmot-interop): split Test 07 into admin-authored halves
The reverse-rename leg issued `wn_b groups rename` on Interop-02, but
Amethyst created Interop-02 — A is the admin, B is a plain member.
MIP-03's `enforceAuthorizedProposalSet` rejects any GroupContextExtensions
commit from a non-admin, so wn's rename was being dropped client-side,
the metadata commit never reached any relay, and Amethyst's UI (correctly
waiting for a kind:445 on Interop-02) never saw "Interop-02-reverse".

Split Test 07 into two separately-recorded legs so each uses a group
where the renamer is actually the admin:

  07a — A (admin of Interop-02) renames to "Interop-02-renamed" via
        the Amethyst UI; wn-side poll observes the new name.

  07b — B (admin of Interop-03) renames to "Interop-03-renamed" via
        `wn_b groups rename`; operator confirms Amethyst picked it up.

Each leg individually records pass/fail/skip so a missing GROUP_03
(Test 03 skipped) doesn't take out the whole test.
2026-04-22 21:31:53 +00:00
Claude 1e181eb305 fix(marmot-interop): drive Test 06 removal from the admin (B), not A
Test 06 was asking the operator to remove C from Interop-05 via the
Amethyst UI — but Interop-05 was created by B (wn), so B is the sole
admin and A is a plain member. MIP-03's authorization gate
(`enforceAuthorizedProposalSet` in `MlsGroup.kt:1842`) rejects any
Remove proposal from a non-admin, so the UI correctly fires "not an
admin" and the test fails against its own author's assumption — the
test was misaligned with the admin model, not the code.

Flip the flow: B (admin) issues the removal via `wn groups
remove-members <gid> <C_NPUB>`, and A / C observe the effect. That
still exercises the same intended invariants:

  - B + A remain in the group, commit + epoch advance propagate.
  - A's Amethyst UI shows C gone (visible confirm).
  - A sends "after removing C" → B decrypts (expected).
  - C cannot decrypt the new message (forward secrecy at the new
    epoch — C's retained keys only open the pre-remove epoch).

The "Amethyst admin issues remove" path is already separately
covered by Test 02 (A creates the group → A is admin → A removes)
and Test 08 (promote A, then A issues an admin-only action). Test
06's focus is forward secrecy after remove, not who may initiate —
so having B drive it is the spec-correct shape.
2026-04-22 21:28:56 +00:00
Claude a6388a6751 feat(marmot-interop): discover A's relays instead of forcing a shared set
The interop harness was forcing specific relays on the Amethyst account —
"configure Amethyst with these five relays, also add them to your DM
Inbox list, also to your Key Package Relays, then republish" — which
made the test artificial:

  - It masked the real failure modes (wn publishing to an A-advertised
    relay wn can't reach, A's 10050 pointing at auth-required relays,
    etc.) because everyone was always on the same happy-path set.
  - It mutated the operator's real Amethyst account config — those
    five relays persist after the run.
  - It short-circuited wn's discovery chain (kind:10002 → 10050 →
    10051 resolution), so the one path most likely to hold subtle bugs
    was never exercised under divergent configs.

Real-world interop is: both clients have their own independently-chosen
relay sets and use the advertised kind:10002/10050/10051 to find each
other. Make the harness match that:

  - Keep the five-relay set as the wn B/C bootstrap (they still need
    somewhere to publish their own identity). Drop the "configure
    Amethyst to match" steps from `instruct_amethyst_setup`;
    confirmation only now ("is A logged in as <npub>, and has A
    published its four lists to any public relay?").

  - Add `discover_a_relays` step between configure + the operator
    prompt. Calls `wn keys check $A_NPUB` on both daemons to trigger
    wn's targeted fetch for kinds [0, 10002, 10050, 10051] (the
    only side-effect available to populate wn's user_relays cache).
    Then reads the cache directly via sqlite3 against wn's DB and
    prints what wn actually learned, grouped by relay_type. Warns
    loudly if A's 10050 shares zero relays with wn's bootstrap — the
    exact failure mode that caused the prior round of "Test 03
    silently times out" reports.

  - `--local-relays` path keeps the old behavior: offline/sandbox
    mode, the harness owns the relay so forcing Amethyst onto it is
    correct.

Scope is the harness only. No Amethyst / quartz / commons changes.
2026-04-22 21:13:16 +00:00
Claude 34f6be225f fix(marmot-interop): prompt operator to set DM Inbox relays too
Test 03 (wn creates a group, invites Amethyst) was failing at the
operator-visible level — "I don't see Interop-03 on Amethyst" — with
no clear signal in the Amethyst logcat because no kind:1059 welcome
gift wrap ever arrived.

Root cause: when wn invites Amethyst it fetches A's kind:10050 inbox
list and publishes the welcome gift wrap there. Amethyst's built-in
DM inbox defaults advertise auth.nostr1.com + relay.0xchat.com, which
wn (connected only to the five harness public relays) cannot reach.
Unless the operator manually adds the harness relays to Amethyst's
DM Inbox list, the gift wrap lands on relays the reader isn't
subscribed to, and the invite is silently dropped.

Confirmed empirically by adding wss://nostr.bitcoiner.social (one of
the harness relays) to Amethyst's DM Inbox list and watching
Interop-03 appear on the next wn create.

Update `instruct_amethyst_setup` to make the DM Inbox step explicit
and call out the specific failure mode it prevents. Also add a nudge
to verify NIP-65 + kind:10050 were actually republished after the
relay list edits.
2026-04-22 20:42:44 +00:00
Claude d1ea9c39e8 fix(marmot-interop): guard empty-array expansion in snapshot_invites
bash 3.2 (stock on macOS) raises "unbound variable" on "${gids[*]}"
when the array is empty under `set -u`. The happy-path sanity check
still worked (the array was populated), but the shell error aborted
execution before the actual test cases ran on a fresh state/ directory.

Only expand when the array has at least one entry.
2026-04-22 18:53:17 +00:00
Claude 1cc29d605a feat(marmot-interop): add nostr.bitcoiner.social + nostr.mom to defaults
Broaden the default public relay set for the interactive harness so
kind:1059 gift wraps and kind:10050 inbox lists have more landing
spots that don't pre-filter Marmot traffic out. Both relays are
known to accept kinds outside the core "note / metadata / contacts"
trio that damus/primal tend to restrict.

The Amethyst-side setup instructions are rendered from the same list,
so operators who configure Amethyst per the harness prompt get the
same five URLs on the app side too.
2026-04-22 18:43:43 +00:00
Claude 76f23c0798 fix(marmot-interop): filter stale welcomes in wait_for_invite
wnd persists pending group invites across daemon restarts, so on any
rerun C (and B) already have unaccepted welcomes from previous runs
lying around. `wait_for_invite` was pulling `.[0]` out of the invite
list, which is the oldest pending — i.e. the stalest — and treating
it as the arrival the caller just triggered. In the sanity probe this
caused the kind:445 step to send to B's NEW group id while C was
accepting a DIFFERENT (old) group, producing a bogus "kind:445 failed
— relays may be dropping group messages" with no actual relay issue.

Add `snapshot_invites <B|C>` which returns the current gids as CSV,
and teach `wait_for_invite` to accept that CSV as an ignore list so
it walks past stale welcomes and only returns a freshly-arrived one.
Use the new snapshot→wait pattern at every call site: sanity check,
Test 02 (B), Test 04 (C), Test 05 (C), Test 08 (C), Test 12 (C).
Also log a gid-mismatch warning in the sanity check when B's created
group and C's accepted welcome disagree, as an extra guard against
the filter being bypassed.
2026-04-22 18:43:28 +00:00
Claude 414fd31bf9 fix(marmot-interop): publish kind:0 for B and C before sanity probes
Several public relays (damus.io in particular) silently drop kind:1059 /
10050 / 30443 from accounts they've never seen before — the sanity
check in the interactive harness reports exactly this failure mode
when the three-relay default set is in use, because wn's identities are
brand-new and have nothing on file.

Before the kind:30443 + 10050/1059/445 probes, publish a kind:0
profile event for each wn identity via `wn profile update --name
--about`. That marks both accounts as "known" on the relay and lets
the subsequent gift-wrap and KeyPackage publishes land. Runs inside
configure_relays (after relays are added, before the first sanity
probe) so the sequencing is: add relay -> advertise profile -> publish
KPs -> start exchanging welcomes.

Does not help when a relay outright refuses the kind (some relays
whitelist only kind:1 / 3 / 7); those still need --local-relays.
2026-04-22 18:32:31 +00:00
Claude fda58f4413 fix(marmot-interop): diagnose B->Amethyst kind:445 drops in Test 02
When `wn_b messages send` fires but Amethyst never displays the reply,
the harness used to swallow the send output and only ask the operator a
blind yes/no — giving no signal about which side of the pipe failed.
Amethyst subscribes to kind:445 on the MLS GroupContext `relays`
extension (falling back to `Account.homeRelays`), so the two
actionable failure modes are:

  - wn published to a relay Amethyst isn't subscribed to (group-relay
    mismatch between the MLS extension and Amethyst's subscription)
  - Amethyst received the event but `GroupEventHandler.add` dropped it
    (no `h` tag, `isMember=false`, decrypt error)

Teach the harness to dump both sides automatically on the reply step:

  - Capture wn's `messages send` stdout/stderr into the run log.
  - `dump_outbound_diagnostics` prints wn's local view of the group
    messages (did the send persist to wn's own DB?), the MLS
    GroupContext relay list (what Amethyst should be subscribed to),
    and live relay connection status.
  - On operator-reported fail, prompt for an Amethyst-side logcat
    grep targeted at the exact log points — MarmotGroupEventsEoseManager
    filter updates, GroupEventHandler.add arrivals, and the membership
    drop branch — naming the current group id so the operator can grep
    for the right hex prefix.
2026-04-22 18:21:22 +00:00
Claude 13741be695 fix(marmot-interop): decode npub locally so member-list checks use hex
`wn users show <npub>` only resolves identities the local daemon has
cached, so A's npub (the Amethyst account, which B's daemon has never
interacted with directly) falls through to the "return input unchanged"
branch. That leaks the raw npub into the Test 02 member-list assertion,
where it's compared against hex pubkeys pulled from `wn groups members`
and fails with "member list missing npub1...".

Add a pure-awk bech32 decoder and use it as the final fallback in
`npub_to_hex`, so unknown npubs still convert to the 32-byte hex form
the daemon emits. Also harden the JSON path to peel a `.result` wrapper
in case a future `wn --json users show` ships one.
2026-04-22 18:01:05 +00:00
Claude c85848968c fix(marmot-interop): recover from stale MLS DB in interactive harness
The interactive harness was failing with the same
KeyringEntryMissingForExistingDatabase error as the headless one —
the MLS SQLite DB on disk references a keyring entry that no longer
exists (keychain entry pruned, data dir restored out of band, or a
previous run used the mock keyring). wnd can't open the DB in that
state, so the daemon exits before its socket appears.

Unlike the headless harness (which wipes unconditionally because its
mock keyring is always ephemeral), the interactive harness uses the
real OS keyring and benefits from preserving B/C identities across
runs. So: try once, and only wipe + retry when stderr actually shows
the keyring-missing error. Also dump stderr/stdout tails inline on
final failure so the operator doesn't have to chase a log path.
2026-04-22 17:38:08 +00:00
Claude ac72262acc fix(marmot-interop): clean up daemons on Ctrl+C in interactive harness
Apply the same SIGINT/SIGTERM/SIGHUP trap hardening to the interactive
harness that just landed on the headless one. Without it, killing the
script mid-run leaves the nohup'd wn daemons alive and the next run
fails in preflight because the sockets are still held.
2026-04-22 17:33:27 +00:00
Claude b21f8677ce fix(marmot-interop): clean up daemons on Ctrl+C / SIGTERM / SIGHUP
The previous trap only fired on the EXIT pseudo-signal, which doesn't
always run cleanly when the script is killed mid-flight — leaving the
nohup'd wnd daemons (and the local relay) alive and still holding the
port. Route SIGINT/SIGTERM/SIGHUP through an explicit `exit`, use a
single idempotent cleanup handler, and preserve the exit code so the
harness still reports failure when killed.
2026-04-22 17:30:33 +00:00
Claude 0f032e39d9 fix(marmot-interop): wipe stale wnd state when mock keyring is in use
The headless harness runs wnd with WHITENOISE_MOCK_KEYRING=1, but the
mock keyring is in-memory only — it starts empty on every wnd restart.
Meanwhile the SQLite databases under $data_dir persist across runs and
reference keys that no longer exist, so the second run onward fails at
startup with KeyringEntryMissingForExistingDatabase before wnd can even
open its control socket. Wipe everything under the daemon's data dir
(except logs and pid) before each start so wnd always comes up cold and
consistent with its ephemeral keyring.

Also surface failure diagnostics inline instead of burying them in a log
file path: print the last 40 lines of stderr and 20 of stdout, report
whether wnd is still alive or already exited, and break out of the 30s
wait loop the moment the daemon dies.
2026-04-22 16:59:07 +00:00
Claude fc38c4cb5d fix(marmot-interop): repair hunk count in skip-retry patch + harden preflight
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
2026-04-22 15:14:04 +00:00
Claude d65b4b0e5d fix(marmot-interop): retry gradle :cli:installDist on transient 503s
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
2026-04-22 15:06:40 +00:00