diff --git a/crates/buzz-relay/src/mesh_status_publisher.rs b/crates/buzz-relay/src/mesh_status_publisher.rs index 4285e7221..c98248987 100644 --- a/crates/buzz-relay/src/mesh_status_publisher.rs +++ b/crates/buzz-relay/src/mesh_status_publisher.rs @@ -62,6 +62,14 @@ pub struct BuzzMeshStatus { pub models: Vec, /// Aggregate peer count from mesh status. pub peer_count: usize, + /// Reporting node's verified mesh owner ID (`owner.owner_id` from mesh + /// status, only when `owner.verified`). Members read these to build the + /// mesh trust allowlist (`--trust-policy allowlist --trust-owner …`), so + /// mesh's own ownership layer enforces "Buzz members only" at gossip. + /// Public data (it appears in signed ownership certificates peers + /// exchange anyway); membership-gated like the rest of this event. + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_id: Option, } /// Model value + display label. `id` is the API/routing value; `name` is UI-only. @@ -109,6 +117,17 @@ pub fn sanitize_mesh_status(payload: &Value, now_unix: u64) -> BuzzMeshStatus { let node_id = string_field(payload, "node_id"); let mesh_id = string_field(payload, "mesh_id"); let mesh_name = string_field(payload, "mesh_name"); + // Owner ID only when the node's own attestation verified — an unverified + // claim must not enter members' trust allowlists. + let owner_id = payload + .get("owner") + .filter(|owner| { + owner + .get("verified") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .and_then(|owner| string_field(owner, "owner_id")); let my_vram_gb = payload.get("my_vram_gb").and_then(Value::as_f64); let mut models = Vec::::new(); @@ -186,6 +205,7 @@ pub fn sanitize_mesh_status(payload: &Value, now_unix: u64) -> BuzzMeshStatus { serve_targets, models, peer_count: peers.len(), + owner_id, } } diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 0f42fb957..324fcab7f 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -7,6 +7,20 @@ const RELAY_MESH_RUNTIME_NO_TARGET: &str = pub type CmdResult = Result; +/// Fetch community members' verified mesh owner IDs from the relay's +/// membership-gated kind:30621 events. This is the mesh trust allowlist: +/// Buzz decides membership, mesh enforces it at gossip via +/// `--trust-policy allowlist`. Best-effort empty on relay errors — the node +/// still trusts itself, and a later restart picks up the full list; an +/// unreachable relay also means no fresh dial pointers, so nothing admits +/// strangers in the interim. +async fn trusted_owner_ids(state: &AppState) -> Vec { + match relay::query_relay(state, &[mesh_llm::mesh_status_filter()]).await { + Ok(events) => mesh_llm::trusted_owner_ids_from_events(&events), + Err(_) => Vec::new(), + } +} + #[tauri::command] pub async fn mesh_availability( state: State<'_, AppState>, @@ -21,8 +35,12 @@ pub async fn mesh_availability( pub async fn mesh_start_node( app: AppHandle, state: State<'_, AppState>, - request: mesh_llm::StartMeshNodeRequest, + mut request: mesh_llm::StartMeshNodeRequest, ) -> CmdResult { + // Trust allowlist from membership-gated status events, resolved before + // taking the runtime lock (relay round-trip). + request.trusted_owner_ids = trusted_owner_ids(&state).await; + let mut runtime = state.mesh_llm_runtime.lock().await; if runtime.is_some() { return Err("mesh node is already running".to_string()); @@ -145,6 +163,7 @@ pub(crate) async fn ensure_client_node_for_model_dial_only( model_id: None, max_vram_gb: None, join_token: Some(addr.to_string()), + trusted_owner_ids: trusted_owner_ids(state).await, }; let mut runtime = state.mesh_llm_runtime.lock().await; if runtime.is_some() { @@ -220,6 +239,7 @@ pub(crate) async fn ensure_client_node_for_model( model_id: None, max_vram_gb: None, join_token: Some(join_token), + trusted_owner_ids: trusted_owner_ids(state).await, }; let mut runtime = state.mesh_llm_runtime.lock().await; if runtime.is_some() { @@ -533,6 +553,7 @@ mod tests { model_id: Some(HOSTED_MODEL.to_string()), max_vram_gb: None, join_token: None, + trusted_owner_ids: Vec::new(), }) .await .expect("serve runtime should start"); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index dbf3bc230..e5624a051 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -40,6 +40,12 @@ pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ // invocations — not listed here. "buzz-dev-mcp", "buzz_dev_mcp", + // Downloaded mesh compute node (mesh_llm/node_process.rs). Spawned with + // the BUZZ_MANAGED_AGENT stamp like agents, so the orphan sweep reclaims + // it after a desktop crash. A user-run standalone `mesh-llm serve` has no + // stamp and is never touched. + "mesh-llm", + "mesh_llm", ]; /// Script interpreters that may host managed agent wrappers (e.g. npm shims). diff --git a/desktop/src-tauri/src/mesh_llm/discovery.rs b/desktop/src-tauri/src/mesh_llm/discovery.rs index ad12a1bf5..5578205b1 100644 --- a/desktop/src-tauri/src/mesh_llm/discovery.rs +++ b/desktop/src-tauri/src/mesh_llm/discovery.rs @@ -99,6 +99,33 @@ pub fn availability_from_events(events: Vec) -> MeshAvailability { } } +/// Collect verified mesh owner IDs from members' kind:30621 status events. +/// +/// These events are relay-signed and readable only by community members; the +/// relay only includes `ownerId` when the reporting node's own attestation +/// verified. The result feeds `--trust-policy allowlist --trust-owner …` on +/// Buzz-spawned nodes: mesh's ownership layer then rejects any peer whose +/// verified owner is not a community member — admission enforced by mesh, +/// membership decided by Buzz. +pub fn trusted_owner_ids_from_events(events: &[nostr::Event]) -> Vec { + let mut owners: Vec = events + .iter() + .filter_map(|event| { + let content = serde_json::from_str::(&event.content).ok()?; + let owner = content + .get("ownerId") + .or_else(|| content.get("owner_id"))? + .as_str()? + .trim() + .to_ascii_lowercase(); + (owner.len() == 64 && owner.bytes().all(|b| b.is_ascii_hexdigit())).then_some(owner) + }) + .collect(); + owners.sort(); + owners.dedup(); + owners +} + pub fn mesh_status_filter() -> serde_json::Value { serde_json::json!({ "kinds": [MESH_STATUS_KIND], diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index 1a032ef3d..674994b1b 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -7,7 +7,7 @@ pub(crate) use coordinator::{ pub use coordinator::{spawn_listener, start_client, MeshCoordinator}; mod discovery; -pub use discovery::{availability_from_events, mesh_status_filter}; +pub use discovery::{availability_from_events, mesh_status_filter, trusted_owner_ids_from_events}; use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity}; mod preset; @@ -19,6 +19,9 @@ pub use node_install::{ensure_node_installed, node_installed, MESH_NODE_VERSION} pub(crate) mod node_process; use node_process::{NodeProcess, NodeSpawnConfig, NodeStatus}; +pub(crate) mod owner_identity; +use owner_identity::ensure_owner_identity; + use serde::{Deserialize, Serialize}; const DEFAULT_MESH_API_PORT: u16 = 9337; @@ -155,6 +158,12 @@ pub struct StartMeshNodeRequest { pub max_vram_gb: Option, #[serde(default)] pub join_token: Option, + /// Verified owner IDs of community members' nodes, from the + /// membership-gated kind:30621 events (`trusted_owner_ids_from_events`). + /// Becomes the node's mesh trust allowlist; this node's own owner ID is + /// added automatically. + #[serde(default)] + pub trusted_owner_ids: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -227,6 +236,16 @@ impl DesktopMeshRuntime { let binary = ensure_node_installed(app) .await .map_err(|error| anyhow::anyhow!("mesh node install failed: {error}"))?; + // Owner identity: generated once per desktop, then reused. The node's + // trust allowlist is members' owner IDs plus our own (a single-node + // mesh must trust itself). + let (owner_key, own_owner_id) = ensure_owner_identity(&binary) + .await + .map_err(|error| anyhow::anyhow!("mesh owner identity setup failed: {error}"))?; + let mut trusted_owner_ids = request.trusted_owner_ids.clone(); + if !trusted_owner_ids.contains(&own_owner_id) { + trusted_owner_ids.push(own_owner_id); + } let model_id = request .model_id .clone() @@ -243,6 +262,9 @@ impl DesktopMeshRuntime { console_port: mesh_console_port()?, max_vram_gb: request.max_vram_gb.map(|v| v as f64), join_tokens: request.join_token.clone().into_iter().collect(), + owner_key, + trusted_owner_ids, + instance_id: app.map(|a| crate::managed_agents::current_instance_id(a)), }; let node = NodeProcess::spawn(config.clone()).await?; diff --git a/desktop/src-tauri/src/mesh_llm/node_install.rs b/desktop/src-tauri/src/mesh_llm/node_install.rs index 5380c5394..4136f3101 100644 --- a/desktop/src-tauri/src/mesh_llm/node_install.rs +++ b/desktop/src-tauri/src/mesh_llm/node_install.rs @@ -293,6 +293,15 @@ mod tests { ensure_node_installed(None).await.expect("cache hit"); assert!(started.elapsed() < std::time::Duration::from_secs(2)); + // Owner identity: first call generates, second call reuses. + let (owner_key, owner_id) = crate::mesh_llm::owner_identity::ensure_owner_identity(&binary) + .await + .expect("owner identity"); + let (_, owner_id_again) = crate::mesh_llm::owner_identity::ensure_owner_identity(&binary) + .await + .expect("owner identity reuse"); + assert_eq!(owner_id, owner_id_again, "owner identity is stable"); + let node = crate::mesh_llm::node_process::NodeProcess::spawn( crate::mesh_llm::node_process::NodeSpawnConfig { binary, @@ -302,6 +311,9 @@ mod tests { console_port: 23131, max_vram_gb: None, join_tokens: Vec::new(), + owner_key, + trusted_owner_ids: vec![owner_id.clone()], + instance_id: Some("xyz.block.buzz.app.test".to_string()), }, ) .await @@ -321,6 +333,23 @@ mod tests { .and_then(serde_json::Value::as_bool), Some(true) ); + // Node ownership attested and verified with our owner identity — + // this is what peers verify before allowlist admission. + assert_eq!( + status + .payload + .pointer("/owner/verified") + .and_then(serde_json::Value::as_bool), + Some(true), + "node ownership must verify" + ); + assert_eq!( + status + .payload + .pointer("/owner/owner_id") + .and_then(serde_json::Value::as_str), + Some(owner_id.as_str()) + ); // OpenAI-compatible surface answers. let models: serde_json::Value = reqwest::get(format!("{}/models", node.api_base_url())) @@ -333,4 +362,160 @@ mod tests { node.stop().await.expect("stop"); } + + /// Two-node admission proof for the Buzz-managed trust gate: a serving + /// node with `--trust-policy allowlist` admits a peer whose owner is + /// allowlisted and refuses to peer with one whose owner is not — even + /// though BOTH hold the same valid invite token. Invite tokens are dial + /// metadata; ownership attestation is the gate. Run manually: + /// `cargo test --features mesh-llm -- --ignored mesh_trust --nocapture` + #[tokio::test(flavor = "multi_thread")] + #[ignore = "spawns three mesh nodes; network + timing dependent"] + async fn mesh_trust_allowlist_admits_member_rejects_stranger() { + use crate::mesh_llm::node_process::{NodeProcess, NodeSpawnConfig}; + + let binary = ensure_node_installed(None).await.expect("install"); + let tmp = std::env::temp_dir().join(format!("buzz-mesh-trust-{}", std::process::id())); + tokio::fs::create_dir_all(&tmp).await.expect("tmp dir"); + + // Three distinct owner identities: host, member, stranger. + let mut keys = Vec::new(); + for name in ["host", "member", "stranger"] { + let path = tmp.join(format!("{name}.key")); + let out = tokio::process::Command::new(&binary) + .args(["auth", "init", "--no-passphrase", "--force", "--owner-key"]) + .arg(&path) + .output() + .await + .expect("auth init"); + assert!(out.status.success(), "auth init {name}"); + let out = tokio::process::Command::new(&binary) + .args(["auth", "status", "--owner-key"]) + .arg(&path) + .output() + .await + .expect("auth status"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let owner = crate::mesh_llm::owner_identity::test_parse_owner_id(&text) + .expect("owner id parse"); + keys.push((path, owner)); + } + let (host_key, host_owner) = keys[0].clone(); + let (member_key, member_owner) = keys[1].clone(); + let (stranger_key, _stranger_owner) = keys[2].clone(); + + let spawn = |serve: bool, + api_port: u16, + console_port: u16, + owner_key: std::path::PathBuf, + trusted: Vec, + join: Vec| { + let binary = binary.clone(); + async move { + NodeProcess::spawn(NodeSpawnConfig { + binary, + serve, + model: None, + api_port, + console_port, + max_vram_gb: None, + join_tokens: join, + owner_key, + trusted_owner_ids: trusted, + instance_id: Some("xyz.block.buzz.app.test".to_string()), + }) + .await + } + }; + + // Host: allowlist = {host, member}. Stranger's owner NOT listed. + let host = spawn( + false, + 29437, + 23231, + host_key, + vec![host_owner.clone(), member_owner.clone()], + Vec::new(), + ) + .await + .expect("host node"); + let token = host + .status() + .await + .expect("host status") + .invite_token + .expect("host invite token"); + + // Member: allowlisted owner, joins with the token → must peer. + let member = spawn( + false, + 29438, + 23232, + member_key, + vec![member_owner.clone(), host_owner.clone()], + vec![token.clone()], + ) + .await + .expect("member node"); + + // Stranger: valid token, but owner not in host's allowlist → must + // NOT be admitted as a peer. + let stranger = spawn( + false, + 29439, + 23233, + stranger_key, + Vec::new(), // trusts no one; irrelevant to host's decision + vec![token.clone()], + ) + .await + .expect("stranger node"); + + // Give gossip time to settle, then read the host's peer table. + let mut member_admitted = false; + let mut stranger_admitted = false; + for _ in 0..15 { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let status = host.status().await.expect("host status"); + let peers = status + .payload + .get("peers") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + let owner_of = |p: &serde_json::Value| { + p.pointer("/owner/owner_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }; + member_admitted = peers + .iter() + .any(|p| owner_of(p).as_deref() == Some(member_owner.as_str())); + stranger_admitted = peers.iter().any(|p| { + owner_of(p).as_deref() != Some(member_owner.as_str()) + && owner_of(p).as_deref() != Some(host_owner.as_str()) + }); + if member_admitted { + break; + } + } + + assert!( + member_admitted, + "allowlisted member owner must be admitted as a peer" + ); + assert!( + !stranger_admitted, + "non-allowlisted owner must NOT appear in the host's peer table" + ); + + stranger.stop().await.ok(); + member.stop().await.ok(); + host.stop().await.ok(); + tokio::fs::remove_dir_all(&tmp).await.ok(); + } } diff --git a/desktop/src-tauri/src/mesh_llm/node_process.rs b/desktop/src-tauri/src/mesh_llm/node_process.rs index 0106dc67f..5f28e730e 100644 --- a/desktop/src-tauri/src/mesh_llm/node_process.rs +++ b/desktop/src-tauri/src/mesh_llm/node_process.rs @@ -46,6 +46,21 @@ pub struct NodeSpawnConfig { pub console_port: u16, pub max_vram_gb: Option, pub join_tokens: Vec, + /// Owner keystore attesting this node (mesh ownership layer). Required: + /// Buzz-managed nodes always run attested so peers can admit them by + /// owner ID. + pub owner_key: PathBuf, + /// Verified owner IDs admitted at gossip (`--trust-policy allowlist`). + /// Buzz builds this from owner IDs published by community members via + /// the membership-gated kind:30621 pipeline; this node's own owner ID + /// is always included. Mesh enforces the gate cryptographically — + /// unattested peers and peers with unlisted owners are rejected even + /// if they hold a valid invite token. + pub trusted_owner_ids: Vec, + /// `BUZZ_MANAGED_AGENT` instance stamp (same scheme as managed agents) + /// so the orphan sweep can reclaim a node left behind by a crashed + /// desktop without ever touching a user's standalone mesh-llm. + pub instance_id: Option, } impl NodeProcess { @@ -71,6 +86,20 @@ impl NodeProcess { for token in &config.join_tokens { cmd.arg("--join").arg(token); } + // Ownership + trust: mesh's idiomatic admission. This node is + // attested by the Buzz-managed owner key (--owner-required makes a + // broken keystore a startup failure, not a silent downgrade), and + // only peers with verified, allowlisted owners are admitted at + // gossip. Buzz's membership is the source of the allowlist. + cmd.arg("--owner-key").arg(&config.owner_key); + cmd.arg("--owner-required"); + cmd.arg("--trust-policy").arg("allowlist"); + for owner_id in &config.trusted_owner_ids { + cmd.arg("--trust-owner").arg(owner_id); + } + if let Some(instance_id) = config.instance_id.as_deref() { + cmd.env("BUZZ_MANAGED_AGENT", instance_id); + } cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) diff --git a/desktop/src-tauri/src/mesh_llm/owner_identity.rs b/desktop/src-tauri/src/mesh_llm/owner_identity.rs new file mode 100644 index 000000000..7acbf3776 --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/owner_identity.rs @@ -0,0 +1,125 @@ +//! Mesh owner identity for Buzz-spawned nodes. +//! +//! Mesh's idiomatic peer admission is its ownership/trust layer: each node is +//! attested by an ed25519 *owner keypair* (`mesh-llm auth init`), peers +//! exchange signed node-ownership certificates during gossip, and a node +//! running `--trust-policy allowlist` only admits peers whose verified owner +//! ID is in its trust list (`policy_accepts_peer`). Invite tokens are dial +//! metadata only — this layer is the cryptographic gate. +//! +//! Buzz is the membership authority: it knows which pubkeys are community +//! members. This module gives every Buzz-managed node a local owner identity +//! (auto-initialized once, cached under the app data dir), and the runtime +//! layer wires the trust allowlist from owner IDs that other members publish +//! through Buzz's membership-gated kind:30621 status events. Net effect: +//! only nodes owned by Buzz members are admitted into the mesh, enforced by +//! mesh itself at gossip — not just by who can reach whom. + +use std::path::PathBuf; + +use tokio::process::Command; + +/// Location of this desktop's mesh owner keystore. Lives next to the node +/// binary cache; not versioned — identity survives node upgrades. +pub fn owner_key_path() -> Result { + let base = dirs::data_dir().ok_or("no platform data dir available")?; + Ok(base.join("buzz").join("mesh-node").join("owner.key")) +} + +/// Ensure the owner keystore exists (generating it on first use) and return +/// `(keystore_path, owner_id_hex)`. +/// +/// Uses `mesh-llm auth init --no-passphrase`: the keystore protects a mesh +/// compute identity, not funds or messages; it lives in the same app-data +/// scope as Buzz's own secret files (0o600 fallback path) and must be +/// usable by an unattended spawn. +pub async fn ensure_owner_identity(binary: &std::path::Path) -> Result<(PathBuf, String), String> { + let key_path = owner_key_path()?; + if let Some(parent) = key_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("owner key dir create failed: {e}"))?; + } + if !key_path.is_file() { + let output = Command::new(binary) + .args(["auth", "init", "--no-passphrase", "--owner-key"]) + .arg(&key_path) + .output() + .await + .map_err(|e| format!("mesh owner key init failed to run: {e}"))?; + if !output.status.success() { + return Err(format!( + "mesh owner key init failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + } + let owner_id = owner_id_from_status(binary, &key_path).await?; + Ok((key_path, owner_id)) +} + +/// Read the owner ID out of `mesh-llm auth status`. +async fn owner_id_from_status( + binary: &std::path::Path, + key_path: &std::path::Path, +) -> Result { + let output = Command::new(binary) + .args(["auth", "status", "--owner-key"]) + .arg(key_path) + .output() + .await + .map_err(|e| format!("mesh auth status failed to run: {e}"))?; + if !output.status.success() { + return Err(format!( + "mesh auth status failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + // mesh-llm prints human-readable auth output on stderr (stdout is + // reserved for machine formats); parse both to stay robust. + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + parse_owner_id(&stdout) + .or_else(|| parse_owner_id(&stderr)) + .ok_or_else(|| "mesh auth status did not report an owner ID".to_string()) +} + +/// Extract the owner ID from `mesh-llm auth status` output. +fn parse_owner_id(stdout: &str) -> Option { + stdout.lines().find_map(|line| { + let (label, value) = line.split_once(':')?; + if !label.trim().eq_ignore_ascii_case("owner id") { + return None; + } + let value = value.trim(); + (value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())) + .then(|| value.to_ascii_lowercase()) + }) +} + +/// Test-only re-export of the parser for sibling test modules. +#[cfg(test)] +pub(crate) fn test_parse_owner_id(text: &str) -> Option { + parse_owner_id(text) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_owner_id_from_auth_status_output() { + let out = "Owner keystore: /tmp/owner.key\nStatus: present\nEncrypted: no\nOwner ID: 32056F9A207C01ABF02AD6B2A095533F117A880DCA317609A666D59AB5D5BD59\nSigning key: 2ce7ac5e32f3ad4c2fd52367687b220cd65538c3a27474ed5809bcf8ea066fce\n"; + assert_eq!( + parse_owner_id(out).as_deref(), + Some("32056f9a207c01abf02ad6b2a095533f117a880dca317609a666d59ab5d5bd59") + ); + } + + #[test] + fn rejects_missing_or_malformed_owner_id() { + assert_eq!(parse_owner_id("Status: present\n"), None); + assert_eq!(parse_owner_id("Owner ID: nothex\n"), None); + assert_eq!(parse_owner_id("Owner ID: 1234\n"), None); + } +}