diff --git a/.github/workflows/mesh-lifecycle.yml b/.github/workflows/mesh-lifecycle.yml new file mode 100644 index 0000000000..4780083ba4 --- /dev/null +++ b/.github/workflows/mesh-lifecycle.yml @@ -0,0 +1,111 @@ +name: Mesh Lifecycle +# Relay-driven mesh lifecycle smoke: membership → signed discovery notes → +# relay-derived allowlist → join → CPU inference over QUIC → stranger denied +# (relay membership rejection + no routed inference, with a differential +# trusted-inference health proof so a dead serve node can't fake a denial). +# Runs the full Buzz "shared compute" join story with three real mesh-llm +# node processes on one runner, using the Buzz relay as the control plane +# (no hand-carried invite tokens). Mirrors the shape mesh-llm's own CI uses +# for its two-node smokes (tiny CPU model, one runner, real QUIC mesh). + +on: + push: + branches: [main] + paths: + - 'crates/buzz-relay/examples/mesh_*.rs' + - 'crates/buzz-relay/Cargo.toml' + - 'crates/buzz-admin/**' + - 'crates/buzz-test-client/**' + - 'crates/buzz-ws-client/**' + - 'Cargo.lock' + - 'desktop/src-tauri/src/mesh_llm/**' + - 'scripts/ci-mesh-lifecycle-smoke.sh' + - 'scripts/start-relay-for-tests.sh' + - '.github/workflows/mesh-lifecycle.yml' + pull_request: + paths: + - 'crates/buzz-relay/examples/mesh_*.rs' + - 'crates/buzz-relay/Cargo.toml' + - 'crates/buzz-admin/**' + - 'crates/buzz-test-client/**' + - 'crates/buzz-ws-client/**' + - 'Cargo.lock' + - 'desktop/src-tauri/src/mesh_llm/**' + - 'scripts/ci-mesh-lifecycle-smoke.sh' + - 'scripts/start-relay-for-tests.sh' + - '.github/workflows/mesh-lifecycle.yml' + workflow_dispatch: + +concurrency: + group: mesh-lifecycle-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + +jobs: + lifecycle-smoke: + name: Relay-Driven Mesh Lifecycle Smoke + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + save-if: ${{ github.event_name != 'pull_request' }} + + # The mesh-llm SDK downloads a signed native runtime (llama.cpp CPU + # build) on first init, and the serve node downloads the smoke model + # from HuggingFace on first run. Key on the lockfile so a mesh pin bump + # rolls the runtime cache; the model ref is stable. + - name: Restore mesh runtime + model caches + id: mesh-caches + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cache/mesh-llm/native-runtimes + ~/.cache/huggingface/hub + key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }} + restore-keys: | + mesh-lifecycle-${{ runner.os }}-smollm2-135m- + + - name: Start integration services + run: | + for attempt in 1 2 3; do + if docker compose up -d postgres redis minio minio-init; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "docker compose up failed after 3 attempts" >&2 + exit 1 + fi + echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 + sleep $((attempt * 5)) + done + + - name: Run relay-driven mesh lifecycle smoke + run: ./scripts/ci-mesh-lifecycle-smoke.sh 2>&1 | tee /tmp/mesh-lifecycle-harness.log + + - name: Save mesh runtime + model caches + if: github.ref == 'refs/heads/main' && steps.mesh-caches.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cache/mesh-llm/native-runtimes + ~/.cache/huggingface/hub + key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }} + + - name: Upload relay + harness logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mesh-lifecycle-logs + path: | + /tmp/buzz-relay.log + /tmp/mesh-lifecycle-harness.log + if-no-files-found: ignore diff --git a/Cargo.lock b/Cargo.lock index 937ead564a..73ecb249d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1196,11 +1196,13 @@ dependencies = [ "buzz-relay-mesh", "buzz-sdk", "buzz-search", + "buzz-test-client", "buzz-workflow", "bytes", "chrono", "dashmap", "deadpool-redis", + "ed25519-dalek", "flate2", "futures", "futures-util", diff --git a/Justfile b/Justfile index c3d755ffeb..0a43249d5f 100644 --- a/Justfile +++ b/Justfile @@ -212,9 +212,10 @@ desktop-tauri-test: _ensure-sidecar-stubs desktop-terminal-performance-test: cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture -# Verify compiled-flag behavior under both compile states (clean + internal). -# Runs the auto-connect compiled-flag test twice with independently supplied -# expected values; build.rs rerun-if-env-changed triggers recompilation. +# Verify compiled-flag behavior under both compile states (clean + capability set). +# Runs the auto-connect and owner-only access focused tests twice with +# independently supplied expected values; build.rs rerun-if-env-changed +# triggers recompilation. desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail @@ -223,10 +224,22 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \ cargo test compiled_flag_matches_expected -- --ignored --nocapture - echo "=== Internal build (flag set) → expect true ===" + env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \ + cargo test --lib + env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \ + cargo test compiled_policy_matches_expected -- --ignored --nocapture + echo "=== Internal build (flags set) → expect true ===" BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \ cargo test compiled_flag_matches_expected -- --ignored --nocapture + BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \ + cargo test --lib + BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \ + cargo test compiled_policy_matches_expected -- --ignored --nocapture echo "Both compiled states verified." # Build the full desktop Tauri app locally (unsigned, for testing) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 0c4e5f158c..65c9dd6203 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -361,50 +361,242 @@ async fn check_sibling_via_profile( false } -const OBSERVER_PUBLISH_INTERVAL: Duration = Duration::from_millis(167); -const OBSERVER_PUBLISH_LIMIT_PER_MINUTE: usize = 90; +/// Observer frames are published at a global rate of AT MOST ONE relay frame +/// per tick — not one per channel, and not one per drain. Everything that +/// accumulates between ticks waits in [`ObserverPublishQueue`] as events and +/// is packed greedily into that single frame. One update per second is smooth +/// enough for a human watching the session viewer, and the global budget is +/// what makes the relay cost model flat: observer frames bill the agent's +/// `LimitType::Messages` quota (`agent_standard_messages_per_min` = 120, +/// enforced in relay `connection.rs::enforce_ws_admission`), shared with the +/// agent's real chat messages. At 1 frame/s telemetry spends at most 60/min — +/// half that budget — regardless of how many channels are active. A slower +/// tick (e.g. 2s → 30/min) would leave more quota headroom for chat at the +/// price of doubled viewer latency; this constant is the knob. +const OBSERVER_PUBLISH_TICK: Duration = Duration::from_secs(1); + +/// Byte budget for EVERYTHING retained while awaiting a publish slot: the +/// event FIFO (serialized, post-`fit_observer_event_to_budget` bytes) PLUS +/// the chunk coalescer's pending buffer (serialized event skeletons + raw +/// accumulated text). Both stores count against this one cap — a +/// high-cardinality chunk flood (many distinct coalescer keys) is bounded +/// exactly like a plain event flood; neither buffer is a bypass around the +/// other. Lossless-ness is bounded by this budget: each publish slot packs +/// one ~64KB frame, gathered queue-wide for the front channel, so a single +/// channel drains at ~64KB/s and 4 MiB buys roughly **64 seconds** of +/// sustained over-production before the oldest items are dropped WITH +/// accounting (a warn carrying the dropped-event count). With C channels +/// producing concurrently the slots round-robin between them, so the +/// per-channel drain is ~64KB/Cs and the budget shortens accordingly — +/// still bytes-per-slot, never events-per-slot (see +/// [`ObserverPublishQueue::next_frame`]). Beyond-budget floods therefore +/// degrade to designed, visible loss — strictly better than the +/// pre-batching pacer's silent 90/min drop. +const OBSERVER_PENDING_QUEUE_MAX_BYTES: usize = 4 * 1024 * 1024; + +/// Observer event kind for a batch envelope wrapping multiple events. +/// +/// The payload is `{"events": [, ...]}` with every inner event +/// carrying its own `seq`/`timestamp`, so consumers process inner events +/// exactly as they would unbatched ones. Single pending events are published +/// unwrapped, so the envelope only appears when there is something to batch. +const OBSERVER_BATCH_KIND: &str = "batch"; -struct ObserverPublishPacer { - next_publish: tokio::time::Instant, - published: VecDeque, +/// Collects observer events awaiting a publish slot. +/// +/// Chunk-type events ride the [`ObserverChunkCoalescer`]; everything else is +/// appended in arrival order, force-flushing pending chunks first — the same +/// ordering rule the pre-batching publisher enforced, so merged chunk text can +/// never leapfrog a tool call that arrived mid-stream. +/// +/// Events wait here as EVENTS, not pre-sealed frames: each publish slot packs +/// one frame at publish time ([`Self::next_frame`]), so a backlog keeps +/// compacting into full frames instead of freezing into a frame queue. +/// +/// The queue is bounded by [`OBSERVER_PENDING_QUEUE_MAX_BYTES`]. When a +/// sustained flood outruns the one-frame-per-tick drain for longer than the +/// budget, the OLDEST events are dropped (the viewer wants recent state) with +/// accounting: a warning carrying the dropped-event count, and +/// `dropped_events` for tests. +#[derive(Default)] +struct ObserverPublishQueue { + coalescer: ObserverChunkCoalescer, + /// `(serialized_len, source_events, event)`, oldest first. Length is + /// captured at enqueue (post-fit) so byte accounting never re-serializes + /// on eviction; `source_events` is how many GENERATED observer events the + /// entry represents (a merged chunk carries every chunk it absorbed), so + /// eviction accounting stays in source units after flush. + events: VecDeque<(usize, u64, observer::ObserverEvent)>, + pending_bytes: usize, + /// SOURCE observer events lost to byte-budget eviction. Counted in + /// generated-event units, not retained entries: a coalesced entry that + /// merged N chunks accounts for N when evicted. A PUBLISHED merged entry + /// delivers all N sources' text in one event, so the invariant is + /// `ingested == dropped_events + Σ source_events over published events`. + dropped_events: u64, } -impl ObserverPublishPacer { - fn new() -> Self { - Self { - // No initial burst: even the first snapshot frame waits for its slot. - next_publish: tokio::time::Instant::now() + OBSERVER_PUBLISH_INTERVAL, - published: VecDeque::with_capacity(OBSERVER_PUBLISH_LIMIT_PER_MINUTE), +impl ObserverPublishQueue { + fn ingest(&mut self, event: observer::ObserverEvent) { + // ObserverChunkCoalescer::ingest returns immediately-publishable events + // (force-flushed pending chunks + non-chunk passthrough, or a pending + // set displaced by the 60KB pre-flush); they join the queue in the + // order the coalescer emitted them, each carrying the count of source + // events it represents. + for (source_events, ready) in self.coalescer.ingest(event) { + self.enqueue(source_events, ready); } + self.enforce_byte_budget(); } - async fn wait(&mut self) { - loop { - let now = tokio::time::Instant::now(); - while self - .published - .front() - .is_some_and(|sent| now.duration_since(*sent) >= Duration::from_secs(60)) - { - self.published.pop_front(); + fn enqueue(&mut self, source_events: u64, mut event: observer::ObserverEvent) { + // Pre-trim at enqueue so (a) byte accounting reflects what will ship + // and (b) one oversized leaf cannot force every frame it touches into + // whole-envelope elision downstream. + fit_observer_event_to_budget(&mut event); + let bytes = serialized_len(&event); + self.pending_bytes += bytes; + self.events.push_back((bytes, source_events, event)); + } + + /// Total bytes retained across BOTH stores — the event FIFO and the + /// coalescer's pending chunk buffer. The budget binds this sum; counting + /// only the FIFO would let a high-cardinality chunk flood (many distinct + /// coalescer keys, nothing ever flushing) grow unbounded outside the cap. + fn total_pending_bytes(&self) -> usize { + self.pending_bytes + self.coalescer.pending_bytes + } + + /// Enforce [`OBSERVER_PENDING_QUEUE_MAX_BYTES`] over the total, dropping + /// OLDEST items first with accounting in SOURCE-event units. Global age + /// order across the two stores is structural: every enqueue path flushes + /// the coalescer first, so every pending coalescer entry is strictly newer + /// than every queued event — eviction is queue front, then coalescer + /// front. The `> 1` guard never drops the sole remaining item (any single + /// fitted event or pre-flush-capped chunk entry is far under the budget). + fn enforce_byte_budget(&mut self) { + let mut dropped = 0u64; + while self.total_pending_bytes() > OBSERVER_PENDING_QUEUE_MAX_BYTES + && self.events.len() + self.coalescer.pending.len() > 1 + { + if let Some((bytes, source_events, _)) = self.events.pop_front() { + self.pending_bytes -= bytes; + dropped += source_events; + } else { + dropped += self.coalescer.drop_oldest().expect("guard ensures an item"); } + } + if dropped > 0 { + self.dropped_events += dropped; + tracing::warn!( + dropped, + total_dropped = self.dropped_events, + pending_bytes = self.total_pending_bytes(), + "observer publish queue over byte budget; dropped oldest events" + ); + } + } - let minute_slot = self.published.front().and_then(|sent| { - (self.published.len() >= OBSERVER_PUBLISH_LIMIT_PER_MINUTE) - .then_some(*sent + Duration::from_secs(60)) - }); - let publish_at = - minute_slot.map_or(self.next_publish, |slot| slot.max(self.next_publish)); - if publish_at > now { - tokio::time::sleep_until(publish_at).await; - continue; - } + /// True when nothing is waiting anywhere — the event queue AND the + /// coalescer's pending chunk buffer. + fn is_empty(&self) -> bool { + self.events.is_empty() && self.coalescer.pending.is_empty() + } - let published_at = tokio::time::Instant::now(); - self.published.push_back(published_at); - self.next_publish = published_at + OBSERVER_PUBLISH_INTERVAL; - return; + /// Pack and remove AT MOST ONE publishable frame: the front event's + /// channel, gathered queue-wide in FIFO order (packed greedily until + /// adding the next event would push the envelope over + /// `OBSERVER_MAX_PLAINTEXT_LEN`). Singletons ship unwrapped. + /// + /// Two invariants bound the gather: + /// - A frame never mixes channels (the desktop archive indexes a frame + /// under its decrypted top-level `channelId`), and events keep their + /// FIFO order *within* each channel. Cross-channel frame order MAY + /// differ from arrival order — the desktop tolerates that everywhere: + /// the transcript store sorts + rebuilds on out-of-order arrival, the + /// archive is per-channel by construction, and the turn store's + /// watermark is keyed per (agent, channel). + /// - A NULL-channel event is a BARRIER nothing gathers across: null-scope + /// events (`agent_panic`-class) can causally couple to any channel, so + /// their relative order against every channel is preserved exactly. + /// Null-channel events themselves ship only as their contiguous front + /// run. + /// + /// Gathering queue-wide (not just the front run) is what keeps the drain + /// rate in BYTES per slot rather than front-run-length events per slot: + /// with round-robin producers (channel A, B, A, B, ...) a front-run + /// packer degrades to ~1 event per slot regardless of size, silently + /// growing latency without ever tripping the byte budget. + /// + /// Pending coalesced chunks are flushed into the queue first, so a + /// publish slot never leaves merged chunk text stranded behind the tick. + fn next_frame(&mut self) -> Option { + for (source_events, ready) in self.coalescer.flush() { + self.enqueue(source_events, ready); } + let channel = self.events.front()?.2.channel_id.clone(); + + let mut picked: Vec = Vec::new(); + let mut kept: VecDeque<(usize, u64, observer::ObserverEvent)> = + VecDeque::with_capacity(self.events.len()); + let mut gathering = true; + while let Some((bytes, source_events, event)) = self.events.pop_front() { + if gathering && event.channel_id == channel { + picked.push(event); + if picked.len() > 1 + && serialized_len(&batch_envelope(&picked)) > OBSERVER_MAX_PLAINTEXT_LEN + { + // Frame full: the overflow event stays queued and leads + // its channel's next slot. + let event = picked.pop().expect("len > 1"); + kept.push_back((bytes, source_events, event)); + gathering = false; + } else { + self.pending_bytes -= bytes; + } + } else { + if gathering && (channel.is_none() || event.channel_id.is_none()) { + // Null-channel barrier (or, for a null-channel frame, the + // end of its contiguous front run): stop gathering. + gathering = false; + } + kept.push_back((bytes, source_events, event)); + } + } + self.events = kept; + Some(seal_batch(picked)) + } +} + +/// A single event ships unwrapped; two or more get the batch envelope. +fn seal_batch(mut events: Vec) -> observer::ObserverEvent { + if events.len() == 1 { + return events.pop().expect("len == 1"); + } + batch_envelope(&events) +} + +/// Build the batch envelope for a set of same-channel events. +/// +/// Envelope metadata mirrors the LAST inner event — the same convention the +/// chunk coalescer uses for merged chunks — so `(timestamp, seq)` ordering and +/// the desktop's latest-live-session tracking see the newest state. +fn batch_envelope(events: &[observer::ObserverEvent]) -> observer::ObserverEvent { + let last = events + .last() + .expect("batch envelope needs at least 1 event"); + observer::ObserverEvent { + seq: last.seq, + timestamp: last.timestamp.clone(), + kind: OBSERVER_BATCH_KIND.to_string(), + agent_index: last.agent_index, + channel_id: last.channel_id.clone(), + session_id: last.session_id.clone(), + turn_id: last.turn_id.clone(), + started_at: last.started_at.clone(), + payload: serde_json::json!({ + "events": serde_json::to_value(events).unwrap_or_default(), + }), } } @@ -445,29 +637,26 @@ async fn run_relay_observer_publisher( owner_pubkey_hex: String, owner_pubkey: PublicKey, ) { - let mut coalescer = ObserverChunkCoalescer::default(); - let mut pacer = ObserverPublishPacer::new(); + let mut queue = ObserverPublishQueue::default(); let max_snapshot_seq = snapshot.iter().map(|event| event.seq).max().unwrap_or(0); for event in snapshot { - for event in coalescer.ingest(event) { - publish_relay_observer_event( - &publisher, - &keys, - &agent_pubkey_hex, - &owner_pubkey_hex, - &owner_pubkey, - &mut pacer, - event, - ) - .await; - } + queue.ingest(event); } - let mut flush_interval = tokio::time::interval(std::time::Duration::from_millis(500)); - flush_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Global pacer: AT MOST ONE relay frame per tick, no matter how many + // channels are active or how large the backlog is. `interval_at` starts + // the first tick a full period out, so a pre-loaded snapshot (up to the + // 1,000-event replay buffer on reconnect) cannot burst at t=0 — the old + // pacer's explicit "no initial burst" property, restored. + let mut publish_tick = tokio::time::interval_at( + tokio::time::Instant::now() + OBSERVER_PUBLISH_TICK, + OBSERVER_PUBLISH_TICK, + ); + publish_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut closed = false; loop { tokio::select! { - result = rx.recv() => { + result = rx.recv(), if !closed => { match result { Ok(event) => { // Skip live events already delivered via the snapshot @@ -475,41 +664,30 @@ async fn run_relay_observer_publisher( if event.seq <= max_snapshot_seq { continue; } - for event in coalescer.ingest(event) { - publish_relay_observer_event( - &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, &mut pacer, event, - ).await; - } + queue.ingest(event); } Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { - for event in coalescer.flush() { - publish_relay_observer_event( - &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, &mut pacer, event, - ).await; - } tracing::warn!(dropped = count, "relay observer publisher lagged"); } Err(tokio::sync::broadcast::error::RecvError::Closed) => { - for event in coalescer.flush() { - publish_relay_observer_event( - &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, &mut pacer, event, - ).await; - } - break; + // Producer gone: stop selecting on the receiver and let + // the tick arm drain what remains — still one frame per + // tick. An unpaced final drain would be a burst bypass + // around everything the pacer exists to prevent. + closed = true; } } } - _ = flush_interval.tick() => { - // Periodic flush ensures live streaming even during continuous chunk delivery. - for event in coalescer.flush() { + _ = publish_tick.tick() => { + if let Some(frame) = queue.next_frame() { publish_relay_observer_event( &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, &mut pacer, event, + &owner_pubkey_hex, &owner_pubkey, frame, ).await; } + if closed && queue.is_empty() { + break; + } } } } @@ -518,12 +696,25 @@ async fn run_relay_observer_publisher( #[derive(Default)] struct ObserverChunkCoalescer { pending: Vec, + /// Approximate serialized bytes retained in `pending` (each entry's + /// serialized skeleton at creation plus appended chunk text). Counted + /// against [`OBSERVER_PENDING_QUEUE_MAX_BYTES`] by the owning + /// [`ObserverPublishQueue`] so this buffer can never grow outside the + /// queue's byte budget (a distinct-key chunk flood parks everything here + /// and nothing would otherwise bound it). + pending_bytes: usize, } struct PendingObserverChunk { key: ObserverChunkKey, event: observer::ObserverEvent, text: String, + /// Bytes this entry contributes to `pending_bytes`. + bytes: usize, + /// GENERATED observer events merged into this entry (1 at creation, +1 + /// per absorbed chunk). Evicting the entry loses this many source events, + /// so drop accounting must charge this count, not 1. + source_events: u64, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -544,10 +735,13 @@ struct ObserverChunkKey { const OBSERVER_CHUNK_MAX_TEXT_BYTES: usize = 60_000; impl ObserverChunkCoalescer { - fn ingest(&mut self, event: observer::ObserverEvent) -> Vec { + /// Returns immediately-publishable events, each paired with the number of + /// SOURCE observer events it represents (merged chunks carry the count of + /// every chunk they absorbed; passthrough events are always 1). + fn ingest(&mut self, event: observer::ObserverEvent) -> Vec<(u64, observer::ObserverEvent)> { let Some((key, text)) = observer_chunk_key_and_text(&event) else { let mut events = self.flush(); - events.push(event); + events.push((1, event)); return events; }; @@ -556,25 +750,64 @@ impl ObserverChunkCoalescer { if pending.text.len() + text.len() >= OBSERVER_CHUNK_MAX_TEXT_BYTES { let events = self.flush(); // Start a new pending entry with the current chunk. - self.pending.push(PendingObserverChunk { key, event, text }); + self.push_pending(key, event, text); return events; } pending.text.push_str(&text); + pending.bytes += text.len(); + pending.source_events += 1; + self.pending_bytes += text.len(); pending.event.seq = event.seq; pending.event.timestamp = event.timestamp; return Vec::new(); } - self.pending.push(PendingObserverChunk { key, event, text }); + self.push_pending(key, event, text); Vec::new() } - fn flush(&mut self) -> Vec { + fn push_pending( + &mut self, + key: ObserverChunkKey, + event: observer::ObserverEvent, + text: String, + ) { + // The entry RETAINS the first chunk's text twice until flush: once + // inside the serialized skeleton (`event.payload` still carries it) + // and once as the extracted `text` copy that appends grow. Both are + // real memory, so both count — charging only `serialized_len` lets a + // high-cardinality flood retain up to 2x the byte budget (each entry + // undercounts by exactly its first chunk's length). + let bytes = serialized_len(&event) + text.len(); + self.pending_bytes += bytes; + self.pending.push(PendingObserverChunk { + key, + event, + text, + bytes, + source_events: 1, + }); + } + + /// Evict the OLDEST pending entry for byte-budget enforcement. Returns + /// the number of SOURCE events the entry represented (its merged chunk + /// count), or `None` when there is nothing to drop. + fn drop_oldest(&mut self) -> Option { + if self.pending.is_empty() { + return None; + } + let removed = self.pending.remove(0); + self.pending_bytes -= removed.bytes; + Some(removed.source_events) + } + + fn flush(&mut self) -> Vec<(u64, observer::ObserverEvent)> { + self.pending_bytes = 0; self.pending .drain(..) .map(|mut pending| { set_observer_chunk_text(&mut pending.event.payload, pending.text); - pending.event + (pending.source_events, pending.event) }) .collect() } @@ -793,10 +1026,8 @@ async fn publish_relay_observer_event( agent_pubkey_hex: &str, owner_pubkey_hex: &str, owner_pubkey: &PublicKey, - pacer: &mut ObserverPublishPacer, mut event: observer::ObserverEvent, ) { - pacer.wait().await; // Trim oversized frames to fit the plaintext cap rather than letting // encrypt_observer_payload reject and drop them whole (silent telemetry loss). fit_observer_event_to_budget(&mut event); @@ -4939,12 +5170,21 @@ mod observer_snapshot_race_tests { // The run loop has exited, dropping the publisher; drain the forwarded // events until the channel closes (deterministic — no try_recv race - // with the test_pair forwarding task). + // with the test_pair forwarding task). With per-tick batching the three + // events arrive inside batch envelopes (or unwrapped when a drain held + // exactly one event); unwrap both shapes. let mut markers = Vec::new(); while let Some(event) = published_rx.recv().await { let payload: serde_json::Value = decrypt_observer_payload(&owner_keys, &event).expect("decrypt published frame"); - markers.push(payload["payload"]["marker"].as_str().unwrap().to_string()); + match payload["payload"]["events"].as_array() { + Some(inner) => markers.extend( + inner + .iter() + .map(|e| e["payload"]["marker"].as_str().unwrap().to_string()), + ), + None => markers.push(payload["payload"]["marker"].as_str().unwrap().to_string()), + } } assert_eq!( markers, @@ -4955,36 +5195,865 @@ mod observer_snapshot_race_tests { } #[cfg(test)] -mod observer_publish_pacer_tests { +mod observer_publish_queue_tests { use super::*; + fn event(seq: u64, kind: &str, channel: Option<&str>) -> observer::ObserverEvent { + observer::ObserverEvent { + seq, + timestamp: format!("2026-04-29T04:00:{:02}Z", seq.min(59)), + kind: kind.to_string(), + agent_index: Some(0), + channel_id: channel.map(ToOwned::to_owned), + session_id: Some("session-1".to_string()), + turn_id: Some("turn-1".to_string()), + started_at: None, + payload: serde_json::json!({ "seq": seq }), + } + } + + fn queue_of(events: Vec) -> ObserverPublishQueue { + let mut queue = ObserverPublishQueue::default(); + for event in events { + queue.ingest(event); + } + queue + } + + /// Collect every frame the queue will produce, one publish slot at a time. + fn drain_frames(queue: &mut ObserverPublishQueue) -> Vec { + let mut frames = Vec::new(); + while !queue.is_empty() { + frames.push(queue.next_frame().expect("queue not empty")); + } + frames + } + + /// Inner seqs of a frame, whether it is an envelope or an unwrapped + /// singleton. + fn frame_seqs(frame: &observer::ObserverEvent) -> Vec { + match frame.payload.get("events").and_then(|v| v.as_array()) { + Some(inner) => inner.iter().map(|e| e["seq"].as_u64().unwrap()).collect(), + None => vec![frame.seq], + } + } + + /// Retained bytes computed by WALKING the entries, independently of the + /// queue's own accumulator. Cap regressions must assert on this, not on + /// `total_pending_bytes()` — asserting the counter against itself passed + /// while the process retained ~2x the budget (Sami/Max round 3: each + /// pending coalescer entry holds the first chunk's text twice, in the + /// serialized skeleton AND the extracted `text` copy). + fn walked_retained_bytes(queue: &ObserverPublishQueue) -> usize { + let fifo: usize = queue + .events + .iter() + .map(|(_, _, event)| serialized_len(event)) + .sum(); + let coalescer: usize = queue + .coalescer + .pending + .iter() + .map(|pending| serialized_len(&pending.event) + pending.text.len()) + .sum(); + fifo + coalescer + } + + /// The walker above is itself an instrument, and every cap test asks it + /// only for `<= CAP` — a blinded walker (missing an arm, or returning 0) + /// would satisfy all of them while hiding exactly the 2x overshoot it was + /// added to catch (Sami round 5, M17-M20). Pin it two-sided: it must SEE + /// the double retention, and it must agree with the accumulator EXACTLY + /// while both stores are non-empty — neither may drift. + #[test] + fn walked_retained_bytes_agrees_with_the_accumulator_exactly() { + fn chunk(seq: u64, message_id: &str, text: &str) -> observer::ObserverEvent { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": message_id, + "content": { "type": "text", "text": text }, + }, + }, + }); + e + } + + let text = "w".repeat(7_000); + let mut queue = ObserverPublishQueue::default(); + // One pending chunk: its text lives in the serialized skeleton AND + // the extracted copy, so a walker blind to either arm reads short. + queue.ingest(chunk(1, "message-a", &text)); + assert!( + walked_retained_bytes(&queue) >= 2 * text.len(), + "the walker must SEE the first chunk's text twice \ + (skeleton + extracted copy), got {}", + walked_retained_bytes(&queue) + ); + + // Populate BOTH stores: the non-chunk event flushes message-a into + // the FIFO and queues itself; fresh pending keys (plus a same-key + // append) rebuild the coalescer side. + queue.ingest(event(2, "tool_call", Some("chan-a"))); + queue.ingest(chunk(3, "message-b", &text)); + queue.ingest(chunk(4, "message-b", &text)); + queue.ingest(chunk(5, "message-c", &text)); + assert!( + !queue.events.is_empty() && !queue.coalescer.pending.is_empty(), + "both arms must be non-empty for the agreement check to bind" + ); + assert_eq!( + queue.total_pending_bytes(), + walked_retained_bytes(&queue), + "accumulator and entry-walk must agree exactly: neither may drift" + ); + } + + /// Two or more pending events for one channel ship as a single batch + /// envelope whose payload carries every inner event in arrival order. + #[test] + fn multiple_events_ship_as_one_envelope_in_order() { + let mut queue = queue_of(vec![ + event(1, "turn_started", Some("chan-a")), + event(2, "acp_read", Some("chan-a")), + event(3, "acp_write", Some("chan-a")), + ]); + + let frame = queue.next_frame().expect("one frame"); + assert!(queue.is_empty(), "one channel, one publish slot"); + assert_eq!(frame.kind, OBSERVER_BATCH_KIND); + assert_eq!(frame.seq, 3, "envelope mirrors the last inner event"); + assert_eq!(frame_seqs(&frame), [1, 2, 3], "arrival order preserved"); + let inner = frame.payload["events"].as_array().expect("events array"); + assert_eq!(inner[1]["kind"], "acp_read", "inner events keep their kind"); + } + + /// A single pending event is published unwrapped — no envelope, so + /// consumers that predate batching still understand quiet periods. + #[test] + fn a_single_event_stays_unwrapped() { + let mut queue = queue_of(vec![event(7, "turn_started", Some("chan-a"))]); + let frame = queue.next_frame().expect("one frame"); + assert!(queue.is_empty()); + assert_eq!(frame.kind, "turn_started"); + assert_eq!(frame.seq, 7); + } + + /// An empty queue yields no frame — a tick with nothing pending must not + /// publish anything. + #[test] + fn empty_queue_yields_no_frame() { + let mut queue = ObserverPublishQueue::default(); + assert!(queue.next_frame().is_none()); + assert!(queue.is_empty()); + } + + /// Frames never mix channels, and each channel's events keep their FIFO + /// order. Gathering is QUEUE-WIDE: the front event's channel collects its + /// events from anywhere in the queue (that is what keeps the drain rate + /// in bytes per slot under interleaving), so cross-channel frame order + /// MAY differ from arrival order — but a null-channel event is a barrier + /// nothing gathers across. + #[test] + fn frames_never_mix_channels_and_gather_queue_wide() { + let mut queue = queue_of(vec![ + event(1, "acp_read", Some("chan-a")), + event(2, "acp_write", Some("chan-a")), + event(3, "acp_read", Some("chan-b")), + event(4, "acp_read", Some("chan-a")), + event(5, "acp_read", None), + ]); + + let frames = drain_frames(&mut queue); + assert_eq!( + frames.len(), + 3, + "gathered: [1,2,4]@a, [3]@b, [5]@None — one frame each" + ); + for frame in &frames { + let channels: HashSet> = match frame.payload.get("events") { + Some(serde_json::Value::Array(inner)) => inner + .iter() + .map(|e| e["channelId"].as_str().map(ToOwned::to_owned)) + .collect(), + _ => std::iter::once(frame.channel_id.clone()).collect(), + }; + assert_eq!(channels.len(), 1, "a frame never mixes channels"); + } + assert_eq!( + frame_seqs(&frames[0]), + [1, 2, 4], + "chan-a gathers queue-wide, FIFO within the channel" + ); + assert_eq!(frames[0].channel_id.as_deref(), Some("chan-a")); + assert_eq!(frames[1].kind, "acp_read", "singleton stays unwrapped"); + assert_eq!(frames[1].channel_id.as_deref(), Some("chan-b")); + assert_eq!(frames[2].channel_id, None); + } + + /// A NULL-channel event is a barrier: channel events queued BEHIND it + /// must not gather into a frame ahead of it, so causally-global events + /// (`agent_panic`-class) keep their exact order against every channel. + /// The null event itself ships only its contiguous front run. + #[test] + fn null_channel_events_are_gather_barriers() { + let mut queue = queue_of(vec![ + event(1, "acp_read", Some("chan-a")), + event(2, "acp_read", Some("chan-b")), + event(3, "agent_panic", None), + event(4, "acp_write", Some("chan-a")), + ]); + + let frames = drain_frames(&mut queue); + let published: Vec> = frames.iter().map(frame_seqs).collect(); + assert_eq!( + published, + [vec![1], vec![2], vec![3], vec![4]], + "seq 4 must not gather past the null barrier into frame 1" + ); + } + + /// The drain-rate regression Sami measured: with two channels strictly + /// alternating, a front-run packer degrades to ONE event per slot + /// (~275 B/s regardless of the 64KB frame budget). Queue-wide gathering + /// must drain an interleaved backlog in ~ceil(events / per-frame-fit) + /// slots per channel, not one slot per event. + #[test] + fn interleaved_channels_drain_at_bytes_per_slot_not_events_per_slot() { + let mut events = Vec::new(); + for i in 0..100u64 { + events.push(event(2 * i + 1, "acp_read", Some("chan-a"))); + events.push(event(2 * i + 2, "acp_read", Some("chan-b"))); + } + let mut queue = queue_of(events); + + let frames = drain_frames(&mut queue); + assert!( + frames.len() <= 4, + "200 tiny alternating events must gather into a few full frames, \ + got {} (front-run packing would need 200 slots)", + frames.len() + ); + for frame in &frames { + assert!(serialized_len(frame) <= OBSERVER_MAX_PLAINTEXT_LEN); + } + // Within each channel, FIFO order survives the gather. + let mut seqs_a = Vec::new(); + let mut seqs_b = Vec::new(); + for frame in &frames { + match frame.channel_id.as_deref() { + Some("chan-a") => seqs_a.extend(frame_seqs(frame)), + Some("chan-b") => seqs_b.extend(frame_seqs(frame)), + other => panic!("unexpected channel {other:?}"), + } + } + assert!(seqs_a.windows(2).all(|w| w[0] < w[1]), "chan-a FIFO"); + assert!(seqs_b.windows(2).all(|w| w[0] < w[1]), "chan-b FIFO"); + assert_eq!(seqs_a.len() + seqs_b.len(), 200, "nothing lost"); + } + + /// A same-channel backlog that cannot fit one 64KB frame splits across + /// SUCCESSIVE publish slots — never multiple frames from one slot — with + /// every frame under the cap and no event lost or reordered. + #[test] + fn oversized_backlogs_split_across_publish_slots_under_the_cap() { + let big_text = "x".repeat(30_000); + let mut queue = queue_of( + (1..=6) + .map(|seq| { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ "seq": seq, "text": big_text }); + e + }) + .collect(), + ); + + let frames = drain_frames(&mut queue); + assert!( + frames.len() > 1, + "six 30KB events cannot fit one 64KB frame" + ); + let mut seen = Vec::new(); + for frame in &frames { + assert!( + serialized_len(frame) <= OBSERVER_MAX_PLAINTEXT_LEN, + "every emitted frame must fit the plaintext cap" + ); + seen.extend(frame_seqs(frame)); + } + assert_eq!( + seen, + [1, 2, 3, 4, 5, 6], + "no event lost or reordered by splitting" + ); + } + + /// The queue preserves the coalescer's ordering rule: a non-chunk event + /// force-flushes pending chunk text ahead of itself, so merged chunks can + /// never leapfrog a tool call that arrived after them. + #[test] + fn non_chunk_events_flush_pending_chunks_ahead_of_themselves() { + fn chunk(seq: u64, text: &str) -> observer::ObserverEvent { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "params": { "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "m1", + "content": { "text": text }, + }} + }); + e + } + + let mut queue = ObserverPublishQueue::default(); + queue.ingest(chunk(1, "hello ")); + queue.ingest(chunk(2, "world")); + queue.ingest(event(3, "tool_call", Some("chan-a"))); + + let frame = queue.next_frame().expect("one frame"); + assert!(queue.is_empty()); + let inner = frame.payload["events"].as_array().expect("batch of 2"); + assert_eq!(inner.len(), 2, "two chunks coalesce into one event"); + assert_eq!( + inner[0]["payload"]["params"]["update"]["content"]["text"], "hello world", + "chunk text merged before the tool call" + ); + assert_eq!(inner[1]["kind"], "tool_call"); + assert!(inner[0]["seq"].as_u64() < inner[1]["seq"].as_u64()); + } + + /// Chunks still pending inside the coalescer (no non-chunk flushed them) + /// are picked up by the publish slot itself, not stranded. + #[test] + fn a_publish_slot_flushes_pending_coalesced_chunks() { + let mut e = event(1, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "params": { "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "m1", + "content": { "text": "buffered" }, + }} + }); + let mut queue = ObserverPublishQueue::default(); + queue.ingest(e); + assert!(!queue.is_empty(), "pending chunk counts as queued work"); + + let frame = queue.next_frame().expect("chunk must ship"); + assert!(queue.is_empty()); + assert_eq!( + frame.payload["params"]["update"]["content"]["text"], + "buffered" + ); + } + + /// Sami's ceiling assertion: when sustained input outruns the one-frame + /// drain budget for longer than the queue's byte budget, the OLDEST events + /// drop with accounting — never silently — and everything that survives + /// publishes in order with nothing else lost. + #[test] + fn over_budget_floods_drop_oldest_with_accounting() { + let big_text = "y".repeat(10_000); + let total = 500usize; // ~5MB of ~10KB events > 4MiB budget + let mut queue = ObserverPublishQueue::default(); + for seq in 1..=total as u64 { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ "seq": seq, "text": big_text }); + queue.ingest(e); + } + + assert!( + queue.dropped_events > 0, + "a 5MB backlog must overflow the 4MiB budget" + ); + assert!( + walked_retained_bytes(&queue) <= OBSERVER_PENDING_QUEUE_MAX_BYTES, + "eviction must restore the byte budget (entry-walked), got {}", + walked_retained_bytes(&queue) + ); + + let frames = drain_frames(&mut queue); + let published: Vec = frames.iter().flat_map(frame_seqs).collect(); + let expected: Vec = (queue.dropped_events + 1..=total as u64).collect(); + assert_eq!( + published, expected, + "exactly the oldest `dropped_events` events are missing; the rest \ + publish in order" + ); + assert_eq!( + published.len() as u64 + queue.dropped_events, + total as u64, + "accounting: published + dropped == ingested" + ); + } + + /// Max's coalescer-bypass regression: a flood of chunks with DISTINCT + /// messageIds never flushes on its own, so every chunk sits in the + /// coalescer's pending buffer. TRUE retained bytes — walked from the + /// entries, never the queue's own accumulator — MUST respect the byte + /// budget with event-level drop accounting. Pre-fix this retained ~25MB + /// against the 4 MiB cap with `pending_bytes == 0` and zero drops; the + /// round-3 refinement (Sami/Max) caught the accumulator itself reading + /// under cap while true retention was 1.99x over. + #[test] + fn distinct_key_chunk_floods_are_bounded_by_the_byte_budget() { + let big_text = "z".repeat(50_000); + let total = 500u64; // ~25MB pending chunk text vs a 4MiB budget + let mut queue = ObserverPublishQueue::default(); + for seq in 1..=total { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": format!("message-{seq}"), + "content": { "type": "text", "text": big_text }, + }, + }, + }); + queue.ingest(e); + } + + let walked = walked_retained_bytes(&queue); + assert!( + walked <= OBSERVER_PENDING_QUEUE_MAX_BYTES, + "TRUE retained bytes (walked from entries) must respect the cap, \ + got {walked}" + ); + assert!( + queue.total_pending_bytes() >= walked, + "the accumulator must never under-count true retention \ + (accumulator {} < walked {walked})", + queue.total_pending_bytes() + ); + assert!( + queue.dropped_events > 0, + "a ~25MB distinct-key chunk flood must record drops" + ); + // Event-level accounting: everything that survives publishes, and + // survivors + dropped == ingested. + let frames = drain_frames(&mut queue); + let survived: u64 = frames.iter().map(|f| frame_seqs(f).len() as u64).sum(); + assert_eq!( + survived + queue.dropped_events, + total, + "accounting: published + dropped == ingested" + ); + // The survivors are the NEWEST events (drop-oldest). + let last_frame_seqs = frame_seqs(frames.last().expect("frames")); + assert_eq!(*last_frame_seqs.last().expect("seqs"), total); + } + + /// Max's merged-chunk accounting regression: one coalescer entry can + /// represent MANY generated observer events (same-messageId chunks merge + /// in place), so evicting it must charge every merged source event to + /// `dropped_events`, not 1 per retained entry. Pre-fix, evicting an entry + /// that merged 50 chunks recorded `dropped_events == 1` and 49 generated + /// events vanished from the accounting. + #[test] + fn evicting_a_merged_chunk_entry_accounts_every_source_event() { + fn chunk(seq: u64, message_id: &str, text: &str) -> observer::ObserverEvent { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": message_id, + "content": { "type": "text", "text": text }, + }, + }, + }); + e + } + + let mut queue = ObserverPublishQueue::default(); + // 50 × 1KB chunks under ONE messageId merge into a single pending + // coalescer entry — the oldest item anywhere in the queue. + let merged_text = "m".repeat(1_000); + let merged_sources = 50u64; + for seq in 1..=merged_sources { + queue.ingest(chunk(seq, "message-merged", &merged_text)); + } + // Flood with distinct-key 50KB chunks until the byte budget evicts + // the oldest entries — the merged entry goes first. + let flood_text = "f".repeat(50_000); + let flood = 100u64; + for seq in 1..=flood { + queue.ingest(chunk( + merged_sources + seq, + &format!("message-{seq}"), + &flood_text, + )); + } + + assert!( + walked_retained_bytes(&queue) <= OBSERVER_PENDING_QUEUE_MAX_BYTES, + "eviction must restore the byte budget (entry-walked), got {}", + walked_retained_bytes(&queue) + ); + let frames = drain_frames(&mut queue); + assert!( + !frames + .iter() + .flat_map(frame_seqs) + .any(|seq| seq <= merged_sources), + "the merged entry (globally oldest) must have been evicted" + ); + // Every survivor is an unmerged distinct-key chunk (1 source each), + // so source-event accounting must close exactly: the merged entry's + // eviction charges all 50 sources. + let survived: u64 = frames.iter().map(|f| frame_seqs(f).len() as u64).sum(); + assert_eq!( + survived + queue.dropped_events, + merged_sources + flood, + "accounting: published sources + dropped sources == ingested" + ); + } + + /// Sami's M13 / Max's forced-flush probe: the OTHER eviction arm. A + /// merged entry FLUSHED into the publish FIFO (by a non-chunk event) must + /// still charge every absorbed source on eviction — the FIFO stores the + /// per-entry count precisely so the ledger survives flush. The + /// coalescer-side regression above never exercises this arm; mutating the + /// FIFO eviction to `dropped += 1` survived all 687 tests until this one. + #[test] + fn evicting_a_flushed_merged_entry_from_the_fifo_accounts_every_source_event() { + fn chunk(seq: u64, message_id: &str, text: &str) -> observer::ObserverEvent { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": message_id, + "content": { "type": "text", "text": text }, + }, + }, + }); + e + } + + let mut queue = ObserverPublishQueue::default(); + // 50 × 1KB chunks merge under one messageId in the coalescer… + let merged_text = "m".repeat(1_000); + let merged_sources = 50u64; + for seq in 1..=merged_sources { + queue.ingest(chunk(seq, "message-merged", &merged_text)); + } + // …then a non-chunk event force-flushes the merged entry into the + // publish FIFO. From here eviction happens on the FIFO arm. + queue.ingest(event(merged_sources + 1, "tool_call", Some("chan-a"))); + assert!( + queue.coalescer.pending.is_empty(), + "the non-chunk event must have flushed the merged entry" + ); + assert_eq!( + queue.events.front().expect("flushed entry queued").1, + merged_sources, + "the FIFO front must carry the merged source count" + ); + + // Distinct-key flood forces byte-budget eviction of the FIFO front. + let flood_text = "f".repeat(50_000); + let flood = 100u64; + for seq in 1..=flood { + queue.ingest(chunk( + merged_sources + 1 + seq, + &format!("message-{seq}"), + &flood_text, + )); + } + + assert!( + walked_retained_bytes(&queue) <= OBSERVER_PENDING_QUEUE_MAX_BYTES, + "eviction must restore the byte budget (entry-walked), got {}", + walked_retained_bytes(&queue) + ); + let frames = drain_frames(&mut queue); + assert!( + !frames + .iter() + .flat_map(frame_seqs) + .any(|seq| seq <= merged_sources), + "the flushed merged entry (globally oldest) must have been evicted" + ); + // Ledger in source units: survivors are unmerged (1 source each), the + // evicted merged FIFO entry must charge all 50 sources. + let survived: u64 = frames.iter().map(|f| frame_seqs(f).len() as u64).sum(); + let ingested = merged_sources + 1 + flood; + assert_eq!( + survived + queue.dropped_events, + ingested, + "accounting: published sources + dropped sources == ingested" + ); + } + + /// Under the byte budget the queue is lossless: every ingested event + /// publishes exactly once. + #[test] + fn under_budget_backlogs_are_lossless() { + let mut queue = queue_of( + (1..=200) + .map(|seq| event(seq, "acp_read", Some("chan-a"))) + .collect(), + ); + let frames = drain_frames(&mut queue); + let published: Vec = frames.iter().flat_map(frame_seqs).collect(); + assert_eq!(published, (1..=200).collect::>()); + assert_eq!(queue.dropped_events, 0); + } +} + +#[cfg(test)] +mod observer_publish_cadence_tests { + use super::*; + use nostr::Keys; + + /// Let every spawned task (publisher loop, test_pair forwarder) run to + /// quiescence WITHOUT advancing paused time. `yield_now` keeps this task + /// runnable, so tokio's auto-advance never fires here — time only moves + /// when the test says so. + async fn settle() { + for _ in 0..64 { + tokio::task::yield_now().await; + } + } + + fn recv_all(rx: &mut tokio::sync::mpsc::Receiver) -> Vec { + let mut out = Vec::new(); + while let Ok(event) = rx.try_recv() { + out.push(event); + } + out + } + + fn count_inner(owner: &Keys, event: &nostr::Event) -> usize { + let payload: serde_json::Value = + decrypt_observer_payload(owner, event).expect("decrypt frame"); + match payload["payload"]["events"].as_array() { + Some(inner) => inner.len(), + None => 1, + } + } + + fn emit_on(observer: &observer::ObserverHandle, channel: Option, marker: &str) { + observer.emit( + "test_event", + None, + &observer::context_for(channel, None, None), + serde_json::json!({ "marker": marker }), + ); + } + + /// THE regression Max demanded: with a backlog needing multiple frames + /// (two channels — a frame never mixes channels, so the backlog takes two + /// publish slots), no frame publishes before its tick. Startup publishes + /// NOTHING at t=0 (Sami's Finding 1: a full replay buffer must not burst + /// on reconnect), frame 1 arrives at +1s, frame 2 no earlier than +2s. #[tokio::test(start_paused = true)] - async fn starts_without_a_burst_and_spaces_frames() { - let started = tokio::time::Instant::now(); - let mut pacer = ObserverPublishPacer::new(); + async fn one_frame_per_second_and_no_startup_burst() { + let observer = observer::ObserverHandle::in_process(); + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let (publisher, mut published_rx) = RelayEventPublisher::test_pair(); + + // Interleave channels so the backlog cannot fit one frame: each run + // boundary forces a new publish slot. + let chan_a = uuid::Uuid::new_v4(); + let chan_b = uuid::Uuid::new_v4(); + emit_on(&observer, Some(chan_a), "a1"); + emit_on(&observer, Some(chan_b), "b1"); + emit_on(&observer, Some(chan_a), "a2"); + + let rx = observer.subscribe(); + let snapshot = observer.snapshot(); + assert_eq!(snapshot.len(), 3, "all three preloaded in the snapshot"); + + let task = tokio::spawn(run_relay_observer_publisher( + snapshot, + rx, + publisher, + agent_keys.clone(), + agent_keys.public_key().to_hex(), + owner_keys.public_key().to_hex(), + owner_keys.public_key(), + )); + + // t=0: nothing may publish, no matter how full the snapshot was. + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 0, + "startup must not burst at t=0" + ); - pacer.wait().await; - let first = tokio::time::Instant::now(); - pacer.wait().await; - let second = tokio::time::Instant::now(); + // t=0.999s: still nothing. + tokio::time::advance(Duration::from_millis(999)).await; + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 0, + "no frame may publish before the first tick" + ); - assert_eq!(first.duration_since(started), OBSERVER_PUBLISH_INTERVAL); - assert_eq!(second.duration_since(first), OBSERVER_PUBLISH_INTERVAL); + // t=1s: exactly ONE frame — chan-a gathered queue-wide, so a1 AND a2 + // ride the first slot together. + tokio::time::advance(Duration::from_millis(1)).await; + settle().await; + let frames = recv_all(&mut published_rx); + assert_eq!(frames.len(), 1, "tick 1 publishes exactly one frame"); + assert_eq!(count_inner(&owner_keys, &frames[0]), 2, "a1 + a2 gathered"); + + // t=1.5s: between ticks, nothing. + tokio::time::advance(Duration::from_millis(500)).await; + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 0, + "frame 2 must wait for tick 2" + ); + + // t=2s: the chan-b frame drains on its own tick. + tokio::time::advance(Duration::from_millis(500)).await; + settle().await; + assert_eq!(recv_all(&mut published_rx).len(), 1, "tick 2: one frame"); + + // Backlog drained; a quiet tick publishes nothing. + tokio::time::advance(Duration::from_secs(1)).await; + settle().await; + assert_eq!(recv_all(&mut published_rx).len(), 0, "quiet tick is quiet"); + + task.abort(); } + /// Shutdown is NOT a burst bypass: when the producer closes with a + /// backlog, the remaining frames still publish one per tick, and the loop + /// exits only after the queue is empty — paced, lossless, in order. #[tokio::test(start_paused = true)] - async fn limits_frames_in_each_rolling_minute() { - let mut pacer = ObserverPublishPacer::new(); - pacer.wait().await; - let first = tokio::time::Instant::now(); - for _ in 1..OBSERVER_PUBLISH_LIMIT_PER_MINUTE { - pacer.wait().await; + async fn shutdown_drain_is_paced_and_lossless() { + let observer = observer::ObserverHandle::in_process(); + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let (publisher, mut published_rx) = RelayEventPublisher::test_pair(); + + let chan_a = uuid::Uuid::new_v4(); + let chan_b = uuid::Uuid::new_v4(); + emit_on(&observer, Some(chan_a), "a1"); + emit_on(&observer, Some(chan_b), "b1"); + emit_on(&observer, Some(chan_a), "a2"); + + let rx = observer.subscribe(); + let snapshot = observer.snapshot(); + // Close the broadcast channel immediately: the entire drain happens + // in "shutdown" mode. + drop(observer); + + let task = tokio::spawn(run_relay_observer_publisher( + snapshot, + rx, + publisher, + agent_keys.clone(), + agent_keys.public_key().to_hex(), + owner_keys.public_key().to_hex(), + owner_keys.public_key(), + )); + + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 0, + "shutdown drain must not burst at t=0" + ); + + let mut markers = Vec::new(); + for tick in 1..=2 { + tokio::time::advance(Duration::from_secs(1)).await; + settle().await; + let frames = recv_all(&mut published_rx); + assert_eq!(frames.len(), 1, "shutdown tick {tick}: exactly one frame"); + let payload: serde_json::Value = + decrypt_observer_payload(&owner_keys, &frames[0]).expect("decrypt"); + match payload["payload"]["events"].as_array() { + Some(inner) => markers.extend( + inner + .iter() + .map(|e| e["payload"]["marker"].as_str().unwrap().to_string()), + ), + None => markers.push(payload["payload"]["marker"].as_str().unwrap().to_string()), + } } + // Gather-packing: chan-a (a1+a2) ships tick 1, chan-b tick 2. + assert_eq!(markers, ["a1", "a2", "b1"], "paced drain loses nothing"); + + // Queue empty + closed: the loop must have exited on its own. + tokio::time::advance(Duration::from_secs(1)).await; + settle().await; + assert!(task.is_finished(), "publisher exits after paced drain"); + } + + /// Pins `MissedTickBehavior::Skip` (Sami's M6 mutant): when the publisher + /// misses ticks — relay backpressure can stall the tick arm past several + /// deadlines, since `publish_event` awaits a bounded mpsc — the interval + /// must fire ONE catch-up tick and realign, not fire once per missed + /// deadline. With `Burst`, a 10s stall against a multi-frame backlog + /// would replay all 10 missed ticks back-to-back: an unpaced burst that + /// bypasses exactly what the pacer exists to prevent. + #[tokio::test(start_paused = true)] + async fn missed_ticks_skip_instead_of_bursting() { + let observer = observer::ObserverHandle::in_process(); + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let (publisher, mut published_rx) = RelayEventPublisher::test_pair(); + + // Three channels => three frames pending (a frame never mixes + // channels), so a bursting interval would have work for every + // spurious catch-up tick. + for chan in 0..3 { + emit_on(&observer, Some(uuid::Uuid::new_v4()), &format!("c{chan}")); + } + let rx = observer.subscribe(); + let snapshot = observer.snapshot(); + + let task = tokio::spawn(run_relay_observer_publisher( + snapshot, + rx, + publisher, + agent_keys.clone(), + agent_keys.public_key().to_hex(), + owner_keys.public_key().to_hex(), + owner_keys.public_key(), + )); + settle().await; - pacer.wait().await; - let ninety_first = tokio::time::Instant::now(); + // Jump 10 seconds in ONE advance — the loop was never polled in + // between, exactly like a stall across 10 deadlines. + tokio::time::advance(Duration::from_secs(10)).await; + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 1, + "Skip: one catch-up frame after a stall — Burst would publish \ + one per missed deadline" + ); + + // The interval realigned: the remaining backlog stays paced. + tokio::time::advance(Duration::from_secs(1)).await; + settle().await; + assert_eq!(recv_all(&mut published_rx).len(), 1, "paced after realign"); - assert_eq!(ninety_first.duration_since(first), Duration::from_secs(60)); + task.abort(); } } @@ -5058,9 +6127,14 @@ mod observer_chunk_coalescer_tests { let events = coalescer.ingest(non_chunk_event(3)); assert_eq!(events.len(), 2); - assert_eq!(events[0].seq, 2); - assert_eq!(chunk_text(&events[0]), "hello world"); - assert_eq!(events[1].kind, "turn_started"); + assert_eq!(events[0].1.seq, 2); + assert_eq!(chunk_text(&events[0].1), "hello world"); + assert_eq!( + events[0].0, 2, + "a merged entry reports every source chunk it absorbed" + ); + assert_eq!(events[1].1.kind, "turn_started"); + assert_eq!(events[1].0, 1); } #[test] @@ -5081,8 +6155,8 @@ mod observer_chunk_coalescer_tests { let events = coalescer.flush(); assert_eq!(events.len(), 2); - assert_eq!(chunk_text(&events[0]), "answer"); - assert_eq!(chunk_text(&events[1]), "thinking"); + assert_eq!(chunk_text(&events[0].1), "answer"); + assert_eq!(chunk_text(&events[1].1), "thinking"); } } diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index aea5cee077..2cbb82411f 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -106,9 +106,12 @@ const REQ_PACING_INTERVAL: Duration = Duration::from_millis(125); /// blocked for more than one REQ's worth of I/O between drain ticks. const DRAIN_BUDGET_PER_ITER: usize = 1; /// Maximum observer telemetry frames parked while the rate-limit gate is armed -/// (or the socket is down). The upstream pacer feeds at most ~6 frames/s, so -/// this covers ~40 s of gating; beyond that the oldest frames are dropped with -/// visible accounting (`gated_observer_dropped`). +/// (or the socket is down). The upstream publisher ships at most ONE batched +/// frame per second GLOBALLY (one publish slot per tick, regardless of how +/// many channels are active), so this covers ~4 minutes of gating; beyond that +/// the oldest frames are dropped with visible accounting +/// (`gated_observer_dropped`). Note each dropped frame may carry a whole batch +/// of events, so event-level loss is larger than the frame count. const GATED_OBSERVER_QUEUE_CAP: usize = 256; use std::time::Instant; diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 267b2d21b5..c7bc31312e 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -104,10 +104,17 @@ pub struct Llm { auth: Arc, } +/// Connect-phase timeout applied to every outgoing LLM HTTP request. +/// +/// A 10-second budget is generous for a TLS + HTTP/2 handshake to a +/// well-provisioned gateway. Repeated connect timeouts indicate a +/// network/reachability problem, not a slow generation. +const LLM_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + impl Llm { pub fn new(cfg: &Config) -> Result { let http = Client::builder() - .connect_timeout(std::time::Duration::from_secs(10)) + .connect_timeout(LLM_CONNECT_TIMEOUT) .read_timeout(cfg.llm_timeout) .build() .map_err(|e| AgentError::Llm(format!("http: {e}")))?; @@ -353,7 +360,7 @@ impl Llm { async fn post_anthropic(&self, cfg: &Config, body: &Value) -> Result { let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/')); - post(&self.http, &url, body, false, |r| { + post(&self.http, &url, body, false, cfg.llm_timeout, |r| { r.header("x-api-key", &cfg.api_key) .header("anthropic-version", &cfg.anthropic_api_version) }) @@ -659,6 +666,7 @@ impl Llm { &url, body_ref, effective_model == MESH_VIRTUAL_MODEL_ID, + cfg.llm_timeout, |r| r.bearer_auth(&bearer), ) .await @@ -681,7 +689,7 @@ impl Llm { let mut bearer = self.auth.bearer().await?; let mut refreshed = false; loop { - match openrouter_post(&self.http, &url, body, &bearer).await { + match openrouter_post(&self.http, &url, body, &bearer, cfg.llm_timeout).await { Err(AgentError::LlmAuth(_)) if !refreshed => { refreshed = true; let new_bearer = self.auth.refresh_now(&bearer).await?; @@ -1731,6 +1739,85 @@ fn is_retryable_transport_error(e: &reqwest::Error) -> bool { e.is_timeout() || e.is_connect() || e.is_request() } +/// Which phase of an HTTP exchange produced a timeout error. +/// +/// Used by `timeout_message` to choose the right factual description. +#[derive(Clone, Copy)] +enum TimeoutPhase { + /// Timeout before any response bytes — transport/send phase. + Transport, + /// Timeout after headers were received, while reading body chunks. + BodyRead, +} + +/// Pure function: build the human-readable timeout message for an LLM call. +/// +/// Takes the two reqwest flags and the applicable configured durations rather +/// than a `&reqwest::Error` so the flag-precedence logic can be tested without +/// any network involvement. +/// +/// `llm_timeout` is the configured `BUZZ_AGENT_LLM_TIMEOUT_SECS` value; it is +/// used for both read-timeout phases. Connect timeouts use `LLM_CONNECT_TIMEOUT`. +fn timeout_message( + is_connect: bool, + llm_timeout: std::time::Duration, + phase: TimeoutPhase, +) -> String { + if is_connect { + // Connect-phase timeout: the TCP/TLS handshake didn't complete. + // reqwest sets both is_timeout() and is_connect() for this case. + format!("connect timeout: no connection established within {LLM_CONNECT_TIMEOUT:?}") + } else { + match phase { + TimeoutPhase::Transport => format!( + "read timeout: no response bytes received within {llm_timeout:?} \ + (consider raising BUZZ_AGENT_LLM_TIMEOUT_SECS)" + ), + TimeoutPhase::BodyRead => format!( + "read timeout: no further response bytes received within {llm_timeout:?} \ + (consider raising BUZZ_AGENT_LLM_TIMEOUT_SECS)" + ), + } + } +} + +/// Produce a human-readable description of a transport-layer reqwest error. +/// +/// reqwest's `Display` for a `read_timeout` fire is the opaque +/// `"error sending request for url (...)"` — the same text as every other +/// pre-response failure — because the HTTP layer lumps them together. +/// We replace that string with a factual message that names which kind of +/// timeout fired, making it immediately obvious in logs whether the client +/// never connected or whether the server stopped sending bytes. +/// +/// `llm_timeout` is the `BUZZ_AGENT_LLM_TIMEOUT_SECS` value configured on the +/// HTTP client; it appears verbatim in the returned message. +fn classify_transport_error(e: &reqwest::Error, llm_timeout: std::time::Duration) -> String { + if e.is_timeout() { + timeout_message(e.is_connect(), llm_timeout, TimeoutPhase::Transport) + } else { + format!("transport: {e}") + } +} + +/// Produce a human-readable description of an error that occurred while +/// reading response body chunks (`resp.chunk()`). +/// +/// A timeout here means headers and possibly body bytes arrived but the +/// stream then stalled past the read timeout. Any other body-decode failure +/// preserves the `"body read: ..."` prefix expected by callers and existing +/// tests. +/// +/// `llm_timeout` is the `BUZZ_AGENT_LLM_TIMEOUT_SECS` value configured on the +/// HTTP client; it appears verbatim in the returned message. +fn classify_body_read_error(e: &reqwest::Error, llm_timeout: std::time::Duration) -> String { + if e.is_timeout() { + timeout_message(e.is_connect(), llm_timeout, TimeoutPhase::BodyRead) + } else { + format!("body read: {e}") + } +} + fn is_unsupported_image_input_error(body: &str) -> bool { body.to_ascii_lowercase() .contains("no endpoints found that support image input") @@ -1816,6 +1903,7 @@ async fn post( url: &str, body: &Value, detect_mesh_fallback: bool, + read_timeout: std::time::Duration, apply: F, ) -> Result where @@ -1840,6 +1928,7 @@ where attempt = attempt + 1, max_attempts = MAX_RETRIES, error = %e, + is_timeout = e.is_timeout(), "llm: transport error, retrying" ); backoff_with_jitter(attempt).await; @@ -1848,7 +1937,7 @@ where return Err(PostError::Agent(terminal_llm_error( call_start.elapsed(), attempt + 1, - &format!("transport: {e}"), + &classify_transport_error(&e, read_timeout), ))); } }; @@ -1944,7 +2033,7 @@ where return Err(PostError::Agent(terminal_llm_error( call_start.elapsed(), attempt + 1, - &format!("body read: {e}"), + &classify_body_read_error(&e, read_timeout), ))); } } @@ -2098,6 +2187,7 @@ async fn openrouter_post( url: &str, body: &Value, bearer: &str, + read_timeout: std::time::Duration, ) -> Result { let body_bytes = serde_json::to_vec(body).map_err(|e| AgentError::Llm(format!("serialize: {e}")))?; @@ -2120,6 +2210,7 @@ async fn openrouter_post( attempt = attempt + 1, max_attempts = MAX_RETRIES, error = %e, + is_timeout = e.is_timeout(), "llm: openrouter transport error, retrying" ); backoff_with_jitter(attempt).await; @@ -2128,7 +2219,7 @@ async fn openrouter_post( return Err(terminal_llm_error( call_start.elapsed(), attempt + 1, - &format!("transport: {e}"), + &classify_transport_error(&e, read_timeout), )); } }; @@ -2263,7 +2354,7 @@ async fn openrouter_post( return Err(terminal_llm_error( call_start.elapsed(), attempt + 1, - &format!("body read: {e}"), + &classify_body_read_error(&e, read_timeout), )) } } @@ -4179,9 +4270,16 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let out = post(&client, &url, &serde_json::json!({}), false, |b| b) - .await - .expect("post should succeed after retry"); + let out = post( + &client, + &url, + &serde_json::json!({}), + false, + Duration::from_secs(5), + |b| b, + ) + .await + .expect("post should succeed after retry"); assert_eq!(out, serde_json::json!({ "ok": true })); assert!( accepts.load(Ordering::SeqCst) >= 2, @@ -4243,9 +4341,16 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let out = post(&client, &url, &serde_json::json!({}), false, |b| b) - .await - .expect("post should succeed after 499 retry"); + let out = post( + &client, + &url, + &serde_json::json!({}), + false, + Duration::from_secs(5), + |b| b, + ) + .await + .expect("post should succeed after 499 retry"); assert_eq!(out, serde_json::json!({ "ok": true })); assert!( accepts.load(Ordering::SeqCst) >= 2, @@ -4294,9 +4399,16 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = post(&client, &url, &serde_json::json!({}), false, |b| b) - .await - .unwrap_err(); + let err = post( + &client, + &url, + &serde_json::json!({}), + false, + Duration::from_secs(5), + |b| b, + ) + .await + .unwrap_err(); match &err { PostError::Agent(AgentError::Llm(msg)) => { assert!( @@ -4452,6 +4564,285 @@ mod tests { ); } + // ---- timeout_message (pure-function tests, no network) ------------------ + + /// Connect timeout (is_connect=true) wins regardless of phase and shows + /// the LLM_CONNECT_TIMEOUT value — never the read-timeout text. + #[test] + fn timeout_message_connect_true_shows_connect_timeout() { + let llm = std::time::Duration::from_secs(240); + for phase in [TimeoutPhase::Transport, TimeoutPhase::BodyRead] { + let msg = timeout_message(true, llm, phase); + assert!( + msg.starts_with("connect timeout:"), + "is_connect=true must start with 'connect timeout:': {msg}" + ); + // The configured connect timeout (10s) must appear verbatim. + assert!( + msg.contains("10s"), + "connect timeout must include the 10s configured value: {msg}" + ); + assert!( + !msg.contains("read timeout"), + "connect timeout must not mention 'read timeout': {msg}" + ); + assert!( + !msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"), + "connect timeout must not reference the read-timeout config knob: {msg}" + ); + } + } + + /// Transport read-timeout (is_connect=false, Transport phase) shows the + /// configured llm_timeout value and the config-knob hint. + #[test] + fn timeout_message_transport_phase_shows_read_timeout_and_duration() { + let llm = std::time::Duration::from_secs(240); + let msg = timeout_message(false, llm, TimeoutPhase::Transport); + assert!( + msg.starts_with("read timeout:"), + "transport read-timeout must start with 'read timeout:': {msg}" + ); + assert!( + msg.contains("240s"), + "transport read-timeout must include the 240s configured value: {msg}" + ); + assert!( + msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"), + "transport read-timeout must reference the config knob: {msg}" + ); + assert!( + !msg.contains("connect timeout"), + "transport read-timeout must not say 'connect timeout': {msg}" + ); + } + + /// Body-read timeout (BodyRead phase) says "no further response bytes" + /// (headers and possibly partial body already arrived) and shows the value. + #[test] + fn timeout_message_body_read_phase_says_no_further_bytes_and_duration() { + let llm = std::time::Duration::from_secs(300); + let msg = timeout_message(false, llm, TimeoutPhase::BodyRead); + assert!( + msg.starts_with("read timeout:"), + "body-read timeout must start with 'read timeout:': {msg}" + ); + assert!( + msg.contains("no further"), + "body-read timeout must say 'no further': {msg}" + ); + assert!( + msg.contains("300s"), + "body-read timeout must include the 300s configured value: {msg}" + ); + assert!( + msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"), + "body-read timeout must reference the config knob: {msg}" + ); + } + + /// A non-default duration threads through correctly — verifies the value + /// is not hard-coded anywhere in the pure function. + #[test] + fn timeout_message_duration_is_not_hardcoded() { + let msg = timeout_message( + false, + std::time::Duration::from_secs(600), + TimeoutPhase::Transport, + ); + assert!( + msg.contains("600s"), + "transport read-timeout must reflect the supplied 600s value: {msg}" + ); + assert!( + !msg.contains("240s"), + "must not hard-code 240s when 600s was supplied: {msg}" + ); + } + + // ---- classify_transport_error / classify_body_read_error (reqwest integration) -- + + /// A real loopback read-timeout must produce a message rooted at "read + /// timeout:" that contains the configured value — and must NOT use reqwest's + /// opaque "error sending request" string. + /// + /// This is the one test that requires real network I/O (loopback only) to + /// verify that reqwest actually sets is_timeout() for the scenario in which + /// Buzz agents stall (server connected but emitting no bytes). + #[tokio::test] + async fn classify_transport_error_read_timeout_is_loopback_verified() { + use tokio::net::TcpListener; + + let llm_timeout = std::time::Duration::from_millis(50); + // Bind and never accept — TCP connect succeeds, no bytes follow. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let _listener = listener; // keep alive so connect succeeds + + let client = reqwest::Client::builder() + .read_timeout(llm_timeout) + .build() + .unwrap(); + + let err = client + .get(format!("http://{addr}/")) + .send() + .await + .expect_err("must time out"); + + // Preconditions: verify reqwest's classification before asserting our output. + assert!( + err.is_timeout(), + "precondition: reqwest must report is_timeout" + ); + assert!( + !err.is_connect(), + "precondition: read timeout must not set is_connect" + ); + + let msg = classify_transport_error(&err, llm_timeout); + assert!( + msg.starts_with("read timeout:"), + "read timeout must start with 'read timeout:': {msg}" + ); + assert!( + msg.contains("50ms"), + "read timeout must include the configured 50ms value: {msg}" + ); + assert!( + msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"), + "read timeout must name the config knob: {msg}" + ); + assert!( + !msg.contains("error sending request"), + "read timeout must not use the opaque reqwest string: {msg}" + ); + } + + /// Non-timeout transport errors preserve the original reqwest error text. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn classify_transport_error_non_timeout_preserves_reqwest_text() { + use tokio::net::TcpListener; + + // Accept-then-close: keep the listener alive so the endpoint stays + // owned throughout, spawn a task that accepts exactly one connection + // and immediately drops the socket. Produces a deterministic + // non-timeout reqwest error (request-class, not is_timeout()) while + // the test holds exclusive ownership of the address — no released-port + // race possible. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + if let Ok((sock, _)) = listener.accept().await { + drop(sock); // close immediately, no response written + } + }); + + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_millis(200)) + .build() + .unwrap(); + + let err = client + .get(format!("http://{addr}/")) + .send() + .await + .expect_err("must fail: server closes connection before response"); + + assert!( + !err.is_timeout(), + "precondition: connection-closed is not a timeout: {err}" + ); + + assert_eq!( + classify_transport_error(&err, std::time::Duration::from_secs(240)), + format!("transport: {err}") + ); + } + + /// A body-read timeout fires after headers arrive but before the body is + /// complete. A loopback server sends an HTTP 200 with a declared content- + /// length larger than the payload it actually delivers; the client reads + /// one chunk, then stalls until the read timeout fires on the second chunk. + /// + /// Asserts the exact wording, configured duration, and config-knob hint. + /// Also covers the non-timeout fallback via classify_body_read_error. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn classify_body_read_error_timeout_says_no_further_bytes() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let llm_timeout = std::time::Duration::from_millis(100); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Server: accept once, send headers + one body chunk, then hang. + tokio::spawn(async move { + if let Ok((mut sock, _)) = listener.accept().await { + // Consume the request. + let mut buf = [0u8; 512]; + let _ = sock.read(&mut buf).await; + // Declare 1 KiB body, send 4 bytes, then do nothing. + let _ = sock + .write_all( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/json\r\n\ + Content-Length: 1024\r\n\ + \r\n\ + test", + ) + .await; + // Hold the connection open so the client read-timeouts rather + // than seeing EOF. + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } + }); + + let client = reqwest::Client::builder() + .read_timeout(llm_timeout) + .build() + .unwrap(); + + let resp = client + .get(format!("http://{addr}/")) + .send() + .await + .expect("headers must arrive before timeout"); + + // Consume the response body — this is where the timeout fires. + let err = resp.bytes().await.expect_err("body read must time out"); + + assert!( + err.is_timeout(), + "precondition: reqwest must report is_timeout for body stall" + ); + + // ---- classify_body_read_error: timeout path ---- + let msg = classify_body_read_error(&err, llm_timeout); + assert!( + msg.starts_with("read timeout:"), + "body-read timeout must start with 'read timeout:': {msg}" + ); + assert!( + msg.contains("no further"), + "body-read timeout must say 'no further': {msg}" + ); + assert!( + msg.contains("100ms"), + "body-read timeout must include the configured 100ms value: {msg}" + ); + assert!( + msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"), + "body-read timeout must reference the config knob: {msg}" + ); + + // ---- classify_body_read_error: non-timeout fallback (pure, no I/O) ---- + // We can't produce a real non-timeout body error without real I/O, but + // the pure-function path is identical to classify_transport_error's + // non-timeout fallback and is covered by the pure tests above. + } + // ---- usage / input-token extraction ------------------------------------- #[test] @@ -6437,9 +6828,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::Llm(s) if s.contains("403") && s.contains("model flagged by moderation")), "403 must surface as AgentError::Llm with status+body, not LlmAuth: got {err:?}" @@ -6464,9 +6861,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::Llm(s) if s.contains("credits exhausted")), "got {err:?}" @@ -6494,9 +6897,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::Llm(s) if s.contains("no OpenRouter endpoint supports")), "parameter-routing 404 must not be reported as a missing model: got {err:?}" @@ -6522,9 +6931,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::UnsupportedImageInput(s) if s.contains("support image input")), "image rejection must reach the history-recovery path: got {err:?}" @@ -6552,9 +6967,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::LlmModelNotFound(s) if s.contains("404") && s.contains("vendor/nonexistent-model")), "a model-level 404 must stay LlmModelNotFound: got {err:?}" @@ -6577,9 +6998,15 @@ mod tests { .build() .unwrap(); let before = std::time::Instant::now(); - let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .expect("second attempt succeeds"); + let out = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .expect("second attempt succeeds"); assert_eq!(out["choices"][0]["message"]["content"], "ok"); assert!( before.elapsed() >= Duration::from_secs(1), @@ -6604,9 +7031,15 @@ mod tests { .await; let http = Client::builder().build().unwrap(); let before = tokio::time::Instant::now(); - let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .expect("second attempt succeeds"); + let out = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .expect("second attempt succeeds"); assert_eq!(out["choices"][0]["message"]["content"], "ok"); assert!( before.elapsed() <= Duration::from_secs(RETRY_AFTER_CAP_SECS + 5), @@ -6628,9 +7061,15 @@ mod tests { .timeout(Duration::from_secs(30)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::Llm(s) if s.contains("no OpenRouter endpoint supports")), "got {err:?}" @@ -6655,9 +7094,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .expect("200 succeeds"); + openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .expect("200 succeeds"); let headers = captured.lock().await; let header_str = headers .first() @@ -6686,9 +7131,15 @@ mod tests { .timeout(Duration::from_secs(30)) .build() .unwrap(); - let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .expect("retry after 499 should succeed"); + let out = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .expect("retry after 499 should succeed"); assert_eq!(out["choices"][0]["message"]["content"], "ok"); assert_eq!( attempts.load(std::sync::atomic::Ordering::SeqCst), @@ -6711,9 +7162,15 @@ mod tests { .timeout(Duration::from_secs(30)) .build() .unwrap(); - let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .expect("retry succeeds"); + let out = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .expect("retry succeeds"); assert_eq!(out["choices"][0]["message"]["content"], "ok"); assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2); } @@ -6762,9 +7219,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::Llm(s) if s.contains("body read")), "truncated body must surface as AgentError::Llm with 'body read': got {err:?}" diff --git a/crates/buzz-core/src/pairing/session.rs b/crates/buzz-core/src/pairing/session.rs index 431b87fcc0..0d43d4d827 100644 --- a/crates/buzz-core/src/pairing/session.rs +++ b/crates/buzz-core/src/pairing/session.rs @@ -223,6 +223,48 @@ impl PairingSession { Ok(event) } + /// (Source) Process a payload sent back by the target. + /// + /// This is used by recovery flows where the QR-displaying device requests + /// a secret from an already-authorized scanning device. + pub fn handle_return_payload( + &mut self, + event: &Event, + ) -> Result<(PayloadType, Zeroizing), PairingError> { + self.check_expired()?; + self.expect_state(SessionState::Transferring)?; + self.expect_role(Role::Source)?; + self.validate_event_from_peer(event)?; + + let msg = self.decrypt_message(event)?; + match msg { + PairingMessage::Payload { + payload_type, + payload, + } => { + self.state = SessionState::PayloadExchanged; + self.record_event(event); + Ok((payload_type, Zeroizing::new(payload))) + } + other => Err(unexpected("payload", &other)), + } + } + + /// (Source) Report whether a returned payload was imported successfully. + pub fn send_source_complete(&mut self, success: bool) -> Result { + self.check_expired()?; + self.expect_state(SessionState::PayloadExchanged)?; + self.expect_role(Role::Source)?; + + let event = self.build_event(&PairingMessage::Complete { success })?; + self.state = if success { + SessionState::Completed + } else { + SessionState::Aborted + }; + Ok(event) + } + /// (Source) Build the payload event carrying the secret. pub fn send_payload( &mut self, @@ -821,6 +863,74 @@ mod tests { assert_eq!(source.state(), SessionState::Completed); } + /// Reverse happy-path: the scanning target returns an nsec and the source + /// reports the import result. Duplicate payloads remain single-use. + #[test] + fn reverse_payload_flow_is_single_use() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer) = PairingSession::new_target(&qr).expect("target"); + let source_sas = source.handle_offer(&offer).expect("offer"); + let sas_confirm = source.confirm_sas().expect("source confirm"); + assert_eq!( + target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"), + source_sas + ); + target.confirm_target_sas().expect("target confirm"); + + let payload = target + .build_event(&PairingMessage::Payload { + payload_type: PayloadType::Nsec, + payload: "nsec1recovered".into(), + }) + .expect("return payload"); + let (payload_type, secret) = source + .handle_return_payload(&payload) + .expect("handle return payload"); + assert_eq!(payload_type, PayloadType::Nsec); + assert_eq!(*secret, "nsec1recovered"); + assert_eq!(source.state(), SessionState::PayloadExchanged); + assert!(source.handle_return_payload(&payload).is_err()); + + let complete = source.send_source_complete(true).expect("source complete"); + assert_eq!(source.state(), SessionState::Completed); + assert!(matches!( + target.decrypt_message(&complete).expect("decrypt complete"), + PairingMessage::Complete { success: true } + )); + } + + #[test] + fn reverse_payload_import_failure_aborts_both_peers() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer) = PairingSession::new_target(&qr).expect("target"); + source.handle_offer(&offer).expect("offer"); + let sas_confirm = source.confirm_sas().expect("source confirm"); + target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"); + target.confirm_target_sas().expect("target confirm"); + let payload = target + .build_event(&PairingMessage::Payload { + payload_type: PayloadType::Nsec, + payload: "invalid".into(), + }) + .expect("return payload"); + source + .handle_return_payload(&payload) + .expect("handle return payload"); + + let complete = source + .send_source_complete(false) + .expect("failure complete"); + assert_eq!(source.state(), SessionState::Aborted); + assert!(matches!( + target.decrypt_message(&complete).expect("decrypt complete"), + PairingMessage::Complete { success: false } + )); + } + /// State machine rejects out-of-order operations. #[test] fn reject_out_of_order_operations() { diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 41bdc3b9e9..cbad2a3b29 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -86,6 +86,11 @@ dev = ["buzz-auth/dev"] [dev-dependencies] mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } +# Relay-driven mesh lifecycle smoke (examples/mesh_relay_lifecycle_smoke.rs): +# the relay client for discovery notes and the exact ed25519 the mesh owner +# keys use for binding verification. +buzz-test-client = { path = "../buzz-test-client" } +ed25519-dalek = "=3.0.0-rc.0" buzz-core = { workspace = true, features = ["test-utils"] } buzz-auth = { workspace = true, features = ["dev"] } reqwest = { workspace = true } diff --git a/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs b/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs new file mode 100644 index 0000000000..7544ca09ea --- /dev/null +++ b/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs @@ -0,0 +1,1065 @@ +//! Relay-driven mesh lifecycle smoke — the full Buzz join story, CI-shaped. +//! +//! Unlike `mesh_serve_client_smoke` (Mdns + hand-carried invite token) and +//! `mesh_admission_smoke` (allowlist mechanics, token passed out-of-band), +//! this harness exercises the *relay as the control plane*, the way the +//! desktop app actually joins a mesh: +//! +//! 1. MEMBERSHIP — two Nostr identities are added to a membership-gated +//! buzz-relay (kind:13534 roster via buzz-admin); a third is not. +//! 2. ADVERTISE — each member process publishes a client-signed kind:30003 +//! status note carrying its MeshLLM owner binding +//! (`ownerId`/`ownerVerifyingKey`/`ownerBindingSig`) and, for the serve +//! node, `serveTargets[].endpointAddr` covered by an endpoint binding +//! signature — the exact payload shape the desktop coordinator publishes. +//! 3. TRUST — the serve node derives its admission allowlist from the relay: +//! status notes ∩ membership roster, and requires the *exact* expected +//! owner set before starting with `TrustPolicy::Allowlist`. +//! 4. JOIN — the client node discovers the serve target from the relay, +//! verifies both bindings and membership, and dials the advertised +//! endpoint. No token is ever handed over out-of-band. +//! 5. INFER — a chat completion against the client's local OpenAI endpoint +//! routes over QUIC to the serve node's model. +//! 6. DENY — the stranger's NIP-42 auth must fail with the relay's +//! membership rejection, and even when handed the leaked endpoint +//! address directly it must not complete an inference — *while the +//! trusted client re-verifies inference immediately afterwards*, so a +//! sick serve node cannot masquerade as an admission denial. +//! +//! ## Scope: an independent protocol harness +//! +//! This harness speaks the same wire protocol as the desktop +//! (`desktop/src-tauri/src/mesh_llm/{identity,discovery,coordinator}.rs`) but +//! deliberately re-implements the binding/verification logic rather than +//! linking desktop code (the desktop crate is outside this workspace). The +//! payloads and canonical binding bytes are kept byte-identical — see the +//! keep-in-sync comments below. A regression inside the desktop's own +//! discovery filtering is covered by the desktop unit tests, not this smoke; +//! what this smoke proves is that the relay + mesh-llm SDK + admission stack +//! actually support the lifecycle end to end. +//! +//! One process per node is load-bearing: mesh-llm keeps process-global state +//! (node endpoint key, ownership attestation under `~/.mesh-llm`), so each +//! role runs with an isolated HOME — exactly how the desktop runs it (one +//! machine = one node). +//! +//! Run in CI via `scripts/ci-mesh-lifecycle-smoke.sh` (which provisions the +//! membership-gated relay), or locally: +//! +//! ```text +//! ./scripts/start-relay-for-tests.sh # with membership env set +//! cargo build --profile ci -p buzz-admin +//! BUZZ_ADMIN_BIN=target/ci/buzz-admin \ +//! cargo run --profile ci -p buzz-relay --example mesh_relay_lifecycle_smoke +//! ``` +use std::collections::BTreeSet; +use std::io::{BufRead, Write}; +use std::process::{Child, ChildStdout, Command, ExitStatus, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use buzz_test_client::BuzzTestClient; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use mesh_llm_host_runtime::crypto::{load_keystore, save_keystore, OwnerKeypair}; +use mesh_llm_sdk::{client, serve, MeshDiscoveryMode, TrustPolicy}; +use nostr::{Alphabet, Event, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag}; +use sha2::{Digest, Sha256}; + +/// NIP-51 bookmark set reused for client-owned mesh discovery notes +/// (`KIND_BUZZ_MESH_MEMBER_STATUS` in the desktop coordinator). +const KIND_MESH_STATUS: u16 = 30_003; +/// NIP-43 membership roster snapshot. +const KIND_MEMBERSHIP: u16 = 13_534; +const STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status"; +const STATUS_K_TAG: &str = "buzz-mesh-status"; + +/// Small, real instruct model; same ref the sibling mesh examples use. +const DEFAULT_MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M"; + +const SERVE_API_PORT: u16 = 19_537; +const SERVE_CONSOLE_PORT: u16 = 13_331; +const CLIENT_API_PORT: u16 = 19_538; +const CLIENT_CONSOLE_PORT: u16 = 13_332; +const STRANGER_API_PORT: u16 = 19_539; +const STRANGER_CONSOLE_PORT: u16 = 13_333; + +/// The trusted client sees the model within seconds on one box; this bounds +/// the stranger's chance to (fail to) see it. Both windows are overridable +/// via env (`MESH_CLIENT_WINDOW_SECS` / `MESH_STRANGER_WINDOW_SECS`) so CI +/// can pin longer windows on slow shared runners instead of re-running the +/// whole job. +const CLIENT_WINDOW_SECS: u64 = 180; +const STRANGER_WINDOW_SECS: u64 = 60; + +fn window_secs(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(default) +} + +fn client_window() -> Duration { + Duration::from_secs(window_secs("MESH_CLIENT_WINDOW_SECS", CLIENT_WINDOW_SECS)) +} + +fn stranger_window() -> Duration { + Duration::from_secs(window_secs( + "MESH_STRANGER_WINDOW_SECS", + STRANGER_WINDOW_SECS, + )) +} + +/// Marker the orchestrator writes to the client child's stdin to request the +/// post-attack inference re-verification. +const VERIFY_AGAIN: &str = "VERIFY_AGAIN"; + +fn main() -> anyhow::Result<()> { + match std::env::var("MESH_ROLE").ok().as_deref() { + Some("serve") => run_role(role_serve()), + Some("client") => run_role(role_client()), + Some("stranger") => run_role(role_stranger()), + _ => orchestrate(), + } +} + +/// Run a role future and exit without unwinding through C++ static +/// destructors: once the native runtime has initialized, normal process exit +/// aborts inside ggml's Metal/CPU device teardown, which would mask the real +/// error under a GGML_ASSERT backtrace. +fn run_role(role: impl std::future::Future>) -> anyhow::Result<()> { + match runtime()?.block_on(role) { + Ok(()) => std::process::exit(0), + Err(error) => { + eprintln!("[role] FAILED: {error:#}"); + std::process::exit(1); + } + } +} + +/// mesh-llm's async chains overflow tokio's default 2 MiB worker stacks; the +/// desktop and the mesh binary itself both run 8 MiB workers for this reason. +fn runtime() -> anyhow::Result { + Ok(tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(8 * 1024 * 1024) + .build()?) +} + +fn env(name: &str) -> anyhow::Result { + std::env::var(name).map_err(|_| anyhow::anyhow!("{name} is required for this role")) +} + +fn relay_ws_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +async fn init_native_runtime() -> anyhow::Result<()> { + // The dynamic host runtime installs the recommended signed native runtime + // on first use when none is cached — the same SDK-owned path the desktop + // relies on. CI caches the install dir across runs. + mesh_llm_host_runtime::initialize_host_runtime() + .await + .map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error:#}")) +} + +// ── Owner binding payloads ─────────────────────────────────────────────────── +// Byte-for-byte the desktop's `identity::member_binding_bytes` / +// `member_endpoint_binding_bytes`; the client role verifies exactly what the +// desktop coordinator publishes. Keep in sync with +// `desktop/src-tauri/src/mesh_llm/identity.rs`. + +fn member_binding_bytes(member_pubkey: &str) -> Vec { + format!( + "buzz-mesh-owner-binding-v1:{}", + member_pubkey.trim().to_ascii_lowercase() + ) + .into_bytes() +} + +fn member_endpoint_binding_bytes(member_pubkey: &str, endpoint_tokens: &[String]) -> Vec { + let mut endpoints = endpoint_tokens + .iter() + .map(|token| token.trim()) + .filter(|token| !token.is_empty()) + .collect::>(); + endpoints.sort_unstable(); + endpoints.dedup(); + + let mut digest = Sha256::new(); + for endpoint in endpoints { + digest.update((endpoint.len() as u64).to_be_bytes()); + digest.update(endpoint.as_bytes()); + } + format!( + "buzz-mesh-owner-endpoint-binding-v1:{}:{}", + member_pubkey.trim().to_ascii_lowercase(), + hex::encode(digest.finalize()) + ) + .into_bytes() +} + +// ── Relay I/O ──────────────────────────────────────────────────────────────── + +fn status_filter() -> Filter { + Filter::new() + .kind(Kind::Custom(KIND_MESH_STATUS)) + .custom_tag(SingleLetterTag::lowercase(Alphabet::K), STATUS_K_TAG) + .limit(100) +} + +fn membership_filter() -> Filter { + Filter::new().kind(Kind::Custom(KIND_MEMBERSHIP)).limit(1) +} + +async fn query_events( + relay: &mut BuzzTestClient, + filters: Vec, +) -> anyhow::Result> { + let sid = format!("mesh-lifecycle-{}", uuid::Uuid::new_v4().simple()); + relay.subscribe(&sid, filters).await?; + let events = relay + .collect_until_eose(&sid, Duration::from_secs(10)) + .await?; + relay.close_subscription(&sid).await?; + Ok(events) +} + +/// Publish this member's client-signed kind:30003 discovery note — the same +/// payload the desktop coordinator's `bind_payload_to_member` + +/// `build_status_report_event` produce. +async fn publish_status( + relay: &mut BuzzTestClient, + keys: &Keys, + owner: &OwnerKeypair, + serve_targets: &[(String, String)], +) -> anyhow::Result<()> { + let member_pubkey = keys.public_key().to_hex(); + let endpoint_tokens: Vec = serve_targets + .iter() + .map(|(_, endpoint)| endpoint.clone()) + .collect(); + let targets_json: Vec = serve_targets + .iter() + .map(|(model, endpoint)| serde_json::json!({ "modelId": model, "endpointAddr": endpoint })) + .collect(); + let models_json: Vec = serve_targets + .iter() + .map(|(model, _)| serde_json::json!({ "id": model })) + .collect(); + let payload = serde_json::json!({ + "ownerId": owner.owner_id(), + "ownerVerifyingKey": hex::encode(owner.verifying_key().as_bytes()), + "ownerBindingSig": + hex::encode(owner.sign_bytes(&member_binding_bytes(&member_pubkey))), + "ownerEndpointBindingSig": hex::encode(owner.sign_bytes( + &member_endpoint_binding_bytes(&member_pubkey, &endpoint_tokens), + )), + "serveTargets": targets_json, + "models": models_json, + }); + let d_tag = format!("{STATUS_D_TAG_PREFIX}:{}", owner.owner_id()); + let d = Tag::parse(["d", d_tag.as_str()]).map_err(|error| anyhow::anyhow!("{error}"))?; + let k = Tag::parse(["k", STATUS_K_TAG]).map_err(|error| anyhow::anyhow!("{error}"))?; + let event = EventBuilder::new(Kind::Custom(KIND_MESH_STATUS), payload.to_string()) + .tags([d, k]) + .sign_with_keys(keys)?; + let ok = relay.send_event(event).await?; + anyhow::ensure!( + ok.accepted, + "relay rejected mesh status note: {}", + ok.message + ); + Ok(()) +} + +// ── Discovery verification (mirrors desktop `discovery.rs`) ───────────────── + +fn membership_set(events: &[Event]) -> Option> { + events + .iter() + .filter(|event| event.kind.as_u16() == KIND_MEMBERSHIP) + .max_by_key(|event| event.created_at) + .map(|event| { + event + .tags + .iter() + .filter_map(|tag| { + let slice = tag.as_slice(); + let name = slice.first()?; + if name != "member" && name != "p" { + return None; + } + slice + .get(1) + .map(|pubkey| pubkey.trim().to_ascii_lowercase()) + }) + .filter(|pubkey| !pubkey.is_empty()) + .collect() + }) +} + +/// `ownerId` must equal sha256(ownerVerifyingKey) and `ownerBindingSig` must +/// verify against the note's Nostr author — a stored note cannot be re-pointed +/// at someone else's mesh identity. +fn verified_owner_id(event: &Event) -> Option { + let content = serde_json::from_str::(&event.content).ok()?; + let owner_id = content.get("ownerId")?.as_str()?.trim(); + let verifying_key_bytes: [u8; 32] = + hex::decode(content.get("ownerVerifyingKey")?.as_str()?.trim()) + .ok()? + .try_into() + .ok()?; + if owner_id != hex::encode(Sha256::digest(verifying_key_bytes)) { + return None; + } + let signature_bytes = hex::decode(content.get("ownerBindingSig")?.as_str()?.trim()).ok()?; + let signature = Signature::from_slice(&signature_bytes).ok()?; + let verifying_key = VerifyingKey::from_bytes(&verifying_key_bytes).ok()?; + verifying_key + .verify(&member_binding_bytes(&event.pubkey.to_hex()), &signature) + .ok()?; + Some(owner_id.to_string()) +} + +/// Extract `(model_id, endpoint_addr)` pairs from a status note, but only when +/// the endpoint binding signature covers exactly the advertised tokens. +fn verified_serve_targets(event: &Event) -> Vec<(String, String)> { + let Ok(content) = serde_json::from_str::(&event.content) else { + return Vec::new(); + }; + let targets: Vec<(String, String)> = content + .get("serveTargets") + .and_then(serde_json::Value::as_array) + .map(|targets| { + targets + .iter() + .filter_map(|target| { + let model = target.get("modelId")?.as_str()?.trim().to_string(); + let endpoint = target.get("endpointAddr")?.as_str()?.trim().to_string(); + (!endpoint.is_empty()).then_some((model, endpoint)) + }) + .collect() + }) + .unwrap_or_default(); + if targets.is_empty() { + return Vec::new(); + } + let endpoint_tokens: Vec = targets + .iter() + .map(|(_, endpoint)| endpoint.clone()) + .collect(); + let Some(verifying_key) = content + .get("ownerVerifyingKey") + .and_then(serde_json::Value::as_str) + .and_then(|value| hex::decode(value.trim()).ok()) + .and_then(|value| <[u8; 32]>::try_from(value).ok()) + .and_then(|value| VerifyingKey::from_bytes(&value).ok()) + else { + return Vec::new(); + }; + let Some(signature) = content + .get("ownerEndpointBindingSig") + .and_then(serde_json::Value::as_str) + .and_then(|value| hex::decode(value.trim()).ok()) + .and_then(|value| Signature::from_slice(&value).ok()) + else { + return Vec::new(); + }; + let bytes = member_endpoint_binding_bytes(&event.pubkey.to_hex(), &endpoint_tokens); + if verifying_key.verify(&bytes, &signature).is_err() { + return Vec::new(); + } + targets +} + +/// Owner ids of current members with valid owner bindings — the relay-derived +/// admission roster (`owner_ids_from_events` semantics). +fn member_owner_ids(events: &[Event]) -> BTreeSet { + let Some(members) = membership_set(events) else { + return BTreeSet::new(); + }; + events + .iter() + .filter(|event| event.kind.as_u16() == KIND_MESH_STATUS) + .filter(|event| members.contains(&event.pubkey.to_hex().to_ascii_lowercase())) + .filter_map(verified_owner_id) + .collect() +} + +// ── Roles ──────────────────────────────────────────────────────────────────── + +/// SERVE (member A): publish presence, derive the allowlist from the relay, +/// require the exact expected owner set, start an allowlist serve node, +/// publish the endpoint, park. +async fn role_serve() -> anyhow::Result<()> { + init_native_runtime().await?; + let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + let keys = Keys::parse(&env("BUZZ_MEMBER_NSEC")?)?; + let owner = load_keystore(std::path::Path::new(&env("MESH_OWNER_KEY")?), None) + .map_err(|error| anyhow::anyhow!("loading serve owner keystore: {error}"))?; + // The exact owner ids the orchestrator provisioned for members A and B. + // Waiting for this exact set (not a count) means the allowlist can only + // ever contain the intended identities. + let expected_owners: BTreeSet = env("MESH_EXPECTED_OWNERS")? + .split(',') + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect(); + anyhow::ensure!( + expected_owners.contains(&owner.owner_id()), + "serve owner id is not in MESH_EXPECTED_OWNERS" + ); + + let mut relay = BuzzTestClient::connect(&relay_ws_url(), &keys) + .await + .map_err(|error| anyhow::anyhow!("serve member relay connect: {error}"))?; + publish_status(&mut relay, &keys, &owner, &[]).await?; + println!("STATUS_PUBLISHED"); + + // TRUST: wait until every expected member owner is visible via the relay + // (statuses ∩ roster), then admit exactly those owners. + let deadline = Instant::now() + Duration::from_secs(120); + loop { + let events = query_events(&mut relay, vec![status_filter(), membership_filter()]).await?; + let mut visible = member_owner_ids(&events); + visible.insert(owner.owner_id()); + if visible.is_superset(&expected_owners) { + break; + } + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for expected owners {expected_owners:?}; saw {visible:?}" + ); + tokio::time::sleep(Duration::from_secs(2)).await; + } + let allowlist: Vec = expected_owners.iter().cloned().collect(); + println!("ALLOWLIST:{}", allowlist.join(",")); + // The upcoming serve::start() blocks through a possibly multi-minute model + // download; an idle relay socket gets closed under it. Reconnect after. + let _ = relay.disconnect().await; + + let cfg = serve::EmbeddedServeConfig::builder() + .model(&model) + .api_port(SERVE_API_PORT) + .console_port(SERVE_CONSOLE_PORT) + // Desktop no-leak invariants: never publish mesh presence, never + // auto-discover. The Buzz relay is the only discovery surface. + .publish(false) + .auto_join(false) + .discovery_mode(MeshDiscoveryMode::Nostr) + .console_ui(true) + .startup_timeout(Duration::from_secs(600)) + .owner_key(env("MESH_OWNER_KEY")?) + .owner_required(true) + .trust_policy(TrustPolicy::Allowlist) + .trust_owners(allowlist) + .build(); + let node = serve::start(cfg).await?; + let endpoint = node + .invite_token() + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("serve node produced no endpoint address"))?; + println!("ENDPOINT:{endpoint}"); + + let http = reqwest::Client::new(); + let base = node.api_base_url().to_string(); + let served = wait_for_model(&http, &base, Duration::from_secs(600)) + .await? + .ok_or_else(|| anyhow::anyhow!("serve node never loaded the model"))?; + + // ADVERTISE: refresh the status note with the live serve target, exactly + // what the desktop's 45s heartbeat publishes once serving. Fresh relay + // connection — the pre-download socket has long been idle-closed. + let mut relay = BuzzTestClient::connect(&relay_ws_url(), &keys) + .await + .map_err(|error| anyhow::anyhow!("serve member relay reconnect: {error}"))?; + publish_status( + &mut relay, + &keys, + &owner, + &[(served.clone(), endpoint.clone())], + ) + .await?; + println!("READY:{served}"); + + // Park; the orchestrator kills this process when the run is over. + loop { + tokio::time::sleep(Duration::from_secs(3600)).await; + } +} + +/// CLIENT (member B): publish presence, discover + verify the serve target +/// from the relay, dial it, prove inference routes over the mesh — then wait +/// for the orchestrator's `VERIFY_AGAIN` and re-prove inference after the +/// stranger's admission attack, so denial is differential, not absence. +async fn role_client() -> anyhow::Result<()> { + init_native_runtime().await?; + let keys = Keys::parse(&env("BUZZ_MEMBER_NSEC")?)?; + let owner = load_keystore(std::path::Path::new(&env("MESH_OWNER_KEY")?), None) + .map_err(|error| anyhow::anyhow!("loading client owner keystore: {error}"))?; + + let mut relay = BuzzTestClient::connect(&relay_ws_url(), &keys) + .await + .map_err(|error| anyhow::anyhow!("client member relay connect: {error}"))?; + publish_status(&mut relay, &keys, &owner, &[]).await?; + println!("STATUS_PUBLISHED"); + + // JOIN: poll the relay until a *verified* serve target from another member + // appears — membership roster, owner binding, and endpoint binding all + // checked, mirroring `availability_from_events`. + let deadline = Instant::now() + Duration::from_secs(900); + let (endpoint, allowlist) = loop { + let events = query_events(&mut relay, vec![status_filter(), membership_filter()]).await?; + let members = membership_set(&events).unwrap_or_default(); + let target = events + .iter() + .filter(|event| event.kind.as_u16() == KIND_MESH_STATUS) + .filter(|event| members.contains(&event.pubkey.to_hex().to_ascii_lowercase())) + .filter(|event| verified_owner_id(event).is_some_and(|id| id != owner.owner_id())) + .flat_map(verified_serve_targets) + .next(); + if let Some((_, endpoint)) = target { + let owners: Vec = member_owner_ids(&events).into_iter().collect(); + break (endpoint, owners); + } + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for a verified serve target on the relay" + ); + tokio::time::sleep(Duration::from_secs(3)).await; + }; + println!("TARGET_FOUND"); + + let cfg = client::EmbeddedClientConfig::builder() + .api_port(CLIENT_API_PORT) + .console_port(CLIENT_CONSOLE_PORT) + .publish(false) + .auto_join(false) + .discovery_mode(MeshDiscoveryMode::Nostr) + .console_ui(true) + .startup_timeout(Duration::from_secs(180)) + .owner_key(env("MESH_OWNER_KEY")?) + .owner_required(true) + .trust_policy(TrustPolicy::Allowlist) + .trust_owners(allowlist) + .build(); + let node = client::start(cfg).await?; + // The relay-discovered endpoint is the dial target — the same + // `dial_endpoint_addr` step the desktop's join watcher performs. The + // desktop's watcher retries every 15s (a first QUIC dial can time out + // while the serve node's endpoint is still warming up). mesh-llm itself + // retries internally per attempt, so keep the outer budget small. + let mut dial_result = Ok(()); + for attempt in 1..=3u32 { + dial_result = node.join_token(&endpoint).await; + match &dial_result { + Ok(()) => break, + Err(error) => { + eprintln!("[client] dial attempt {attempt}/3 failed: {error:#}"); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } + dial_result?; + + let http = reqwest::Client::new(); + let base = node.api_base_url().to_string(); + let Some(model) = wait_for_model(&http, &base, client_window()).await? else { + println!("NONE"); + let _ = node.stop().await; + std::process::exit(0); + }; + println!("SEEN:{model}"); + match try_completion(&http, &base, &model).await { + Ok(content) => println!("INFER_OK:{content}"), + Err(error) => { + println!("INFER_FAIL:{error}"); + let _ = node.stop().await; + std::process::exit(0); + } + } + + // Post-attack health proof: hold the mesh session open until the + // orchestrator has run the stranger, then prove the serve node still + // routes trusted inference. This is what makes the stranger's failure an + // admission denial rather than a dead server. + let line = tokio::task::spawn_blocking(|| { + let mut line = String::new(); + std::io::stdin().read_line(&mut line).map(|_| line) + }) + .await??; + if line.trim() == VERIFY_AGAIN { + match try_completion(&http, &base, &model).await { + Ok(content) => println!("INFER_AGAIN_OK:{content}"), + Err(error) => println!("INFER_AGAIN_FAIL:{error}"), + } + } + let _ = node.stop().await; + // Skip C++ static destructors (ggml aborts in global teardown). + std::process::exit(0); +} + +/// STRANGER (non-member C): NIP-42 auth must fail with the relay's membership +/// rejection, and the mesh must not route inference for it even with the +/// leaked endpoint address. +async fn role_stranger() -> anyhow::Result<()> { + let keys = Keys::parse(&env("BUZZ_MEMBER_NSEC")?)?; + let leaked_endpoint = env("MESH_LEAKED_ENDPOINT")?; + + // DENY (relay read): the membership-gated relay must reject the + // stranger's NIP-42 auth with its membership error specifically. Any + // other failure (relay down, timeout) is inconclusive and fails the + // test; a successful auth is a gating regression and also fails. + match BuzzTestClient::connect(&relay_ws_url(), &keys).await { + Err(error) => { + let message = error.to_string(); + if message.contains("not a relay member") { + println!("RELAY_DENIED_MEMBERSHIP"); + } else { + println!("RELAY_ERR:{message}"); + } + } + Ok(mut relay) => { + let statuses = query_events(&mut relay, vec![status_filter()]) + .await + .map(|events| { + events + .iter() + .filter(|event| event.kind.as_u16() == KIND_MESH_STATUS) + .count() + }) + .unwrap_or(usize::MAX); + println!("RELAY_AUTH_OK:{statuses}"); + let _ = relay.disconnect().await; + } + } + + // DENY (admission): dial the serve node directly with the leaked endpoint. + // The stranger's owner id is not on the allowlist, so the mesh must refuse + // to route anything to it. Note the dial itself may locally "succeed" — + // mesh-llm applies the receiving node's owner policy after the handshake — + // so the decisive probe is routed inference, cross-checked against the + // trusted client's post-attack inference by the orchestrator. + init_native_runtime().await?; + let cfg = client::EmbeddedClientConfig::builder() + .api_port(STRANGER_API_PORT) + .console_port(STRANGER_CONSOLE_PORT) + .publish(false) + .auto_join(false) + .discovery_mode(MeshDiscoveryMode::Nostr) + .console_ui(true) + .startup_timeout(Duration::from_secs(180)) + .owner_key(env("MESH_OWNER_KEY")?) + .owner_required(true) + .build(); + let node = client::start(cfg).await?; + let _ = node.join_token(&leaked_endpoint).await; + + let http = reqwest::Client::new(); + let base = node.api_base_url().to_string(); + match wait_for_model(&http, &base, stranger_window()).await? { + Some(model) => { + println!("SEEN:{model}"); + match try_completion(&http, &base, &model).await { + Ok(content) => println!("INFER_OK:{content}"), + Err(error) => println!("INFER_FAIL:{error}"), + } + } + None => println!("NONE"), + } + let _ = node.stop().await; + std::process::exit(0); +} + +// ── Orchestrator ───────────────────────────────────────────────────────────── + +fn orchestrate() -> anyhow::Result<()> { + let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + eprintln!("[lifecycle] model: {model}"); + let admin = + std::env::var("BUZZ_ADMIN_BIN").unwrap_or_else(|_| "target/ci/buzz-admin".to_string()); + anyhow::ensure!( + std::path::Path::new(&admin).exists(), + "buzz-admin binary not found at {admin} (set BUZZ_ADMIN_BIN)" + ); + + let scratch = std::env::temp_dir().join(format!("buzz-mesh-lifecycle-{}", std::process::id())); + std::fs::create_dir_all(&scratch)?; + + // Nostr identities: A (serve member), B (client member), C (stranger). + let member_a = Keys::generate(); + let member_b = Keys::generate(); + let stranger = Keys::generate(); + + // MeshLLM owner keystores, one per role. The orchestrator keeps the owner + // ids so the serve role can gate on the exact expected identity set. + let make_owner = |name: &str| -> anyhow::Result<(String, String)> { + let keypair = OwnerKeypair::generate(); + let path = scratch.join(format!("{name}.keystore.json")); + save_keystore(&path, &keypair, None, true) + .map_err(|error| anyhow::anyhow!("saving {name} keystore: {error}"))?; + Ok((path.display().to_string(), keypair.owner_id())) + }; + let (serve_key, serve_owner_id) = make_owner("serve")?; + let (client_key, client_owner_id) = make_owner("client")?; + let (stranger_key, _stranger_owner_id) = make_owner("stranger")?; + let expected_owners = format!("{serve_owner_id},{client_owner_id}"); + + // MEMBERSHIP: A and B become relay members via buzz-admin (publishes the + // kind:13534 roster snapshot). C is deliberately not added. + for (label, keys) in [("A", &member_a), ("B", &member_b)] { + let status = Command::new(&admin) + .args(["add-member", "--pubkey", &keys.public_key().to_hex()]) + .status()?; + anyhow::ensure!(status.success(), "buzz-admin add-member {label} failed"); + eprintln!( + "[lifecycle] member {label} added: {}", + keys.public_key().to_hex() + ); + } + + // Isolated HOMEs (mesh-llm keeps node identity under ~/.mesh-llm), with + // the native runtime + HF caches resolved from the real environment first. + let native_cache = std::env::var_os("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR") + .map(std::path::PathBuf::from) + .unwrap_or(real_cache_dir()?.join("mesh-llm/native-runtimes")); + let hf_cache = std::env::var_os("HF_HUB_CACHE") + .map(std::path::PathBuf::from) + .unwrap_or(real_cache_dir()?.join("huggingface/hub")); + let role_home = |name: &str| -> anyhow::Result { + let home = scratch.join(format!("{name}-home")); + std::fs::create_dir_all(&home)?; + Ok(home.display().to_string()) + }; + + let exe = std::env::current_exe()?; + let secret_hex = |keys: &Keys| format!("{}", keys.secret_key().display_secret()); + + // SERVE child (member A). + eprintln!("[lifecycle] starting SERVE member (relay-derived allowlist)..."); + let mut serve_child = Command::new(&exe) + .env("MESH_ROLE", "serve") + .env("MESH_SMOKE_MODEL", &model) + .env("BUZZ_MEMBER_NSEC", secret_hex(&member_a)) + .env("MESH_OWNER_KEY", &serve_key) + .env("MESH_EXPECTED_OWNERS", &expected_owners) + .env("HOME", role_home("serve")?) + .env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache) + .env("HF_HUB_CACHE", &hf_cache) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let serve_lines = spawn_line_reader( + serve_child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("no serve stdout"))?, + ); + let serve_guard = KillOnDrop(&mut serve_child); + expect_line(&serve_lines, "STATUS_PUBLISHED", Duration::from_secs(180))?; + eprintln!("[lifecycle] serve member published its discovery note"); + + // CLIENT child (member B) — started now so the serve node can see B's + // owner binding on the relay and admit it. stdin stays piped for the + // post-attack VERIFY_AGAIN request. + eprintln!("[lifecycle] starting CLIENT member (relay-driven join)..."); + let mut client_child = Command::new(&exe) + .env("MESH_ROLE", "client") + .env("BUZZ_MEMBER_NSEC", secret_hex(&member_b)) + .env("MESH_OWNER_KEY", &client_key) + .env("HOME", role_home("client")?) + .env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache) + .env("HF_HUB_CACHE", &hf_cache) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let client_lines = spawn_line_reader( + client_child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("no client stdout"))?, + ); + let mut client_stdin = client_child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("no client stdin"))?; + let client_guard = KillOnDrop(&mut client_child); + + let allowlist = expect_line(&serve_lines, "ALLOWLIST:", Duration::from_secs(300))?; + anyhow::ensure!( + allowlist.split(',').map(str::trim).collect::>() + == BTreeSet::from([serve_owner_id.as_str(), client_owner_id.as_str()]), + "LIFECYCLE FAIL: serve allowlist {allowlist} is not exactly the expected member owners" + ); + eprintln!("[lifecycle] PASS 1/6: relay-derived allowlist is exactly {{A, B}}: {allowlist}"); + let endpoint = expect_line(&serve_lines, "ENDPOINT:", Duration::from_secs(600))?; + eprintln!("[lifecycle] serve endpoint acquired (relay advertisement lands with READY)"); + let served = expect_line(&serve_lines, "READY:", Duration::from_secs(900))?; + eprintln!("[lifecycle] PASS 2/6: serve member ready + advertised model: {served}"); + + // Client verdict: discovery + join + first inference. + let (which, seen) = expect_one_of(&client_lines, &["SEEN:", "NONE"], Duration::from_secs(900))?; + anyhow::ensure!( + which == "SEEN:", + "LIFECYCLE FAIL: client member never saw the model via relay-driven join" + ); + eprintln!("[lifecycle] PASS 3/6: client member discovered + joined via relay, sees: {seen}"); + let (which, detail) = expect_one_of( + &client_lines, + &["INFER_OK:", "INFER_FAIL:"], + Duration::from_secs(180), + )?; + anyhow::ensure!( + which == "INFER_OK:", + "LIFECYCLE FAIL: client saw the model but inference did not route: {detail}" + ); + eprintln!("[lifecycle] PASS 4/6: inference routed over the mesh: {detail:?}"); + + // STRANGER child (C): must be denied by the relay's membership gate and + // must not route inference through the mesh. + eprintln!("[lifecycle] starting STRANGER (non-member, leaked endpoint)..."); + let mut stranger_child = Command::new(&exe) + .env("MESH_ROLE", "stranger") + .env("BUZZ_MEMBER_NSEC", secret_hex(&stranger)) + .env("MESH_OWNER_KEY", &stranger_key) + .env("MESH_LEAKED_ENDPOINT", &endpoint) + .env("HOME", role_home("stranger")?) + .env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache) + .env("HF_HUB_CACHE", &hf_cache) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let stranger_lines = spawn_line_reader( + stranger_child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("no stranger stdout"))?, + ); + let stranger_guard = KillOnDrop(&mut stranger_child); + + // Relay leg: only the relay's own membership rejection counts as denied. + let (which, detail) = expect_one_of( + &stranger_lines, + &["RELAY_DENIED_MEMBERSHIP", "RELAY_AUTH_OK:", "RELAY_ERR:"], + Duration::from_secs(120), + )?; + match which { + "RELAY_DENIED_MEMBERSHIP" => { + eprintln!("[lifecycle] PASS 5/6: relay rejected the stranger's NIP-42 auth (membership gate)"); + } + "RELAY_AUTH_OK:" => anyhow::bail!( + "LIFECYCLE FAIL: membership-gated relay authenticated a non-member (saw {detail} statuses)" + ), + _ => anyhow::bail!( + "LIFECYCLE INCONCLUSIVE: stranger relay connect failed for a non-membership reason: {detail}" + ), + } + + // Mesh leg: the stranger must not complete an inference. + let (which, detail) = expect_one_of( + &stranger_lines, + &["SEEN:", "NONE"], + stranger_window() + Duration::from_secs(300), + )?; + let stranger_infer = if which == "SEEN:" { + let model = detail; + let (verdict, body) = expect_one_of( + &stranger_lines, + &["INFER_OK:", "INFER_FAIL:"], + Duration::from_secs(180), + )?; + anyhow::ensure!( + verdict != "INFER_OK:", + "LIFECYCLE FAIL: stranger reused the leaked endpoint and inferred through {model}: {body:?}" + ); + format!("saw gossip for {model} but inference was rejected: {body}") + } else { + "saw no routed model".to_string() + }; + // Defuse the kill-guard (the stranger exits on its own after its verdict); + // dropping it here would SIGKILL the child before we can read its status. + std::mem::forget(stranger_guard); + let stranger_status = wait_child(&mut stranger_child, Duration::from_secs(60), "stranger")?; + anyhow::ensure!( + stranger_status.success(), + "LIFECYCLE INCONCLUSIVE: stranger child exited with {stranger_status}" + ); + + // Differential health proof: the trusted client must still route + // inference *after* the stranger's attempt. Without this, a serve node + // that died mid-run would make the stranger's failure look like a denial. + client_stdin.write_all(format!("{VERIFY_AGAIN}\n").as_bytes())?; + client_stdin.flush()?; + let (which, detail) = expect_one_of( + &client_lines, + &["INFER_AGAIN_OK:", "INFER_AGAIN_FAIL:"], + Duration::from_secs(180), + )?; + anyhow::ensure!( + which == "INFER_AGAIN_OK:", + "LIFECYCLE FAIL: trusted client could not infer after the stranger's attempt \ + (serve node unhealthy — stranger denial is inconclusive): {detail}" + ); + eprintln!( + "[lifecycle] PASS 6/6: stranger denied ({stranger_infer}) while trusted inference \ + still routes: {detail:?}" + ); + + eprintln!("[lifecycle] PASS: full relay-driven mesh lifecycle verified"); + drop(client_guard); + let _ = wait_child(&mut client_child, Duration::from_secs(60), "client"); + drop(serve_guard); + let _ = serve_child.wait(); + let _ = std::fs::remove_dir_all(&scratch); + Ok(()) +} + +// ── Child-process plumbing ─────────────────────────────────────────────────── + +/// Lines from a child's stdout, pumped by a dedicated reader thread so waits +/// can enforce hard deadlines (`BufRead::lines` alone blocks indefinitely). +struct ChildLines { + rx: mpsc::Receiver>, +} + +fn spawn_line_reader(stdout: ChildStdout) -> ChildLines { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in std::io::BufReader::new(stdout).lines() { + if tx.send(line).is_err() { + break; + } + } + }); + ChildLines { rx } +} + +/// Wait (with a hard deadline) for a line starting with `prefix`; returns the +/// suffix. Non-matching lines are skipped. +fn expect_line(lines: &ChildLines, prefix: &str, timeout: Duration) -> anyhow::Result { + expect_one_of(lines, &[prefix], timeout).map(|(_, rest)| rest) +} + +/// Wait (with a hard deadline) for a line starting with any of `prefixes`; +/// returns the matched prefix and the suffix. +fn expect_one_of<'a>( + lines: &ChildLines, + prefixes: &[&'a str], + timeout: Duration, +) -> anyhow::Result<(&'a str, String)> { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| anyhow::anyhow!("timed out waiting for one of {prefixes:?}"))?; + match lines.rx.recv_timeout(remaining) { + Ok(Ok(line)) => { + for prefix in prefixes { + if let Some(rest) = line.strip_prefix(prefix) { + return Ok((prefix, rest.to_string())); + } + } + } + Ok(Err(error)) => { + anyhow::bail!("child stdout read error before {prefixes:?}: {error}") + } + Err(mpsc::RecvTimeoutError::Timeout) => { + anyhow::bail!("timed out waiting for one of {prefixes:?}") + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!("child exited before printing one of {prefixes:?}") + } + } + } +} + +/// Wait for a child to exit, killing it if the deadline passes. +fn wait_child(child: &mut Child, timeout: Duration, label: &str) -> anyhow::Result { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("{label} child exceeded {timeout:?} and was killed"); + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +/// Kill the child on drop so a failed assertion never leaks a process. +struct KillOnDrop<'a>(&'a mut Child); +impl Drop for KillOnDrop<'_> { + fn drop(&mut self) { + let _ = self.0.kill(); + } +} + +/// The real user's OS cache dir, resolved before HOME is overridden for the +/// child processes. +fn real_cache_dir() -> anyhow::Result { + let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME is not set"))?; + #[cfg(target_os = "macos")] + return Ok(std::path::PathBuf::from(home).join("Library/Caches")); + #[cfg(not(target_os = "macos"))] + return Ok(std::path::PathBuf::from(home).join(".cache")); +} + +/// Poll `/models` until a model id appears or the window closes. +async fn wait_for_model( + http: &reqwest::Client, + api_base: &str, + window: Duration, +) -> anyhow::Result> { + let url = format!("{api_base}/models"); + let deadline = Instant::now() + window; + while Instant::now() < deadline { + tokio::time::sleep(Duration::from_secs(3)).await; + if let Ok(resp) = http.get(&url).send().await { + let body = resp.text().await.unwrap_or_default(); + if let Ok(json) = serde_json::from_str::(&body) { + if let Some(id) = json["data"].get(0).and_then(|m| m["id"].as_str()) { + return Ok(Some(id.to_string())); + } + } + } + } + Ok(None) +} + +/// One chat completion against a node's OpenAI endpoint; Ok(content) only if +/// it really routed and produced non-empty output. +async fn try_completion( + http: &reqwest::Client, + api_base: &str, + model: &str, +) -> anyhow::Result { + let resp = http + .post(format!("{api_base}/chat/completions")) + .timeout(Duration::from_secs(120)) + .json(&serde_json::json!({ + "model": model, + "messages": [{"role": "user", "content": "Reply with exactly one word: PONG"}], + "max_tokens": 16, + "temperature": 0.0 + })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + if !status.is_success() { + anyhow::bail!("{status}: {body}"); + } + let content = serde_json::from_str::(&body)?["choices"][0]["message"] + ["content"] + .as_str() + .unwrap_or("") + .to_string(); + if content.trim().is_empty() { + anyhow::bail!("empty content"); + } + Ok(content) +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b..ab9397e858 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ "**/add-community-screenshots.spec.ts", "**/hosted-communities-settings-screenshots.spec.ts", "**/invites-settings-screenshots.spec.ts", + "**/mesh-compute-screenshots.spec.ts", "**/messaging.spec.ts", "**/message-feedback-snapshots.spec.ts", "**/custom-emoji.spec.ts", diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2b997af891..2cdd785c73 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,6 +1,9 @@ // Shared schema, included from the same source the runtime command parses with, // so the build-time validation below and the runtime parse cannot drift. include!("src/commands/reconnect_hook_config.rs"); +// Same source of truth the runtime filters with, so a baked build env cannot +// carry a reserved key the runtime believes it already rejected. +include!("src/managed_agents/reserved_env_keys.rs"); use base64::Engine as _; @@ -13,9 +16,16 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); + // Explicit owner-only agent-access capability. Release packaging sets this + // presence-only marker; OSS/custom builds leave agent access configurable. + if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY=1"); + } + if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}"); } @@ -59,6 +69,20 @@ fn main() { line ); } + // The baked env is written into every spawned agent's environment + // LAST (see `managed_agents/runtime.rs`), after Buzz sets the + // access gates and identity vars. A baked reserved key would + // therefore silently override the gate the UI promises, so reject + // it at build time instead of shipping a binary that bypasses its + // own enforcement. + if is_reserved_env_key(key) { + panic!( + "BUZZ_BUILD_AGENT_ENV line {}: `{}` is reserved by Buzz and cannot be baked \ + into a build (it would override Buzz's own identity/access env)", + line_no + 1, + key + ); + } } let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ENV={encoded}"); diff --git a/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs b/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs index 6d99337c4b..59e94a1f63 100644 --- a/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs +++ b/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs @@ -330,7 +330,10 @@ fn child_shell_is_the_resolved_shell_not_the_inherited_one() { /// grows a new secret, this points at the file to update. #[test] fn reserved_keys_are_covered() { - let source = include_str!("../../../src/managed_agents/env_vars.rs"); + // The list lives in its own file because `build.rs` `include!`s the same + // source (see `managed_agents/reserved_env_keys.rs`); read it there rather + // than through the module that includes it. + let source = include_str!("../../../src/managed_agents/reserved_env_keys.rs"); let declared: Vec<&str> = source .lines() .skip_while(|line| !line.contains("RESERVED_ENV_KEYS")) diff --git a/desktop/src-tauri/src/commands/agent_access.rs b/desktop/src-tauri/src/commands/agent_access.rs new file mode 100644 index 0000000000..ef118e82b2 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_access.rs @@ -0,0 +1,18 @@ +/// Return whether this build enforces owner-only managed-agent access. +#[tauri::command] +pub fn agent_access_owner_only() -> bool { + crate::managed_agents::owner_only_access_build() +} + +#[cfg(test)] +mod tests { + #[test] + #[ignore = "requires BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY"] + fn compiled_policy_matches_expected() { + let expected = std::env::var("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY") + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be set") + .parse::() + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be true or false"); + assert_eq!(super::agent_access_owner_only(), expected); + } +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index e17a90cac3..dd61fc9398 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1355,9 +1355,9 @@ pub async fn delete_managed_agent( // 2. Harness sees it, exits gracefully, sets presence to "offline" // 3. Desktop's existing presence polling sees "offline" — UI updates automatically // No backend Tauri command needed. Presence IS the status. - #[path = "agents_deploy.rs"] mod deploy; +pub(super) mod provider_access; use deploy::build_deploy_payload; #[cfg(test)] use deploy::{deploy_payload_json, DeployProjections}; diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs new file mode 100644 index 0000000000..467230e56f --- /dev/null +++ b/desktop/src-tauri/src/commands/agents/provider_access.rs @@ -0,0 +1,196 @@ +//! Upgrade reconciliation for provider-backed managed-agent access. + +use tauri::AppHandle; + +use crate::{ + app_state::AppState, + managed_agents::{ + find_managed_agent_mut, load_managed_agents, save_managed_agents, BackendKind, + ManagedAgentRecord, + }, + util::now_iso, +}; + +pub(super) fn needs_reconciliation_with_policy( + record: &ManagedAgentRecord, + owner_only_access: bool, +) -> bool { + owner_only_access && record.backend != BackendKind::Local && record.backend_agent_id.is_some() +} + +#[derive(Debug)] +struct ProviderAccessTarget { + pubkey: String, + provider_id: String, + config: serde_json::Value, + cached_binary_path: Option, + agent_json: Result, +} + +fn collect_targets_with( + records: Vec, + owner_only_access: bool, + mut build_payload: impl FnMut(&ManagedAgentRecord) -> Result, +) -> Vec { + records + .into_iter() + .filter(|record| needs_reconciliation_with_policy(record, owner_only_access)) + .map(|record| match record.backend.clone() { + BackendKind::Provider { id, config } => ProviderAccessTarget { + agent_json: build_payload(&record), + pubkey: record.pubkey, + provider_id: id, + config, + cached_binary_path: record.provider_binary_path, + }, + BackendKind::Local => { + unreachable!("provider access reconciliation selected a local agent") + } + }) + .collect() +} + +/// Redeploy every existing provider agent in an owner-only access build. +/// +/// The saved `backend_agent_id` only proves that some provider deployment +/// exists. A marked build sends the current owner-only payload before each +/// community UI load. Workspace apply fails closed if any provider rejects it. +pub(crate) async fn reconcile_on_workspace_apply( + app: &AppHandle, + state: &AppState, +) -> Result<(), String> { + if !crate::managed_agents::owner_only_access_build() { + return Ok(()); + } + + let targets = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + collect_targets_with(load_managed_agents(app)?, true, |record| { + super::build_deploy_payload(app, state, record) + }) + }; + + for target in targets { + let ProviderAccessTarget { + pubkey, + provider_id, + config, + cached_binary_path, + agent_json, + } = target; + let agent_json = match agent_json { + Ok(agent_json) => agent_json, + Err(error) => { + persist_failure(app, state, &pubkey, &error)?; + return Err(format!( + "provider access reconciliation failed for agent {pubkey}: {error}" + )); + } + }; + if let Err(error) = super::deploy_to_provider( + app, + state, + &pubkey, + &provider_id, + &config, + agent_json, + cached_binary_path.as_deref(), + ) + .await + { + return Err(format!( + "provider access reconciliation failed for agent {pubkey}: {error}" + )); + } + } + + Ok(()) +} + +fn persist_failure( + app: &AppHandle, + state: &AppState, + pubkey: &str, + error: &str, +) -> Result<(), String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|lock_error| lock_error.to_string())?; + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + record.last_error = Some(error.to_string()); + record.updated_at = now_iso(); + save_managed_agents(app, &records) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(backend: BackendKind, backend_agent_id: Option<&str>) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null + })) + .unwrap(); + record.backend = backend; + record.backend_agent_id = backend_agent_id.map(str::to_string); + record + } + + #[test] + fn upgrade_collects_existing_provider_and_builds_projected_payload() { + let records = vec![ + record( + BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({"region": "test"}), + }, + Some("existing"), + ), + record( + BackendKind::Provider { + id: "not-deployed".into(), + config: serde_json::json!({}), + }, + None, + ), + record(BackendKind::Local, Some("stale")), + ]; + + let targets = collect_targets_with(records, true, |_| { + Ok(serde_json::json!({"respond_to": "owner-only"})) + }); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].pubkey, "agent"); + assert_eq!(targets[0].provider_id, "provider"); + assert_eq!(targets[0].config["region"], "test"); + assert_eq!( + targets[0].agent_json.as_ref().unwrap()["respond_to"], + "owner-only" + ); + } + + #[test] + fn unmarked_build_collects_no_upgrade_targets() { + let records = vec![record( + BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }, + Some("existing"), + )]; + + assert!( + collect_targets_with(records, false, |_| { Ok(serde_json::Value::Null) }).is_empty() + ); + } +} diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index d924394b29..47ee5f92d4 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -24,6 +24,8 @@ pub(super) struct DeployProjections { /// Effective parallelism derived from the same resolved `descriptor.command` /// as `launch.policy_env["BUZZ_ACP_AGENTS"]`. pub effective_parallelism: u32, + /// Access fields projected from the same build policy that gates local starts. + pub owner_only_access: bool, } /// Resolve the deploy-specific structured model/provider for a managed agent. @@ -170,6 +172,7 @@ pub(super) fn build_deploy_payload( effective_provider: effective.provider.value, effective_prompt: effective.system_prompt.value, effective_parallelism, + owner_only_access: crate::managed_agents::owner_only_access_build(), }, merged_user_env, launch, @@ -179,8 +182,8 @@ pub(super) fn build_deploy_payload( /// Pure serialization half of [`build_deploy_payload`]. Legacy top-level fields /// remain for display/bookkeeping; providers execute the resolved `launch` block. /// `projections.effective_parallelism` is pre-computed from the same resolved -/// descriptor as `launch.policy_env["BUZZ_ACP_AGENTS"]` — the two fields are -/// always consistent regardless of stale `record.agent_command` pins. +/// descriptor as `launch.policy_env["BUZZ_ACP_AGENTS"]`. Access is projected from +/// the same compiled policy that gates local starts. pub(super) fn deploy_payload_json( record: &ManagedAgentRecord, relay_url: String, @@ -188,6 +191,8 @@ pub(super) fn deploy_payload_json( merged_env: BTreeMap, launch: serde_json::Value, ) -> serde_json::Value { + let (respond_to, respond_to_allowlist) = + crate::managed_agents::projected_access_with_policy(record, projections.owner_only_access); serde_json::json!({ "name": &record.name, "relay_url": relay_url, @@ -204,8 +209,8 @@ pub(super) fn deploy_payload_json( // Legacy top-level field: projected from the same resolved descriptor as // launch.policy_env["BUZZ_ACP_AGENTS"] — the two are always consistent. "parallelism": projections.effective_parallelism, - "respond_to": record.respond_to, - "respond_to_allowlist": &record.respond_to_allowlist, + "respond_to": respond_to, + "respond_to_allowlist": respond_to_allowlist, "env_vars": merged_env, "launch": launch, }) @@ -363,6 +368,7 @@ mod tests { effective_provider: None, effective_prompt: None, effective_parallelism, + owner_only_access: false, }, BTreeMap::new(), launch.clone(), @@ -407,6 +413,7 @@ mod tests { effective_provider: None, effective_prompt: None, effective_parallelism, + owner_only_access: false, }, BTreeMap::new(), launch.clone(), @@ -452,6 +459,7 @@ mod tests { effective_provider: None, effective_prompt: None, effective_parallelism, + owner_only_access: false, }, BTreeMap::new(), launch.clone(), diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 20061debe7..54a03e2bab 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -411,6 +411,27 @@ fn legacy_avatar_empty_when_nothing_resolves() { // ── Provider deploy payload completeness ───────────────────────────────────── +fn deploy_payload_for_policy( + record: &ManagedAgentRecord, + owner_only_access: bool, +) -> serde_json::Value { + deploy_payload_json( + record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: Some("gpt-x".to_string()), + effective_provider: Some("openai".to_string()), + effective_prompt: None, + effective_parallelism: record.parallelism, + owner_only_access, + }, + std::collections::BTreeMap::new(), + // Access projection is the subject here; the launch block is exercised + // by the shared provider fixture test below. + serde_json::Value::Null, + ) +} + /// The shared provider fixture is the contract arbiter: it must be the exact /// richest deploy request produced by the real desktop serializers. #[test] @@ -473,6 +494,8 @@ fn deploy_payload_matches_the_shared_full_launch_fixture() { &descriptor.command, record.parallelism, ), + // Fixture asserts the record's own access fields survive. + owner_only_access: false, }, std::collections::BTreeMap::from([("USER_KEY".into(), "user-value".into())]), launch, @@ -507,3 +530,129 @@ fn tauri_platform_configs_bundle_kubernetes_only_on_supported_hosts() { ); } } + +#[test] +fn current_build_deploy_payload_forwards_compiled_policy() { + use crate::managed_agents::{BackendKind, RespondTo}; + + let expected_owner_only = match std::env::var("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY") { + Ok(value) => value + .parse::() + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be true or false"), + Err(std::env::VarError::NotPresent) + if !crate::managed_agents::owner_only_access_build() => + { + false + } + Err(std::env::VarError::NotPresent) => { + panic!( + "BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be set for owner-only-access-build tests" + ) + } + Err(std::env::VarError::NotUnicode(_)) => { + panic!("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be valid UTF-8") + } + }; + let mut record = bare_agent_record(None, None, None); + record.backend = BackendKind::Provider { + id: "provider".to_string(), + config: serde_json::json!({}), + }; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + + let payload = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: None, + effective_provider: None, + effective_prompt: None, + effective_parallelism: record.parallelism, + owner_only_access: crate::managed_agents::owner_only_access_build(), + }, + std::collections::BTreeMap::new(), + // The compiled access policy is the subject here; the launch block is + // exercised by the shared provider fixture test above. + serde_json::Value::Null, + ); + let expected_mode = if expected_owner_only { + "owner-only" + } else { + "anyone" + }; + + assert_eq!( + payload["respond_to"], expected_mode, + "current-build deploy payload did not forward the compiled policy", + ); + let expected_allowlist = if expected_owner_only { + serde_json::json!([]) + } else { + serde_json::json!(["a".repeat(64)]) + }; + assert_eq!( + payload["respond_to_allowlist"], expected_allowlist, + "current-build deploy payload did not apply the compiled policy to the stale allowlist", + ); +} + +#[test] +fn provider_upgrade_reconciliation_targets_existing_deployments_only_in_marked_builds() { + use crate::managed_agents::BackendKind; + + let mut record = bare_agent_record(None, None, None); + record.backend = BackendKind::Provider { + id: "provider".to_string(), + config: serde_json::json!({}), + }; + record.backend_agent_id = Some("existing-provider-agent".to_string()); + record.respond_to = crate::managed_agents::RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + + assert!(provider_access::needs_reconciliation_with_policy( + &record, true + )); + let payload = deploy_payload_for_policy(&record, true); + assert_eq!(payload["respond_to"], "owner-only"); + assert_eq!(payload["respond_to_allowlist"], serde_json::json!([])); + assert!(!provider_access::needs_reconciliation_with_policy( + &record, false + )); + + record.backend_agent_id = None; + assert!(!provider_access::needs_reconciliation_with_policy( + &record, true + )); + + record.backend = BackendKind::Local; + record.backend_agent_id = Some("stale-provider-id".to_string()); + assert!(!provider_access::needs_reconciliation_with_policy( + &record, true + )); +} + +#[test] +fn owner_only_access_deploy_payload_clamps_stale_access() { + use crate::managed_agents::{BackendKind, RespondTo}; + + let mut record = bare_agent_record(None, None, None); + record.backend = BackendKind::Provider { + id: "provider".to_string(), + config: serde_json::json!({}), + }; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + + let payload = deploy_payload_for_policy(&record, true); + + assert_eq!( + payload["respond_to"], "owner-only", + "owner-only-access deploy payload widened stale access" + ); + assert_eq!( + payload["respond_to_allowlist"], + serde_json::json!([]), + "owner-only-access deploy payload retained a stale allowlist" + ); +} diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index bddf2e725a..ec2357b85e 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -404,7 +404,7 @@ pub async fn import_identity( /// as a command `Err` would claim a half-applied import that actually /// succeeded. The leftover blob is still passphrase-encrypted and is /// replaced by the next backup creation; we log and move on. -fn commit_imported_identity( +pub(crate) fn commit_imported_identity( state: &AppState, data_dir: &std::path::Path, keys: nostr::Keys, diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index aaed5701ad..7e1b01113b 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -218,7 +218,9 @@ async fn query_mesh_discovery_events_at( } } -async fn query_mesh_discovery_events(state: &AppState) -> Result, String> { +pub(super) async fn query_mesh_discovery_events( + state: &AppState, +) -> Result, String> { query_mesh_discovery_events_at(state, &relay::relay_ws_url_with_override(state)).await } diff --git a/desktop/src-tauri/src/commands/mesh_snapshot.rs b/desktop/src-tauri/src/commands/mesh_snapshot.rs index f30c95f939..0bd419f2a7 100644 --- a/desktop/src-tauri/src/commands/mesh_snapshot.rs +++ b/desktop/src-tauri/src/commands/mesh_snapshot.rs @@ -22,15 +22,12 @@ type CmdResult = Result; /// state. A genuine relay/transport failure still returns `Err`. #[tauri::command] pub async fn mesh_snapshot(state: State<'_, AppState>) -> CmdResult { - let events = crate::relay::query_relay( - &state, - &[ - mesh_llm::mesh_status_filter(), - mesh_llm::relay_membership_filter(), - ], - ) - .await - .map_err(|error| format!("Shared compute status query failed: {error}"))?; + // Reuse discovery's author-scoped, composite-cursor pagination. A direct + // two-filter query stops at the status filter's 100-event page size and + // silently undercounts larger communities. + let events = super::mesh_llm::query_mesh_discovery_events(&state) + .await + .map_err(|error| format!("Shared compute status query failed: {error}"))?; // Identify this member's own device so the card can say "including yours". // A missing/locked identity is not fatal here — the snapshot is still diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 6bac9b90c6..d65da43238 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +mod agent_access; mod agent_auth; mod agent_config; mod agent_discovery; @@ -69,6 +70,7 @@ mod window_vibrancy; mod workflows; mod workspace; +pub use agent_access::*; pub use agent_auth::*; pub use agent_config::*; pub use agent_discovery::*; diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index fc874a0150..aedd67854c 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -9,7 +9,7 @@ use buzz_core_pkg::pairing::types::{AbortReason, PayloadType}; use futures_util::{SinkExt, StreamExt}; use nostr::ToBech32; use serde::Serialize; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tokio::sync::mpsc; use tokio_tungstenite::{connect_async, tungstenite::Message}; use tokio_util::sync::CancellationToken; @@ -33,16 +33,36 @@ struct PairingErrorPayload { message: String, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PairingMode { + SendIdentity, + RecoverIdentity, +} + +#[derive(Clone)] +struct PairingTaskContext { + mode: PairingMode, + generation: Arc, + generation_fence: Arc>, + task_generation: u64, +} + /// Managed Tauri state for an active pairing session. pub struct PairingHandle { session: Arc>>, generation: Arc, + /// Linearizes cancellation/replacement against recovered identity commits. + generation_fence: Arc>, + /// Serializes session setup so an older start cannot resume after relay + /// discovery and overwrite a newer session's shared state. + start_lock: tokio::sync::Mutex<()>, cancel: std::sync::Mutex>, /// Send JSON-serialized events to the background WS task for relay publication. outbound_tx: std::sync::Mutex>>, /// Pre-built payload string (contains nsec) to send after SAS confirmation. /// Wrapped in Zeroizing so the nsec is cleared from memory on drop. payload: std::sync::Mutex>>, + mode: Arc>, } impl PairingHandle { @@ -50,9 +70,12 @@ impl PairingHandle { Self { session: Arc::new(tokio::sync::Mutex::new(None)), generation: Arc::new(AtomicU64::new(0)), + generation_fence: Arc::new(std::sync::Mutex::new(())), + start_lock: tokio::sync::Mutex::new(()), cancel: std::sync::Mutex::new(None), outbound_tx: std::sync::Mutex::new(None), payload: std::sync::Mutex::new(None), + mode: Arc::new(std::sync::Mutex::new(PairingMode::SendIdentity)), } } @@ -63,21 +86,36 @@ impl PairingHandle { } } -/// Start a NIP-AB pairing session as the source device. -/// -/// Creates a `PairingSession`, connects to the relay, and returns the -/// `nostrpair://` QR URI for the frontend to display. The mobile peer will -/// receive the desktop's nsec (NIP-OA auth — no token minting needed). +/// Start a NIP-AB pairing session that sends this desktop identity to mobile. #[tauri::command] pub async fn start_pairing( app: AppHandle, state: State<'_, AppState>, pairing: State<'_, PairingHandle>, ) -> Result { - let task_generation = pairing - .generation - .fetch_add(1, Ordering::SeqCst) - .wrapping_add(1); + start_pairing_session(app, state, pairing, PairingMode::SendIdentity).await +} + +/// Start a recovery session. The fresh desktop shows the QR and receives the +/// full identity from an already-authorized phone after both users approve SAS. +#[tauri::command] +pub async fn start_identity_recovery_pairing( + app: AppHandle, + state: State<'_, AppState>, + pairing: State<'_, PairingHandle>, +) -> Result { + start_pairing_session(app, state, pairing, PairingMode::RecoverIdentity).await +} + +async fn start_pairing_session( + app: AppHandle, + state: State<'_, AppState>, + pairing: State<'_, PairingHandle>, + mode: PairingMode, +) -> Result { + let _start_guard = pairing.start_lock.lock().await; + let task_generation = + invalidate_pairing_generation(&pairing.generation, &pairing.generation_fence)?; if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } @@ -86,54 +124,52 @@ pub async fn start_pairing( let mut session = pairing.session.lock().await; *session = None; } - - let keys = state.signing_keys()?; - let nsec = keys - .secret_key() - .to_bech32() - .map_err(|e| format!("encode nsec: {e}"))?; - let pubkey_hex = keys.public_key().to_hex(); + *pairing.mode.lock().map_err(|e| e.to_string())? = mode; + *pairing.payload.lock().map_err(|e| e.to_string())? = None; let ws_url = relay_ws_url_with_override(&state); let http_url = relay_api_base_url_with_override(&state); - - // NIP-43 relays gate connections on membership, so an unpaired peer can't - // reach the main relay yet — it must go through the /pair sidecar. Open - // relays (no NIP-43) accept the peer directly. We key off the relay's - // own NIP-11 declaration of NIP-43 support rather than `auth_required`, - // which is also true for plain NIP-42 / NIP-OA relays where the main - // relay is reachable. let pairing_relay_url = resolve_pairing_relay_url(&ws_url, probe_pairing_relay(&ws_url).await)?; - let (session, qr_payload) = PairingSession::new_source(pairing_relay_url.clone()); - let qr_uri = encode_qr(&qr_payload); + let mut qr_uri = encode_qr(&qr_payload); + if mode == PairingMode::RecoverIdentity { + qr_uri.push_str("&mode=recover"); + } - let payload_json = serde_json::json!({ - "relayUrl": http_url, - "pubkey": pubkey_hex, - "nsec": nsec, - }); + if mode == PairingMode::SendIdentity { + let keys = state.signing_keys()?; + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|e| format!("encode nsec: {e}"))?; + let payload_json = serde_json::json!({ + "relayUrl": http_url, + "pubkey": keys.public_key().to_hex(), + "nsec": nsec, + }); + *pairing.payload.lock().map_err(|e| e.to_string())? = + Some(Zeroizing::new(payload_json.to_string())); + } { - let mut s = pairing.session.lock().await; - *s = Some(session); + let mut active = pairing.session.lock().await; + *active = Some(session); } - *pairing.payload.lock().map_err(|e| e.to_string())? = - Some(Zeroizing::new(payload_json.to_string())); let (outbound_tx, outbound_rx) = mpsc::channel::(16); let cancel = CancellationToken::new(); - *pairing.outbound_tx.lock().map_err(|e| e.to_string())? = Some(outbound_tx); *pairing.cancel.lock().map_err(|e| e.to_string())? = Some(cancel.clone()); - let session_arc = Arc::clone(&pairing.session); - let generation = Arc::clone(&pairing.generation); tauri::async_runtime::spawn(pairing_ws_task( pairing_relay_url, - session_arc, - generation, - task_generation, + Arc::clone(&pairing.session), + PairingTaskContext { + mode, + generation: Arc::clone(&pairing.generation), + generation_fence: Arc::clone(&pairing.generation_fence), + task_generation, + }, cancel, outbound_rx, app, @@ -161,27 +197,30 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<() tx.send(sas_confirm_json) .await - .map_err(|_| "failed to send sas-confirm")?; - - let payload = pairing - .payload - .lock() - .map_err(|e| e.to_string())? - .take() - .ok_or("no payload prepared")?; + .map_err(|_| "Pairing code expired. Create a new code and try again.")?; - let payload_json = { - let mut guard = pairing.session.lock().await; - let session = guard.as_mut().ok_or("no active pairing session")?; - let event = session - .send_payload(PayloadType::Custom, payload) - .map_err(|e| e.to_string())?; - event_to_relay_json(&event) - }; - - tx.send(payload_json) - .await - .map_err(|_| "failed to send payload")?; + let mode = *pairing.mode.lock().map_err(|e| e.to_string())?; + if mode == PairingMode::SendIdentity { + let payload = pairing + .payload + .lock() + .map_err(|e| e.to_string())? + .take() + .ok_or("no payload prepared")?; + + let payload_json = { + let mut guard = pairing.session.lock().await; + let session = guard.as_mut().ok_or("no active pairing session")?; + let event = session + .send_payload(PayloadType::Custom, payload) + .map_err(|e| e.to_string())?; + event_to_relay_json(&event) + }; + + tx.send(payload_json) + .await + .map_err(|_| "failed to send payload")?; + } Ok(()) } @@ -189,6 +228,14 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<() /// Cancel the active pairing session. #[tauri::command] pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), String> { + // Invalidate the task before waiting for its session lock. Recovery may be + // blocked on identity persistence after releasing this lock, and must see + // cancellation before crossing the durable commit boundary. + invalidate_pairing_generation(&pairing.generation, &pairing.generation_fence)?; + if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { + token.cancel(); + } + let abort_json = { let mut guard = pairing.session.lock().await; if let Some(session) = guard.as_mut() { @@ -213,11 +260,6 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str } } - pairing.generation.fetch_add(1, Ordering::SeqCst); - - if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { - token.cancel(); - } pairing.clear(); { @@ -231,8 +273,7 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str async fn pairing_ws_task( relay_url: String, session: Arc>>, - generation: Arc, - task_generation: u64, + context: PairingTaskContext, cancel: CancellationToken, mut outbound_rx: mpsc::Receiver, app: AppHandle, @@ -240,26 +281,24 @@ async fn pairing_ws_task( if let Err(e) = pairing_ws_task_inner( &relay_url, &session, - &generation, - task_generation, + &context, &cancel, &mut outbound_rx, &app, ) .await { - if pairing_task_is_current(&generation, task_generation) { + if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); } } - clear_pairing_session_if_current(&session, &generation, task_generation).await; + clear_pairing_session_if_current(&session, &context.generation, context.task_generation).await; } async fn pairing_ws_task_inner( relay_url: &str, session: &Arc>>, - generation: &AtomicU64, - task_generation: u64, + context: &PairingTaskContext, cancel: &CancellationToken, outbound_rx: &mut mpsc::Receiver, app: &AppHandle, @@ -290,14 +329,14 @@ async fn pairing_ws_task_inner( tokio::pin!(hard_timeout); loop { - if !pairing_task_is_current(generation, task_generation) { + if !pairing_task_is_current(&context.generation, context.task_generation) { break; } tokio::select! { _ = cancel.cancelled() => break, _ = &mut hard_timeout => { - if pairing_task_is_current(generation, task_generation) { + if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-error", PairingErrorPayload { message: "Session timed out".into(), }); @@ -317,7 +356,7 @@ async fn pairing_ws_task_inner( let Message::Text(text) = msg else { continue }; if let Some(event) = parse_relay_event(text.as_str(), "pair") { - if !pairing_task_is_current(generation, task_generation) { + if !pairing_task_is_current(&context.generation, context.task_generation) { break; } @@ -325,7 +364,7 @@ async fn pairing_ws_task_inner( let Some(s) = guard.as_mut() else { break }; if let Ok(reason) = s.handle_abort(&event) { - if pairing_task_is_current(generation, task_generation) { + if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-aborted", PairingAbortedPayload { reason: format!("{reason:?}"), }); @@ -334,28 +373,83 @@ async fn pairing_ws_task_inner( } if let Ok(sas) = s.handle_offer(&event) { - if pairing_task_is_current(generation, task_generation) { + if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); } continue; } - match s.handle_complete(&event) { - Ok(()) => { - if pairing_task_is_current(generation, task_generation) { - let _ = app.emit("pairing-complete", serde_json::json!({})); + if context.mode == PairingMode::RecoverIdentity { + if let Ok((payload_type, payload)) = s.handle_return_payload(&event) { + if let Err(message) = validate_recovery_payload_type(payload_type) { + let complete = s + .send_source_complete(false) + .map_err(|e| e.to_string())?; + write + .send(Message::Text(event_to_relay_json(&complete).into())) + .await + .map_err(|e| format!("publish complete failed: {e}"))?; + if pairing_task_is_current( + &context.generation, + context.task_generation, + ) { + let _ = app.emit( + "pairing-error", + PairingErrorPayload { message }, + ); + } + break; } + + let payload = payload; + drop(guard); + + let imported = import_recovered_identity( + app, + payload, + &context.generation, + &context.generation_fence, + context.task_generation, + ) + .await; + let success = imported.is_ok(); + let complete = { + let mut guard = session.lock().await; + if !pairing_task_is_current( + &context.generation, + context.task_generation, + ) { + break; + } + let Some(s) = guard.as_mut() else { break }; + s.send_source_complete(success) + .map_err(|e| e.to_string())? + }; + let completion_result = write + .send(Message::Text(event_to_relay_json(&complete).into())) + .await + .map_err(|e| format!("publish complete failed: {e}")); + finish_recovery(imported, completion_result, context, app)?; break; } - Err(ref e) if format!("{e}").contains("success=false") => { - if pairing_task_is_current(generation, task_generation) { - let _ = app.emit("pairing-error", PairingErrorPayload { - message: "Mobile device reported failure importing credentials".into(), - }); + } else { + match s.handle_complete(&event) { + Ok(()) => { + if pairing_task_is_current(&context.generation, context.task_generation) { + let _ = app.emit("pairing-complete", serde_json::json!({})); + } + break; } - break; + Err(ref e) if format!("{e}").contains("success=false") => { + if pairing_task_is_current(&context.generation, context.task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Mobile device reported failure importing credentials".into(), + }); + } + break; + } + Err(_) => {} } - Err(_) => {} } } } @@ -365,10 +459,111 @@ async fn pairing_ws_task_inner( Ok(()) } +async fn import_recovered_identity( + app: &AppHandle, + nsec: Zeroizing, + generation: &Arc, + generation_fence: &Arc>, + task_generation: u64, +) -> Result<(), String> { + let app = app.clone(); + let generation = Arc::clone(generation); + let generation_fence = Arc::clone(generation_fence); + tokio::task::spawn_blocking(move || { + let keys = nostr::Keys::parse(nsec.trim()) + .map_err(|e| format!("Phone sent an invalid identity: {e}"))?; + let state = app.state::(); + let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?; + commit_recovery_if_current(&generation, &generation_fence, task_generation, || { + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("app data dir: {e}"))?; + std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; + let key_path = data_dir.join("identity.key"); + crate::commands::identity::commit_imported_identity(&state, &data_dir, keys, |keys| { + let store = + crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir) + })?; + Ok(()) + }) + }) + .await + .map_err(|e| format!("identity recovery task failed: {e}"))? +} + +fn ensure_pairing_task_is_current( + generation: &AtomicU64, + task_generation: u64, +) -> Result<(), String> { + if pairing_task_is_current(generation, task_generation) { + Ok(()) + } else { + Err("Pairing session was superseded or cancelled".into()) + } +} + +fn invalidate_pairing_generation( + generation: &AtomicU64, + generation_fence: &std::sync::Mutex<()>, +) -> Result { + let _fence = generation_fence.lock().map_err(|e| e.to_string())?; + Ok(generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1)) +} + +fn commit_recovery_if_current( + generation: &AtomicU64, + generation_fence: &std::sync::Mutex<()>, + task_generation: u64, + commit: impl FnOnce() -> Result, +) -> Result { + let _fence = generation_fence.lock().map_err(|e| e.to_string())?; + ensure_pairing_task_is_current(generation, task_generation)?; + commit() +} + +fn recovery_result_after_completion( + imported: Result<(), String>, + _completion_result: Result<(), String>, +) -> Result<(), String> { + // Once the identity is durable, notifying the peer cannot roll it back. + imported +} + +fn finish_recovery( + imported: Result<(), String>, + completion_result: Result<(), String>, + context: &PairingTaskContext, + app: &AppHandle, +) -> Result<(), String> { + if !pairing_task_is_current(&context.generation, context.task_generation) { + return Ok(()); + } + + match recovery_result_after_completion(imported, completion_result) { + Ok(()) => { + let _ = app.emit("pairing-complete", serde_json::json!({})); + } + Err(message) => { + let _ = app.emit("pairing-error", PairingErrorPayload { message }); + } + } + Ok(()) +} + fn pairing_task_is_current(generation: &AtomicU64, task_generation: u64) -> bool { generation.load(Ordering::SeqCst) == task_generation } +fn validate_recovery_payload_type(payload_type: PayloadType) -> Result<(), String> { + if payload_type == PayloadType::Nsec { + Ok(()) + } else { + Err("Mobile device sent an unsupported recovery payload".into()) + } +} + async fn clear_pairing_session_if_current( session: &Arc>>, generation: &AtomicU64, @@ -590,143 +785,9 @@ where } #[cfg(test)] -mod pairing_generation_tests { - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Arc; - - use super::{clear_pairing_session_if_current, PairingSession}; - - #[tokio::test] - async fn stale_task_does_not_clear_replacement_session() { - let (initial, _) = PairingSession::new_source("ws://initial.example".to_string()); - let session = Arc::new(tokio::sync::Mutex::new(Some(initial))); - let generation = AtomicU64::new(1); - - generation.store(2, Ordering::SeqCst); - let (replacement, _) = PairingSession::new_source("ws://replacement.example".to_string()); - *session.lock().await = Some(replacement); - - clear_pairing_session_if_current(&session, &generation, 1).await; - - assert!(session.lock().await.is_some()); - } - - #[tokio::test] - async fn current_task_clears_its_session() { - let (active, _) = PairingSession::new_source("ws://active.example".to_string()); - let session = Arc::new(tokio::sync::Mutex::new(Some(active))); - let generation = AtomicU64::new(3); - - clear_pairing_session_if_current(&session, &generation, 3).await; - - assert!(session.lock().await.is_none()); - } -} +#[path = "pairing_generation_tests.rs"] +mod pairing_generation_tests; #[cfg(test)] -mod pairing_relay_tests { - use super::{ - pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay, - }; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - #[tokio::test] - async fn live_nip11_probe_discovers_configured_pairing_relay() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind test NIP-11 server"); - let addr = listener.local_addr().expect("test server address"); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept NIP-11 request"); - let mut request = vec![0; 2048]; - let bytes_read = stream.read(&mut request).await.expect("read request"); - let request = String::from_utf8_lossy(&request[..bytes_read]); - assert!(request.starts_with("GET / HTTP/1.1")); - assert!(request - .to_ascii_lowercase() - .contains("accept: application/nostr+json")); - - let body = r#"{"pairing_relay_url":"ws://127.0.0.1:5000"}"#; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - stream - .write_all(response.as_bytes()) - .await - .expect("write response"); - }); - - assert_eq!( - probe_pairing_relay(&format!("ws://{addr}")).await, - PairingRelay::Configured("ws://127.0.0.1:5000".to_string()) - ); - server.await.expect("NIP-11 server task"); - } - - #[test] - fn configured_pairing_relay_takes_precedence_over_legacy_path() { - let document = serde_json::json!({ - "pairing_relay_url": "wss://pairing.buzz.xyz", - "supported_nips": [43] - }); - - assert_eq!( - pairing_relay_from_nip11(&document), - PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()) - ); - } - - #[test] - fn invalid_pairing_relay_url_falls_back_to_legacy_path() { - let document = serde_json::json!({ - "pairing_relay_url": "https://pairing.buzz.xyz", - "supported_nips": [43] - }); - - assert_eq!( - pairing_relay_from_nip11(&document), - PairingRelay::LegacyPath - ); - } - - #[test] - fn document_without_pairing_configuration_uses_main_relay() { - let document = serde_json::json!({ "supported_nips": [1, 11] }); - - assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay); - } - - #[test] - fn configured_pairing_relay_resolves_to_configured_url() { - let resolved = resolve_pairing_relay_url( - "wss://flint.communities.buzz.xyz", - PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()), - ) - .expect("resolve configured pairing relay"); - - assert_eq!(resolved, "wss://pairing.buzz.xyz"); - } - - #[test] - fn legacy_pairing_relay_appends_pair_path() { - let resolved = resolve_pairing_relay_url( - "wss://flint.communities.buzz.xyz/community", - PairingRelay::LegacyPath, - ) - .expect("resolve legacy pairing relay"); - - assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community/pair"); - } - - #[test] - fn main_relay_pairing_uses_main_relay_url() { - let resolved = resolve_pairing_relay_url( - "wss://sprout-oss.stage.blox.sqprod.co", - PairingRelay::MainRelay, - ) - .expect("resolve main pairing relay"); - - assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co"); - } -} +#[path = "pairing_relay_tests.rs"] +mod pairing_relay_tests; diff --git a/desktop/src-tauri/src/commands/pairing_generation_tests.rs b/desktop/src-tauri/src/commands/pairing_generation_tests.rs new file mode 100644 index 0000000000..8a2291ae86 --- /dev/null +++ b/desktop/src-tauri/src/commands/pairing_generation_tests.rs @@ -0,0 +1,129 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use super::{ + clear_pairing_session_if_current, commit_recovery_if_current, invalidate_pairing_generation, + recovery_result_after_completion, validate_recovery_payload_type, PairingHandle, + PairingSession, PayloadType, +}; + +#[tokio::test] +async fn overlapping_starts_are_serialized() { + let pairing = Arc::new(PairingHandle::new()); + let first_pairing = Arc::clone(&pairing); + let (locked_tx, locked_rx) = tokio::sync::oneshot::channel(); + let first = tokio::spawn(async move { + let _guard = first_pairing.start_lock.lock().await; + locked_tx.send(()).expect("signal acquired start lock"); + tokio::time::sleep(Duration::from_millis(50)).await; + }); + + locked_rx.await.expect("first start acquired lock"); + assert!(pairing.start_lock.try_lock().is_err()); + first.await.expect("first start task"); + assert!(pairing.start_lock.try_lock().is_ok()); +} + +#[test] +fn recovery_rejects_non_nsec_payloads() { + assert!(validate_recovery_payload_type(PayloadType::Nsec).is_ok()); + assert_eq!( + validate_recovery_payload_type(PayloadType::Custom).unwrap_err(), + "Mobile device sent an unsupported recovery payload" + ); +} + +#[test] +fn superseded_recovery_cannot_commit_identity() { + let generation = AtomicU64::new(2); + let committed = std::sync::atomic::AtomicBool::new(false); + + let generation_fence = std::sync::Mutex::new(()); + let result = commit_recovery_if_current(&generation, &generation_fence, 1, || { + committed.store(true, Ordering::SeqCst); + Ok(()) + }); + + assert_eq!( + result.unwrap_err(), + "Pairing session was superseded or cancelled" + ); + assert!(!committed.load(Ordering::SeqCst)); +} + +#[test] +fn invalidation_after_check_waits_for_identity_commit() { + let generation = Arc::new(AtomicU64::new(7)); + let generation_fence = Arc::new(std::sync::Mutex::new(())); + let (checked_tx, checked_rx) = std::sync::mpsc::channel(); + let (finish_tx, finish_rx) = std::sync::mpsc::channel(); + let committed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let recovery_generation = Arc::clone(&generation); + let recovery_fence = Arc::clone(&generation_fence); + let recovery_committed = Arc::clone(&committed); + let recovery = std::thread::spawn(move || { + commit_recovery_if_current(&recovery_generation, &recovery_fence, 7, || { + checked_tx.send(()).expect("signal generation checked"); + finish_rx.recv().expect("release identity commit"); + recovery_committed.store(true, Ordering::SeqCst); + Ok(()) + }) + }); + + checked_rx.recv().expect("generation checked"); + let invalidation_generation = Arc::clone(&generation); + let invalidation_fence = Arc::clone(&generation_fence); + let (attempted_tx, attempted_rx) = std::sync::mpsc::channel(); + let (invalidated_tx, invalidated_rx) = std::sync::mpsc::channel(); + let invalidation = std::thread::spawn(move || { + attempted_tx.send(()).expect("signal invalidation attempt"); + let next = invalidate_pairing_generation(&invalidation_generation, &invalidation_fence) + .expect("invalidate generation"); + invalidated_tx.send(next).expect("signal invalidated"); + }); + + attempted_rx.recv().expect("invalidation attempted"); + assert!(invalidated_rx + .recv_timeout(Duration::from_millis(50)) + .is_err()); + assert!(!committed.load(Ordering::SeqCst)); + + finish_tx.send(()).expect("finish identity commit"); + recovery.join().expect("recovery task").unwrap(); + assert!(committed.load(Ordering::SeqCst)); + assert_eq!(invalidated_rx.recv().expect("invalidation completed"), 8); + invalidation.join().expect("invalidation task"); +} + +#[test] +fn completion_publish_failure_does_not_undo_successful_import() { + assert!(recovery_result_after_completion(Ok(()), Err("socket closed".into())).is_ok()); +} + +#[tokio::test] +async fn stale_task_does_not_clear_replacement_session() { + let (initial, _) = PairingSession::new_source("ws://initial.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(initial))); + let generation = AtomicU64::new(1); + + generation.store(2, Ordering::SeqCst); + let (replacement, _) = PairingSession::new_source("ws://replacement.example".to_string()); + *session.lock().await = Some(replacement); + + clear_pairing_session_if_current(&session, &generation, 1).await; + + assert!(session.lock().await.is_some()); +} + +#[tokio::test] +async fn current_task_clears_its_session() { + let (active, _) = PairingSession::new_source("ws://active.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(active))); + let generation = AtomicU64::new(3); + + clear_pairing_session_if_current(&session, &generation, 3).await; + + assert!(session.lock().await.is_none()); +} diff --git a/desktop/src-tauri/src/commands/pairing_relay_tests.rs b/desktop/src-tauri/src/commands/pairing_relay_tests.rs new file mode 100644 index 0000000000..f0e765eb9c --- /dev/null +++ b/desktop/src-tauri/src/commands/pairing_relay_tests.rs @@ -0,0 +1,104 @@ +use super::{ + pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::test] +async fn live_nip11_probe_discovers_configured_pairing_relay() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test NIP-11 server"); + let addr = listener.local_addr().expect("test server address"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept NIP-11 request"); + let mut request = vec![0; 2048]; + let bytes_read = stream.read(&mut request).await.expect("read request"); + let request = String::from_utf8_lossy(&request[..bytes_read]); + assert!(request.starts_with("GET / HTTP/1.1")); + assert!(request + .to_ascii_lowercase() + .contains("accept: application/nostr+json")); + + let body = r#"{"pairing_relay_url":"ws://127.0.0.1:5000"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write response"); + }); + + assert_eq!( + probe_pairing_relay(&format!("ws://{addr}")).await, + PairingRelay::Configured("ws://127.0.0.1:5000".to_string()) + ); + server.await.expect("NIP-11 server task"); +} + +#[test] +fn configured_pairing_relay_takes_precedence_over_legacy_path() { + let document = serde_json::json!({ + "pairing_relay_url": "wss://pairing.buzz.xyz", + "supported_nips": [43] + }); + + assert_eq!( + pairing_relay_from_nip11(&document), + PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()) + ); +} + +#[test] +fn invalid_pairing_relay_url_falls_back_to_legacy_path() { + let document = serde_json::json!({ + "pairing_relay_url": "https://pairing.buzz.xyz", + "supported_nips": [43] + }); + + assert_eq!( + pairing_relay_from_nip11(&document), + PairingRelay::LegacyPath + ); +} + +#[test] +fn document_without_pairing_configuration_uses_main_relay() { + let document = serde_json::json!({ "supported_nips": [1, 11] }); + + assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay); +} + +#[test] +fn configured_pairing_relay_resolves_to_configured_url() { + let resolved = resolve_pairing_relay_url( + "wss://flint.communities.buzz.xyz", + PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()), + ) + .expect("resolve configured pairing relay"); + + assert_eq!(resolved, "wss://pairing.buzz.xyz"); +} + +#[test] +fn legacy_pairing_relay_appends_pair_path() { + let resolved = resolve_pairing_relay_url( + "wss://flint.communities.buzz.xyz/community", + PairingRelay::LegacyPath, + ) + .expect("resolve legacy pairing relay"); + + assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community/pair"); +} + +#[test] +fn main_relay_pairing_uses_main_relay_url() { + let resolved = resolve_pairing_relay_url( + "wss://sprout-oss.stage.blox.sqprod.co", + PairingRelay::MainRelay, + ) + .expect("resolve main pairing relay"); + + assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co"); +} diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 731a99d9d9..aa88bfe39a 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -212,6 +212,8 @@ pub async fn apply_workspace( .map_err(|e| format!("spawn_blocking failed: {e}"))??; let state = restore_app.state::(); + super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?; + // Backfill this exact relay+owner scope only after the workspace has been // applied. Running at process boot would target the fallback relay and // collapse every community into one pending-event store. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e20bf1010c..b88e94e25a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -774,6 +774,7 @@ pub fn run() { get_managed_agent_log, get_agent_models, discover_agent_models, + agent_access_owner_only, get_agent_config_surface, get_runtime_file_config, get_baked_build_env_keys, @@ -882,6 +883,7 @@ pub fn run() { set_audio_output_device, get_audio_output_device, start_pairing, + start_identity_recovery_pairing, confirm_pairing_sas, cancel_pairing, apply_workspace, diff --git a/desktop/src-tauri/src/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs index da2312b457..5bcedd8975 100644 --- a/desktop/src-tauri/src/macos_notifications.rs +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -8,6 +8,7 @@ use std::{ collections::VecDeque, + path::Path, ptr::NonNull, sync::{mpsc, Mutex, OnceLock}, time::Duration, @@ -128,7 +129,7 @@ pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> { // objc2 cannot turn that exception into a Rust error, so do not call // into the framework at all in this environment. eprintln!( - "buzz-desktop: macOS notifications disabled because the process has no bundle identifier" + "buzz-desktop: macOS notifications disabled because the process is not running from an app bundle" ); return Ok(()); } @@ -293,7 +294,30 @@ pub(crate) fn take_pending_activations() -> Result, Strin } fn is_bundled_application() -> bool { - NSBundle::mainBundle().bundleIdentifier().is_some() + let bundle = NSBundle::mainBundle(); + bundle.bundleIdentifier().is_some() + && bundle.executablePath().is_some_and(|executable_path| { + is_application_bundle_layout( + Path::new(&bundle.bundlePath().to_string()), + Path::new(&executable_path.to_string()), + ) + }) +} + +fn is_application_bundle_layout(bundle_path: &Path, executable_path: &Path) -> bool { + let Some(macos_path) = executable_path.parent() else { + return false; + }; + let Some(contents_path) = macos_path.parent() else { + return false; + }; + + bundle_path + .extension() + .is_some_and(|extension| extension == "app") + && macos_path.file_name() == Some("MacOS".as_ref()) + && contents_path.file_name() == Some("Contents".as_ref()) + && contents_path.parent() == Some(bundle_path) } fn target_from_response(response: &UNNotificationResponse) -> Option { @@ -311,10 +335,12 @@ fn parse_target(serialized: &str) -> Option { #[cfg(test)] mod tests { use super::{ - is_bundled_application, parse_target, permission_state, queue_activation, - take_pending_activations, NotificationPermissionState, MAX_PENDING_ACTIVATIONS, + is_application_bundle_layout, is_bundled_application, parse_target, permission_state, + queue_activation, take_pending_activations, NotificationPermissionState, + MAX_PENDING_ACTIVATIONS, }; use objc2_user_notifications::UNAuthorizationStatus; + use std::path::Path; #[test] fn activation_queue_is_bounded_and_drained() { @@ -336,6 +362,26 @@ mod tests { assert!(!is_bundled_application()); } + #[test] + fn requires_the_executable_to_use_the_app_bundle_layout() { + assert!(is_application_bundle_layout( + Path::new("/Applications/Buzz.app"), + Path::new("/Applications/Buzz.app/Contents/MacOS/buzz-desktop"), + )); + assert!(!is_application_bundle_layout( + Path::new("/tmp/Fake.app"), + Path::new("/tmp/Fake.app/buzz-desktop"), + )); + assert!(!is_application_bundle_layout( + Path::new("/Users/developer/buzz/desktop/src-tauri/target/debug"), + Path::new("/Users/developer/buzz/desktop/src-tauri/target/debug/buzz-desktop"), + )); + assert!(!is_application_bundle_layout( + Path::new("/Applications/Buzz.app"), + Path::new("/Applications/Other.app/Contents/MacOS/buzz-desktop"), + )); + } + #[test] fn maps_native_authorization_states_to_frontend_contract() { assert_eq!( diff --git a/desktop/src-tauri/src/managed_agents/access_policy.rs b/desktop/src-tauri/src/managed_agents/access_policy.rs new file mode 100644 index 0000000000..2d8326abc3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/access_policy.rs @@ -0,0 +1,190 @@ +//! Distribution policy at managed-agent enforcement boundaries. +//! +//! ## What this build capability guarantees, and what it does not +//! +//! `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY` marks a build whose managed agents may +//! answer only their owner. Enforcement is applied at the two boundaries where +//! Desktop hands access to something that runs the agent, and nowhere else. The +//! stored record and its relay-advertised access fields are left untouched, so +//! the same profile keeps its user-chosen access when it is opened in an OSS +//! build. +//! +//! Enforced: +//! +//! - **Local spawn.** [`build_respond_to_env_with_policy`] clamps +//! `BUZZ_ACP_RESPOND_TO` to `owner-only` and pins the independent +//! `BUZZ_ACP_ALLOWED_RESPOND_TO=owner-only` guard on every start, whatever +//! the record says. +//! - **Provider deployment, including upgrades.** +//! [`projected_access_with_policy`] projects owner-only into every payload. +//! Workspace apply redeploys each existing provider agent before the marked +//! build renders community UI. A failed redeploy fails the apply, so Desktop +//! does not present the locked owner-only control as applied while the remote +//! deployment may still use a wider policy. +//! +//! ## "owner-only" is owner plus verified same-owner sibling agents +//! +//! The harness gate this projection targets admits the human owner *and* every +//! cryptographically NIP-OA-verified agent that shares that owner (see +//! `crates/buzz-acp/src/lib.rs`). That is the intended boundary, not an +//! oversight: an owner's own agents are inside their trust boundary, and Buzz's +//! built-in Welcome team relies on it, because the lead instructs its teammates +//! while every teammate is created owner-only (see +//! `welcomeTeammateHasExpectedAccess` in +//! `desktop/src/features/onboarding/welcomeGuide.ts`). Read every use of +//! "owner-only" in this module as `owner ∪ verified same-owner agents`. The +//! setting's own copy says so: the line under Only me reads "Only you and your +//! agents can send instructions." (`RespondToField.tsx`). The dropdown label +//! stays "Only me", which is the audience the user picks. + +use super::{validate_respond_to_allowlist, ManagedAgentRecord, RespondTo}; + +pub(crate) type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); + +/// Release packaging sets `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY`; OSS/custom +/// builds do not. +pub(crate) fn owner_only_access_build() -> bool { + option_env!("BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY").is_some() +} + +pub(crate) fn owner_only() -> bool { + owner_only_with_policy(owner_only_access_build()) +} + +pub(crate) fn owner_only_with_policy(owner_only_access: bool) -> bool { + owner_only_access +} + +/// Project effective access at a behavioral boundary without changing the +/// stored or relay-advertised access fields. +pub(crate) fn projected_access_with_policy( + record: &ManagedAgentRecord, + owner_only_access: bool, +) -> (RespondTo, Vec) { + if owner_only_with_policy(owner_only_access) { + (RespondTo::OwnerOnly, Vec::new()) + } else { + (record.respond_to, record.respond_to_allowlist.clone()) + } +} + +/// Build the inbound-author access environment for a launched agent. The +/// explicit policy input keeps owner-only access enforcement testable without +/// weakening the production caller's compile-time decision. +pub(crate) fn build_respond_to_env_with_policy( + record: &ManagedAgentRecord, + owner_hex: Option<&str>, + enforced_owner_only: bool, +) -> Result { + let (respond_to, _) = projected_access_with_policy(record, enforced_owner_only); + let normalized = validate_respond_to_allowlist(&record.respond_to_allowlist)?; + if respond_to == RespondTo::Allowlist && normalized.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + + let mut set = vec![("BUZZ_ACP_RESPOND_TO", respond_to.as_str().to_string())]; + let mut remove = Vec::new(); + if enforced_owner_only { + set.push(( + "BUZZ_ACP_ALLOWED_RESPOND_TO", + RespondTo::OwnerOnly.as_str().to_string(), + )); + } else { + remove.push("BUZZ_ACP_ALLOWED_RESPOND_TO"); + } + if respond_to == RespondTo::Allowlist { + set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); + } else { + remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); + } + + if record.auth_tag.is_none() { + if let Some(owner) = owner_hex { + set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + Ok((set, remove)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::BackendKind; + + fn record(backend: BackendKind) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null + })) + .unwrap(); + record.backend = backend; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + record + } + + #[test] + fn owner_only_access_policy_rejects_malformed_stored_allowlist_before_clamping() { + let mut record = record(BackendKind::Local); + record.respond_to_allowlist = vec!["malformed stale allowlist".into()]; + + let error = build_respond_to_env_with_policy(&record, Some("owner"), true) + .expect_err("owner-only access policy accepted a malformed stored allowlist"); + + assert!( + error.contains("invalid pubkey in respond-to allowlist"), + "owner-only access policy returned the wrong malformed-allowlist error: {error}", + ); + } + + #[test] + fn owner_only_access_enforcement_clamps_local_and_provider() { + for (label, backend) in [ + ("local", BackendKind::Local), + ( + "provider", + BackendKind::Provider { + id: "p".into(), + config: serde_json::json!({}), + }, + ), + ] { + let record = record(backend); + let (gate_set, _) = + build_respond_to_env_with_policy(&record, Some("owner"), true).unwrap(); + let gate_set: std::collections::HashMap<_, _> = gate_set.into_iter().collect(); + assert_eq!( + gate_set.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only"), + "owner-only runtime env did not clamp {label} agent", + ); + assert_eq!( + gate_set + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only"), + "owner-only runtime env omitted the {label} agent guard", + ); + + let (respond_to, allowlist) = projected_access_with_policy(&record, true); + assert_eq!( + respond_to, + RespondTo::OwnerOnly, + "owner-only provider payload did not clamp {label} agent", + ); + assert!( + allowlist.is_empty(), + "owner-only provider payload retained {label} agent allowlist", + ); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/agent_env.rs b/desktop/src-tauri/src/managed_agents/agent_env.rs index bf6bcb2298..05979e76cb 100644 --- a/desktop/src-tauri/src/managed_agents/agent_env.rs +++ b/desktop/src-tauri/src/managed_agents/agent_env.rs @@ -58,6 +58,19 @@ fn build_env_map( } } } + // Defense in depth. `build.rs` already refuses to bake a reserved key, so + // reaching this filter means the binary was produced by a build that + // skipped that check. Drop the key rather than let it override the access + // gate: the baked map is written into the spawned agent's environment last + // (see `managed_agents/runtime.rs`), so a baked `BUZZ_ACP_RESPOND_TO` would + // otherwise win over the gate Desktop just set. + map.retain(|key, _| { + if super::env_vars::is_reserved_env_key(key) { + eprintln!("buzz-desktop: ignoring reserved env var `{key}` from the baked build env"); + return false; + } + true + }); map } @@ -356,4 +369,66 @@ mod tests { "unrelated merged_env keys must pass through unchanged" ); } + + // ── baked reserved-key filtering ────────────────────────────────────── + // + // The baked map is written into a spawned agent's environment LAST (see + // `managed_agents/runtime.rs`), after Buzz sets the access gates. If a + // baked reserved key survived here, an internal build packaged with + // `BUZZ_ACP_RESPOND_TO=anyone` would answer anyone while the UI shows + // "Only me". `build.rs` rejects such a key at build time; these tests pin + // the runtime backstop for a binary built without that check. + + #[test] + fn build_env_map_drops_baked_access_gate_keys() { + use base64::Engine as _; + let raw = "BUZZ_ACP_RESPOND_TO=anyone\nBUZZ_ACP_ALLOWED_RESPOND_TO=anyone\nBUZZ_ACP_RESPOND_TO_ALLOWLIST=deadbeef\nDATABRICKS_MODEL=goose-claude-opus-4-8"; + let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + let map = build_env_map(None, None, Some(&blob)); + for key in [ + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + ] { + assert!( + !map.contains_key(key), + "baked `{key}` must not reach the spawned agent env" + ); + } + assert_eq!( + map.get("DATABRICKS_MODEL").map(String::as_str), + Some("goose-claude-opus-4-8"), + "non-reserved baked keys must still pass through" + ); + } + + #[test] + fn build_env_map_drops_baked_reserved_keys_case_insensitively() { + use base64::Engine as _; + // `is_reserved_env_key` compares case-insensitively, and so must the + // baked filter: env lookup is case-sensitive on Unix, but a lowercase + // spelling would still be a reserved key smuggled past a case-sensitive + // check on Windows. + let raw = "buzz_acp_respond_to=anyone\nBuzz_Private_Key=nsec1fake"; + let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + let map = build_env_map(None, None, Some(&blob)); + assert!( + map.is_empty(), + "reserved keys in any casing must be dropped from the baked env: {map:?}" + ); + } + + #[test] + fn build_env_map_drops_every_reserved_key() { + use base64::Engine as _; + for key in super::super::env_vars::RESERVED_ENV_KEYS { + let raw = format!("{key}=baked-value"); + let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + let map = build_env_map(None, None, Some(&blob)); + assert!( + map.is_empty(), + "baked reserved key `{key}` must be dropped, got {map:?}" + ); + } + } } diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index f912d5bbc0..9ca5fd080d 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -5,19 +5,13 @@ //! Precedence: desktop parent env < persona env < agent env (last wins on //! key collision). See `runtime::spawn_agent_child`. //! -//! A small set of *reserved* keys — Buzz's identity and secrets, and -//! control-plane values set by the Desktop — are rejected at save time and -//! stripped at runtime so a typo or malicious value can't swap the agent's -//! nsec or bypass a harness-specific execution cap. Behavior knobs +//! A small set of *reserved* keys includes Buzz's identity, secrets, security +//! gates, and control-plane values. Save-time validation rejects those keys. +//! Runtime filtering strips old persisted overrides. Behavior knobs //! (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely -//! overridable — those have dedicated UI fields, but power users may want -//! to bypass them. -//! -//! `BUZZ_ACP_AGENTS` is reserved because the Desktop resolves the effective -//! parallelism (applying per-harness caps such as OpenClaw's cap of 5) and -//! writes the result into `launch.policy_env`. A user-supplied -//! `BUZZ_ACP_AGENTS` would bypass the cap and cause OpenClaw agents to spawn -//! uncapped workers against their single shared Gateway daemon. +//! overridable. Power users can still bypass their dedicated UI fields. +//! `BUZZ_ACP_AGENTS` is reserved because Desktop applies harness-specific caps +//! before it writes the provider launch policy. use std::collections::BTreeMap; @@ -47,77 +41,9 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool { .any(|k| k.eq_ignore_ascii_case(key)) } -/// Env var keys that Buzz sets itself and users must not override from -/// the persona/agent env_vars UI. Four categories: -/// -/// 1. **Identity / secrets** — overriding would swap the agent's nsec or -/// leak credentials. -/// 2. **Code-execution surface** — overriding the binary/args lets the -/// user run arbitrary code as the agent process. -/// 3. **Security gates** — overriding the respond-to mode/allowlist or -/// relay URL would silently break the saved security settings (the UI -/// shows owner-only while the running agent answers anyone, for -/// example), or redirect the agent to an attacker-controlled relay. -/// 4. **Control-plane execution policy** — the Desktop owns the effective -/// value, derived from structured record fields after applying per-harness -/// caps. A user-supplied override would bypass the cap and produce a -/// worker pool size that neither the record nor the UI represents. -/// -/// This list is deliberately narrow — it only covers keys with security or -/// correctness implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, -/// BUZZ_ACP_SYSTEM_PROMPT, …) remain freely overridable; those have -/// dedicated UI fields but power users may want to bypass them. -pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ - // Identity / secrets. - "BUZZ_PRIVATE_KEY", - "NOSTR_PRIVATE_KEY", - "BUZZ_AUTH_TAG", - "BUZZ_API_TOKEN", - "BUZZ_ACP_PRIVATE_KEY", - "BUZZ_ACP_API_TOKEN", - // Relay URL: overriding would let a malicious config redirect the - // agent to an attacker-controlled relay. - "BUZZ_RELAY_URL", - // Code-execution surface: overriding would let the user run arbitrary - // binaries/args as the agent process. - "BUZZ_ACP_AGENT_COMMAND", - "BUZZ_ACP_AGENT_ARGS", - "BUZZ_ACP_MCP_COMMAND", - // Control-plane parallelism: the Desktop resolves the effective - // worker-pool size (applying any per-harness cap) and writes it into - // launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the - // harness cap and cause OpenClaw agents to spawn uncapped workers. - "BUZZ_ACP_AGENTS", - // Security gates: respond-to mode + allowlist + legacy owner-only - // fallback. Overriding would make the running agent's gate diverge - // from the saved/UI-visible settings. - "BUZZ_ACP_RESPOND_TO", - "BUZZ_ACP_RESPOND_TO_ALLOWLIST", - "BUZZ_ACP_AGENT_OWNER", - // Stable agent identity used for git attribution and private-conversation - // provenance must come from the managed-agent record, not user overrides. - "BUZZ_ACP_DISPLAY_NAME", - // Remote lifetime/presence policy: user env must not disable the - // desktop/provider-owned bounds while the saved record still promises them. - "BUZZ_ACP_EXIT_AFTER_INACTIVITY", - "BUZZ_ACP_NO_PRESENCE", - // Readiness handoff: desktop is the ONLY readiness source. A saved or - // ambient env var must not be able to forge setup mode (NotReady) on a - // Ready agent or suppress it (empty/stale payload) on a NotReady one. - "BUZZ_ACP_SETUP_PAYLOAD", - // Desktop ownership markers: these brand every spawned harness with the - // launching Desktop instance. A user-supplied override would let a - // definition masquerade as a different instance or fake the nonce used - // for same-session sweep decisions. - "BUZZ_MANAGED_AGENT", - "BUZZ_MANAGED_AGENT_START_NONCE", -]; - -pub(crate) fn is_reserved_env_key(key: &str) -> bool { - RESERVED_ENV_KEYS - .iter() - .any(|reserved| reserved.eq_ignore_ascii_case(key)) -} +// Canonical reserved-key list + predicate, shared verbatim with `build.rs`. +// See `reserved_env_keys.rs` for why this is `include!`d rather than a module. +include!("reserved_env_keys.rs"); /// Returns true if `key` is a well-formed POSIX-shaped env var name: /// `[A-Za-z_][A-Za-z0-9_]*`. This is a hard requirement, not a stylistic diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index 534c2e0835..34cdfede2c 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -150,7 +150,11 @@ fn reserved_keys_include_respond_to_gate() { // Respond-to mode + allowlist control who the agent answers. // Overriding via env_vars would let the running agent answer // anyone even when the UI/record says owner-only. - for key in ["BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST"] { + for key in [ + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + ] { assert!(is_reserved_env_key(key), "{key} should be reserved"); let agent = map(&[(key, "anyone")]); let merged = merged_user_env(&BTreeMap::new(), &agent); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 986ce4e0c0..fe90ce430f 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,8 +1,10 @@ +pub(crate) mod access_policy; mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; pub(crate) mod agent_snapshot_envelope; pub(crate) mod team_snapshot; +pub(crate) use access_policy::{owner_only, owner_only_access_build, projected_access_with_policy}; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs new file mode 100644 index 0000000000..8698d3a51d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -0,0 +1,79 @@ +// Canonical reserved-env-key list, `include!`d into BOTH `build.rs` +// (compile-time rejection of baked `BUZZ_BUILD_AGENT_ENV` collisions) and +// `managed_agents/env_vars.rs` (save-time validation and spawn-time +// filtering). Build scripts cannot import from the crate, so sharing the +// source via `include!` is what guarantees the build-time check and the +// runtime filter use one identical list — zero drift surface. See +// `commands/reconnect_hook_config.rs` for the same pattern. +// +// Keep this file dependency-free: no crate-internal imports, no external +// crates. Both consumers compile it as-is. + +/// Env var keys that Buzz sets itself and users must not override from +/// the persona/agent env_vars UI. Three categories: +/// +/// 1. **Identity / secrets** — overriding would swap the agent's nsec or +/// leak credentials. +/// 2. **Code-execution surface** — overriding the binary/args lets the +/// user run arbitrary code as the agent process. +/// 3. **Security gates** — overriding the respond-to mode/allowlist or +/// relay URL would silently break the saved security settings (the UI +/// shows owner-only while the running agent answers anyone, for +/// example), or redirect the agent to an attacker-controlled relay. +/// +/// This list is deliberately narrow — it only covers keys with security +/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely +/// overridable; those have dedicated UI fields but power users may want +/// to bypass them. +pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ + // Identity / secrets. + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + // Relay URL: overriding would let a malicious config redirect the + // agent to an attacker-controlled relay. + "BUZZ_RELAY_URL", + // Code-execution surface: overriding would let the user run arbitrary + // binaries/args as the agent process. + "BUZZ_ACP_AGENT_COMMAND", + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_MCP_COMMAND", + // Control-plane parallelism: the Desktop resolves the effective + // worker-pool size (applying any per-harness cap) and writes it into + // launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the + // harness cap and cause OpenClaw agents to spawn uncapped workers. + "BUZZ_ACP_AGENTS", + // Security gates: respond-to mode + allowlist + deployment allowlist + + // legacy owner-only fallback. Overriding would make the running agent's + // gate diverge from the saved/UI-visible settings. + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + "BUZZ_ACP_AGENT_OWNER", + // Stable agent identity used for git attribution and private-conversation + // provenance must come from the managed-agent record, not user overrides. + "BUZZ_ACP_DISPLAY_NAME", + // Remote lifetime/presence policy: user env must not disable the + // desktop/provider-owned bounds while the saved record still promises them. + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + "BUZZ_ACP_NO_PRESENCE", + // Readiness handoff: desktop is the ONLY readiness source. A saved or + // ambient env var must not be able to forge setup mode (NotReady) on a + // Ready agent or suppress it (empty/stale payload) on a NotReady one. + "BUZZ_ACP_SETUP_PAYLOAD", + // Desktop ownership markers: these brand every spawned harness with the + // launching Desktop instance. A user-supplied override would let a + // definition masquerade as a different instance or fake the nonce used + // for same-session sweep decisions. + "BUZZ_MANAGED_AGENT", + "BUZZ_MANAGED_AGENT_START_NONCE", +]; + +pub(crate) fn is_reserved_env_key(key: &str) -> bool { + RESERVED_ENV_KEYS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(key)) +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 7afd80d1d6..ec804869c4 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -16,9 +16,9 @@ use crate::{ mod path; pub(in crate::managed_agents) use path::build_augmented_path; -pub(crate) use path::compose_path_entries; -pub(crate) use path::should_skip_claude_executable; -pub(crate) use path::should_use_inherited; +pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited}; + +pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondToEnv}; mod metadata; pub(crate) use metadata::{ @@ -33,8 +33,6 @@ pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; mod sweep; pub(crate) use sweep::sweep_untracked_bundle_harnesses; -type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); - mod process; #[cfg(test)] use process::{ @@ -370,44 +368,7 @@ pub(crate) fn build_respond_to_env( record: &ManagedAgentRecord, owner_hex: Option<&str>, ) -> Result { - // Defensive re-validation: an on-disk record could have been hand-edited. - let normalized = super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?; - if record.respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), - ); - } - - let mut set: Vec<(&'static str, String)> = Vec::new(); - let mut remove: Vec<&'static str> = Vec::new(); - - set.push(( - "BUZZ_ACP_RESPOND_TO", - record.respond_to.as_str().to_string(), - )); - - if record.respond_to == super::types::RespondTo::Allowlist { - set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); - } else { - remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); - } - - // Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without - // it the harness can't resolve the owner, and owner-dependent gate modes - // would drop every event. Forwarding the workspace owner pubkey via - // BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records - // (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before. - if record.auth_tag.is_none() { - if let Some(owner) = owner_hex { - set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - - Ok((set, remove)) + build_respond_to_env_with_policy(record, owner_hex, super::owner_only()) } pub(crate) fn configure_runtime_cli( @@ -1015,5 +976,8 @@ pub fn start_managed_agent_process( Ok(()) } +#[cfg(test)] +mod test_fixtures; + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs new file mode 100644 index 0000000000..9836d983ed --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -0,0 +1,93 @@ +use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; + +pub(super) const EXPECTED_ACCESS_ENV: &str = "BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY"; + +pub(super) fn expected_owner_only() -> bool { + match std::env::var(EXPECTED_ACCESS_ENV) { + Ok(value) => value + .parse::() + .unwrap_or_else(|_| panic!("{EXPECTED_ACCESS_ENV} must be true or false")), + Err(std::env::VarError::NotPresent) + if !crate::managed_agents::owner_only_access_build() => + { + false + } + Err(std::env::VarError::NotPresent) => { + panic!("{EXPECTED_ACCESS_ENV} must be set for owner-only-access-build tests") + } + Err(std::env::VarError::NotUnicode(_)) => { + panic!("{EXPECTED_ACCESS_ENV} must be valid UTF-8") + } + } +} + +pub(super) fn expected_mode(oss_mode: &'static str) -> &'static str { + if expected_owner_only() { + "owner-only" + } else { + oss_mode + } +} + +/// Construct a minimal record fixture for runtime tests. +pub(super) fn fixture( + respond_to: RespondTo, + allowlist: Vec, + auth_tag: Option, +) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "p".into(), + name: "n".into(), + persona_id: None, + private_key_nsec: "nsec1fake".into(), + auth_tag, + relay_url: "ws://localhost:3000".into(), + avatar_url: None, + acp_command: "buzz-acp".into(), + agent_command: "goose".into(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "now".into(), + updated_at: "now".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to, + respond_to_allowlist: allowlist, + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index bea4b1c3e3..762b0fe2a6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -117,73 +117,10 @@ fn unknown_command_returns_none() { // ── build_respond_to_env tests ─────────────────────────────────────── -use super::build_respond_to_env; +use super::test_fixtures::{expected_mode, expected_owner_only, fixture}; +use super::{build_respond_to_env, build_respond_to_env_with_policy}; use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; -/// Construct a minimal record fixture for env-building tests. Only the -/// fields read by `build_respond_to_env` matter here. -fn fixture( - respond_to: RespondTo, - allowlist: Vec, - auth_tag: Option, -) -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "p".into(), - name: "n".into(), - persona_id: None, - private_key_nsec: "nsec1fake".into(), - auth_tag, - relay_url: "ws://localhost:3000".into(), - avatar_url: None, - acp_command: "buzz-acp".into(), - agent_command: "goose".into(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 320, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - env_vars: std::collections::BTreeMap::new(), - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: Default::default(), - backend_agent_id: None, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: "now".into(), - updated_at: "now".into(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to, - respond_to_allowlist: allowlist, - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - } -} - #[test] fn build_env_owner_only_sets_mode_and_removes_others() { let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into())); @@ -195,6 +132,18 @@ fn build_env_owner_only_sets_mode_and_removes_others() { ); assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + if expected_owner_only() { + assert_eq!( + set_map + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only") + ); + assert!(!remove.contains(&"BUZZ_ACP_ALLOWED_RESPOND_TO")); + } else { + assert!(!set_map.contains_key("BUZZ_ACP_ALLOWED_RESPOND_TO")); + assert!(remove.contains(&"BUZZ_ACP_ALLOWED_RESPOND_TO")); + } // auth_tag is present → no AGENT_OWNER fallback fires. assert!(remove.contains(&"BUZZ_ACP_AGENT_OWNER")); } @@ -214,14 +163,19 @@ fn build_env_allowlist_sets_both_envs_and_joins() { let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); assert_eq!( set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), - Some("allowlist") - ); - assert_eq!( - set_map - .get("BUZZ_ACP_RESPOND_TO_ALLOWLIST") - .map(String::as_str), - Some(format!("{a},{b}").as_str()), + Some(expected_mode("allowlist")), + "runtime wrapper did not apply the declared build policy", ); + if expected_owner_only() { + assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + } else { + assert_eq!( + set_map + .get("BUZZ_ACP_RESPOND_TO_ALLOWLIST") + .map(String::as_str), + Some(format!("{a},{b}").as_str()), + ); + } } #[test] @@ -231,7 +185,30 @@ fn build_env_anyone_omits_allowlist_var() { let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); assert_eq!( set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), - Some("anyone") + Some(expected_mode("anyone")), + "runtime wrapper did not apply the declared build policy", + ); + assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); +} + +#[test] +fn owner_only_access_policy_overrides_stale_anyone_record_at_runtime() { + let rec = fixture(RespondTo::Anyone, vec!["a".repeat(64)], Some("tag".into())); + let (set, remove) = build_respond_to_env_with_policy(&rec, Some("owner"), true).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + + assert_eq!( + set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only"), + "owner-only-access runtime env widened stale access", + ); + assert_eq!( + set_map + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only"), + "owner-only-access runtime env omitted the owner-only guard", ); assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); @@ -271,8 +248,17 @@ fn build_env_rejects_corrupted_allowlist() { #[test] fn build_env_rejects_empty_allowlist_in_allowlist_mode() { let rec = fixture(RespondTo::Allowlist, vec![], Some("tag".into())); - let err = build_respond_to_env(&rec, Some("owner")).unwrap_err(); - assert!(err.contains("at least one pubkey")); + if expected_owner_only() { + let (set, _) = build_respond_to_env(&rec, Some("owner")).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only") + ); + } else { + let err = build_respond_to_env(&rec, Some("owner")).unwrap_err(); + assert!(err.contains("at least one pubkey")); + } } // ── persona fixture helpers ───────────────────────────────────────── diff --git a/desktop/src-tauri/src/mesh_llm/snapshot.rs b/desktop/src-tauri/src/mesh_llm/snapshot.rs index bc95d6d42a..b76b4bd12d 100644 --- a/desktop/src-tauri/src/mesh_llm/snapshot.rs +++ b/desktop/src-tauri/src/mesh_llm/snapshot.rs @@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize}; use super::discovery::{ device_name_from_status, endpoint_binding_is_valid, endpoint_id_from_status, - latest_membership_list, owner_id_from_status_event, status_is_fresh, + latest_membership_list, owner_id_from_status_event, status_is_fresh, STATUS_FRESHNESS_SECS, }; use super::{MeshServeTarget, MESH_STATUS_KIND}; @@ -62,6 +62,13 @@ pub struct MeshSnapshotDevice { pub state: MeshDeviceState, /// Whether this is the local member's own device. pub is_self: bool, + /// Community member who signed this status note. Safe for profile lookup; + /// endpoint tokens, owner signatures, and raw hardware remain excluded. + pub member_pubkey: Option, + /// Relay event creation time, in Unix seconds. + pub reported_at: Option, + /// Approximate hosted-model footprint reported by MeshLLM, when available. + pub model_size_gb: Option, } /// What the community's shared compute looks like right now. @@ -70,10 +77,15 @@ pub struct MeshSnapshotDevice { pub struct MeshSnapshot { /// Devices advertising at least one routable model. pub sharing_device_count: usize, + /// Unique current members with at least one ready serving device. + pub contributor_member_count: usize, /// Summed shared capacity across sharing devices. `None` when no sharing /// device published a figure — the UI then shows a count with no GB, never /// a misleading `0 GB`. pub shared_capacity_gb: Option, + /// Summed model footprint only when every sharing device reports it. + /// `None` preserves mixed-version honesty instead of presenting a partial sum. + pub allocated_capacity_gb: Option, /// Distinct models ready to use, deduped across devices. pub models: Vec, /// Per-device rows, for the topology view. Sharing devices first. @@ -87,6 +99,10 @@ pub struct MeshSnapshot { /// note, so their hardware is unknown by design — they never consented to /// disclose it. Ghost nodes in the topology are a count, never GB. pub member_count: usize, + /// When this projection was evaluated, in Unix seconds. + pub observed_at: u64, + /// Maximum age of a relay-reported serving status. + pub freshness_seconds: u64, /// Why the snapshot is empty, when it is. Never a hard error: an empty /// mesh is a normal, expected state. pub reason: Option, @@ -95,6 +111,8 @@ pub struct MeshSnapshot { impl MeshSnapshot { fn empty(reason: impl Into) -> Self { Self { + observed_at: nostr::Timestamp::now().as_secs(), + freshness_seconds: STATUS_FRESHNESS_SECS, reason: Some(reason.into()), ..Default::default() } @@ -204,6 +222,9 @@ pub fn snapshot_from_events( state: device_state(&content, !models.is_empty()), models, is_self, + member_pubkey: Some(event.pubkey.to_hex()), + reported_at: Some(event.created_at.as_secs()), + model_size_gb: f64_field(&content, "model_size_gb"), }, ); } @@ -224,6 +245,11 @@ pub fn snapshot_from_events( .filter(|device| device.state == MeshDeviceState::Serving) .collect::>(); let sharing_device_count = sharing.len(); + let contributor_member_count = sharing + .iter() + .filter_map(|device| device.member_pubkey.as_ref()) + .collect::>() + .len(); let includes_self = sharing.iter().any(|device| device.is_self); // Sum only reported figures; keep `None` when nobody reported one so the // UI can drop the number instead of printing `0 GB`. @@ -231,6 +257,14 @@ pub fn snapshot_from_events( .iter() .filter_map(|device| device.capacity_gb) .fold(None::, |acc, gb| Some(acc.unwrap_or(0.0) + gb)); + let allocated_capacity_gb = (!sharing.is_empty() + && sharing.iter().all(|device| device.model_size_gb.is_some())) + .then(|| { + sharing + .iter() + .filter_map(|device| device.model_size_gb) + .sum() + }); let mut models = sharing .iter() .flat_map(|device| device.models.iter().cloned()) @@ -246,11 +280,15 @@ pub fn snapshot_from_events( MeshSnapshot { member_count: members.len(), + contributor_member_count, sharing_device_count, shared_capacity_gb, + allocated_capacity_gb, models, devices, includes_self, + observed_at: now, + freshness_seconds: STATUS_FRESHNESS_SECS, reason, } } @@ -265,6 +303,8 @@ mod tests { assert_eq!(snapshot.sharing_device_count, 0); assert_eq!(snapshot.shared_capacity_gb, None); assert!(snapshot.reason.is_some()); + assert_eq!(snapshot.freshness_seconds, STATUS_FRESHNESS_SECS); + assert!(snapshot.observed_at > 0); } #[test] diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 0d4417a754..39950d5902 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -36,7 +36,7 @@ ], "macOSPrivateApi": true, "security": { - "csp": null + "csp": "default-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net/npm/@mediapipe/; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost buzz-media: http://buzz-media.localhost https: http: wss: ws:; img-src 'self' buzz-media: http://buzz-media.localhost data: blob: https: http:; media-src 'self' buzz-media: http://buzz-media.localhost data: blob: https: http:; worker-src 'self' blob:" } }, "plugins": { diff --git a/desktop/src-tauri/tests/csp.rs b/desktop/src-tauri/tests/csp.rs new file mode 100644 index 0000000000..a8cc880e41 --- /dev/null +++ b/desktop/src-tauri/tests/csp.rs @@ -0,0 +1,201 @@ +//! Guards on the packaged-app Content-Security-Policy in `tauri.conf.json`. +//! +//! The CSP is only enforced on assets Tauri itself serves, so neither +//! `just dev` (loads the Vite `devUrl`) nor the Playwright suite (runs under +//! `vite preview`) can catch a policy that breaks the app. These tests pin the +//! non-obvious sources the frontend actually needs, so a future tightening +//! fails here instead of in a signed build. +//! +//! Kept as an integration test so the policy can be checked without the app +//! crate having to declare a test-only module. + +use std::collections::HashMap; + +const TAURI_CONF: &str = include_str!("../tauri.conf.json"); + +fn csp_directives() -> HashMap> { + let conf: serde_json::Value = + serde_json::from_str(TAURI_CONF).expect("tauri.conf.json is valid JSON"); + let csp = conf["app"]["security"]["csp"] + .as_str() + .expect("app.security.csp is set as a policy string"); + + csp.split(';') + .filter_map(|directive| { + let mut parts = directive.split_whitespace(); + let name = parts.next()?; + Some((name.to_owned(), parts.map(str::to_owned).collect())) + }) + .collect() +} + +fn sources(directive: &str) -> Vec { + csp_directives() + .remove(directive) + .unwrap_or_else(|| panic!("csp is missing the {directive} directive")) +} + +#[test] +fn script_src_allows_wasm_instantiation() { + // Shiki's default engine (Oniguruma) instantiates inlined WebAssembly for + // every code block; MediaPipe selfie segmentation does the same. Without + // this token both silently degrade — highlighting drops to plain text and + // animated avatars keep their background. + assert!(sources("script-src").contains(&"'wasm-unsafe-eval'".to_owned())); +} + +/// The `MEDIAPIPE_WASM_BASE` literal the frontend hands to `FilesetResolver`. +fn mediapipe_wasm_base() -> String { + const CAPTURE: &str = include_str!("../../src/features/profile/lib/animatedAvatarCapture.ts"); + + let after = CAPTURE + .split_once("const MEDIAPIPE_WASM_BASE =") + .expect("animatedAvatarCapture.ts declares MEDIAPIPE_WASM_BASE") + .1; + let url = after + .split_once('"') + .expect("MEDIAPIPE_WASM_BASE is a double-quoted string literal") + .1; + url.split_once('"') + .expect("MEDIAPIPE_WASM_BASE literal is terminated") + .0 + .to_owned() +} + +/// The npm scope the MediaPipe loader must come from. A CSP source ending in +/// `/` is a path *prefix* — paths can't be wildcarded — so this admits any +/// `@mediapipe` package while excluding the rest of what jsDelivr serves. +const MEDIAPIPE_SCOPE: &str = "https://cdn.jsdelivr.net/npm/@mediapipe/"; + +#[test] +fn script_src_scopes_the_mediapipe_loader() { + // `FilesetResolver.forVisionTasks` loads `vision_wasm[_nosimd]_internal.js` + // via a `