Skip to content

Train train/2026-10-31.2 - #110

Merged
QuicksilverSlick merged 29 commits into
mainfrom
train/2026-10-31.2
Oct 31, 2026
Merged

QuicksilverSlick merged 29 commits into
mainfrom
train/2026-10-31.2

Conversation

@QuicksilverSlick

Copy link
Copy Markdown
Owner

Steward train. The steward reads this body back; do not edit it.

Repo: QuicksilverSlick/buzz
Branch: train/2026-10-31.2
Base: b0e64e3
Round: 0
Release: desktop-release: a green main CI publishes the desktop update 1-2 h after the merge.

PR Head Tier Built-by
#93 2230b16 green tapped claude-code/claude-opus-5-5
#76 d23f1a8 green tapped claude-code/claude-opus-5-5
#94 d542e86 green tapped claude-code/claude-opus-5-5
#96 1ec091c green claude-code/claude-opus-5-5
#97 bed9669 green claude-code/claude-opus-5-5

Notes:

Built-by: steward

QuicksilverSlick and others added 29 commits October 27, 2026 08:26
Bishop (a Cloudflare Worker) cannot reach Block's hosted relay directly:
Cloudflare-to-Cloudflare requests end in error 526, while a Cloudflare
Container gets through. buzz-relay-gateway is the keyless forwarder that
runs in that container. The Worker keeps every key and signs every event;
the gateway only forwards bytes. This first part is the skeleton.

- Config::from_env reads four public values (bind address, relay URL,
  link URL, gateway-auth public key) and never holds a secret. A missing
  or invalid variable names the variable, never its value.
- The relay URL must be wss://host[:port] with nothing else; ws:// is
  accepted only on loopback, for the offline tests.
- policy.rs is the anti-open-proxy table: exact method and path matches
  (POST /query, /events, /count; GET / for NIP-11; the socket on /), a
  closed request header set with no duplicates, and a response filter
  that keeps only content-type and retry-after.
- The binary serves /healthz, installs the ring provider, logs JSON, and
  handles SIGTERM itself (it is PID 1 in the image).

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… logs

Review fixes for part 1.

- axum::serve sets no hyper timer, so a client that stops mid-request
  holds hyper's graceful shutdown open for good, and SIGTERM never leads
  to an exit. serve() now returns 2 s after its work is done whatever the
  open connections are doing. tests/serve.rs leaves a request half sent
  and checks that serve returns.
- fmt().init() also bridges `log` records, and at trace tungstenite logs
  every frame it sends or receives in full. log_filter() builds the
  filter from RUST_LOG and always turns tungstenite and tokio_tungstenite
  off, so no RUST_LOG value prints link or relay frames.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Worker opens a link with Authorization: Nostr <base64 event>, a kind
27235 event signed by a dedicated gateway-auth key that is never a relay
member. buzz_auth::verify_nip98_event checks the kind, the signature, the
60 s window, one u (the configured link URL) and one method (GET); the
gateway then requires the pinned pubkey and exactly one nonce of 16 to
128 characters.

- Each bearer works once: its event id is claimed in an in-memory replay
  cache for the relay's 120 s TTL. Only a bearer that passed every other
  check uses a slot, and a full cache refuses new bearers with 503 rather
  than forgetting old ones.
- Refusals answer 401 {"error":"unauthorized"} and log only a reason code
  (missing, malformed, invalid, wrong_key, no_nonce, replayed), never the
  header or the verifier's text, which quotes bearer content.
- At most four links at once; the frame loop lands in a later part, so a
  good bearer gets 501 for now.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fix for part 2. The bearer window is inclusive and counted in
whole seconds, so a bearer dated 60 s ahead is valid for up to 121 s,
while the cache let its id go after exactly 120 s. Ids are now kept for
122 s, and the unit test checks that an id is still refused at 120 s.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ept it

The 122 s monotonic hold covers the inclusive whole-second window, but a
backward wall-clock step after the claim could make the verifier accept a
bearer the cache had already forgotten. Each entry now also stays until the
wall clock is past its created_at plus the window.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every frame is one JSON object in a WebSocket text frame, with a type
field. Worker to gateway: http, ws_open, ws_send, ws_close, ping, pong.
Gateway to Worker: hello, http_res, ws_opened, ws_msg, ws_closed, error,
rotate, ping, pong. Bodies travel as standard base64 so they arrive byte
for byte, and relay socket text travels as a JSON string, sent as is.

- Inbound frames parse through one flat struct, because serde_json's
  RawValue does not survive internally tagged enums. That keeps the raw
  headers object, so the policy still sees a duplicated key.
- An unknown type maps to Unknown (the link stays open), even when it
  reuses a known field name with another type. A known type with a
  missing or mistyped field is BadFrame.
- Request and socket ids are 1 to 64 of [A-Za-z0-9_-], so they are safe
  to log.
- The frame types that carry headers, bodies or relay text do not derive
  Debug, so they cannot reach a log line by accident.
- Every error code has a fixed message that never quotes frame content.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fix for part 3. Inbound::parse read every frame into one flat
struct, so each field name had one type for all frame types. A ping
carrying "code":"later" (a ws_close field) failed to parse, got
bad_frame, and closed the link with 1008, although the protocol says
unknown fields are ignored and new optional fields stay v1.

parse() now reads `type` first, then deserializes the text as that type
alone: HttpFrame for http (RawValue needs a plain struct), and a
serde-tagged Inbound for the rest, with #[serde(other)] for unknown
types. A duplicated top-level key is still bad_frame. The unit tests
gain foreign-field cases for ping, pong, ws_send and http.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A good bearer now upgrades to the link WebSocket. The link sends hello
(version 1, link_id, expiry and the limits), answers ping with pong, and
pings the Worker every 20 s. HTTP and relay socket frames answer
not_supported until the next parts land.

- Lifetime: rotate 5 minutes before the hour, then close 4001, so the
  Worker's reconnect path runs every hour and one bearer never lasts long.
- Idle: no Worker frame for 60 s closes 4002.
- Backpressure: frames for the Worker go into a queue bounded at 1,024
  frames and 16 MiB of JSON, with try_send only. Nothing waits on it; when
  it fills, the Worker is not reading and the link closes 4003.
- Protocol: a binary frame closes 1003 (reserved for the Phase 2 audio
  bridge), a malformed frame gets bad_frame then 1008, a frame over 2 MiB
  closes 1009, and an unknown type gets unknown_type with the link open.
- Shutdown: /v1/link and /healthz answer 503, links close 1012, and serve
  keeps accepting until every tracked link has closed.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An http frame now becomes exactly one request to the pinned relay origin,
on a path taken from the route table, never from frame text. The decoded
body_b64 bytes go out as the body and the response bytes come back
base64-encoded as they arrived, so a NIP-98 payload tag the Worker signed
still verifies at the relay.

- The client never follows redirects (a 3xx comes back as a status, its
  location dropped), ignores proxy env, never decompresses, and is
  https-only for a wss relay. It sets only user-agent; nothing can add
  X-Forwarded-*, X-Pubkey, cookies, Origin or Accept-Encoding, or move
  Host.
- No retries, ever: a retry would burn the NIP-98 event id. A 429 and its
  body pass through untouched.
- Limits: 16 requests in flight per link (busy beyond), a 1 MiB request
  body, a 4 MiB response read in chunks, 10 s to connect and 30 s per
  request (upstream_connect, upstream_timeout).
- Shutdown gives in-flight requests up to 10 s while new ones get
  shutting_down, then closes the link 1012.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nect

Review fix for part 5. reqwest marks an expired connect_timeout as a
timeout too, so a relay that never answered the TCP or TLS handshake was
reported as upstream_timeout. That code means a request may have reached
the relay; upstream_connect means it did not. A connect error is now
upstream_connect whatever its cause. The test for it lands in part 7,
which has room for it.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ws_open now opens one relay WebSocket on the pinned wss origin plus /,
with only a user-agent header; no Worker header is forwarded, because
NIP-42 runs in-band. The relay's ["AUTH", challenge] reaches the Worker as
an ordinary ws_msg, and the Worker's signed reply goes back with ws_send.
The gateway parses no Nostr frames: it only times the AUTH exchange by
prefix (auth_challenge_ms, auth_reply_ms), since the relay closes a socket
5 s after its challenge.

- Every socket reports exactly one ws_closed, with by (relay, gateway or
  worker), the close code, and http_status when the upgrade was refused.
- The gateway keeps reading every relay socket, so the relay's pings are
  answered at once. A relay frame over 1 MiB, 90 s of relay silence, or 64
  Worker frames the relay is not taking close that one socket.
- ws_send text over 512 KiB gets frame_too_large and the socket stays
  open, so one bad frame cannot drop every subscription on it.
- At most 8 sockets per link. The huddle audio path is reserved for the
  Phase 2 bridge and answers not_supported.
- No relay socket outlives its link: on any link end each socket closes
  (1001 on shutdown, after in-flight HTTP drains), reports ws_closed, and
  anything still running after 2 s is aborted. The gateway never reopens
  a socket or replays a REQ; reconnects are the Worker's job.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dockerfile.relay-gateway builds a static musl binary on Alpine and runs
it on distroless static as the nonroot user, on port 8080, which is what
a Cloudflare Container needs (linux/amd64). Both base images are pinned
by digest. The image env holds only the bind address and RUST_LOG (plus
the base image's PATH and SSL_CERT_FILE); the three relay and link values
come from the Durable Object's start() env, and none of them is secret.
The binary is PID 1, so it handles SIGTERM itself. The image is about
6 MB compressed.

tests/logs.rs runs one HTTP forward and one socket AUTH whose bearer,
body and frames all carry a marker, at TRACE level, and checks that the
captured log holds neither the marker nor "Nostr ". It is its own test
binary: tracing caches callsite interest process-wide, so a scoped
subscriber misses events while other tests run on other threads.

The README states the trust boundary, the environment and how to test.
The tests are not in the Justfile unit-test list yet, so CI does not run
them.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes for parts 5 to 7. They sit here because parts 5 and 6 are
at the PR size limit.

- A Worker frame's write to the relay ran inside the socket loop's
  select! arm, so a relay that stopped reading stalled the whole task:
  the queue overflow, the silence timer and the link's end went unseen,
  and later ws_send frames were dropped with no ws_closed. The write now
  races those three, so the socket still closes with backpressure,
  relay_silent or link_closed.
- When the relay closed a socket, the task returned at once and dropped
  tungstenite's queued close reply. It now flushes it (1 s cap), so the
  relay gets its close echoed, as RFC 6455 5.5.1 asks.
- tests/http.rs checks that a relay that never finishes the TLS
  handshake gets upstream_connect (the part 5 fix).
- tests/logs.rs installs the global subscriber through log_filter("trace")
  as main.rs does, `log` bridge included, and checks that a dependency's
  `log` record arrives. Without the tungstenite pins it now fails.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Workspace membership only buys clippy and check; the unit lane lists each
crate it tests. nextest gives each test its own process, which tests/logs.rs
needs for its global subscriber.

Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each Huddle transcript is a kind:9 event. The local user signs this
event. Thus, do not send audio from other participants to the STT
pipeline. If the desktop transcribes remote audio, it incorrectly
identifies the remote speaker as the local user. This error occurs on
each desktop that receives the audio.

Remove the remote-human transcription path. Do not filter remote
transcripts before publication. The STT pipeline now receives audio only
from the local microphone. This design also prevents Parakeet from
processing remote audio that the desktop cannot publish correctly.

Known regression: legacy text-only agents no longer receive transcripts
of speech from mobile participants. These agents cannot respond to that
speech until an agent audio runtime is available. An agent that joins
the Opus room directly does not depend on transcripts and is therefore
unaffected, but no such agent exists yet.

Signed-off-by: hr-o <126079238+hr-o@users.noreply.github.com>
(cherry picked from commit afad635)

Fork note: taken unchanged from upstream PR 7043 as the huddle-voices
slice 0 stopgap. Applies cleanly on fork main 68fdecf; the applied
diff is identical to upstream's, so an upstream sync sees the same change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ustfile

Editing the Justfile matches CI's desktop path filter, so this PR kept
pulling in the desktop e2e suite and its flakes. The Unit Tests job now
runs the same `cargo nextest run -p buzz-relay-gateway` as its own step,
and the PR no longer touches the Justfile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: QuicksilverSlick <russelledeming@gmail.com>
Slice 3a of the huddle live-voices design (part 1 of 3; the input
module follows on stacked branches).

- New crate crates/buzz-huddle-bridge, built on buzz-audio-client. It
  joins the workspace as a path dev-dependency of buzz-acp, so the root
  Cargo.toml is unchanged. A dev-dependency keeps Opus and cmake out of
  the buzz-acp release, cross (musl) and sprig builds until slice 8a
  uses the crate and moves it to [dependencies].
- join: the audio upgrade as a Request (headers such as a NIP-FI
  assertion can be added later), the kind 22242 answer signed by the
  agent with its NIP-OA owner tag (the tags buzz-acp's relay auth
  uses), and buzz_audio_client's connect, which sends
  wire::PROTOCOL_VERSION. A refusal (including upgrade_required) is
  logged with that version.
- frames: every v1 frame between the PC bridge and Talos /df/voice as
  serde types, frames-v1/schema.json, and one golden fixture per frame
  (27) for Talos to copy. Tests round-trip every fixture, check the
  schema names the same frames and fields, and refuse other versions.
- Windows CI runs cargo test -p buzz-huddle-bridge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the dialed request

- frames-v1: split into ToTalos and ToPc, refuse unknown fields; the schema
  gets to_talos/to_pc defs with unevaluatedProperties false. Tests walk the
  schema for every fixture, check enums against Rust, and require every
  declared field.
- The contract now states its clocks (secret windows widened by a skew
  margin and applied on receipt), that no live PCM goes between replay and
  replay_end, and the open/resume binding to the NIP-98 signer.
- join: the NIP-42 relay tag comes from the request's origin, so it always
  names the host that issued the challenge; auth_event is private; the
  CryptoProvider precondition is documented.
- just test-unit runs cargo test -p buzz-huddle-bridge on Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Slice 3a, part 2 of 3 (stacked on the crate and frames-v1 commit,
PR #94).

Input turns relay room audio into what one agent voice may hear:
- per-peer Opus decode with buzz-audio-client's PeerJitterBuffer;
- the peer index to pubkey map from the relay's joined, roster and
  left messages, dropping a slot whose occupant changed;
- a caller-supplied policy: the agent set (bot roles, trusted 30177
  records, Talos, this agent) always wins, then the owner and the
  pubkeys the agent's author gate admits; everyone else is dropped
  before decode;
- speech from decoded PCM energy, never the client-written level_dbov
  byte, with an onset frame for every admitted human so a quiet voice
  can wake;
- one human at a time with a 300 ms hangover, a speaker frame at each
  change, continuous 20 ms PCM16 chunks while awake (silence when
  nobody holds the floor), and a send log mapping session ms to the
  peer so transcript deltas can be attributed;
- 48 to 24 kHz by plain decimation for now; part 3 adds the half-band
  low-pass in front of it.

Frames out are ToTalos (onset, speaker, roster).

Tests feed real Opus frames: forged level bytes are ignored, agents
and unadmitted peers are never heard, and the floor holds and the send
log names each span.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Slice 3a, part 3 of 3 (stacked on the room input commit, part 2).

- 48 to 24 kHz now goes through a 31-tap half-band low-pass in front
  of the decimation, so nothing above 12 kHz aliases into speech.
- Each admitted peer keeps an 8 s ring of 24 kHz audio. replay()
  returns a span of it for a wake, and a new floor starts with the
  onset's last 100 ms from the ring, so the first syllable is not
  clipped.
- The ring is bounded by age on every tick (the count cap stays as a
  backstop), and replay() takes the caller's clock too, so sparse
  ticks or a late replay never hand out audio older than 8 s. An entry
  stamped ahead of the clock (the clock stepped back) is dropped too.
- Audio heard while half-duplex suppression is on never enters the
  ring, so a replay or preroll can't carry the voice's own echo.
- secret {from_ms, to_ms} is widened by secret_skew_ms (1 s to start)
  on both sides and starts dropping on receipt, even if from_ms is
  still ahead on the PC's clock, as the frames-v1 docs promise. The
  window leaves every ring, the <=10 ms not yet sent is silenced, and
  audio inside it is neither recorded nor forwarded.
- The desktop's route file {isolated, huddle, updated_at} is read for
  one huddle; a missing file, bad JSON or another huddle means not
  isolated. It names the owner's desktop only while the owner has one
  peer in the room; every other peer counts as not isolated.
- Half-duplex: while the voice's own output plays plus a tail (300 ms
  for the desktop, 600 ms elsewhere until probe P measures the phone),
  a non-isolated peer gets no onset and no forwarded audio, and a span
  that starts in that window carries echo: true for its whole length.

Tests: sparse ticks never replay audio older than the ring, echo-time
audio is never in the ring, a secret window is widened by the skew and
starts on receipt, and the resampler passes 1 kHz and stops 20 kHz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From another pod the relay's `joined` lists only the newcomer, so treating
it as a full snapshot dropped every other peer, the owner included. Now
`joined` adds or replaces its peers and only `roster` replaces the map.

A second `awake` while awake cleared up to 10 ms of audio the session
clock had already counted and restarted the span. It is now a no-op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ho-ring-v2

Brings part 2's review fixes (joined as an upsert, a repeated awake as a
no-op) under part 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…closed, replay on the clock

Review fixes for the ring, secret windows and half-duplex:

- A tick gap over 50 ms, a clock step back or a secret window's end now
  restarts every peer's jitter buffer and filter, so audio that arrived
  inside a window or an echo is never played out late.
- A secret window not yet over holds even if the clock steps back.
- read_route takes now and refuses a file more than 15 s old (three of
  the desktop's heartbeats). It still needs `huddle`, so a file from
  another huddle or without one counts as not isolated.
- replay() answers replay_req itself: replay, PCM, replay_end. It pads
  pending live audio to a chunk first, counts the replay on the session
  clock and in the send log, and sends nothing while the voice is quiet.
- A talker in its echo, or one with no pause for floor_cap_ms (30 s),
  yields the floor to any other talker. An onset that fell in the echo
  goes out once the echo clears.

The previous commit said nothing above 12 kHz aliases. The half-band
filter takes about 30 dB off 14 kHz and 50 dB off 16 kHz and up, and the
test now checks that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@QuicksilverSlick
QuicksilverSlick merged commit 1f1e0fa into main Oct 31, 2026
77 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants