mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mesh): membership-gated admission via mesh's ownership trust layer
Buzz decides membership; mesh enforces it — the idiomatic split: - Every Buzz-managed node gets an auto-initialized mesh owner identity (mesh-llm auth init, cached at mesh-node/owner.key) and runs with --owner-key --owner-required --trust-policy allowlist. - The relay's kind:30621 sanitizer now carries the reporter's VERIFIED owner id (unverified claims are dropped), so the membership-gated status pipeline doubles as the trust-allowlist distribution channel. - Desktops build --trust-owner lists from those events at node start (trusted_owner_ids_from_events); own owner id always included. - Invite tokens remain dial metadata only. Proven by an ignored 3-node e2e (mesh_trust_allowlist_admits_member_rejects_stranger): a peer with an allowlisted owner is admitted; a stranger holding the SAME valid invite token never enters the peer table. - mesh-llm added to KNOWN_AGENT_BINARIES and the node stamped with BUZZ_MANAGED_AGENT, so the orphan sweep reclaims crash leftovers without touching user-run standalone mesh-llm processes. - Public iroh relays stay disabled (v1 posture): reachability continues to flow through the relay's membership-validated call-me-now pairing.
This commit is contained in:
@@ -62,6 +62,14 @@ pub struct BuzzMeshStatus {
|
||||
pub models: Vec<MeshModelOption>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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::<MeshModelOption>::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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,20 @@ const RELAY_MESH_RUNTIME_NO_TARGET: &str =
|
||||
|
||||
pub type CmdResult<T> = Result<T, String>;
|
||||
|
||||
/// 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<String> {
|
||||
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<mesh_llm::MeshNodeStatus> {
|
||||
// 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");
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -99,6 +99,33 @@ pub fn availability_from_events(events: Vec<nostr::Event>) -> 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<String> {
|
||||
let mut owners: Vec<String> = events
|
||||
.iter()
|
||||
.filter_map(|event| {
|
||||
let content = serde_json::from_str::<serde_json::Value>(&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],
|
||||
|
||||
@@ -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<u64>,
|
||||
#[serde(default)]
|
||||
pub join_token: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[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?;
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
join: Vec<String>| {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,21 @@ pub struct NodeSpawnConfig {
|
||||
pub console_port: u16,
|
||||
pub max_vram_gb: Option<f64>,
|
||||
pub join_tokens: Vec<String>,
|
||||
/// 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<String>,
|
||||
/// `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<String>,
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -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<PathBuf, String> {
|
||||
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<String, String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user