Commit Graph

12000 Commits

Author SHA1 Message Date
Vitor Pamplona e37c546c37 Merge pull request #2561 from vitorpamplona/claude/debug-marmot-test-uWyAO
test(marmot-interop): unwrap newer whitenoise-rs `groups show` shape
2026-04-25 01:03:51 -04:00
Claude 57c3265ca0 feat(marmot): receive standalone PrivateMessage proposals (RFC 9420 §6.3.2)
The PROPOSAL branch of `MlsGroup.decrypt` previously threw
`IllegalStateException("Standalone PrivateMessage proposals not yet
supported")`. That worked in the openmls-as-Whitenoise topology because
MDK emits SelfRemove as a PublicMessage — but any peer using the
AlwaysCiphertext wire-format policy (RFC 9420 §6.3.2) wraps standalone
proposals in PrivateMessage, and we'd hard-fail on first contact.

Implements the PROPOSAL branch symmetrically with the existing COMMIT
branch:

1. Decode the Proposal struct from PrivateMessageContent (no length
   prefix), read signature<V>, drain zero-padding.
2. Restrict to SelfRemove (mirrors `receivePublicMessageProposal`'s
   policy — the only standalone proposal type that needs to ride
   outside a commit; widening is one-line if interop demands it).
3. Verify the FramedContentTBS signature with `wire_format =
   PRIVATE_MESSAGE` against the sender's leaf signature_key.
4. Stage the proposal in `pendingProposals` together with the encoded
   AuthenticatedContent (wire_format ‖ FramedContent ‖ signature) so
   a subsequent inbound commit folding it in by ProposalRef can
   resolve via the §5.2 hash. PrivateMessage AC bytes carry no
   membership_tag — auth = signature only.

Adds a symmetric `encryptProposalAsPrivateMessage` helper (internal,
mirrors `encrypt` for application data) so tests can exercise the
inbound path with a real wire frame produced by the same code path
peers use.

2 new tests: round-trip Bob→Alice SelfRemove via Welcome flow with
independent MlsGroup instances, verifies the proposal lands in
Alice's pending pool with AC bytes captured; and rejection of a
non-SelfRemove standalone PrivateMessage proposal (PSK in the test).

Marmot test suite green; marmot-interop-headless 16/16.

Closes audit gap #1.

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 05:00:24 +00:00
Claude 8c38394385 fix(marmot): bound forward-ratchet steps and tighten PrivateMessage AAD cap
Two DoS surfaces in the inbound path:

**#8 — unbounded forward-ratchet on PrivateMessage decrypt.** A
malicious sender (or any peer who can put bytes in the wire frame)
controls the `generation` field of an inbound PrivateMessage. The
SecretTree was happy to fast-forward the sender's application or
handshake ratchet by however many steps that field implied — every
step costing one HKDF-Expand. A single packet with `generation =
2^31` would have pinned a CPU at SHA-256 for minutes. The skipped-key
cache (`MAX_SKIPPED_KEYS = 1000`) bounds memory but NOT compute: it
just stops *caching* past the cap, the ratchet keeps walking.

Adds `MAX_RATCHET_STEPS_PER_CALL = 4096` and rejects any decrypt that
asks for a larger jump from the sender's current head, applied at
both `applicationKeyNonceForGeneration` and
`handshakeKeyNonceForGeneration`. 4096 leaves room for legitimate
catch-up (mobile waking from a long sleep) while bounding the
worst-case per-packet cost to ~4096 SHA-256 invocations.

**#11 — oversized PrivateMessage AAD allocation.** The underlying
[TlsReader] already caps every opaque<V> read at 1 MiB, so the
ciphertext field is bounded — but `authenticated_data` and
`encrypted_sender_data` were also allowed up to 1 MiB even though
both fields legitimately carry a few hundred bytes at most. A
pre-verification frame would force ~3 MiB of allocation per inbound
packet. Tightens both fields to 64 KiB at PrivateMessage decode
(below TlsReader's global cap) so the per-frame floor is predictable.

2 new tests: `secretTree_rejectsRatchetJumpsBeyondCap` exercises the
boundary (4096 OK, larger throws with a "jump too large" message)
and `privateMessage_rejectsOversizedAuthenticatedData` hand-builds
a frame with a 65 KiB AAD field and confirms rejection. Marmot
test suite green; interop 16/16.

Closes audit gaps #8 and #11.

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 04:48:40 +00:00
Claude 1f42afa61a fix(marmot): hard-fail UpdatePath silent decrypt + verify parent_hash on Welcome
Two RFC 9420 hardening gaps in the inbound path:

**#3 — UpdatePath silent decrypt failures.** When `processCommit` was
handed a commit whose UpdatePath either (a) had no node at our common
ancestor with the sender or (b) had no ciphertext encrypted to a node
in our copath resolution, the path-decrypt block silently fell through
and left `commit_secret = 0`. The downstream confirmation_tag check
caught it and rolled back, but the rollback surfaced as a generic
"ConfirmationTagMismatch" instead of pointing at the real cause —
a tree-shape mismatch between our view and the sender's. Now hard-fail
with a precise error naming the indices involved.

**#6 — parent_hash chain verification on Welcome.** `processWelcome`
was checking the GroupInfo signature and the GroupContext.tree_hash,
but neither gates a forged parent_hash inside a COMMIT-source leaf —
the tree_hash check only proves the ratchet_tree extension matches
the bytes the signer signed, not that the stored parent_hash values
are consistent with the tree shape. A peer that DOES validate
parent_hash (per RFC 9420 §7.9) would reject every commit produced
from such a tree, splitting the group on the next epoch.

Adds `verifyTreeParentHashesForJoin(tree)` as a static-tree
counterpart to `verifyParentHash` (which only handles the
post-UpdatePath dynamic case). For each COMMIT-source leaf, recompute
the parent_hash chain top-down on the leaf's filtered direct path and
compare to the stored value. Returns null on success or a human-
readable mismatch reason. Wired into `processWelcome` right after the
tree_hash check, before any capability or key-schedule work.

Also moves `encodeParentHashInput` into the companion object so the
static and instance variants share one TLS encoder, and adds an
`exportTreeBytes()` test accessor.

2 new tests: trivial single-member tree accepts; a tampered
COMMIT-source parent_hash on a 3-member tree is rejected with a
message naming the offending leaf. Quartz marmot tests green;
marmot-interop-headless 16/16. (Two unrelated NostrClient network
tests fail under the full :quartz:jvmTest run — pre-existing flake,
no MLS code touched.)

Closes audit gaps #3 and #6.

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 04:37:57 +00:00
Claude a42499435b fix(marmot): enforce required_capabilities on Add and Welcome (RFC 9420 §7.2)
When a group installs a `required_capabilities` extension (every Marmot
group does, listing MarmotGroupData=0xF2EE, SelfRemove=0x000A, Basic
credential), every member's leaf MUST advertise those types in its own
[Capabilities]. We were validating none of that:

- `applyProposalAdd` checked version + ciphersuite but never matched the
  new KP's capabilities against the group's required_capabilities. A
  non-conformant member silently joined; the next commit that touched
  their leaf got rejected by spec-conformant peers, splitting the group.
- `processWelcome` similarly never checked the joiner's own KP against
  the group's required set, nor did it sweep existing members' leaves.
  A misconfigured GroupInfo signer could invite us into an incoherent
  group whose first commit we'd silently reject forever.

Adds two helpers in `MlsGroup.Companion`:
- `findRequiredCapabilities(extensions)`: decodes the §7.2 struct from
  a GroupContext extension list, or returns null if absent.
- `requireCapabilitiesMeetRequirements(caps, req, who)`: throws with a
  specific (extensions=, proposals=, credentials=) diff naming the
  missing types — turns silent interop breaks into one debuggable line.

Wires the gate in two places:
- `applyProposalAdd` rejects the Add proposal if the new leaf falls
  short of the group's required set.
- `processWelcome` rejects the join if either (a) our own KP doesn't
  meet the group's requirements or (b) any existing member's leaf
  doesn't — the latter catches a malformed GroupInfo at join time.

5 new tests: round-trip decoding, rejection on missing extension /
proposal, acceptance when caps are a superset, and an end-to-end
`addMember` rejection of a hand-crafted KP with SelfRemove stripped
(re-signed so the rejection is from the capability gate, not the
signature check). Quartz test suite green; marmot interop 16/16.

Closes audit gaps #4, #5, #10.

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 04:22:42 +00:00
Claude d6a61d5ac5 fix(marmot): inbound MIP-03 admin gate, RFC 9420 §5.3 PSK secret, ProposalRef AC bytes
Three audit gaps in the Marmot MLS implementation, fixed together because
they share the same call sites:

1. **MIP-03 inbound authorization gate.** `enforceAuthorizedProposalSet`
   only fired for *outbound* commits (it implicitly checked the local
   member). A peer could send us a non-admin GroupContextExtensions
   rename, a non-admin Remove, or an admin-emptying GCE and we would
   silently apply it. `enforceAuthorizedProposalSet` now takes an explicit
   `committerLeafIndex` (defaults to `myLeafIndex` for the local case)
   and uses `isLeafAdmin(committerLeafIndex)` instead of `isLocalAdmin()`.
   `processCommitInner` resolves the proposal list against our pending
   pool, then runs both `enforceAuthorizedProposalSet` and
   `enforceNoAdminDepletion` against the resolved set before applying.
   External commits skip the check (sender has no leaf yet, and §12.4.3.2
   already restricts the proposal list).

2. **RFC 9420 §5.3 psk_secret derivation.** The previous
   `computePskSecret` HKDF-Extracted bare PSK values with the running
   `pskSecret` as salt and ignored `psktype` / `psk_nonce` / index /
   count entirely — incompatible with any spec-conformant peer. Per
   §5.3 each step is now:
       psk_extracted_i = HKDF.Extract(0, psk_i)
       psk_input_i     = ExpandWithLabel(psk_extracted_i, "derived psk",
                                         PSKLabel(id_i, i, n), Nh)
       psk_secret_i    = HKDF.Extract(psk_secret_{i-1}, psk_input_i)
   `buildPskLabel` encodes the full `PreSharedKeyID || index || count`
   struct. Resumption PSKs (`psktype == 2`) reject loudly because
   `Proposal.Psk` lacks `(usage, psk_group_id, psk_epoch)` — silently
   encoding a broken PSKLabel would diverge from peers without warning.

3. **ProposalRef hash for locally-published proposals.** RFC 9420 §5.2
   hashes the encoded AuthenticatedContent, not the bare Proposal.
   `buildSelfRemoveProposalMessage` now stages the published proposal in
   `pendingProposals` together with the AC bytes (wire_format ‖
   FramedContent ‖ FramedContentAuthData), so a subsequent inbound
   commit that folds it in by ProposalRef can resolve the hash. Bare-
   proposal fallback in `processCommitInner` retained for legacy local
   entries that pre-date the capture.

Tests: 7 new cases in `MarmotMipBehaviorTest` covering inbound non-admin
rejection (Add / GCE rename), admin-depletion rejection, AC bytes capture,
PSK empty/single/ordering/resumption-rejected paths. Full quartz JVM test
suite passes; marmot-interop-headless 16/16.

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 03:56:45 +00:00
Claude fc99bed55a fix(cli): default amy launcher to C.UTF-8 so emoji argv survives
`marmot message react "$gid" "$id" "🍕"` was producing a kind:7 whose
inner content was four `U+FFFD` replacement characters instead of the
emoji. Whitenoise's `message_aggregator::emoji_utils` rejected it with
`Invalid reaction content: ����`, and the openmls receiver retried the
event ten times before giving up — by which point the secret tree had
already consumed that generation, so the retry surfaced as a confusing
`Ciphertext generation out of bounds 1 / SecretReuseError`. That last
error was a downstream symptom; the root cause is argv decoding.

Why it happens: JEP 400 (Java 18+) pins `file.encoding` to UTF-8 but
deliberately leaves `sun.jnu.encoding` — the encoding the JVM uses to
decode argv, filenames, and native interop strings — tied to the OS
locale, and `sun.jnu.encoding` is read **before** the JVM parses any
`-D` flag, so it can't be overridden via `applicationDefaultJvmArgs`.
Under POSIX/C locales (no `LANG`/`LC_ALL` set, common on CI runners
and stripped-down containers) `sun.jnu.encoding` ends up at
`ANSI_X3.4-1968` — i.e. ASCII — and every byte > 0x7F in argv lands
as `U+FFFD` before our code ever sees it. The four UTF-8 bytes of 🍕
(F0 9F 8D 95) become four replacement chars, get signed into the kind:7
content, and the test fails on receipt.

The only place we can set the encoding from is the launching shell.
Patch the gradle-generated start scripts (POSIX `amy`, Windows
`amy.bat`) right after `:cli:startScripts`:

* POSIX: if neither `LANG` nor `LC_ALL` is set, `export LANG=C.UTF-8`
  before invoking Java. Existing locale settings are respected.
* Windows: `chcp 65001` to switch the console to UTF-8 before Java
  runs.

Tested manually: with `LANG=` (sandbox default) `amy` previously sent
`argv[0]=????` for an emoji argument; with the patch it sends `🍕` and
`sun.jnu.encoding=UTF-8`, and whitenoise accepts the kind:7 reaction.

The interop test 09 polling also needed a fix unrelated to the
encoding bug: wn aggregates kind:7 reactions onto the anchor message
under `.reactions.by_emoji.<emoji>` instead of surfacing them as
standalone messages whose `.content` is the emoji, so the previous
`wait_for_message B "$mls_gid" "🍕"` would have failed even with a
correctly-decoded reaction. The new poll inspects each message's
`.reactions.by_emoji` keys for the emoji literal.

Marmot interop score: 15/16 → **16/16** 🎉

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 02:53:38 +00:00
Claude 9d912ad82a fix(marmot): receive standalone SelfRemove proposals (test 15)
Two bugs that conspired to break interop test 15 once the test 14 OOM
was fixed:

1. **No receive path for standalone PublicMessage proposals.**
   wn/openmls publishes a non-admin's `SelfRemove` as a kind:445 carrying
   a `PublicMessage(content_type=PROPOSAL)` envelope and waits for an
   admin to fold it into the next commit. Quartz's `MarmotInboundProcessor`
   answered every such event with `Error("Standalone proposals not yet
   supported")` and dropped it. The admin's subsequent commit then
   failed with `Commit references unknown proposal (ref not found in
   pending proposals)` because nobody had staged the SelfRemove.

   Add `MlsGroup.receivePublicMessageProposal(pubMsg)` that:
   - rejects mismatched epoch / group_id / sender,
   - reconstructs the FramedContentTBS exactly as the proposer did and
     verifies the leaf signature,
   - verifies the membership_tag against the current epoch's
     membership_key (same threat model as inbound PublicMessage commits),
   - decodes the inner Proposal (only `SelfRemove` is accepted today —
     other types come bundled in commits' `proposals` lists),
   - stages the proposal in `pendingProposals` so a later commit can
     resolve its `ProposalRef`.

   `processPublicMessage` now routes `ContentType.PROPOSAL` through
   that helper and returns a new `GroupEventResult.ProposalStaged`
   variant (also surfaced as `MarmotIngestResult.ProposalStaged`),
   replacing the old hard-error path.

2. **Wrong `ProposalRef` hash input.** RFC 9420 §5.2 specifies that a
   `ProposalRef` hashes the **encoded `AuthenticatedContent`** that
   delivered the proposal, not the bare `Proposal` struct. Quartz was
   hashing `proposal.toTlsBytes()` only — fine for our local-only
   flows where commits inline rather than reference our own pending
   proposals, but fatal once we needed to match wn's reference to an
   inbound proposal.

   Extend `PendingProposal` with an optional `authenticatedContentBytes`
   field. The standalone-proposal receive path captures the full
   `wire_format || FramedContent || FramedContentAuthData` envelope at
   stage time. The reference-resolution code in `processCommitInner`
   prefers those bytes when present and falls back to the bare-proposal
   hash for locally-proposed entries (which never get referenced
   today).

Marmot interop score: 14/16 → 15/16 (test 9 — amy's kind:7 reaction
triggers a `SecretReuseError` on B's wn — is unrelated to this path
and remains for follow-up).

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 02:22:04 +00:00
Claude 359b6069f1 fix(marmot): handle being removed mid-commit without OOM (test 14)
When wn admin-removes A in interop test 14, A processed the proposal
locally — `tree.removeLeaf(A.leafIndex)` blanked her leaf and shrank
`tree.leafCount` past `myLeafIndex` — and then immediately walked into

    BinaryTree.directPath(myLeafIndex, tree.leafCount)

at MlsGroup.processCommitInner. With `myLeafIndex >= leafCount`, the
left-balanced parent walk had no valid stopping point: each recursion
step doubled the candidate parent index, integer-overflowed past 2^31,
and either returned garbage or kept appending to the result list until
the JVM OOM'd. The OOM bubbled up as a `runBlocking` failure that
rolled back the whole commit — so A also stayed locally convinced she
was still a member, and the test timed out waiting for `not_member`.

Three layered fixes so the failure mode can't recur:

* `BinaryTree.parent`/`directPath`/`copath` now `require` an in-range
  input. The root and any node ≥ nodeCount used to silently loop or
  return garbage; now they throw `IllegalArgumentException` immediately.
  This is defense-in-depth — a future caller passing an invalid index
  gets a stack trace at the boundary instead of an OOM five frames
  deep.

* `MlsGroup.processCommitInner` short-circuits the path-decrypt + epoch
  advancement when the proposals in the commit just removed *us*. We
  preserve the proposal-side tree mutations so the caller (and the
  outer state machine) can observe that we're out, clear pending
  proposals + sent keys, and return. Without this short-circuit the
  function would derive a bogus all-zero `commit_secret`, fail
  `confirmation_tag` verification, throw, and roll the snapshot back —
  leaving us still convinced we were a live member.

* `MlsGroupManager.isMember` and a new `MlsGroup.isLocalMember()`
  helper now return false once our leaf is null or past `leafCount`.
  The post-Remove group entry still lives in `groups` so callers can
  inspect the final tree, but every cli command (`group show`,
  `message send`, etc.) sees `not_member` and returns the right error.

Also add a comprehensive `BinaryTreeTest` covering non-power-of-2
trees (3, 5, …, 32 leaves) and the boundary cases (root has no
parent, leafIndex ≥ leafCount must throw). The pre-existing tests
only exercised the 4-leaf example from RFC 9420 Appendix C; nothing
hit the `parentInRange` branch, which is exactly where the OOM lived.

Test 14's bash polling needed a small companion fix: it captured
amy's stderr through `2>&1` and fed the result to `jq`, but the
captured stream is interleaved with quartz `Log.d(…)` debug lines,
so the JSON parse always failed. Switch to a `grep` for the
`"error":"not_member"` literal — that signature only appears in
the JSON payload and survives the debug noise. Also tee the
captured output into `$LOG_FILE` so post-mortem logs include each
polling iteration's `[cli] ingest …` traces.

Marmot interop score: 11/16 → 14/16 (tests 9, 15 are unrelated MLS
issues — see follow-up).

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 01:32:08 +00:00
Claude c74bbb8510 test(marmot-interop): unwrap newer whitenoise-rs groups show shape
Whitenoise-rs ≥ v0.2.x nests the group object one level deeper —
`{"result": {"group": {...}}}` instead of `{"result": {...}}`. The two
metadata-name pollers in tests 07 and 10 still asked for `.result.name`
and silently saw the empty string, so even when wn-side `groups show`
returned the correct new name the polling loops timed out:

    07 metadata rename       fail   B saw name="" not "Interop-02-renamed"
    10 concurrent commits    fail   diverged: A sees "race-from-amethyst", B sees ""

Both queries now also peel a `.group` wrapper when present, leaving the
older bare-`result` shape working too. Re-running the headless harness
flips 07 + 10 from fail → pass; the remaining failures (09, 14, 15) are
unrelated MLS-encryption / Remove-commit issues that need their own fix.

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-24 23:56:47 +00:00
Vitor Pamplona 7c3574e6ca Merge pull request #2558 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-24 19:21:37 -04:00
Vitor Pamplona f41e5d278a Merge pull request #2559 from vitorpamplona/claude/secure-local-files-1mC5q
Add secure private-key storage with OS keychain and NIP-49 support
2026-04-24 19:18:06 -04:00
Crowdin Bot 7540b7304a New Crowdin translations by GitHub Action 2026-04-24 21:30:10 +00:00
Vitor Pamplona f2ba799a85 Merge pull request #2557 from vitorpamplona/claude/update-swipe-dismiss-state-zJAnD
Simplify SwipeToDelete by using onDismiss callback
2026-04-24 17:29:40 -04:00
Vitor Pamplona 80e36e004a Merge pull request #2556 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-24 17:28:46 -04:00
Vitor Pamplona 3f57060cde Merge pull request #2554 from vitorpamplona/claude/optimize-ci-workflows-avIOq
Consolidate CI/CD workflow: merge test and build jobs
2026-04-24 17:28:39 -04:00
Claude 04f543b7a5 fix: migrate SwipeToDeleteContainer off deprecated confirmValueChange
The confirmValueChange parameter on rememberSwipeToDismissBoxState is
deprecated — state changes should not be vetoed via callback. Move the
onStartToEnd action to SwipeToDismissBox.onDismiss and rely on
enableDismissFromEndToStart = false to restrict the anchor set.

https://claude.ai/code/session_01CfYsUSGeuBnYsCqa6FDSPk
2026-04-24 21:22:55 +00:00
Claude 158f387bc0 test(cli): wire --secret-backend=plaintext into dm ghost identity init
dm-03 and dm-04 spawn a throwaway "ghost" identity by calling $AMY_BIN
directly (not through amy_a / amy_d), so they missed the plaintext
backend flag added in the previous commit and were failing with
"No TTY and no passphrase source" on headless CI.

https://claude.ai/code/session_01SqdMfLdXvb3GskFLcEj739
2026-04-24 21:19:01 +00:00
Crowdin Bot b3e7808fc1 New Crowdin translations by GitHub Action 2026-04-24 21:11:04 +00:00
Vitor Pamplona 5f5aaf2941 Merge pull request #2553 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-24 17:09:29 -04:00
Vitor Pamplona ad84f2c6cc Merge pull request #2555 from vitorpamplona/claude/cli-github-actions-deploy-HVNK4
Add native distribution packaging for Amy CLI (jlink + jpackage)
2026-04-24 17:09:18 -04:00
Claude b507cb5986 feat(cli): move private key out of identity.json into keychain / NIP-49
identity.json previously stored `privKeyHex` and `nsec` as plaintext fields.
0600 file perms keep other OS users out, but not another app running as the
same user — and that is the threat model the CLI actually cares about.

Introduce a SecretStore indirection:

 * identity.json now persists only the public parts plus a typed
   `secret: IdentitySecret` envelope (keychain | ncryptsec | plaintext).
 * macOS uses `/usr/bin/security` (Keychain ACLs bind the item to the binary
   that stored it, so other same-user apps need user consent).
 * Linux uses `secret-tool` if a Secret Service is running on the session
   D-Bus; the gain there is at-rest encryption while the keyring is locked.
 * On any platform without a keychain, auto falls back to NIP-49
   (scrypt + XChaCha20) with the passphrase read from --passphrase-file,
   then $AMY_PASSPHRASE, then a TTY prompt. Another same-user app can read
   the blob but cannot decrypt it without the passphrase.
 * `--secret-backend=plaintext` is an explicit opt-in for dev scripts and
   the interop test harness.

Legacy identity.json files that still carry top-level privKeyHex/nsec are
read transparently and auto-migrate on the next save.

whoami, `create` / `login` existence checks, and init-re-run now use a
metadata-only load path so they do not trigger a keychain prompt or ask
for a passphrase just to echo the npub.

Tests: cli/tests/ setups wire --secret-backend=plaintext through the amy_a
/ amy_d wrappers so headless CI runs do not stall on a TTY passphrase
prompt.

https://claude.ai/code/session_01SqdMfLdXvb3GskFLcEj739
2026-04-24 21:06:18 +00:00
Claude e68f77b2f1 feat(cli): ship amy on macOS + Linux via GitHub Release
Adds a new build-cli matrix to create-release.yml so every tag push also
produces self-contained amy binaries:

  - amy-<ver>-macos-x64.tar.gz     (macos-13 runner)
  - amy-<ver>-macos-arm64.tar.gz   (macos-14 runner)
  - amy-<ver>-linux-x64.tar.gz     (ubuntu-latest)
  - amy-<ver>-linux-x64.deb        (ubuntu-latest)
  - amy-<ver>-linux-x64.rpm        (ubuntu-latest)

Each tarball is a flat tree (bin/amy + lib/*.jar + runtime/) with a
jlink'd JDK 21 embedded — no system Java required on the user machine.
The .deb / .rpm install the same tree under /opt/amy/.

Windows is intentionally deferred until cli/ is validated on Windows
(data-dir path handling, file locking on groups/<gid>.mls, identity.json
line endings).

Two follow-ups flagged in comments:

  - :commons leaks Compose + Skiko (~40 MB) as transitive deps to :cli.
    Tarball lands at ~98 MB instead of the plan's <80 MB target. Size
    budget in the workflow is set to 200 MB until :commons is split into
    core + ui modules.
  - .deb/.rpm install to /opt/amy/bin/amy with no /usr/local/bin symlink.
    Users must add to PATH or symlink manually after install.

Wire-up:
  - cli/build.gradle.kts gains jlinkRuntime + amyImage (Sync task that
    combines installDist output with the jlink runtime + a shell
    launcher) + jpackageDeb + jpackageRpm.
  - scripts/asset-name.sh gains cli_asset_name() + collect_cli_assets()
    alongside the existing desktop collector, preserving the
    <product>-<version>-<family>-<arch>.<ext> naming contract.

See cli/plans/2026-04-21-cli-distribution.md for the overall plan.

https://claude.ai/code/session_01Tbh6F7TtEeceb4K3stcUWp
2026-04-24 20:57:04 +00:00
Claude ca92c5a87a ci: drop assembleDebug and its APK uploads
The release workflow builds its own signed release APKs/AABs and does
not consume the debug outputs. Testers use the Benchmark APK, which is
still built and uploaded. Dropping assembleDebug removes a redundant
APK packaging pass from every PR/main run.
2026-04-24 20:56:34 +00:00
Claude 6d930ef3cc ci: split assembleDebug and assembleBenchmark into separate gradle calls
Combining them into a single Gradle invocation caused
:amethyst:packagePlayBenchmark to fail inside AGP's
IncrementalSplitterRunnable worker — both variants share packaging
state in one task graph and the benchmark leg trips on it.

The old workflow always ran these as two separate `./gradlew` calls.
Match that pattern. They still run on the same runner with a warm
Gradle cache, so the compilation savings from sharing quartz/commons
with the test+packageDeb step are preserved; we only pay two Gradle
startups, which is trivial.
2026-04-24 20:52:19 +00:00
Claude b77e6aa54f feat(cli): restrict on-disk data to owner-only permissions
identity.json, relays.json, state.json, MLS group state/retained epochs,
keypackage bundles, and the Marmot message archive were written with
default umask — typically world-readable on Unix. On a shared machine
another OS user could read the stored private key or decrypted chat
history.

Introduce SecureFileIO: overwrites go via a sibling tempfile with POSIX
0600 attrs set at creation time and ATOMIC_MOVE into place, directories
are created 0700, and on upgrade DataDir.init tightens any pre-existing
loose files. Non-POSIX filesystems (Windows) fall back to setReadable/
Writable/Executable to strip other-user access.

This is defense against other OS users. Another app running as the same
user is still in scope — the FileStores doc continues to note that real
deployments MUST encrypt at rest.

https://claude.ai/code/session_01SqdMfLdXvb3GskFLcEj739
2026-04-24 20:30:55 +00:00
Claude 2c5f08a16a ci: merge test and build jobs to eliminate duplicate compilation
Previously each OS ran Gradle twice (or three times on Ubuntu): once to
compile+test, then again to build desktop packages, plus a third run on
Ubuntu for Android APKs. Merging these into a single matrix job per OS
lets Gradle build the task graph once and reuse compiled classes across
test, desktop packaging, and Android assembly.

- Combine test + build-desktop into one matrix job (test-and-build)
- Fold build-android into the same Ubuntu matrix leg
- Use --no-daemon consistently; drop the redundant --stop steps
- Raise timeout to 60 min to cover the combined work
2026-04-24 20:10:03 +00:00
Crowdin Bot cef4036cab New Crowdin translations by GitHub Action 2026-04-24 19:18:22 +00:00
Vitor Pamplona f341bc776e Merge pull request #2552 from vitorpamplona/claude/cli-profile-feed-commands-03lOu
Add profile and notes commands to CLI
2026-04-24 15:16:52 -04:00
Claude 8cbe8c67fe refactor(cli): rename default data-dir to ./amy and nest MLS state under marmot/
Two on-disk layout changes that only affect freshly-created data-dirs (no
migration shim since `amethyst-cli-data` was never tagged or released):

- Default `--data-dir` is now `./amy` instead of `./amethyst-cli-data` —
  shorter, matches the binary name, and still overridable via the flag or
  `$AMETHYST_CLI_DATA`.
- MLS-only files (`groups/` and `keypackages.bundle`) move under a new
  `marmot/` subdirectory so the top-level root stays tidy as more
  non-Marmot state lands (notes, profile caches, etc). Existing
  top-level files (`identity.json`, `relays.json`, `state.json`) are
  unchanged.

README tree diagram and DEVELOPMENT.md test table updated to match.
2026-04-24 19:15:58 +00:00
Vitor Pamplona 3423891506 Merge pull request #2551 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-24 14:52:20 -04:00
Crowdin Bot c24fba81d1 New Crowdin translations by GitHub Action 2026-04-24 18:41:23 +00:00
Vitor Pamplona 7448641445 Merge pull request #2503 from greenart7c3/main
feat(media): add playback controls and autoplay support for GIFs
2026-04-24 14:39:41 -04:00
Claude 825e2fd911 refactor(cli): group post + feed under notes subcommand
Moves the top-level `post` and `feed` verbs under a single `notes` group
(`amy notes post`, `amy notes feed`). Keeps the per-event-family grouping
consistent with `profile` (kind:0), `dm` (NIP-17), and `marmot` (MLS).

PostCommand and FeedCommand are unchanged — the new NotesCommands object
is a thin dispatcher that routes `notes post` / `notes feed` into them.
2026-04-24 18:28:52 +00:00
Vitor Pamplona 857caa97c9 Merge pull request #2550 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-24 14:27:34 -04:00
Crowdin Bot a9081923f8 New Crowdin translations by GitHub Action 2026-04-24 18:15:56 +00:00
Vitor Pamplona 0e8767b9cd No need for String.format 2026-04-24 14:13:39 -04:00
Vitor Pamplona e1a133ac9f removes unneeded annotation 2026-04-24 14:11:07 -04:00
Claude 525c4bce0f feat(cli): add profile, post, and feed verbs
Adds three new top-level verbs to amy that mirror the core social-feed
surface of the Android client:

- `amy profile show [USER]` / `amy profile edit ...` reads and patches the
  user's NIP-01 kind:0 metadata. `edit` reuses MetadataEvent.updateFromPast
  semantics so unset flags keep prior values and blank values delete the
  field, falling back to MetadataEvent.createNew when no kind:0 exists yet.
- `amy post TEXT` publishes a NIP-10 kind:1 short text note to the user's
  outbox relays via TextNoteEvent.build.
- `amy feed [--author USER] [--following] [--limit N] [--since|--until TS]`
  reads kind:1 notes — own / single-author / contact-list — using
  Context.drain with author-scoped filters, dedups by id, and returns the
  newest-first window as JSON.

All three keep the thin-assembly-layer rule: no Nostr or business logic in
cli/, every event is built/signed via quartz/ and published through the
existing Context.publish + Context.drain helpers.
2026-04-24 18:01:12 +00:00
Vitor Pamplona d8a8082887 removes the account retainer logic 2026-04-24 13:39:00 -04:00
Vitor Pamplona 711a70cdb4 Merge pull request #2539 from vitorpamplona/claude/fix-giftwrap-unwrap-2659O
Preload all writable accounts for always-on notifications
2026-04-24 13:28:29 -04:00
Vitor Pamplona 141cbf93c3 Merge pull request #2538 from vitorpamplona/claude/add-reply-notifications-GvMOn
Add reply and mention notifications for public notes
2026-04-24 13:27:14 -04:00
Vitor Pamplona 13fc014e66 Merge pull request #2548 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-24 13:14:55 -04:00
Claude 82e4448b58 refactor(quartz): move lowercase-p notification check into PTag
The default Event.notifies body was hand-rolling a tag scan for lowercase
`p`, duplicating the tag-shape knowledge that already lives in PTag.
Give PTag ownership of the check:

    PTag.isNotifying(tags, userHex)  // iterates tags and uses PTag.isTagged

Event.notifies now delegates to it. Kinds that override (CommentEvent,
WakeUpEvent) still work — they compose or replace the default as before.

https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
2026-04-24 16:36:21 +00:00
Crowdin Bot b5671b7449 New Crowdin translations by GitHub Action 2026-04-24 16:19:07 +00:00
davotoula 999184cced optimise imports 2026-04-24 18:15:58 +02:00
Claude 10bbd0ddb2 refactor(notifications): per-kind Event.notifies(HexKey) routing
The notification pipeline previously hard-coded a lowercase-`p`-tag match
in two places (the observer predicate and consumeFromCache via
taggedUserIds). That's correct for most kinds but wrong for two:

- NIP-22 CommentEvent: a comment several levels deep only tags the root
  author via uppercase `P` (RootAuthorTag). Pure lowercase-p routing
  missed "someone replied deep in your thread" notifications.

- Experimental WakeUpEvent (kind 23903): its `p` tags are the authors of
  the subject events it references — Bob reacting to Alice's post yields
  a WakeUpEvent with p=Bob, even though Alice's device is the one that
  needs to wake up. Transport-layer routing (push/relay subscription)
  already delivered the event to the right device, so the in-event
  routing has to be permissive.

Introduce `open fun Event.notifies(userHex: HexKey): Boolean` with a
lowercase-`p` default that covers NIP-01/04/17/25/28/34/57/68/71/84/AC/
chess/wiki/long-form/poll mentions. Each kind with distinct semantics
overrides:

- CommentEvent.notifies: super.notifies(u) || rootAuthorKeys().contains(u)
  — picks up uppercase P root-author routing on top of lowercase p.
- WakeUpEvent.notifies: true — every logged-in account is a valid wake
  target once the event has reached LocalCache on this device.

NotificationDispatcher's observer predicate and EventNotificationConsumer.
consumeFromCache both now ask `event.notifies(accountHex)` instead of
extracting taggedUserIds themselves. The zap path's redundant
isTaggedUser re-check is gone too (it was duplicating what the outer
routing already enforced).

https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
2026-04-24 16:06:55 +00:00
Vitor Pamplona 6a55c752e8 Merge pull request #2547 from greenart7c3/feat/reorder-payment-targets-icon
feat(reactions): place payment targets icon after zap by default
2026-04-24 11:47:21 -04:00
Claude 86a86e1426 refactor(notifications): observer takes a single predicate
Filter.match is itself just a boolean predicate, so carrying a Filter and
a separate composition predicate on NewEventMatchingFilter duplicated the
abstraction. Collapse to one predicate:

- NewEventMatchingFilter(predicate, onNew): drops the Filter parameter.
- LocalCache.observeNewEvents(predicate): primary API.
- LocalCache.observeNewEvents(filter): kept as a one-liner that forwards
  filter::match, so existing feed/DAL callers are unchanged.

In the dispatcher the freshness check, kind check, p-tag match, and
since cutoff now live in a single lambda, short-circuiting on cheap
kind comparison first. NOTIFICATION_KINDS is now a Set<Int> for O(1)
membership instead of O(n) List.contains — cheap, but on a hot path
that runs for every new cache insertion it pays for itself.

https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
2026-04-24 14:39:42 +00:00
greenart7c3 e30413a081 feat(reactions): hide payment targets icon by default
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:15:00 -03:00