mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 15:48:36 +00:00
quartz and quic now come from Maven Central (libs.quartz, libs.quic), but a few module docs and skills still described them as Gradle modules here: - marmotQuic/README.md, nestsClient/tests/README.md: `:quic` -> quic-kmp - cli/DEVELOPMENT.md, commonsUI/ARCHITECTURE.md: `:quartz` -> libs.quartz - amy-expert / kotlin-multiplatform extraction recipes: protocol code is a quartz-repo PR (built here with -PquartzDir), not a git mv into quartz/ - AGENTS.md Tech Stack and the kotlin-multiplatform skill: the app modules moved to kotlinx.serialization; Jackson remains only inside quartz's JVM code, so the "don't migrate to kotlinx.serialization" rule is reversed - BUILDING.md: the currentTimeMillis ban is verifyKmpPurity, not a Spotless rule
145 lines
6.6 KiB
Markdown
145 lines
6.6 KiB
Markdown
# marmotQuic
|
|
|
|
Marmot's raw QUIC transport binding for agent text stream previews
|
|
(`transports/quic.md`), on top of the pure-Kotlin QUIC stack from
|
|
[quic-kmp](https://github.com/vitorpamplona/quic-kmp) (`libs.quic`).
|
|
|
|
## Why this is not `nestsClient`'s WebTransport
|
|
|
|
Both features move bytes over quic-kmp, but they enter it at different layers.
|
|
|
|
`nestsClient` speaks **WebTransport**: HTTP/3, an Extended CONNECT handshake, a
|
|
`:protocol` pseudo-header, QPACK, SETTINGS negotiation. Its
|
|
`WebTransportSession` abstraction starts *above* all of that.
|
|
|
|
Marmot's binding is **raw QUIC**. It negotiates its own ALPN —
|
|
`marmot.quic_broker.v1` for the broker path, `marmot.quic_stream.v1` for the
|
|
direct one — and writes frames straight onto QUIC streams. There is no HTTP/3
|
|
in it at all, so `WebTransportSession` is the wrong shape.
|
|
|
|
What both share is everything below that line, which is the hard part and is
|
|
already built: the QUIC connection, TLS 1.3, ALPN negotiation, stream
|
|
multiplexing, loss recovery and the UDP socket.
|
|
|
|
## Shape
|
|
|
|
- A **publisher** opens a client-initiated *unidirectional* stream, writes a
|
|
`publish` control envelope, then record frames.
|
|
- A **subscriber** opens a client-initiated *bidirectional* stream, writes a
|
|
`subscribe` control envelope, and reads the fan-out on the return direction.
|
|
- A **direct sender** opens a client-initiated *unidirectional* stream and
|
|
writes record frames with **no** control envelope — the dialed endpoint is
|
|
already the one receiver, so there is no room to name.
|
|
|
|
A broker rejects the wrong pairing. Every role frames the same way:
|
|
`uint32 frame_len || bytes` — on the broker path the control envelope first and
|
|
then each `AgentTextStreamRecordV1`, on the direct path records from the first
|
|
byte.
|
|
|
|
Note the direct path's connection direction: the **receiver** listens and the
|
|
**sender** dials, inverted from the broker path where both ends dial the
|
|
broker. Only the sender half is here; quic-kmp is a client stack with no server
|
|
role, so this module cannot expose a direct-path endpoint of its own.
|
|
|
|
The codecs — control envelope, frame reader/writer with both caps, `quic://`
|
|
candidate parsing — live in `quartz` next to the rest of agent-text-stream,
|
|
because they are pure bytes and belong with the feature. This module is only
|
|
the connection.
|
|
|
|
## The broker sees nothing
|
|
|
|
Records are encrypted under a key derived from the group's MLS exporter. A
|
|
broker holds no key and learns only the routing pair
|
|
`(stream_id, start_event_id)` plus ciphertext. It is an untrusted forwarder,
|
|
and a candidate that points somewhere hostile still cannot forge a record.
|
|
|
|
## TLS trust
|
|
|
|
Preview endpoints and brokers are commonly self-signed, and the binding says so:
|
|
a client MAY pin the endpoint certificate by exact DER or SHA-256 fingerprint
|
|
instead of chaining to a CA. `PinnedCertificateValidator` (in quic-kmp) is that
|
|
pin. It replaces the chain and the hostname check and nothing else — the peer
|
|
still has to sign the TLS transcript with the pinned certificate's private key,
|
|
so copying a public certificate off the wire buys an attacker nothing.
|
|
|
|
`amy marmot stream send|watch` takes `--pin-sha256 HEX[,HEX…]`; the reference
|
|
broker prints its own `server_cert_sha256_fingerprint` in its startup JSON.
|
|
|
|
## Interop tests
|
|
|
|
`MarmotQuicBrokerInteropTest` drives our publisher and subscriber through MDK's
|
|
own reference broker, and `MarmotQuicDirectInteropTest` drives our direct
|
|
sender against MDK's direct receiver (`wn stream receive`). Both are the only
|
|
way to know the binding is right: an ALPN string, a stream direction, a missing
|
|
control envelope and a frame prefix are all things an implementation will
|
|
happily agree with itself about.
|
|
|
|
Start the broker from an MDK checkout:
|
|
|
|
```bash
|
|
cargo build --release --bin marmot-quic-broker --bin wn
|
|
./target/release/marmot-quic-broker --bind 127.0.0.1:4450 --json
|
|
```
|
|
|
|
then:
|
|
|
|
```bash
|
|
./gradlew :marmotQuic:jvmTest \
|
|
-DmarmotQuicBroker=127.0.0.1:4450 \
|
|
-DmarmotQuicBrokerPin=<server_cert_sha256_fingerprint from that JSON> \
|
|
-DmarmotWn=/path/to/mdk/target/release/wn
|
|
```
|
|
|
|
Each property gates its own cases and they skip visibly without it, so an
|
|
ordinary `./gradlew test` never needs the reference implementation on the
|
|
machine. `-DmarmotWn` needs no running process: the test spawns
|
|
`wn stream receive` itself on a free port.
|
|
|
|
## Using it
|
|
|
|
`amy marmot stream` drives the whole feature; the harness's tests 18 and 19
|
|
run it in both directions against MDK.
|
|
|
|
```bash
|
|
amy marmot stream start GID --broker quic://127.0.0.1:4450
|
|
amy marmot stream send GID --stream-id … --start-event-id … --broker … "hello"
|
|
amy marmot stream send GID --stream-id … --start-event-id … --direct quic://host:port "hello"
|
|
amy marmot stream watch GID --stream-id …
|
|
amy marmot stream finish GID --stream-id … --transcript-hash … --chunk-count N "hello"
|
|
```
|
|
|
|
## Advertised, but not started
|
|
|
|
The implementation is complete and tested, and nothing in the app starts it.
|
|
|
|
Those are two separate things, and conflating them broke interop once. Our
|
|
KeyPackage **does** advertise component `0x8006` and the `0xF2D1` receive role,
|
|
because the reference client installs `agent-text-stream.quic.v1` with
|
|
`required_member_roles = receive` into the required set of **every group it
|
|
creates**, and refuses an invitee whose leaf omits either. Dropping the
|
|
advertisement on the grounds that "nothing publishes previews" made an Amethyst
|
|
user un-addable to any group a White Noise user started — the traffic claim was
|
|
right, the capability claim was not. A capability says "this client can handle
|
|
it", never "this group uses it".
|
|
|
|
What we do NOT advertise is `send` (`0xF2D2`) and `fanout` (`0xF2D4`): we can be
|
|
shown a preview, we do not originate one. A group that requires either refuses
|
|
us, and `CurrentProfileWelcomeTest` asserts that refusal so widening the default
|
|
stays a deliberate decision.
|
|
|
|
What is not started: the Android chat screen builds no watcher and dials no
|
|
broker a kind:1200 advertises. The codecs, this module, the CLI
|
|
(`amy marmot stream …`) and the interop tests all still work and still run.
|
|
Turning the live path on is re-adding the watcher to `MarmotGroupChatView`.
|
|
|
|
## Not done
|
|
|
|
- The Android GUI renders previews but does not originate a stream — that is
|
|
an agent's job, and no agent runs in the app yet. Only `amy` publishes one.
|
|
- The desktop app has no Marmot chat screen at all, so there is nothing to
|
|
render a preview into. The watcher it would use already lives in `commons`.
|
|
- The direct path's **receiving** half. quic-kmp has no server role, so this
|
|
module can dial a direct receiver but cannot be one. v1 also defines no
|
|
start-payload candidate format for the direct path, so a sender only reaches
|
|
a receiver whose endpoint it already knows out of band.
|