Files
buzz/crates/buzz-test-client/tests/e2e_mesh_llm.rs
Mic Neale 240cdd3ea1 chore: mesh upgrade, clean up legacy special case code, simplify model selection for mesh (#5289)
Shared compute now has exactly two model choices: MeshLLM's virtual
`mesh`
model, or a model you name. Buzz picks between them in one place, and
buzz-agent no longer knows meshes exist.

## What changed

- **MeshLLM v0.74.0 → v0.75.1.** v0.75.0 added
`degrade_to_single_model`, so a
  `model=mesh` request is answered by one served model when there is no
committee to form, instead of failing. v0.75.1 adds Mesh-LLM#1196, which
skips stale pre-0.75 runtime cache entries rather than aborting startup
on
them — without it, anyone who had run mesh on 0.73/0.74 could not start.
- **Deleted the client-side mesh catalog probe.** buzz-agent used to
poll
`/v1/models` (5s TTL, 30s cooldown, two-observation debounce) to decide
whether `mesh` was safe to send. MeshLLM now decides per request, so the
  polling, its hysteresis, and its 503 fallback are gone.
- **One mapping point.** `relay_mesh_wire_model()` turns the stored
value into
a wire name: `auto` becomes `mesh`, a named model passes through. The
spawn
env, the ACP harness, and the readiness probe all use it, so they cannot
disagree — previously `BUZZ_ACP_MODEL` and the probe both said `auto`, a
name
  the mesh does not advertise.
- **Removed the `nostr-relay-pool` advisory exception.** #5404 allowed
RUSTSEC-2026-0243 "after mesh-llm migrates to nostr-sdk >= 0.45".
v0.75.1
does, so the retired crate is gone from both lockfiles and the exception
  would only mask a future advisory for it.
- **Deleted `scripts/ensure-mesh-native-runtime.sh`** and its six
justfile call
sites. It built llama.cpp from source into the runtime cache; the app
already
  downloads the signed release runtime itself, and CI never called it.

## Why it is better

**−639 lines of Rust.** Availability is decided by the node that knows
the
answer, per request, instead of by a client cache that could be stale
for up to
30 seconds. A second worker joining now takes effect on the next request
rather
than after two confirming probes.

## Behaviour change

A 503 on an explicit `mesh` request takes the ordinary transport retry
under
the same model instead of failing over to a second one — there is no
second
model to fail over to now. MoA repairs partial committee results
internally
before it reaches that point.

## Validation

`crates/buzz-relay/examples/mesh_agent_e2e.rs` now sends `mesh` where it
previously sent `auto` or the physical model id, so no leg was covering
what
Buzz actually puts on the wire. 4/4 on gemma-4-E4B, gemma-4-26B-A4B, and
Qwen3-8B — including a real ACP tool call through `mesh` into
buzz-dev-mcp,
asserted by reading the written file back off disk.

Hand-tested in the desktop app on both gemma-4 sizes: picked Auto, agent
logged
`model_id=mesh`, replied in channel.

## Not covered

A committee that forms and then loses a worker returns 502, and that
needs two
workers to reproduce — not testable on one machine.

---------

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
2026-08-11 11:40:07 -04:00

294 lines
11 KiB
Rust

//! End-to-end acceptance tests for Buzz shared compute.
//!
//! These tests require a membership-gated buzz-relay and a mesh-enabled desktop
//! publishing its client-signed discovery note. Live-inference rows additionally
//! require two desktop mesh nodes (serve + client).
//! All tests are `#[ignore]` by default — they need infra CI does not host
//! (native llama, multi-node, model download). The deterministic trust
//! invariants are unit-tested in the desktop mesh module; this file is the
//! opt-in full-stack acceptance layer.
//!
//! # Running (manual / runbook)
//!
//! ```text
//! # 1. the mesh-enabled desktop installs the signed native runtime itself on
//! # first init; stale pre-0.75 cache entries are skipped rather than
//! # fatal (MeshLLM >= 0.75.1), so no manual cleanup is needed
//! # 2. start the normal membership-gated relay and a mesh-enabled desktop
//! # 3. have that desktop publish status, then run the trust assertions:
//! RELAY_URL=ws://localhost:3000 \
//! cargo test --test e2e_mesh_llm trust -- --ignored --nocapture
//! # 4. run the live A->B inference row (needs 2 mesh nodes + a small model):
//! # point at B's local OpenAI endpoint; without it the test SKIPS (no silent pass):
//! MESH_OPENAI_BASE=http://127.0.0.1:9337/v1 \
//! cargo test --test e2e_mesh_llm live_agent_completes -- --ignored --nocapture
//! # MEMBER_NSEC is the publishing desktop identity; STRANGER_NSEC is not a member:
//! MEMBER_NSEC=nsec1... STRANGER_NSEC=nsec1... \
//! cargo test --test e2e_mesh_llm trust -- --ignored --nocapture
//! ```
//!
//! ## Acceptance matrix (= the demo, as a test)
//! | # | Assertion | This file | Also covered by |
//! |---|-----------|-----------|-----------------|
//! | 1 | member reads its kind:30003 owner-bound status, no secrets | `trust_member_reads_mesh_status` | desktop discovery units |
//! | 2 | non-member REQ for kind:30003 returns nothing | `trust_nonmember_read_denied` | relay membership tests |
//! | 3 | untrusted Mesh owner cannot infer with a leaked join token | hardware harness | `mesh_admission_smoke` |
//! | 4 | B's agent completes a chat against A's model over mesh | `live_agent_completes_chat_over_mesh` | runbook |
//! | 5 | dropped member → typed auth failure reaches lastError | runbook (desktop harness) | buzz-agent `-32001` unit |
//! | 6 | split: model too big → 2 serve nodes → chat completes | `live_split_model_completes` | runbook |
use std::time::Duration;
use buzz_test_client::BuzzTestClient;
use nostr::{Alphabet, Filter, Keys, Kind, SingleLetterTag};
/// NIP-51 bookmark set used for client-owned Mesh discovery notes.
const KIND_BUZZ_MESH_MEMBER_STATUS: u16 = 30003;
const MESH_STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status:";
const MESH_STATUS_TYPE: &str = "buzz-mesh-status";
fn relay_url() -> String {
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
}
/// Load a relay identity from an env-provided nsec. Returns `None` (and prints
/// why) when the fixture is absent, so the caller skips rather than running
/// against a `Keys::generate()` identity whose membership is undefined —
/// asserting "member sees status" against a random key is the bug Perci caught.
fn keys_from_env(var: &str) -> Option<Keys> {
match std::env::var(var) {
Ok(nsec) if !nsec.trim().is_empty() => match Keys::parse(nsec.trim()) {
Ok(keys) => Some(keys),
Err(e) => panic!("{var} is set but not a valid nsec/hex secret key: {e}"),
},
_ => {
eprintln!(
"SKIP: {var} not set — provision a relay {} identity and re-run (see module docs)",
if var.contains("MEMBER") {
"member"
} else {
"non-member"
}
);
None
}
}
}
fn sub_id(name: &str) -> String {
format!("e2e-mesh-{name}-{}", uuid::Uuid::new_v4().simple())
}
fn mesh_status_filter() -> Filter {
Filter::new()
.kind(Kind::Custom(KIND_BUZZ_MESH_MEMBER_STATUS))
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), MESH_STATUS_TYPE)
}
/// Assertion 1: an authenticated relay member can read its own client-signed
/// kind:30003 status. The content carries an owner binding and EndpointAddr dial
/// pointers, but no secret keys or local paths.
///
/// Requires a mesh-enabled relay that has published at least one status event.
#[tokio::test]
#[ignore]
async fn trust_member_reads_mesh_status() {
let url = relay_url();
let Some(member) = keys_from_env("MEMBER_NSEC") else {
return;
};
let mut client = BuzzTestClient::connect(&url, &member)
.await
.expect("member connect+auth");
let sid = sub_id("member-read");
client
.subscribe(&sid, vec![mesh_status_filter()])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(10))
.await
.expect("collect");
let status = events
.iter()
.find(|e| {
e.kind == Kind::Custom(KIND_BUZZ_MESH_MEMBER_STATUS) && e.pubkey == member.public_key()
})
.expect("the publishing member must see its kind:30003 mesh status event");
assert_eq!(
status.pubkey,
member.public_key(),
"status must be signed by the desktop member, not the relay"
);
assert!(status.tags.iter().any(|tag| {
let values = tag.as_slice();
values.first().map(String::as_str) == Some("d")
&& values
.get(1)
.is_some_and(|value| value.starts_with(MESH_STATUS_D_TAG_PREFIX))
}));
let content: serde_json::Value =
serde_json::from_str(&status.content).expect("content is JSON");
for field in ["ownerId", "ownerVerifyingKey", "ownerBindingSig"] {
assert!(
content[field]
.as_str()
.is_some_and(|value| !value.trim().is_empty()),
"status carries {field}"
);
}
// Dial pointer present (EndpointAddr is connectivity, not a secret).
let targets = content["serveTargets"]
.as_array()
.expect("serveTargets array");
if let Some(t) = targets.first() {
assert!(
t.get("endpointAddr").is_some(),
"serve target carries its EndpointAddr dial pointer"
);
}
// No secrets / no local-machine leakage in the published projection.
let raw = status.content.to_lowercase();
for forbidden in [
"nsec",
"secret",
"/users/",
"/home/",
"runtime_dir",
"local_path",
] {
assert!(
!raw.contains(forbidden),
"published status must not leak `{forbidden}`"
);
}
client.disconnect().await.ok();
}
/// Assertion 2: a valid Nostr identity that is NOT a relay member gets nothing
/// back for a kind:30003 mesh-status REQ — membership gates the read.
///
/// Requires a relay with `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true` and a published
/// status event that members can see (paired with assertion 1).
#[tokio::test]
#[ignore]
async fn trust_nonmember_read_denied() {
let url = relay_url();
let Some(stranger) = keys_from_env("STRANGER_NSEC") else {
return;
};
let mut client = match BuzzTestClient::connect(&url, &stranger).await {
Ok(c) => c,
// A closed relay may refuse NIP-42 auth for a non-member outright —
// that is also a valid "denied" outcome.
Err(_) => return,
};
let sid = sub_id("stranger-read");
client
.subscribe(&sid, vec![mesh_status_filter()])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(10))
.await
.expect("collect");
let leaked = events
.iter()
.any(|e| e.kind == Kind::Custom(KIND_BUZZ_MESH_MEMBER_STATUS));
assert!(
!leaked,
"non-member must NOT receive kind:30003 mesh status"
);
client.disconnect().await.ok();
}
/// Assertion 4 (the headline demo): with desktop A serving a model and desktop
/// B running a mesh client + a launched buzz-agent pointed at B's local
/// `:9337/v1`, a chat completion returns a non-empty response routed over the
/// mesh to A's GPU.
///
/// This needs two live mesh nodes + a small served model — runbook only, never
/// in default CI. Left as a documented, compiling placeholder so the acceptance
/// matrix is executable code, not prose; wire the live harness when M1 lands.
#[tokio::test]
#[ignore]
async fn live_agent_completes_chat_over_mesh() {
// RUNBOOK (M1 hardware): see module docs.
// A: Share compute → serve a small model. B: mesh client up on :9337.
// Point this test at B's local OpenAI endpoint via MESH_OPENAI_BASE
// (e.g. http://127.0.0.1:9337/v1). When set, we drive a real completion
// over the mesh and assert non-empty output — no endpoint, no silent pass.
let Ok(base) = std::env::var("MESH_OPENAI_BASE") else {
eprintln!(
"SKIP: MESH_OPENAI_BASE not set — needs a live mesh client endpoint (see module docs)"
);
return;
};
let base = base.trim_end_matches('/').to_string();
let http = reqwest::Client::new();
// Resolve the served model id (the node assigns its own, not our ref).
let models: serde_json::Value = http
.get(format!("{base}/models"))
.send()
.await
.expect("GET /models")
.json()
.await
.expect("/models JSON");
let model_id = models["data"][0]["id"]
.as_str()
.expect("at least one model served over the mesh")
.to_string();
let resp: serde_json::Value = http
.post(format!("{base}/chat/completions"))
.json(&serde_json::json!({
"model": model_id,
"messages": [{"role": "user", "content": "Reply with exactly one word: PONG"}],
"max_tokens": 512,
"temperature": 0.0,
}))
.send()
.await
.expect("POST /chat/completions over mesh")
.json()
.await
.expect("completion JSON");
let content = resp["choices"][0]["message"]["content"]
.as_str()
.expect("completion has message content");
assert!(
!content.trim().is_empty(),
"chat completion over the mesh must return non-empty content"
);
}
/// Assertion 6 (split): a model too large for one node + two serve nodes in the
/// same mesh → mesh auto-splits → the same chat (assertion 4) completes via the
/// split route. Auto-split is mesh runtime behavior (no Buzz code); this row
/// only verifies two serve desktops in one mesh produce a working split.
///
/// Runbook only — needs a known too-large-for-one-node fixture + 2 serve nodes.
#[tokio::test]
#[ignore]
async fn live_split_model_completes() {
// RUNBOOK: A + C both serve the oversized model into the same mesh; B's
// agent completes a chat; mesh elects a split topology (>=2 stage participants).
// Genuinely multi-node — cannot be automated single-process. Skips in CI;
// run manually with a real split harness.
println!("SKIP: live_split_model_completes is a manual runbook test — needs 2 serve nodes (see module docs)");
return;
}