mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Two halves of the same gap in `transports/quic.md`. **The direct path.** The binding has a second delivery mode we had not built: the sender dials the receiver, opens one unidirectional stream and writes records with no control envelope at all. It is deliberately smaller than the broker path — the dialed endpoint is already the one receiver, so there is no room to claim — and it negotiates its own ALPN so an incompatible change to either mode cannot reach the other. Note the inverted direction: here the RECEIVER listens and the SENDER dials, which is also why v1 gives it no start-payload discovery and it is only usable against an endpoint known out of band. Only the sending half is here. `:quic` is a client stack with no server role, so this module can dial a direct receiver but cannot be one; that is recorded in the README rather than half-built. **The pin.** Preview endpoints and brokers are commonly self-signed and the binding expects that, saying a client MAY pin by exact DER or SHA-256 fingerprint. What we had instead was `PermissiveCertificateValidator` on the CLI path, which is not a weaker trust model — it is none, and anyone on the path can be the broker. `PinnedCertificateValidator` 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`, and `--insecure` still exists for a throwaway local broker but now has to be asked for by name. Both are verified against the reference implementation, which is the only thing that can tell an ALPN string, a stream direction, an absent envelope and a frame prefix from an implementation agreeing with itself: our direct sender against `wn stream receive`, and the pin — accepted and refused — against a real handshake with `marmot-quic-broker`. One thing that only showed up under a real handshake: a certificate the validator refuses closes the connection before it is established, and the transport was reporting that as PeerClosed. A caller walking a candidate list reads that kind to decide what to do next, and "never connected" is not "the peer hung up on us", so it is classified on the connection's actual status now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
120 lines
5.3 KiB
Markdown
120 lines
5.3 KiB
Markdown
# marmotQuic
|
|
|
|
Marmot's raw QUIC transport binding for agent text stream previews
|
|
(`transports/quic.md`), on top of the repo's own pure-Kotlin `:quic` stack.
|
|
|
|
## Why this is not `nestsClient`'s WebTransport
|
|
|
|
Both features move bytes over `:quic`, 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` 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`) 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"
|
|
```
|
|
|
|
## 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` 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.
|