Sprout × mesh-llm: in-process mesh node (serve/consume) + relay admission (#798)

This commit is contained in:
tlongwell-block
2026-06-02 10:28:20 -04:00
committed by GitHub
parent 6481428e2b
commit ede8ddb425
48 changed files with 13971 additions and 374 deletions

View File

@@ -479,7 +479,52 @@ jobs:
touch "desktop/src-tauri/binaries/sprout-dev-mcp-$TARGET"
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
touch "desktop/src-tauri/binaries/sprout-$TARGET"
# Mesh rev is derived from Cargo.lock so a dependency bump needs no
# lockstep edit here; the cache key tracks it automatically.
- name: Resolve mesh-llm rev
id: mesh_rev
run: |
set -euo pipefail
REV=$(grep -oE 'mesh-llm\.git\?rev=[0-9a-f]{40}' Cargo.lock | head -1 | grep -oE '[0-9a-f]{40}')
[[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; }
echo "rev=$REV" >> "$GITHUB_OUTPUT"
echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT"
- name: Restore mesh llama build cache
id: llama_cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ github.workspace }}/.cache/mesh-llama
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
- name: Build mesh llama native libraries
if: steps.llama_cache.outputs.cache-hit != 'true'
env:
MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }}
run: |
set -euo pipefail
cargo fetch --manifest-path desktop/src-tauri/Cargo.toml
SHORT="$MESH_REV_SHORT"
MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1)
if [[ -z "$MESH_ROOT" ]]; then
echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch"
exit 1
fi
export LLAMA_STAGE_BACKEND=metal
export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal"
export CMAKE_OSX_DEPLOYMENT_TARGET=10.15
"$MESH_ROOT/scripts/prepare-llama.sh" pinned
"$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15
- name: Save mesh llama build cache
if: steps.llama_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ github.workspace }}/.cache/mesh-llama
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
- name: Build Tauri app
run: cd desktop && pnpm tauri build
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
MACOSX_DEPLOYMENT_TARGET: "10.15"
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
LLAMA_STAGE_BACKEND: metal
LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal
SKIPPY_LLAMA_AUTO_BUILD: "0"

View File

@@ -75,6 +75,46 @@ jobs:
cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr -p sprout-cli
./scripts/bundle-sidecars.sh
# Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it.
- name: Resolve mesh-llm rev
id: mesh_rev
run: |
set -euo pipefail
REV=$(grep -oE 'mesh-llm\.git\?rev=[0-9a-f]{40}' Cargo.lock | head -1 | grep -oE '[0-9a-f]{40}')
[[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; }
echo "rev=$REV" >> "$GITHUB_OUTPUT"
echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT"
- name: Restore mesh llama build cache
id: llama_cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ github.workspace }}/.cache/mesh-llama
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
- name: Build mesh llama native libraries
if: steps.llama_cache.outputs.cache-hit != 'true'
env:
MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }}
run: |
set -euo pipefail
cargo fetch --manifest-path desktop/src-tauri/Cargo.toml
SHORT="$MESH_REV_SHORT"
MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1)
if [[ -z "$MESH_ROOT" ]]; then
echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch"
exit 1
fi
export LLAMA_STAGE_BACKEND=metal
export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal"
export CMAKE_OSX_DEPLOYMENT_TARGET=10.15
"$MESH_ROOT/scripts/prepare-llama.sh" pinned
"$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15
- name: Save mesh llama build cache
if: steps.llama_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ github.workspace }}/.cache/mesh-llama
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
- name: Build unsigned Tauri app
run: cd desktop && pnpm tauri build --verbose --no-sign --config src-tauri/tauri.release.conf.json
env:
@@ -83,6 +123,11 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
MACOSX_DEPLOYMENT_TARGET: "10.15"
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
LLAMA_STAGE_BACKEND: metal
LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal
SKIPPY_LLAMA_AUTO_BUILD: "0"
- name: Locate unsigned DMG
id: unsigned

5080
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -190,6 +190,7 @@ impl AgentError {
pub fn json_rpc_code(&self) -> i32 {
match self {
Self::InvalidParams(_) => -32602,
Self::LlmAuth(_) => -32001,
_ => -32000,
}
}

View File

@@ -145,6 +145,12 @@ pub const KIND_NIP29_GROUP_ROLES: u32 = 39003;
/// Workflow definition (parameterized replaceable, d=workflow_uuid).
pub const KIND_WORKFLOW_DEF: u32 = 30620;
/// Mesh-LLM relay status (relay-signed, parameterized replaceable, d=sprout-relay-mesh).
///
/// Published only by the relay. Carries a sanitized, member-readable projection
/// of mesh status, including EndpointAddr dial pointers for serving nodes.
pub const KIND_MESH_LLM_RELAY_STATUS: u32 = 30621;
/// Lower bound of the NIP-33 parameterized replaceable range (30000–39999).
pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000;
/// Upper bound of the NIP-33 parameterized replaceable range (30000–39999).
@@ -370,6 +376,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_SYSTEM_MESSAGE,
KIND_CHANNEL_SUMMARY,
KIND_PRESENCE_SNAPSHOT,
KIND_MESH_LLM_RELAY_STATUS,
KIND_DM_OPEN,
KIND_DM_ADD_MEMBER,
KIND_DM_HIDE,
@@ -480,7 +487,10 @@ pub const fn is_command_kind(kind: u32) -> bool {
/// Returns `true` if `kind` is a relay-only sidecar kind.
/// Client submission of these kinds must be rejected.
pub const fn is_relay_only_kind(kind: u32) -> bool {
matches!(kind, KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT)
matches!(
kind,
KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT | KIND_MESH_LLM_RELAY_STATUS
)
}
/// Extract the kind from a nostr Event as u32.
@@ -498,6 +508,7 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 {
// Compile-time: new kinds are in the expected ranges.
const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–19999
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_RELAY_STATUS)); // 30621 ∈ 30000–39999
// Compile-time: NIP-34 parameterized replaceable kinds are in the correct range.
const _: () = assert!(

View File

@@ -65,5 +65,7 @@ metrics-exporter-prometheus = { workspace = true }
dev = ["sprout-auth/dev"]
[dev-dependencies]
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "bd16da4955a9dceee8f1e0a02c5814944f4e12a5", package = "mesh-llm-sdk", default-features = false, features = ["client", "serve"] }
sprout-core = { workspace = true, features = ["test-utils"] }
sprout-auth = { workspace = true, features = ["dev"] }
reqwest = { workspace = true }

View File

@@ -0,0 +1,154 @@
//! Local mesh serve→client→inference smoke test.
//!
//! Proves the full path Sprout's "Share compute" + "Run on relay mesh" pair
//! relies on, on a single box and without a relay or Nostr discovery:
//!
//! 1. Start a SERVE node hosting a GGUF (the `serve::start` path desktop
//! uses in Share-compute mode), and read its mesh invite token.
//! 2. Start a CLIENT node joined to that serve node via the invite token
//! (the `client::start` path an agent's "Run on relay mesh" provider
//! uses), binding its own local OpenAI-compatible endpoint.
//! 3. Drive one chat completion against the CLIENT endpoint and assert it
//! routed through the mesh to the serve node and produced real output.
//!
//! Serve-only proves less than the PR claims (serve + client + routing), so
//! this exercises the client hop end to end.
//!
//! This is hardware-gated and NOT a CI test — it loads a real model and runs
//! inference. It lives as an example so CI never auto-runs it.
//!
//! Usage:
//! # default model is a ~100MB instruct model, downloaded on first run:
//! cargo run -p sprout-relay --example mesh_serve_client_smoke
//!
//! # or point at any local .gguf / hf model ref (e.g. the on-hardware 35B):
//! MESH_SMOKE_MODEL=/path/to/model.gguf \
//! cargo run -p sprout-relay --example mesh_serve_client_smoke
use std::time::Duration;
use mesh_llm_sdk::{client, serve, MeshDiscoveryMode};
/// Small, real instruct model the mesh project itself uses for CI smoke.
/// Downloaded on first run; ~100MB.
const DEFAULT_MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M";
const SERVE_API_PORT: u16 = 19337;
const SERVE_CONSOLE_PORT: u16 = 13131;
const CLIENT_API_PORT: u16 = 19338;
const CLIENT_CONSOLE_PORT: u16 = 13132;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());
eprintln!("[smoke] model: {model}");
// ── 1. Serve node ────────────────────────────────────────────────────────
let serve_cfg = serve::EmbeddedServeConfig::builder()
.model(&model)
.api_port(SERVE_API_PORT)
.console_port(SERVE_CONSOLE_PORT)
// publish so the client can join via the invite token; Mdns keeps
// discovery local — no relay, no Nostr.
.publish(true)
.auto_join(false)
.discovery_mode(MeshDiscoveryMode::Mdns)
// console_ui(true) is required for readiness polling at rev bd16da4
// (serve::start polls :console_port/api/status, which only binds when
// !headless). See mesh_serve_smoke.rs for the full note.
.console_ui(true)
.build();
eprintln!("[smoke] starting serve node...");
let serve_node = serve::start(serve_cfg).await?;
let serve_base = serve_node.api_base_url().to_string();
eprintln!("[smoke] serve up, api_base_url = {serve_base}");
let invite = serve_node
.invite_token()
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("serve node produced no invite token to join"))?;
eprintln!("[smoke] serve invite token acquired (len {})", invite.len());
// Wait until the serve node reports the model loaded, so the client has
// something to route to.
let http = reqwest::Client::new();
let serve_model_id = wait_for_model(&http, &serve_base).await?;
eprintln!("[smoke] serve model ready: {serve_model_id}");
// ── 2. Client node, joined to the serve node ─────────────────────────────
let client_cfg = client::EmbeddedClientConfig::builder()
.api_port(CLIENT_API_PORT)
.console_port(CLIENT_CONSOLE_PORT)
.publish(false)
.auto_join(false)
.discovery_mode(MeshDiscoveryMode::Mdns)
.join_token(&invite)
.console_ui(true)
.build();
eprintln!("[smoke] starting client node joined to serve...");
let client_node = client::start(client_cfg).await?;
let client_base = client_node.api_base_url().to_string();
eprintln!("[smoke] client up, api_base_url = {client_base}");
// The served model must propagate across the mesh to the client's
// /models view before we can route a completion through it.
let routed_model_id = wait_for_model(&http, &client_base).await?;
eprintln!("[smoke] client sees routed model: {routed_model_id}");
// ── 3. One real completion, through the CLIENT (mesh hop) ─────────────────
let chat_url = format!("{client_base}/chat/completions");
let req = serde_json::json!({
"model": routed_model_id,
"messages": [{"role": "user", "content": "Reply with exactly one word: PONG"}],
"max_tokens": 512,
"temperature": 0.0
});
eprintln!("[smoke] POST {chat_url} (routes serve←client over mesh)");
let resp = http.post(&chat_url).json(&req).send().await?;
let status = resp.status();
let body = resp.text().await?;
println!("[smoke] completion status={status}");
println!("[smoke] completion body={body}");
// Tear down both nodes before asserting, so a failed assert still cleans up.
let _ = client_node.stop().await;
let _ = serve_node.stop().await;
if !status.is_success() {
anyhow::bail!("completion through client failed: {status}");
}
let json: serde_json::Value = serde_json::from_str(&body)?;
let content = json["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("");
let finish = json["choices"][0]["finish_reason"].as_str().unwrap_or("");
if content.trim().is_empty() {
anyhow::bail!("completion routed but content was empty");
}
println!("[smoke] OK — routed completion finish_reason={finish:?} content={content:?}");
eprintln!("[smoke] PASS: serve→client→inference proven over mesh");
Ok(())
}
/// Poll a node's `/models` until it reports a model, returning the served id
/// (the node assigns its own id, e.g. `local-gguf/sha256-…`, not our ref).
async fn wait_for_model(http: &reqwest::Client, api_base: &str) -> anyhow::Result<String> {
let url = format!("{api_base}/models");
for i in 0..120 {
tokio::time::sleep(Duration::from_secs(5)).await;
match http.get(&url).send().await {
Ok(r) => {
let body = r.text().await.unwrap_or_default();
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(id) = json["data"].get(0).and_then(|m| m["id"].as_str()) {
return Ok(id.to_string());
}
}
eprintln!("[smoke] waiting ({}s) {url} -> {body}", (i + 1) * 5);
}
Err(e) => eprintln!("[smoke] waiting ({}s) {url} err: {e}", (i + 1) * 5),
}
}
anyhow::bail!("model never became visible at {api_base} within timeout")
}

View File

@@ -0,0 +1,96 @@
//! Local mesh-serve inference smoke test.
//!
//! Serves a GGUF model through the same `mesh_llm_sdk::serve` path Sprout
//! desktop uses in "Share compute" mode, then drives one chat completion
//! against the node's local OpenAI-compatible endpoint. No mesh publish, no
//! auto-join, no Nostr discovery — pure single-node serve-and-self-consume,
//! which is exactly the loopback variant we can prove on one box.
//!
//! Usage:
//! cargo run -p sprout-relay --example mesh_serve_smoke -- <path-to.gguf>
use std::time::Duration;
use mesh_llm_sdk::{serve, MeshDiscoveryMode};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let model = std::env::args()
.nth(1)
.ok_or_else(|| anyhow::anyhow!("usage: mesh_serve_smoke <path-to.gguf>"))?;
eprintln!("[smoke] serving model: {model}");
let config = serve::EmbeddedServeConfig::builder()
.model(&model)
.api_port(19337)
.console_port(13131)
.publish(false)
.auto_join(false)
.discovery_mode(MeshDiscoveryMode::Mdns)
// NOTE: console_ui(true) is required here, not cosmetic. In mesh rev
// bd16da4, serve::start always polls `:console_port/api/status` to
// confirm readiness, but the console HTTP server only binds when
// !headless (i.e. console_ui == true). With console_ui(false) the
// poll never succeeds and startup times out after 30s. Desktop's
// Share-compute path sets console_ui(false) — likely hits the same
// wall. Flagged to the team.
.console_ui(true)
.build();
let node = serve::start(config).await?;
let base = node.api_base_url().to_string();
eprintln!("[smoke] node up, api_base_url = {base}");
// Poll until the model reports loaded/ready (give it generous time — first
// load of a 17GB GGUF into Metal can take a while).
let http = reqwest::Client::new();
let models_url = format!("{base}/models");
let mut model_id = String::new();
for i in 0..120 {
tokio::time::sleep(Duration::from_secs(5)).await;
match http.get(&models_url).send().await {
Ok(r) => {
let body = r.text().await.unwrap_or_default();
// The serve node assigns its own id (e.g. local-gguf/sha256-…),
// not the file path we passed — pull it from /models.
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(id) = json["data"].get(0).and_then(|m| m["id"].as_str()) {
model_id = id.to_string();
eprintln!(
"[smoke] /models after {}s, served id = {model_id}",
(i + 1) * 5
);
break;
}
}
eprintln!("[smoke] waiting ({}s) /models -> {body}", (i + 1) * 5);
}
Err(e) => eprintln!("[smoke] waiting ({}s) /models err: {e}", (i + 1) * 5),
}
}
if model_id.is_empty() {
anyhow::bail!("model never became ready within timeout");
}
// One real completion — use the server's own model id.
let chat_url = format!("{base}/chat/completions");
let req = serde_json::json!({
"model": model_id,
"messages": [
{"role": "user", "content": "Reply with exactly one word: PONG"}
],
"max_tokens": 512,
"temperature": 0.0
});
eprintln!("[smoke] POST {chat_url}");
let resp = http.post(&chat_url).json(&req).send().await?;
let status = resp.status();
let body = resp.text().await?;
println!("[smoke] completion status={status}");
println!("[smoke] completion body={body}");
node.stop().await?;
if !status.is_success() {
anyhow::bail!("completion request failed: {status}");
}
Ok(())
}

View File

@@ -38,6 +38,70 @@ pub mod relay_members {
use crate::state::AppState;
/// Transport-neutral outcome of a relay-membership check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MembershipDecision {
/// Relay membership enforcement is disabled.
OpenRelay,
/// Caller is directly present in `relay_members`.
Member,
/// Caller is admitted through a NIP-OA owner that is a relay member.
ViaOwner(nostr::PublicKey),
/// Caller is not admitted.
Denied,
}
/// Check relay membership without committing to an HTTP response shape.
pub async fn check_relay_membership(
state: &AppState,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
) -> Result<MembershipDecision, String> {
if !state.config.require_relay_membership {
return Ok(MembershipDecision::OpenRelay);
}
let pubkey_hex = hex::encode(pubkey_bytes);
let is_member = state
.db
.is_relay_member(&pubkey_hex)
.await
.map_err(|e| format!("relay membership check failed: {e}"))?;
if is_member {
return Ok(MembershipDecision::Member);
}
if state.config.allow_nip_oa_auth {
if let Some(tag_json) = auth_tag_header {
let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes)
.map_err(|e| format!("invalid agent pubkey for NIP-OA check: {e}"))?;
match sprout_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) {
Ok(owner_pubkey) => {
let owner_hex = owner_pubkey.to_hex();
let owner_is_member =
state.db.is_relay_member(&owner_hex).await.map_err(|e| {
format!("relay membership check (owner) failed: {e}")
})?;
if owner_is_member {
debug!(
agent = %pubkey_hex,
owner = %owner_hex,
"NIP-OA membership granted via owner"
);
return Ok(MembershipDecision::ViaOwner(owner_pubkey));
}
}
Err(e) => {
info!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}");
}
}
}
}
Ok(MembershipDecision::Denied)
}
/// Enforce relay membership for a pubkey, with NIP-OA agent delegation fallback.
///
/// Returns `Ok(Some(owner_pubkey))` when the agent is not a direct member but
@@ -54,60 +118,21 @@ pub mod relay_members {
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
) -> Result<Option<nostr::PublicKey>, (StatusCode, Json<serde_json::Value>)> {
if !state.config.require_relay_membership {
return Ok(None);
}
let pubkey_hex = hex::encode(pubkey_bytes);
let is_member = state.db.is_relay_member(&pubkey_hex).await.map_err(|e| {
tracing::error!("relay membership check failed: {e}");
super::internal_error(&format!("relay membership check failed: {e}"))
})?;
if is_member {
return Ok(None);
}
// NIP-OA fallback: check if agent's owner is a relay member.
if state.config.allow_nip_oa_auth {
if let Some(tag_json) = auth_tag_header {
let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).map_err(|e| {
super::internal_error(&format!("invalid agent pubkey for NIP-OA check: {e}"))
})?;
match sprout_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) {
Ok(owner_pubkey) => {
let owner_hex = owner_pubkey.to_hex();
let owner_is_member =
state.db.is_relay_member(&owner_hex).await.map_err(|e| {
super::internal_error(&format!(
"relay membership check (owner) failed: {e}"
))
})?;
if owner_is_member {
debug!(
agent = %pubkey_hex,
owner = %owner_hex,
"NIP-OA membership granted via owner"
);
return Ok(Some(owner_pubkey));
}
}
Err(e) => {
info!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}");
}
}
match check_relay_membership(state, pubkey_bytes, auth_tag_header).await {
Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None),
Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)),
Ok(MembershipDecision::Denied) => Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "relay_membership_required",
"message": "You must be a relay member to access this relay"
})),
)),
Err(e) => {
tracing::error!("relay membership check errored: {e}");
Err(super::internal_error(&e))
}
}
Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "relay_membership_required",
"message": "You must be a relay member to access this relay"
})),
))
}
/// Extract NIP-OA owner from an auth tag without membership enforcement.

View File

@@ -22,16 +22,16 @@ use sprout_core::kind::{
KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST,
KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP,
KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA,
KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE,
KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF,
KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE,
RELAY_ADMIN_REMOVE_MEMBER,
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, KIND_MUTE_LIST,
KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PROFILE,
KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED,
KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED,
KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE,
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER,
RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
};
use sprout_core::verification::verify_event;
@@ -353,6 +353,9 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
// events. A stray `h` tag must not channel-scope them.
| KIND_IA_ARCHIVE_REQUEST
| KIND_IA_UNARCHIVE_REQUEST
// Mesh-LLM relay status is relay-signed and global. Clients may
// subscribe to it, but must not channel-scope or submit it.
| KIND_MESH_LLM_RELAY_STATUS
)
}
@@ -1931,6 +1934,15 @@ mod tests {
}
}
#[test]
fn mesh_llm_relay_status_is_global_only_and_relay_only() {
assert!(is_global_only_kind(KIND_MESH_LLM_RELAY_STATUS));
assert!(sprout_core::kind::is_relay_only_kind(
KIND_MESH_LLM_RELAY_STATUS
));
assert!(!requires_h_channel_scope(KIND_MESH_LLM_RELAY_STATUS));
}
#[test]
fn nip51_and_nip65_lists_are_global_only() {
for kind in [

View File

@@ -0,0 +1,236 @@
//! Sprout admission helpers for mesh-LLM iroh-relay connections.
//!
//! This module intentionally keeps the admission logic independent from the
//! concrete `iroh-relay` server type for the first landing. Max's runtime lane
//! owns adding the iroh dependency and wiring this decision into
//! `AccessConfig::Restricted`. The security boundary lives here: a connecting
//! endpoint must present a NIP-98 bearer signed by a direct relay member.
//!
//! V1 deliberately admits direct relay members only. NIP-OA owner delegation for
//! agent-owned mesh nodes is a follow-up so the mesh compute trust boundary stays
//! legible.
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
use base64::Engine;
use nostr::PublicKey;
use crate::api::relay_members::MembershipDecision;
use crate::state::AppState;
/// Path component appended to the configured iroh-relay public URL.
pub const IROH_RELAY_PATH: &str = "/relay";
const NIP98_METHOD: &str = "GET";
const MAX_BEARER_LEN: usize = 64 * 1024;
/// Result of a mesh iroh admission check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IrohAdmissionDecision {
/// Allow the connection for this direct relay member.
Allow {
/// NIP-98-proven member pubkey.
pubkey: PublicKey,
},
/// Deny the connection. Reason is for server logs/tests, not client display.
Deny(String),
}
/// Decide whether an iroh-relay connection should be admitted.
pub async fn decide_admission(
state: &AppState,
canonical_url: &str,
auth_token: Option<&str>,
) -> IrohAdmissionDecision {
let pubkey = match verify_bearer(canonical_url, auth_token) {
Ok(pubkey) => pubkey,
Err(reason) => return IrohAdmissionDecision::Deny(reason),
};
let membership =
crate::api::relay_members::check_relay_membership(state, &pubkey.to_bytes(), None).await;
admission_from_membership(membership, pubkey)
}
/// Map a relay-membership outcome to an iroh admission decision.
///
/// This is the mesh admission *invariant*, isolated from any I/O so it can be
/// asserted directly: relay membership is the only thing that admits. A direct
/// member (or an open relay) is allowed; everything else — non-members,
/// NIP-OA owner-delegated agents (denied in v1), and membership-check errors —
/// is denied. Possession of dial metadata or a valid NIP-98 bearer is by itself
/// never sufficient; only membership flips this to `Allow`.
pub fn admission_from_membership(
membership: Result<MembershipDecision, String>,
pubkey: PublicKey,
) -> IrohAdmissionDecision {
match membership {
Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => {
IrohAdmissionDecision::Allow { pubkey }
}
Ok(MembershipDecision::ViaOwner(_)) => IrohAdmissionDecision::Deny(
// v1 deliberately denies NIP-OA owner-delegated agents even when their
// owner is a relay member. HTTP endpoints accept that delegation; iroh
// admission does not, keeping the mesh-compute trust boundary tighter
// and legible. Lifting this is a follow-up gated on NIP-OA scope review.
"owner-delegated mesh admission is not enabled in v1".to_string(),
),
Ok(MembershipDecision::Denied) => {
IrohAdmissionDecision::Deny(format!("not a relay member: {pubkey}"))
}
Err(error) => IrohAdmissionDecision::Deny(format!("membership check failed: {error}")),
}
}
/// Verify a base64-encoded NIP-98 bearer for the iroh-relay WebSocket URL.
pub fn verify_bearer(canonical_url: &str, auth_token: Option<&str>) -> Result<PublicKey, String> {
let token = match auth_token {
Some(token) if !token.trim().is_empty() => token.trim(),
_ => return Err("missing bearer token".to_string()),
};
if token.len() > MAX_BEARER_LEN {
return Err(format!("bearer token exceeds {MAX_BEARER_LEN} bytes"));
}
let json = decode_bearer(token).ok_or_else(|| "bearer token is not base64 JSON".to_string())?;
sprout_auth::verify_nip98_event(&json, canonical_url, NIP98_METHOD, None)
.map_err(|error| format!("NIP-98 verification failed: {error}"))
}
fn decode_bearer(token: &str) -> Option<String> {
for engine in [STANDARD, URL_SAFE, STANDARD_NO_PAD, URL_SAFE_NO_PAD] {
if let Ok(bytes) = engine.decode(token) {
if let Ok(json) = String::from_utf8(bytes) {
return Some(json);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::STANDARD;
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
fn bearer_for(keys: &Keys, canonical_url: &str, method: &str) -> String {
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags([
Tag::parse(["u", canonical_url]).unwrap(),
Tag::parse(["method", method]).unwrap(),
])
.custom_created_at(Timestamp::now())
.sign_with_keys(keys)
.unwrap();
STANDARD.encode(serde_json::to_string(&event).unwrap())
}
#[test]
fn verify_bearer_accepts_valid_nip98() {
let keys = Keys::generate();
let url = "https://relay.example/iroh/relay";
let token = bearer_for(&keys, url, NIP98_METHOD);
assert_eq!(verify_bearer(url, Some(&token)).unwrap(), keys.public_key());
}
#[test]
fn verify_bearer_rejects_missing_token() {
assert!(verify_bearer("https://relay.example/iroh/relay", None).is_err());
}
#[test]
fn verify_bearer_rejects_wrong_method() {
let keys = Keys::generate();
let url = "https://relay.example/iroh/relay";
let token = bearer_for(&keys, url, "POST");
let error = verify_bearer(url, Some(&token)).unwrap_err();
assert!(error.contains("method"), "{error}");
}
#[test]
fn verify_bearer_rejects_wrong_url() {
let keys = Keys::generate();
let token = bearer_for(&keys, "https://other.example/iroh/relay", NIP98_METHOD);
let error = verify_bearer("https://relay.example/iroh/relay", Some(&token)).unwrap_err();
assert!(error.contains("URL"), "{error}");
}
#[test]
fn verify_bearer_rejects_expired_timestamp() {
// NIP-98's ±60s window must reject stale bearers — guards against
// observed-token replay outside the live admission moment.
let keys = Keys::generate();
let url = "https://relay.example/iroh/relay";
let stale = Timestamp::now() - 120u64;
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags([
Tag::parse(["u", url]).unwrap(),
Tag::parse(["method", NIP98_METHOD]).unwrap(),
])
.custom_created_at(stale)
.sign_with_keys(&keys)
.unwrap();
let token = STANDARD.encode(serde_json::to_string(&event).unwrap());
let error = verify_bearer(url, Some(&token)).unwrap_err();
assert!(error.contains("window"), "{error}");
}
// ── Admission invariant: relay membership is the ONLY factor ──────────────
//
// These assert the policy mapping directly (no db/AppState needed). The
// bearer-proof layer is covered by the verify_bearer tests above; here we
// pin that a *proven* identity is admitted iff it is a relay member.
fn any_pubkey() -> PublicKey {
Keys::generate().public_key()
}
#[test]
fn admission_allows_direct_relay_member() {
let pk = any_pubkey();
let decision = admission_from_membership(Ok(MembershipDecision::Member), pk);
assert_eq!(decision, IrohAdmissionDecision::Allow { pubkey: pk });
}
#[test]
fn admission_allows_when_relay_is_open() {
// require_relay_membership disabled → OpenRelay → admitted.
let pk = any_pubkey();
let decision = admission_from_membership(Ok(MembershipDecision::OpenRelay), pk);
assert_eq!(decision, IrohAdmissionDecision::Allow { pubkey: pk });
}
#[test]
fn admission_denies_non_member() {
// A valid Nostr identity that is not a relay member gets nothing.
let pk = any_pubkey();
let decision = admission_from_membership(Ok(MembershipDecision::Denied), pk);
assert!(matches!(decision, IrohAdmissionDecision::Deny(_)));
}
#[test]
fn admission_denies_owner_delegation_in_v1() {
// NIP-OA owner-delegated agents are explicitly NOT admitted to the mesh
// in v1, even though HTTP endpoints accept the same delegation.
let pk = any_pubkey();
let owner = any_pubkey();
let decision = admission_from_membership(Ok(MembershipDecision::ViaOwner(owner)), pk);
match decision {
IrohAdmissionDecision::Deny(reason) => {
assert!(reason.contains("owner-delegated"), "{reason}");
}
other => panic!("owner delegation must be denied, got {other:?}"),
}
}
#[test]
fn admission_denies_on_membership_check_error() {
// Fail closed: an errored membership lookup denies, never admits.
let pk = any_pubkey();
let decision = admission_from_membership(Err("db down".to_string()), pk);
assert!(matches!(decision, IrohAdmissionDecision::Deny(_)));
}
}

View File

@@ -14,6 +14,10 @@ pub mod connection;
pub mod error;
/// WebSocket message handlers for NIP-01 client commands.
pub mod handlers;
/// Mesh iroh-relay admission helpers.
pub mod iroh_relay;
/// Relay-signed mesh-LLM status publisher.
pub mod mesh_status_publisher;
/// Prometheus metrics: recorder, upkeep, HTTP middleware.
pub mod metrics;
/// NIP-11 relay information document.

View File

@@ -0,0 +1,340 @@
//! Relay-signed mesh-LLM status publication.
//!
//! The relay owns the Nostr publication surface for Sprout mesh status. mesh-llm
//! remains the source of live runtime truth, but the relay sanitizes that status
//! and republishes a member-readable, relay-signed parameterized event. This
//! keeps discovery inside Sprout's relay-membership boundary and avoids mesh's
//! public-relay publisher path.
use std::sync::Arc;
use nostr::{EventBuilder, Kind, Tag};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sprout_core::kind::KIND_MESH_LLM_RELAY_STATUS;
use tracing::info;
use crate::handlers::event::dispatch_persistent_event;
use crate::state::AppState;
/// Stable d-tag for the relay's mesh status event.
pub const MESH_STATUS_D_TAG: &str = "sprout-relay-mesh";
/// Content schema discriminator.
pub const MESH_STATUS_TYPE: &str = "sprout-mesh-status";
/// Sanitized relay-published mesh status.
///
/// Carries dial metadata (mesh identity + per-target `endpoint_addr` invite
/// tokens), not access grants. iroh admission via NIP-98 → relay membership is
/// the only gate; possession of these fields confers no ability to dial a
/// serving node that has not admitted the caller's pubkey.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SproutMeshStatus {
/// Schema version.
pub v: u32,
/// Constant discriminator: `sprout-mesh-status`.
#[serde(rename = "type")]
pub status_type: String,
/// Unix timestamp when the relay generated this projection.
pub updated_at: u64,
/// Mesh identity, if mesh-llm has joined/created one.
#[serde(skip_serializing_if = "Option::is_none")]
pub mesh_id: Option<String>,
/// Human mesh name, if configured.
#[serde(skip_serializing_if = "Option::is_none")]
pub mesh_name: Option<String>,
/// Serving targets reachable by relay members.
pub serve_targets: Vec<MeshServeTarget>,
/// Deduplicated model options for agent/provider pickers.
pub models: Vec<MeshModelOption>,
/// Aggregate peer count from mesh status.
pub peer_count: usize,
}
/// Model value + display label. `id` is the API/routing value; `name` is UI-only.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeshModelOption {
/// Stable routable OpenAI model id/ref. This is the value clients pass to `/v1`.
pub id: String,
/// Optional UI label. Must never replace [`Self::id`] for routing.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
/// A model served at an EndpointAddr dial pointer.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeshServeTarget {
/// Stable routable OpenAI model id/ref served at this target.
pub model_id: String,
/// Optional UI label for [`Self::model_id`].
#[serde(skip_serializing_if = "Option::is_none")]
pub model_name: Option<String>,
/// mesh-llm invite token: base64(json(EndpointAddr)). This is dial
/// metadata, not an access grant; iroh admission remains the gate.
pub endpoint_addr: String,
#[serde(skip_serializing_if = "Option::is_none")]
/// Optional node label/hostname for UI display.
pub node_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Optional serving capacity hint for this target.
pub capacity: Option<MeshTargetCapacity>,
}
/// Capacity hint for a serving target.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeshTargetCapacity {
/// Advertised VRAM capacity in GB, if known.
pub vram_gb: Option<f64>,
}
/// Build the relay-owned status projection from mesh's `/api/status` JSON.
pub fn sanitize_mesh_status(payload: &Value, now_unix: u64) -> SproutMeshStatus {
let endpoint_addr = string_field(payload, "token").unwrap_or_default();
let node_id = string_field(payload, "node_id");
let mesh_id = string_field(payload, "mesh_id");
let mesh_name = string_field(payload, "mesh_name");
let my_vram_gb = payload.get("my_vram_gb").and_then(Value::as_f64);
let mut models = Vec::<MeshModelOption>::new();
let mut serve_targets = Vec::<MeshServeTarget>::new();
for model_id in string_array_field(payload, "hosted_models")
.into_iter()
.chain(string_array_field(payload, "serving_models"))
{
push_model(&mut models, &model_id, None);
if !endpoint_addr.is_empty() {
push_target(
&mut serve_targets,
MeshServeTarget {
model_id,
model_name: None,
endpoint_addr: endpoint_addr.clone(),
node_name: node_id.clone(),
capacity: Some(MeshTargetCapacity {
vram_gb: my_vram_gb,
}),
},
);
}
}
let peers = payload
.get("peers")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
for peer in &peers {
let peer_endpoint = string_field(peer, "invite_token")
.or_else(|| string_field(peer, "endpoint_addr"))
.or_else(|| string_field(peer, "endpointAddr"));
let peer_name = string_field(peer, "hostname").or_else(|| string_field(peer, "id"));
let peer_vram_gb = peer.get("vram_gb").and_then(Value::as_f64);
for model_id in string_array_field(peer, "hosted_models")
.into_iter()
.chain(string_array_field(peer, "serving_models"))
{
push_model(&mut models, &model_id, None);
if let Some(endpoint_addr) = peer_endpoint.clone() {
push_target(
&mut serve_targets,
MeshServeTarget {
model_id,
model_name: None,
endpoint_addr,
node_name: peer_name.clone(),
capacity: Some(MeshTargetCapacity {
vram_gb: peer_vram_gb,
}),
},
);
}
}
}
models.sort_by(|a, b| a.id.cmp(&b.id));
serve_targets.sort_by(|a, b| {
a.model_id
.cmp(&b.model_id)
.then_with(|| a.endpoint_addr.cmp(&b.endpoint_addr))
});
SproutMeshStatus {
v: 1,
status_type: MESH_STATUS_TYPE.to_string(),
updated_at: now_unix,
mesh_id,
mesh_name,
serve_targets,
models,
peer_count: peers.len(),
}
}
/// Publish sanitized mesh status as a relay-signed kind:30621 event.
pub async fn publish_mesh_status_from_payload(
state: &Arc<AppState>,
payload: &Value,
) -> anyhow::Result<()> {
let now_unix = chrono::Utc::now().timestamp().max(0) as u64;
let status = sanitize_mesh_status(payload, now_unix);
publish_mesh_status(state, &status).await
}
/// Publish a pre-sanitized mesh status. Exposed for tests and integration seams.
pub async fn publish_mesh_status(
state: &Arc<AppState>,
status: &SproutMeshStatus,
) -> anyhow::Result<()> {
let content = serde_json::to_string(status)?;
let tags = vec![
Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?,
Tag::parse(["d", MESH_STATUS_D_TAG])
.map_err(|e| anyhow::anyhow!("failed to build d tag: {e}"))?,
Tag::parse(["k", MESH_STATUS_TYPE])
.map_err(|e| anyhow::anyhow!("failed to build k tag: {e}"))?,
];
let event = EventBuilder::new(Kind::Custom(KIND_MESH_LLM_RELAY_STATUS as u16), content)
.tags(tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_MESH_LLM_RELAY_STATUS}: {e}"))?;
let (stored, was_inserted) = state
.db
.replace_parameterized_event(&event, MESH_STATUS_D_TAG, None)
.await?;
if was_inserted {
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
dispatch_persistent_event(
state,
&stored,
KIND_MESH_LLM_RELAY_STATUS,
&relay_pubkey_hex,
)
.await;
}
info!(
targets = status.serve_targets.len(),
models = status.models.len(),
"mesh-LLM relay status published"
);
Ok(())
}
fn string_field(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn string_array_field(value: &Value, key: &str) -> Vec<String> {
value
.get(key)
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn push_model(models: &mut Vec<MeshModelOption>, id: &str, name: Option<String>) {
if models.iter().any(|model| model.id == id) {
return;
}
models.push(MeshModelOption {
id: id.to_string(),
name,
});
}
fn push_target(targets: &mut Vec<MeshServeTarget>, target: MeshServeTarget) {
if targets.iter().any(|existing| {
existing.model_id == target.model_id && existing.endpoint_addr == target.endpoint_addr
}) {
return;
}
targets.push(target);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitizer_projects_models_and_endpoint_addr_without_raw_runtime() {
let payload = serde_json::json!({
"token": "endpoint-token-a",
"node_id": "node-a",
"mesh_id": "mesh-1",
"mesh_name": "sprout",
"hosted_models": ["Qwen3-8B-Q4_K_M"],
"serving_models": ["Qwen3-8B-Q4_K_M"],
"my_vram_gb": 12.0,
"runtime": { "stages": [{ "source_model_path": "/secret/model.gguf" }] },
"local_instances": [{ "runtime_dir": "/tmp/private" }],
"peers": [{
"id": "peer-b",
"hostname": "gpu-b",
"invite_token": "endpoint-token-b",
"hosted_models": ["TinyLlama"],
"vram_gb": 8.0
}]
});
let status = sanitize_mesh_status(&payload, 123);
assert_eq!(status.status_type, MESH_STATUS_TYPE);
assert_eq!(status.mesh_id.as_deref(), Some("mesh-1"));
assert_eq!(status.models.len(), 2);
assert!(status.models.iter().any(|m| m.id == "Qwen3-8B-Q4_K_M"));
assert!(status.models.iter().any(|m| m.id == "TinyLlama"));
assert_eq!(status.serve_targets.len(), 2);
assert!(status
.serve_targets
.iter()
.any(|target| target.model_id == "Qwen3-8B-Q4_K_M"
&& target.endpoint_addr == "endpoint-token-a"));
assert!(status
.serve_targets
.iter()
.any(|target| target.model_id == "TinyLlama"
&& target.endpoint_addr == "endpoint-token-b"));
let serialized = serde_json::to_string(&status).unwrap();
assert!(!serialized.contains("source_model_path"));
assert!(!serialized.contains("runtime_dir"));
assert!(!serialized.contains("/secret"));
}
#[test]
fn sanitizer_keeps_model_id_separate_from_label() {
let payload = serde_json::json!({
"token": "endpoint-token-a",
"hosted_models": ["hf://meshllm/demo@main"],
"peers": []
});
let status = sanitize_mesh_status(&payload, 123);
assert_eq!(status.models[0].id, "hf://meshllm/demo@main");
assert_eq!(status.models[0].name, None);
assert_eq!(status.serve_targets[0].model_id, "hf://meshllm/demo@main");
assert_eq!(status.serve_targets[0].model_name, None);
}
}

View File

@@ -0,0 +1,286 @@
//! End-to-end acceptance tests for the relay-hosted mesh-LLM feature.
//!
//! These tests require a running sprout-relay with mesh embedded
//! (`SPROUT_MESH_ENABLED=true`, `SPROUT_REQUIRE_RELAY_MEMBERSHIP=true`) and,
//! for the live-inference rows, 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 `sprout-relay` (`mesh_status_publisher`,
//! `iroh_relay`); this file is the opt-in full-stack acceptance layer.
//!
//! # Running (manual / runbook)
//!
//! ```text
//! # 1. one-time local llama build (see docs/mesh-llm-local-build.md)
//! # 2. start a mesh-enabled relay
//! SPROUT_MESH_ENABLED=true SPROUT_REQUIRE_RELAY_MEMBERSHIP=true \
//! cargo run -p sprout-relay
//! # 3. run the trust assertions (no GPU needed):
//! 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
//! # trust rows need real relay identities, not generated keys:
//! 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 kind:30621 status w/ dial pointer, no secrets | `trust_member_reads_mesh_status` | relay `mesh_status_publisher` units |
//! | 2 | non-member REQ for kind:30621 returns nothing | `trust_nonmember_read_denied` | — |
//! | 3 | non-member iroh dial denied (NIP-98→membership) | runbook (needs iroh dial) | relay `iroh_relay` admission units |
//! | 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) | sprout-agent `-32001` unit |
//! | 6 | split: model too big → 2 serve nodes → chat completes | `live_split_model_completes` | runbook |
use std::time::Duration;
use nostr::{Filter, Keys, Kind};
use sprout_test_client::SproutTestClient;
/// Sprout's relay-owned mesh status kind (must match `sprout_core::kind`).
const KIND_MESH_LLM_RELAY_STATUS: u16 = 30621;
const MESH_STATUS_D_TAG: &str = "sprout-relay-mesh";
const MESH_STATUS_TYPE: &str = "sprout-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_MESH_LLM_RELAY_STATUS))
.identifier(MESH_STATUS_D_TAG)
}
// ── (1) member reads the relay-signed status, with dial pointer, no secrets ──
/// Assertion 1: an authenticated relay member can REQ the relay-signed
/// kind:30621 status event; its content carries the sanitized projection
/// (mesh/models/serveTargets with EndpointAddr dial pointers) and NO secrets
/// (no invite-secret, no local paths, no raw runtime object).
///
/// 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 = SproutTestClient::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_MESH_LLM_RELAY_STATUS))
.expect("a member must see at least one kind:30621 status event");
// Relay-signed (the relay keypair, not this member).
assert_ne!(
status.pubkey,
member.public_key(),
"status must be relay-signed, not member-signed"
);
let content: serde_json::Value =
serde_json::from_str(&status.content).expect("content is JSON");
assert_eq!(content["type"], MESH_STATUS_TYPE, "type discriminator");
// 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();
}
// ── (2) non-member read denied ───────────────────────────────────────────────
/// Assertion 2: a valid Nostr identity that is NOT a relay member gets nothing
/// back for a kind:30621 REQ — membership gates the read.
///
/// Requires a relay with `SPROUT_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 SproutTestClient::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_MESH_LLM_RELAY_STATUS));
assert!(
!leaked,
"non-member must NOT receive kind:30621 mesh status"
);
client.disconnect().await.ok();
}
// ── (4) the demo: B's agent completes a chat against A's model over the mesh ──
/// Assertion 4 (the headline demo): with desktop A serving a model and desktop
/// B running a mesh client + a launched sprout-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"
);
}
// ── (6) split variant ────────────────────────────────────────────────────────
/// 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 Sprout 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. Left unwired
// so `--ignored` can never report it green without a real split harness.
panic!("live_split_model_completes: not implemented — runbook only (see module docs)");
}

View File

@@ -3,6 +3,9 @@ ignore = [
# instant 0.1.13 — unmaintained crate. Transitive dep: nostr → instant.
# Will be resolved when nostr crate updates its dependencies.
{ id = "RUSTSEC-2024-0384", reason = "transitive dep via nostr; no upstream fix available" },
# paste 1.0.15 — unmaintained. Transitive dep: mesh-llm → iroh → netlink-* → paste.
# No safe upgrade available; tracked for upstream (iroh/netlink) replacement.
{ id = "RUSTSEC-2024-0436", reason = "transitive dep via mesh-llm → iroh → netlink; no upstream fix available" },
]
[licenses]
@@ -22,9 +25,53 @@ allow = [
"MITNFA",
"MPL-2.0",
"BSL-1.0",
"Unlicense",
# bzip2/libbzip2's permissive BSD-like license. New via desktop zip/bzip2
# transitive deps; compatible with Apache-2.0 distribution.
"bzip2-1.0.6",
]
confidence-threshold = 0.8
# mesh-llm workspace crates (pinned git dep) omit a per-crate `license` field in
# their manifests, so cargo-deny reports them as unlicensed. The mesh-llm repo is
# licensed "MIT OR Apache-2.0" (workspace Cargo.toml + top-level LICENSE = Apache-2.0);
# clarify each pulled-in member to that expression. Remove once mesh sets the field
# upstream (filed).
[[licenses.clarify]]
crate = "mesh-llm-config"
expression = "MIT OR Apache-2.0"
license-files = []
[[licenses.clarify]]
crate = "mesh-llm-gpu-bench"
expression = "MIT OR Apache-2.0"
license-files = []
[[licenses.clarify]]
crate = "mesh-llm-host-runtime"
expression = "MIT OR Apache-2.0"
license-files = []
[[licenses.clarify]]
crate = "mesh-llm-plugin"
expression = "MIT OR Apache-2.0"
license-files = []
[[licenses.clarify]]
crate = "mesh-llm-system"
expression = "MIT OR Apache-2.0"
license-files = []
[[licenses.clarify]]
crate = "mesh-mixture-of-agents"
expression = "MIT OR Apache-2.0"
license-files = []
[[licenses.clarify]]
crate = "sprout-desktop"
expression = "Apache-2.0"
license-files = []
[licenses.private]
ignore = true

View File

@@ -44,6 +44,7 @@ export default defineConfig({
"**/profile.spec.ts",
"**/tokens.spec.ts",
"**/persona-env-vars.spec.ts",
"**/mesh-compute.spec.ts",
],
use: {
...devices["Desktop Chrome"],

View File

@@ -51,10 +51,10 @@ const overrides = new Map([
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
["src-tauri/src/migration.rs", 1010], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES + SHARED_AGENT_DIRS symlink-to-canonical + sibling pack migration) + mcp_command provider reconciliation + persona_pack_path reconciliation + tests
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
["src-tauri/src/commands/agents.rs", 900], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload + relay-mesh client preflight (start_local_agent_with_preflight + StartTarget split: async ensure outside store lock, then sync spawn under lock)
["src-tauri/src/commands/messages.rs", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits)
["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests
["src-tauri/src/managed_agents/runtime.rs", 1330], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux), instance-scoped by app identifier so coexisting Sprout instances don't reap each other's agents
["src-tauri/src/managed_agents/runtime.rs", 1385], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux), instance-scoped by app identifier so coexisting Sprout instances don't reap each other's agents + relay_mesh_model_id detector (mesh preset-env match) + tests
["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests
["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field
["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery)
@@ -81,7 +81,7 @@ const overrides = new Map([
["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize)
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
["src-tauri/src/lib.rs", 770], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch + SIGINT/SIGTERM/SIGHUP signal handlers for agent process cleanup
["src-tauri/src/lib.rs", 780], // + mesh command registrations + PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch + SIGINT/SIGTERM/SIGHUP signal handlers for agent process cleanup
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime
]);

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,7 @@ windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
[dependencies]
atomic-write-file = "0.3"
anyhow = "1"
dirs = "6"
tauri = { version = "2", features = [] }
tauri-plugin-deep-link = "2"
@@ -55,6 +56,7 @@ url = "2"
sprout-core = { path = "../../crates/sprout-core" }
sprout-persona = { path = "../../crates/sprout-persona" }
sprout-sdk = { path = "../../crates/sprout-sdk" }
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "bd16da4955a9dceee8f1e0a02c5814944f4e12a5", package = "mesh-llm-sdk", default-features = false, features = ["client", "serve"] }
base64 = "0.22"
sha2 = "0.11"
tar = "0.4"

View File

@@ -6,6 +6,7 @@ use std::{
use nostr::{Keys, ToBech32};
use tauri::{AppHandle, Manager};
use tokio::sync::Mutex as AsyncMutex;
use crate::huddle::HuddleState;
use crate::managed_agents::ManagedAgentProcess;
@@ -33,6 +34,8 @@ pub struct AppState {
pub media_proxy_port: AtomicU16,
/// IOKit power assertion state — prevents idle sleep while agents run.
pub prevent_sleep: Arc<Mutex<crate::prevent_sleep::PreventSleepState>>,
/// In-process mesh-llm node started by Sprout Desktop.
pub mesh_llm_runtime: AsyncMutex<Option<crate::mesh_llm::DesktopMeshRuntime>>,
}
pub fn build_app_state() -> AppState {
@@ -78,6 +81,7 @@ pub fn build_app_state() -> AppState {
prevent_sleep: Arc::new(Mutex::new(
crate::prevent_sleep::PreventSleepState::default(),
)),
mesh_llm_runtime: AsyncMutex::new(None),
}
}

View File

@@ -7,13 +7,13 @@ use crate::{
build_managed_agent_summary, discover_provider_candidates, ensure_persona_is_active,
find_managed_agent_mut, invoke_provider, load_managed_agents, load_personas,
managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir,
normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary,
save_managed_agents, start_managed_agent_process, stop_managed_agent_process,
sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind,
BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND,
DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
DEFAULT_MCP_COMMAND,
normalize_agent_args, provider_deploy, read_log_tail, relay_mesh_model_id,
resolve_provider_binary, save_managed_agents, start_managed_agent_process,
stop_managed_agent_process, sync_managed_agent_processes, try_regenerate_nest,
validate_provider_config, BackendKind, BackendProviderInfo, CreateManagedAgentRequest,
CreateManagedAgentResponse, ManagedAgentLogResponse, ManagedAgentRecord,
ManagedAgentSummary, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM,
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
},
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
@@ -27,6 +27,64 @@ fn workspace_owner_hex(state: &AppState) -> Result<String, String> {
Ok(keys.public_key().to_hex())
}
async fn ensure_relay_mesh_for_record(
state: &AppState,
record: &ManagedAgentRecord,
) -> Result<(), String> {
let Some(model_id) = relay_mesh_model_id(record) else {
return Ok(());
};
crate::commands::mesh_llm::ensure_client_node_for_model(state, model_id).await?;
Ok(())
}
async fn start_local_agent_with_preflight(
app: &AppHandle,
state: &AppState,
pubkey: &str,
owner_hex: &str,
) -> Result<ManagedAgentSummary, String> {
let record_snapshot = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(app)?;
records
.iter()
.find(|record| record.pubkey == pubkey)
.cloned()
.ok_or_else(|| format!("agent {pubkey} not found"))?
};
if record_snapshot.backend != BackendKind::Local {
return Err(format!("agent {pubkey} is not a local agent"));
}
ensure_relay_mesh_for_record(state, &record_snapshot).await?;
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut records = load_managed_agents(app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let record = find_managed_agent_mut(&mut records, pubkey)?;
if record.backend != BackendKind::Local {
return Err(format!("agent {pubkey} is no longer a local agent"));
}
start_managed_agent_process(app, record, &mut runtimes, Some(owner_hex))?;
save_managed_agents(app, &records)?;
let record = records
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
build_managed_agent_summary(app, record, &runtimes)
}
/// Build the standard agent JSON payload for provider deploy calls.
///
/// Fails closed if the agent points at a `persona_id` we can't load — persona
@@ -287,8 +345,8 @@ pub async fn create_managed_agent(
Some(tag)
};
// ── Phase 3: save record and optionally spawn (sync lock) ─────────────────
let (agent, spawn_error) = {
// ── Phase 3: save record (sync lock) ───────────────────────────────────────
let agent = {
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -438,17 +496,6 @@ pub async fn create_managed_agent(
records.push(record);
let mut spawn_error = None;
if input.spawn_after_create && input.backend == BackendKind::Local {
let record = find_managed_agent_mut(&mut records, &pubkey)?;
if let Err(error) =
start_managed_agent_process(&app, record, &mut runtimes, Some(&owner_hex))
{
record.updated_at = now_iso();
record.last_error = Some(error.clone());
spawn_error = Some(error);
}
}
save_managed_agents(&app, &records)?;
let record = records
@@ -457,7 +504,38 @@ pub async fn create_managed_agent(
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
let agent = build_managed_agent_summary(&app, record, &runtimes)?;
(agent, spawn_error)
agent
};
// ── Phase 3b: local spawn (async preflight outside store lock) ───────────
let mut spawn_error = None;
let agent = if input.spawn_after_create && input.backend == BackendKind::Local {
match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex).await {
Ok(agent) => agent,
Err(error) => {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut records = load_managed_agents(&app)?;
let runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let record = find_managed_agent_mut(&mut records, &pubkey)?;
record.updated_at = now_iso();
record.last_error = Some(error.clone());
save_managed_agents(&app, &records)?;
spawn_error = Some(error);
let record = records
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
build_managed_agent_summary(&app, record, &runtimes)?
}
}
} else {
agent
};
try_regenerate_nest(&app);
@@ -566,8 +644,17 @@ pub async fn start_managed_agent(
// Snapshot the workspace owner pubkey for the legacy auth_tag fallback.
// Read outside the records lock to keep lock ordering simple.
let owner_hex = workspace_owner_hex(&state)?;
// Collect backend info and handle local vs provider under lock.
let (backend, cached_binary_path, agent_json) = {
enum StartTarget {
Local,
Provider {
backend: BackendKind,
cached_binary_path: Option<String>,
agent_json: serde_json::Value,
},
}
// Collect backend info under lock; async preflight/spawn happens below.
let target = {
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -585,55 +672,56 @@ pub async fn start_managed_agent(
let record = find_managed_agent_mut(&mut records, &pubkey)?;
if record.backend == BackendKind::Local {
// Local: spawn in-process and return immediately.
start_managed_agent_process(&app, record, &mut runtimes, Some(&owner_hex))?;
save_managed_agents(&app, &records)?;
StartTarget::Local
} else {
StartTarget::Provider {
backend: record.backend.clone(),
cached_binary_path: record.provider_binary_path.clone(),
agent_json: build_deploy_payload(&app, record)?,
}
}
};
match target {
StartTarget::Local => {
start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex).await
}
StartTarget::Provider {
backend: BackendKind::Provider { id, config },
cached_binary_path,
agent_json,
} => {
deploy_to_provider(
&app,
&state,
&pubkey,
&id,
&config,
agent_json,
cached_binary_path.as_deref(),
)
.await?;
// Return updated summary.
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(&app)?;
let runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let record = records
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
return build_managed_agent_summary(&app, record, &runtimes);
build_managed_agent_summary(&app, record, &runtimes)
}
let payload = build_deploy_payload(&app, record)?;
(
record.backend.clone(),
record.provider_binary_path.clone(),
payload,
)
};
// Provider backend: deploy via shared helper (async, outside lock).
if let BackendKind::Provider { ref id, ref config } = backend {
deploy_to_provider(
&app,
&state,
&pubkey,
id,
config,
agent_json,
cached_binary_path.as_deref(),
)
.await?;
// Return updated summary.
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(&app)?;
let runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let record = records
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
return build_managed_agent_summary(&app, record, &runtimes);
StartTarget::Provider { backend, .. } => Err(format!(
"agent {pubkey} has unsupported backend kind: {backend:?}"
)),
}
Err(format!("agent {pubkey} has unsupported backend kind"))
}
#[tauri::command]

View File

@@ -0,0 +1,229 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use nostr::{EventBuilder, JsonUtil, Kind, Tag};
use reqwest::Method;
use tauri::{AppHandle, State};
use crate::{app_state::AppState, mesh_llm, relay};
pub type CmdResult<T> = Result<T, String>;
#[tauri::command]
pub async fn mesh_availability(
state: State<'_, AppState>,
) -> CmdResult<mesh_llm::MeshAvailability> {
match relay::query_relay(&state, &[mesh_llm::mesh_status_filter()]).await {
Ok(events) => Ok(mesh_llm::availability_from_events(events)),
Err(error) => Ok(mesh_llm::MeshAvailability::unavailable(error)),
}
}
#[tauri::command]
pub async fn mesh_start_node(
_app: AppHandle,
state: State<'_, AppState>,
mut request: mesh_llm::StartMeshNodeRequest,
) -> CmdResult<mesh_llm::MeshNodeStatus> {
let mut runtime = state.mesh_llm_runtime.lock().await;
if runtime.is_some() {
return Err("mesh node is already running".to_string());
}
hydrate_private_relay_config(&state, &mut request).await?;
let started = mesh_llm::DesktopMeshRuntime::start(request)
.await
.map_err(|error| error.to_string())?;
let status = started
.status()
.await
.map_err(|error| format!("mesh node started but status probe failed: {error}"))?;
*runtime = Some(started);
Ok(status)
}
#[tauri::command]
pub async fn mesh_ensure_client_node(
state: State<'_, AppState>,
request: mesh_llm::EnsureMeshClientRequest,
) -> CmdResult<mesh_llm::MeshNodeStatus> {
ensure_client_node_for_model(&state, request.model_id).await
}
pub(crate) async fn ensure_client_node_for_model(
state: &AppState,
model_id: impl AsRef<str>,
) -> CmdResult<mesh_llm::MeshNodeStatus> {
let requested_model = model_id.as_ref().trim();
if requested_model.is_empty() {
return Err("modelId is required".to_string());
}
{
let runtime = state.mesh_llm_runtime.lock().await;
if let Some(runtime) = runtime.as_ref() {
let status = runtime.status().await.map_err(|error| error.to_string())?;
return match status.mode {
Some(mesh_llm::MeshNodeMode::Client) => Ok(status),
Some(mesh_llm::MeshNodeMode::Serve) => Err(
"this desktop is currently sharing compute; stop sharing before using relay mesh as a client"
.to_string(),
),
None => Ok(status),
};
}
}
let availability = match relay::query_relay(&state, &[mesh_llm::mesh_status_filter()]).await {
Ok(events) => mesh_llm::availability_from_events(events),
Err(error) => return Err(format!("failed to read relay mesh status: {error}")),
};
if !availability.available {
return Err(availability
.reason
.unwrap_or_else(|| "relay mesh is not available".to_string()));
}
let target = availability
.serve_targets
.iter()
.find(|target| target.model_id == requested_model)
.ok_or_else(|| format!("relay mesh has no serve target for model {requested_model}"))?;
let mut start = mesh_llm::StartMeshNodeRequest {
mode: mesh_llm::MeshNodeMode::Client,
model_id: None,
max_vram_gb: None,
join_token: Some(target.endpoint_addr.clone()),
iroh_relay_url: None,
iroh_relay_auth: None,
};
hydrate_private_relay_config(&state, &mut start).await?;
let mut runtime = state.mesh_llm_runtime.lock().await;
if runtime.is_some() {
return Err("mesh node changed while starting relay mesh client".to_string());
}
let started = mesh_llm::DesktopMeshRuntime::start(start)
.await
.map_err(|error| format!("mesh client failed to start: {error}"))?;
let status = started
.status()
.await
.map_err(|error| format!("mesh client started but status probe failed: {error}"))?;
*runtime = Some(started);
Ok(status)
}
async fn hydrate_private_relay_config(
state: &AppState,
request: &mut mesh_llm::StartMeshNodeRequest,
) -> Result<(), String> {
if request.iroh_relay_url.is_none() {
request.iroh_relay_url = Some(fetch_iroh_relay_url(state).await?);
}
if request.iroh_relay_auth.is_none() {
let relay = request
.iroh_relay_url
.as_deref()
.ok_or_else(|| "relay did not advertise iroh_relay_url".to_string())?;
request.iroh_relay_auth = Some(build_iroh_relay_bearer(state, relay)?);
}
Ok(())
}
async fn fetch_iroh_relay_url(state: &AppState) -> Result<String, String> {
#[derive(serde::Deserialize)]
struct Nip11Info {
iroh_relay_url: Option<String>,
}
let url = relay::relay_api_base_url_with_override(state);
let response = state
.http_client
.get(&url)
.header("Accept", "application/nostr+json")
.send()
.await
.map_err(|error| format!("failed to fetch relay NIP-11: {error}"))?;
if !response.status().is_success() {
return Err(relay::relay_error_message(response).await);
}
let info = response
.json::<Nip11Info>()
.await
.map_err(|error| format!("failed to parse relay NIP-11: {error}"))?;
info.iroh_relay_url
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "relay NIP-11 does not advertise iroh_relay_url".to_string())
}
fn build_iroh_relay_bearer(state: &AppState, relay_url: &str) -> Result<String, String> {
let canonical = canonical_iroh_relay_auth_url(relay_url)?;
let tags = vec![
Tag::parse(vec!["u", canonical.as_str()])
.map_err(|error| format!("url tag failed: {error}"))?,
Tag::parse(vec!["method", Method::GET.as_str()])
.map_err(|error| format!("method tag failed: {error}"))?,
];
let keys = state.keys.lock().map_err(|error| error.to_string())?;
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.sign_with_keys(&keys)
.map_err(|error| format!("sign failed: {error}"))?;
Ok(BASE64.encode(event.as_json().as_bytes()))
}
fn canonical_iroh_relay_auth_url(relay_url: &str) -> Result<String, String> {
let mut parsed = url::Url::parse(relay_url)
.map_err(|error| format!("invalid iroh relay URL {relay_url:?}: {error}"))?;
parsed.set_query(None);
parsed.set_fragment(None);
let mut path = parsed.path().trim_end_matches('/').to_string();
if !path.ends_with("/relay") {
if path.is_empty() {
path = "/relay".to_string();
} else {
path.push_str("/relay");
}
}
parsed.set_path(&path);
Ok(parsed.to_string().trim_end_matches('/').to_string())
}
#[tauri::command]
pub async fn mesh_stop_node(state: State<'_, AppState>) -> CmdResult<mesh_llm::MeshNodeStatus> {
let runtime = state.mesh_llm_runtime.lock().await.take();
if let Some(runtime) = runtime {
runtime.stop().await.map_err(|error| error.to_string())?;
}
Ok(mesh_llm::stopped_status())
}
#[tauri::command]
pub async fn mesh_node_status(state: State<'_, AppState>) -> CmdResult<mesh_llm::MeshNodeStatus> {
let runtime = state.mesh_llm_runtime.lock().await;
match runtime.as_ref() {
Some(runtime) => runtime.status().await.map_err(|error| error.to_string()),
None => Ok(mesh_llm::stopped_status()),
}
}
#[tauri::command]
pub async fn mesh_installed_models(
state: State<'_, AppState>,
) -> CmdResult<Vec<mesh_llm::MeshModelOption>> {
let runtime = state.mesh_llm_runtime.lock().await;
if let Some(runtime) = runtime.as_ref() {
return runtime
.installed_models()
.await
.map_err(|error| error.to_string());
}
Ok(Vec::new())
}
#[tauri::command]
pub fn mesh_agent_preset(
request: mesh_llm::MeshAgentPresetRequest,
) -> CmdResult<mesh_llm::MeshAgentPreset> {
mesh_llm::agent_preset(request)
}

View File

@@ -12,6 +12,7 @@ mod identity_archive;
mod media;
mod media_download;
mod media_transcode;
mod mesh_llm;
mod messages;
pub mod pairing;
mod personas;
@@ -35,6 +36,7 @@ pub use identity::*;
pub use identity_archive::*;
pub use media::*;
pub use media_download::*;
pub use mesh_llm::*;
pub use messages::*;
pub use pairing::*;
pub use personas::*;

View File

@@ -4,6 +4,7 @@ mod events;
mod huddle;
mod managed_agents;
mod media_proxy;
mod mesh_llm;
mod migration;
mod models;
pub mod nostr_convert;
@@ -491,9 +492,9 @@ pub fn run() {
// Keep launch-time agent restoration off the synchronous setup path
// so the frontend can mount and reveal the window promptly.
tauri::async_runtime::spawn_blocking(move || {
tauri::async_runtime::spawn(async move {
if let Err(error) =
restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref())
restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref()).await
{
eprintln!("sprout-desktop: failed to restore managed agents: {error}");
}
@@ -579,6 +580,13 @@ pub fn run() {
delete_managed_agent,
get_managed_agent_log,
get_agent_models,
mesh_availability,
mesh_start_node,
mesh_ensure_client_node,
mesh_stop_node,
mesh_node_status,
mesh_installed_models,
mesh_agent_preset,
update_managed_agent,
discover_backend_providers,
probe_backend_provider,

View File

@@ -1,6 +1,7 @@
use super::{
find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, save_managed_agents,
spawn_agent_child, sync_managed_agent_processes, BackendKind, ManagedAgentProcess,
find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, relay_mesh_model_id,
save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind,
ManagedAgentProcess,
};
use crate::app_state::AppState;
use crate::util;
@@ -16,7 +17,7 @@ type AgentSpawnResult = (String, SpawnResult);
/// A (under lock): sync process state, cleanup, collect agents to start
/// B (no locks): resolve commands and spawn processes in parallel
/// C (re-lock): write back PIDs and status to records on disk
pub fn restore_managed_agents_on_launch(
pub async fn restore_managed_agents_on_launch(
app: &tauri::AppHandle,
shutdown_started: &AtomicBool,
) -> Result<(), String> {
@@ -102,6 +103,24 @@ pub fn restore_managed_agents_on_launch(
.ok()
.map(|k| k.public_key().to_hex());
let mut mesh_preflight_failures = std::collections::HashSet::new();
for record in &agents_to_start {
let Some(model_id) = relay_mesh_model_id(record) else {
continue;
};
if let Err(error) = crate::commands::ensure_client_node_for_model(&state, model_id).await {
persist_restore_error(app, &state, &record.pubkey, error)?;
mesh_preflight_failures.insert(record.pubkey.clone());
}
}
let agents_to_start: Vec<_> = agents_to_start
.into_iter()
.filter(|record| !mesh_preflight_failures.contains(&record.pubkey))
.collect();
if agents_to_start.is_empty() {
return Ok(());
}
// ── Phase B (no locks): resolve commands and spawn processes in parallel ──
let spawn_results: Vec<AgentSpawnResult> = std::thread::scope(|scope| {
let owner_hex_ref = owner_hex.as_deref();
@@ -163,3 +182,20 @@ pub fn restore_managed_agents_on_launch(
Ok(())
}
fn persist_restore_error(
app: &tauri::AppHandle,
state: &AppState,
pubkey: &str,
error: String,
) -> Result<(), String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut records = load_managed_agents(app)?;
let record = find_managed_agent_mut(&mut records, pubkey)?;
record.updated_at = util::now_iso();
record.last_error = Some(error);
save_managed_agents(app, &records)
}

View File

@@ -575,7 +575,9 @@ pub fn sync_managed_agent_processes(
record.last_error = if status.success() {
None
} else {
Some(format!("harness exited with status {status}"))
super::meaningful_agent_error_from_log(&runtime.log_path)
.unwrap_or_else(|| format!("harness exited with status {status}"))
.into()
};
}
@@ -1041,6 +1043,26 @@ fn child_rust_log_filter() -> String {
}
}
/// Returns the relay-mesh model id for agents whose provider env points at the
/// local mesh client endpoint. The caller is responsible for ensuring that
/// endpoint is alive before spawning the agent process.
pub fn relay_mesh_model_id(record: &ManagedAgentRecord) -> Option<String> {
let base_url = record.env_vars.get("OPENAI_COMPAT_BASE_URL")?.trim();
if base_url.trim_end_matches('/') != "http://127.0.0.1:9337/v1" {
return None;
}
let provider = record.env_vars.get("SPROUT_AGENT_PROVIDER")?.trim();
if provider != "openai" {
return None;
}
record
.env_vars
.get("OPENAI_COMPAT_MODEL")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(str::to_string)
}
pub fn start_managed_agent_process(
app: &AppHandle,
record: &mut ManagedAgentRecord,
@@ -1147,6 +1169,7 @@ pub fn stop_managed_agent_process(
#[cfg(test)]
mod tests {
use super::relay_mesh_model_id;
use crate::managed_agents::known_acp_provider;
#[test]
@@ -1238,6 +1261,36 @@ mod tests {
}
}
#[test]
fn relay_mesh_model_id_detects_mesh_preset_env() {
let mut rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
rec.env_vars = std::collections::BTreeMap::from([
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
(
"OPENAI_COMPAT_BASE_URL".to_string(),
"http://127.0.0.1:9337/v1/".to_string(),
),
("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()),
]);
assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3"));
}
#[test]
fn relay_mesh_model_id_ignores_non_mesh_openai_env() {
let mut rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
rec.env_vars = std::collections::BTreeMap::from([
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
(
"OPENAI_COMPAT_BASE_URL".to_string(),
"https://api.openai.com/v1".to_string(),
),
("OPENAI_COMPAT_MODEL".to_string(), "gpt-5".to_string()),
]);
assert_eq!(relay_mesh_model_id(&rec), None);
}
#[test]
fn build_env_owner_only_sets_mode_and_removes_others() {
let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));

View File

@@ -198,8 +198,55 @@ fn bytecount_newlines(buf: &[u8]) -> usize {
buf.iter().filter(|&&b| b == b'\n').count()
}
pub fn meaningful_agent_error_from_log(path: &Path) -> Option<String> {
let tail = read_log_tail(path, 200).ok()?;
tail.lines().rev().map(str::trim).find_map(|line| {
if line.starts_with("Agent reported error:") {
return Some(line.to_string());
}
if line.starts_with("llm auth:") {
return Some(format!("Agent reported error: {line}"));
}
None
})
}
#[cfg(test)]
mod tests {
use std::io::Write as _;
use tempfile::NamedTempFile;
fn write_log(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().expect("temp log");
file.write_all(content.as_bytes()).expect("write log");
file
}
#[test]
fn meaningful_agent_error_from_log_promotes_wrapped_llm_auth() {
let file = write_log("noise\nAgent reported error: llm auth: denied\n");
assert_eq!(
super::meaningful_agent_error_from_log(file.path()).as_deref(),
Some("Agent reported error: llm auth: denied")
);
}
#[test]
fn meaningful_agent_error_from_log_promotes_unwrapped_llm_auth() {
let file = write_log("noise\nllm auth: denied\n");
assert_eq!(
super::meaningful_agent_error_from_log(file.path()).as_deref(),
Some("Agent reported error: llm auth: denied")
);
}
#[test]
fn meaningful_agent_error_from_log_does_not_promote_midline_auth_text() {
let file = write_log("noise before llm auth: denied\n");
assert!(super::meaningful_agent_error_from_log(file.path()).is_none());
}
#[test]
fn strips_ansi_from_typical_tracing_line() {
let input = "\x1b[2m2026-05-27T15:16:32\x1b[0m \x1b[32m INFO\x1b[0m \x1b[2msprout_acp\x1b[0m\x1b[2m:\x1b[0m starting";

View File

@@ -0,0 +1,498 @@
use std::collections::BTreeMap;
use mesh_llm_sdk::{client, serve, EmbeddedNodeHandle, MeshDiscoveryMode};
use serde::{Deserialize, Serialize};
const DEFAULT_MESH_API_PORT: u16 = 9337;
const DEFAULT_MESH_CONSOLE_PORT: u16 = 3131;
const MESH_STATUS_KIND: u64 = 30_621;
const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1";
const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "sprout-mesh-local";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MeshModelOption {
pub id: String,
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MeshServeTarget {
pub model_id: String,
pub model_name: Option<String>,
pub endpoint_addr: String,
pub node_name: Option<String>,
pub capacity: Option<MeshTargetCapacity>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MeshTargetCapacity {
pub vram_gb: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MeshHealthStatus {
Ok,
Degraded,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MeshHealth {
pub status: MeshHealthStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl MeshHealth {
fn ok() -> Self {
Self {
status: MeshHealthStatus::Ok,
reason: None,
}
}
fn degraded(reason: impl Into<String>) -> Self {
Self {
status: MeshHealthStatus::Degraded,
reason: Some(reason.into()),
}
}
fn failed(reason: impl Into<String>) -> Self {
Self {
status: MeshHealthStatus::Failed,
reason: Some(reason.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MeshAvailability {
pub capable: bool,
pub admitted: bool,
pub available: bool,
pub reason: Option<String>,
pub models: Vec<MeshModelOption>,
pub serve_targets: Vec<MeshServeTarget>,
}
impl MeshAvailability {
pub fn unavailable(reason: impl Into<String>) -> Self {
Self {
capable: false,
admitted: false,
available: false,
reason: Some(reason.into()),
models: Vec::new(),
serve_targets: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MeshNodeMode {
Serve,
Client,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MeshNodeState {
Off,
Starting,
Running,
Stopping,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct StartMeshNodeRequest {
pub mode: MeshNodeMode,
#[serde(default)]
pub model_id: Option<String>,
#[serde(default)]
pub max_vram_gb: Option<u64>,
#[serde(default)]
pub join_token: Option<String>,
#[serde(default)]
pub iroh_relay_url: Option<String>,
#[serde(default)]
pub iroh_relay_auth: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EnsureMeshClientRequest {
pub model_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MeshNodeStatus {
pub state: MeshNodeState,
pub mode: Option<MeshNodeMode>,
pub health: MeshHealth,
pub api_base_url: Option<String>,
pub console_url: Option<String>,
pub model_id: Option<String>,
pub model_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub invite_token: Option<String>,
}
pub fn stopped_status() -> MeshNodeStatus {
MeshNodeStatus {
state: MeshNodeState::Off,
mode: None,
health: MeshHealth::ok(),
api_base_url: None,
console_url: None,
model_id: None,
model_name: None,
invite_token: None,
}
}
pub struct DesktopMeshRuntime {
handle: EmbeddedNodeHandle,
mode: MeshNodeMode,
model_id: Option<String>,
model_name: Option<String>,
}
impl DesktopMeshRuntime {
pub async fn start(request: StartMeshNodeRequest) -> anyhow::Result<Self> {
validate_no_leak_request(&request)?;
let model_id = request
.model_id
.clone()
.filter(|value| !value.trim().is_empty());
let model_name = model_id.clone();
let handle = match request.mode {
MeshNodeMode::Serve => {
let model = model_id
.clone()
.ok_or_else(|| anyhow::anyhow!("modelId is required for serve mode"))?;
let mut builder = serve::EmbeddedServeConfig::builder()
.model(model)
.api_port(DEFAULT_MESH_API_PORT)
.console_port(DEFAULT_MESH_CONSOLE_PORT)
.publish(false)
.auto_join(false)
.discovery_mode(MeshDiscoveryMode::Nostr)
.console_ui(true);
if let Some(max_vram_gb) = request.max_vram_gb {
builder = builder.max_vram_gb(max_vram_gb as f64);
}
if let Some(join_token) = request.join_token.as_deref() {
builder = builder.join_token(join_token);
}
if let Some(relay) = request.iroh_relay_url.as_deref() {
builder = builder.iroh_relay(relay);
if let Some(auth) = request.iroh_relay_auth.as_deref() {
builder = builder.iroh_relay_auth(relay, auth);
}
}
serve::start(builder.build()).await?
}
MeshNodeMode::Client => {
let mut builder = client::EmbeddedClientConfig::builder()
.api_port(DEFAULT_MESH_API_PORT)
.console_port(DEFAULT_MESH_CONSOLE_PORT)
.publish(false)
.auto_join(false)
.discovery_mode(MeshDiscoveryMode::Nostr)
.console_ui(true);
if let Some(join_token) = request.join_token.as_deref() {
builder = builder.join_token(join_token);
}
if let Some(relay) = request.iroh_relay_url.as_deref() {
builder = builder.iroh_relay(relay);
if let Some(auth) = request.iroh_relay_auth.as_deref() {
builder = builder.iroh_relay_auth(relay, auth);
}
}
client::start(builder.build()).await?
}
};
Ok(Self {
handle,
mode: request.mode,
model_id,
model_name,
})
}
pub async fn status(&self) -> anyhow::Result<MeshNodeStatus> {
let status = self.handle.status().await?;
self.status_from_sdk(status)
}
pub async fn installed_models(&self) -> anyhow::Result<Vec<MeshModelOption>> {
let status = self.handle.status().await?;
Ok(models_from_status_payload(Some(&status.payload)))
}
fn status_from_sdk(
&self,
status: mesh_llm_sdk::EmbeddedNodeStatus,
) -> anyhow::Result<MeshNodeStatus> {
let health = health_from_payload(&status.payload);
Ok(MeshNodeStatus {
state: if matches!(health.status, MeshHealthStatus::Failed) {
MeshNodeState::Failed
} else {
MeshNodeState::Running
},
mode: Some(self.mode),
health,
api_base_url: Some(status.api_base_url),
console_url: Some(status.console_url),
model_id: self.model_id.clone(),
model_name: self.model_name.clone(),
invite_token: status.invite_token,
})
}
pub async fn stop(self) -> anyhow::Result<()> {
self.handle.stop().await
}
}
fn validate_no_leak_request(request: &StartMeshNodeRequest) -> anyhow::Result<()> {
if request.join_token.as_deref().is_some_and(str::is_empty) {
anyhow::bail!("joinToken cannot be empty when provided");
}
if request.iroh_relay_url.as_deref().is_none_or(str::is_empty) {
anyhow::bail!("relay NIP-11 must advertise iroh_relay_url before starting mesh");
}
if request.iroh_relay_auth.as_deref().is_none_or(str::is_empty) {
anyhow::bail!("iroh relay admission bearer is required before starting mesh");
}
Ok(())
}
fn health_from_payload(payload: &serde_json::Value) -> MeshHealth {
if let Some(reason) = find_progressish_reason(payload) {
return MeshHealth::degraded(reason);
}
if let Some(status) = payload.get("status").and_then(serde_json::Value::as_str) {
if matches!(status, "failed" | "error") {
return MeshHealth::failed(status);
}
}
MeshHealth::ok()
}
fn find_progressish_reason(value: &serde_json::Value) -> Option<String> {
// Match a typed phase field (not stringify-and-grep over the whole payload).
let phase = ["phase", "status", "state", "stage"]
.into_iter()
.find_map(|key| value.get(key).and_then(serde_json::Value::as_str))?
.to_ascii_lowercase();
for needle in ["download", "fetch", "resolv", "prepar"] {
if phase.contains(needle) {
return Some(match needle {
"download" => "downloading model".to_string(),
"fetch" => "fetching model".to_string(),
"resolv" => "resolving model".to_string(),
_ => "preparing model".to_string(),
});
}
}
None
}
pub fn models_from_status_payload(payload: Option<&serde_json::Value>) -> Vec<MeshModelOption> {
let mut out = Vec::new();
if let Some(payload) = payload {
collect_model_options(payload, &mut out);
}
dedupe_models(out)
}
fn collect_model_options(value: &serde_json::Value, out: &mut Vec<MeshModelOption>) {
match value {
serde_json::Value::Object(map) => {
if let Some(id) = map
.get("model_id")
.or_else(|| map.get("modelId"))
.or_else(|| map.get("model_ref"))
.or_else(|| map.get("modelRef"))
.or_else(|| map.get("id"))
.and_then(serde_json::Value::as_str)
{
let name = map
.get("name")
.or_else(|| map.get("display_name"))
.or_else(|| map.get("displayName"))
.and_then(serde_json::Value::as_str)
.map(ToString::to_string);
push_model(out, id, name);
}
for child in map.values() {
collect_model_options(child, out);
}
}
serde_json::Value::Array(values) => {
for child in values {
collect_model_options(child, out);
}
}
serde_json::Value::String(value) if looks_like_model_ref(value) => {
push_model(out, value, None);
}
_ => {}
}
}
fn looks_like_model_ref(value: &str) -> bool {
// Family-agnostic: a bare string is a ref only via URI scheme or .gguf ext.
let trimmed = value.trim();
!trimmed.is_empty()
&& (trimmed.starts_with("hf://") || trimmed.to_ascii_lowercase().ends_with(".gguf"))
}
fn push_model(out: &mut Vec<MeshModelOption>, id: &str, name: Option<String>) {
let id = id.trim();
if id.is_empty() || id.starts_with("http://") || id.starts_with("https://") {
return;
}
out.push(MeshModelOption {
id: id.to_string(),
name,
});
}
fn dedupe_models(models: Vec<MeshModelOption>) -> Vec<MeshModelOption> {
let mut by_id = BTreeMap::<String, Option<String>>::new();
for model in models {
by_id
.entry(model.id)
.and_modify(|name| {
if name.is_none() {
*name = model.name.clone();
}
})
.or_insert(model.name);
}
by_id
.into_iter()
.map(|(id, name)| MeshModelOption { id, name })
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MeshAgentPresetRequest {
pub model_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MeshAgentPreset {
pub provider_id: String,
pub label: String,
pub acp_command: String,
pub agent_command: String,
pub agent_args: Vec<String>,
pub mcp_command: String,
pub model: String,
pub env_vars: BTreeMap<String, String>,
}
pub fn agent_preset(request: MeshAgentPresetRequest) -> Result<MeshAgentPreset, String> {
let model = request.model_id.trim();
if model.is_empty() {
return Err("modelId is required".to_string());
}
Ok(MeshAgentPreset {
provider_id: "relay-mesh".to_string(),
label: "Relay mesh".to_string(),
acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(),
agent_command: crate::managed_agents::DEFAULT_AGENT_COMMAND.to_string(),
agent_args: Vec::new(),
mcp_command: crate::managed_agents::DEFAULT_MCP_COMMAND.to_string(),
model: model.to_string(),
env_vars: BTreeMap::from([
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
(
"OPENAI_COMPAT_BASE_URL".to_string(),
RELAY_MESH_API_BASE_URL.to_string(),
),
("OPENAI_COMPAT_MODEL".to_string(), model.to_string()),
(
"OPENAI_COMPAT_API_KEY".to_string(),
RELAY_MESH_API_KEY_PLACEHOLDER.to_string(),
),
("OPENAI_COMPAT_API".to_string(), "chat".to_string()),
]),
})
}
pub fn availability_from_events(events: Vec<nostr::Event>) -> MeshAvailability {
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
return MeshAvailability::unavailable("relay mesh status is not published yet");
};
let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
return MeshAvailability::unavailable("relay mesh status is malformed");
};
let serve_targets = content
.get("serveTargets")
.or_else(|| content.get("serve_targets"))
.cloned()
.and_then(|value| serde_json::from_value::<Vec<MeshServeTarget>>(value).ok())
.unwrap_or_default();
let models = content
.get("models")
.cloned()
.and_then(|value| serde_json::from_value::<Vec<MeshModelOption>>(value).ok())
.unwrap_or_else(|| {
dedupe_models(
serve_targets
.iter()
.map(|target| MeshModelOption {
id: target.model_id.clone(),
name: target.model_name.clone(),
})
.collect(),
)
});
let available = !serve_targets.is_empty();
MeshAvailability {
capable: true,
admitted: true,
available,
reason: if available {
None
} else {
Some("no relay mesh serve targets are available".to_string())
},
models,
serve_targets,
}
}
pub fn mesh_status_filter() -> serde_json::Value {
serde_json::json!({
"kinds": [MESH_STATUS_KIND],
"#d": ["sprout-relay-mesh"],
"limit": 1
})
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod mod_tests;

View File

@@ -0,0 +1,34 @@
//! Unit tests for `mesh_llm/mod.rs` private helpers (kept in a sibling file so
//! `mod.rs` stays under the 500-line budget; `#[path]`-included from there).
use super::{find_progressish_reason, looks_like_model_ref};
use serde_json::json;
#[test]
fn progressish_reads_typed_phase_not_whole_tree() {
assert_eq!(
find_progressish_reason(&json!({"phase": "downloading weights"})),
Some("downloading model".to_string())
);
// Regression (Sami N1): an unrelated field mentioning a progress word must
// not trip the badge — only the typed phase field counts.
assert_eq!(
find_progressish_reason(&json!({
"phase": "ready",
"model_name": "prepared-qwen-preparing"
})),
None
);
assert_eq!(find_progressish_reason(&json!({"foo": "bar"})), None);
}
#[test]
fn model_ref_is_family_agnostic() {
assert!(looks_like_model_ref("hf://org/model"));
assert!(looks_like_model_ref("some-model.gguf"));
assert!(looks_like_model_ref("Some-Model.GGUF"));
// Families that used to be hardcoded must route via the structured path,
// not a name allowlist here (Sami N2):
assert!(!looks_like_model_ref("Mistral-7B"));
assert!(!looks_like_model_ref("Qwen3-35B"));
assert!(!looks_like_model_ref(""));
}

View File

@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
friendlyAgentLastError,
RELAY_MESH_DENIED_COPY,
} from "./friendlyAgentLastError.ts";
test("null lastError → null", () => {
assert.equal(friendlyAgentLastError(null), null);
});
test("empty/whitespace lastError → null", () => {
assert.equal(friendlyAgentLastError(""), null);
assert.equal(friendlyAgentLastError(" "), null);
});
test("sprout-acp wrapped auth failure → denied copy", () => {
const result = friendlyAgentLastError(
"Agent reported error: llm auth: 401 unauthorized: ...",
);
assert.deepEqual(result, {
severity: "denied",
copy: RELAY_MESH_DENIED_COPY,
});
});
test("unwrapped sprout-agent prefix → denied copy", () => {
// sprout-agent's AgentError::LlmAuth Display is "llm auth: <body>"; if the
// desktop ever picks that up directly (no AcpError wrapper), we should
// still recognize it as denial.
const result = friendlyAgentLastError("llm auth: 403 forbidden");
assert.deepEqual(result, {
severity: "denied",
copy: RELAY_MESH_DENIED_COPY,
});
});
test("generic harness exit message → passthrough", () => {
const result = friendlyAgentLastError("harness exited with status code 137");
assert.deepEqual(result, {
severity: "generic",
copy: "harness exited with status code 137",
});
});
test("trims whitespace before matching", () => {
const result = friendlyAgentLastError(
" Agent reported error: llm auth: nope\n",
);
assert.equal(result?.severity, "denied");
assert.equal(result?.copy, RELAY_MESH_DENIED_COPY);
});
test("substring 'llm auth:' that isn't at start is NOT treated as denial", () => {
// Some other failure that happens to mention 'llm auth:' deep in a message
// — we only promote when the failure *is* an auth failure, signalled by
// the prefix. Anything else stays passthrough so we don't lie about the
// cause of an unrelated crash.
const result = friendlyAgentLastError(
"harness exited with status code 1: stderr mentions llm auth: misleadingly",
);
assert.equal(result?.severity, "generic");
assert.ok(result?.copy.startsWith("harness exited"));
});
test("non-auth Agent reported error stays generic", () => {
const result = friendlyAgentLastError(
"Agent reported error: llm: 500 internal server error",
);
assert.equal(result?.severity, "generic");
assert.equal(
result?.copy,
"Agent reported error: llm: 500 internal server error",
);
});

View File

@@ -0,0 +1,57 @@
/**
* Promote certain machine-readable `lastError` strings to user-facing copy.
*
* The mesh-llm seam (Max's commit `5196203…`) flows like this:
* sprout-agent — gets HTTP 401/403 from the OpenAI-compatible mesh endpoint
* → raises `AgentError::LlmAuth("…")` (json_rpc_code `-32001`,
* Display prefix `"llm auth: …"`)
* sprout-acp — wraps it as `AcpError::AgentError("Agent reported error: llm auth: …")`
* desktop managed-agent supervisor — on nonzero exit, scans `read_log_tail`
* for `"Agent reported error:"` / `"llm auth:"` and persists
* that line into `ManagedAgent.lastError` instead of the
* generic `"harness exited with status …"`.
*
* v1 caveat (named, not hidden): the typed `-32001` code never reaches
* desktop structurally — desktop only supervises the child process and ACP's
* `ObserverHandle` is in-process inside that child. So `lastError` is the
* recovered string, not the original code. The follow-up to make this 9/10
* structural is an ACP status file or desktop-owned observer sink; that lives
* elsewhere. For now we match the string this function exists to render.
*
* Returns:
* - null when there's nothing to show (null/empty lastError).
* - A `{ severity: "denied"; copy: string }` object for the auth-failure
* case, so the UI can render with the right visual weight (destructive).
* - A `{ severity: "generic"; copy: string }` pass-through for any other
* lastError, so generic harness exits still surface their text instead of
* being swallowed.
*/
export type FriendlyAgentLastError =
| { severity: "denied"; copy: string }
| { severity: "generic"; copy: string };
/**
* The exact copy for the relay-mesh denial. Centralized as a constant so the
* test asserts the user-facing string verbatim rather than a fuzzy pattern.
*/
export const RELAY_MESH_DENIED_COPY =
"Relay mesh denied this agent — check your relay membership.";
export function friendlyAgentLastError(
raw: string | null,
): FriendlyAgentLastError | null {
if (raw == null) return null;
const trimmed = raw.trim();
if (trimmed.length === 0) return null;
// Match either the unwrapped sprout-agent prefix or the sprout-acp wrap.
// The desktop supervisor recovers whichever appears first in the log tail.
if (
trimmed.startsWith("Agent reported error: llm auth:") ||
trimmed.startsWith("llm auth:")
) {
return { severity: "denied", copy: RELAY_MESH_DENIED_COPY };
}
return { severity: "generic", copy: trimmed };
}

View File

@@ -35,6 +35,8 @@ import {
ProviderConfigFields,
} from "./ProviderConfigFields";
import { CreateAgentRespondToField } from "./RespondToField";
import { RelayMeshAgentSection } from "@/features/mesh-compute/ui/RelayMeshAgentSection";
import { meshEnsureClientNode } from "@/shared/api/tauriMesh";
import { useLastRuntimeProvider } from "@/features/agents/lib/useLastRuntimeProvider";
// ── Dialog ────────────────────────────────────────────────────────────────────
@@ -86,6 +88,17 @@ export function CreateAgentDialog({
React.useState<BackendProviderProbeResult | null>(null);
const [probeError, setProbeError] = React.useState<string | null>(null);
// ── Relay-mesh flow state ──────────────────────────────────────────────────
// When `useMesh` is on, the agent runs sprout-agent against a member's
// shared compute. The ACP runtime + backend selectors are hidden; runtime
// fields are driven by `mesh_agent_preset(meshModelId)` and the submit
// input carries `model: meshModelId`.
const [useMesh, setUseMesh] = React.useState(false);
const [meshModelId, setMeshModelId] = React.useState("");
const [meshClientError, setMeshClientError] = React.useState<string | null>(
null,
);
const providers = providersQuery.data ?? [];
const allProviders = allProvidersQuery.data ?? [];
const unavailableCount = allProviders.filter(
@@ -102,7 +115,10 @@ export function CreateAgentDialog({
() => backendProviders.find((p) => p.id === runOn) ?? null,
[backendProviders, runOn],
);
const isProviderMode = runOn !== "local";
// Relay mesh always runs in local mode (sprout-agent + OpenAI-compat env);
// when on, it suppresses the backend "Run on" branch even if a stale
// `runOn` value remains. The relay-mesh path is its own thing.
const isProviderMode = !useMesh && runOn !== "local";
const isSpawnSupported =
prereqs?.acp.available === true && prereqs?.mcp.available === true;
@@ -228,6 +244,9 @@ export function CreateAgentDialog({
setProviderConfig({});
setProbedProvider(null);
setProbeError(null);
setUseMesh(false);
setMeshModelId("");
setMeshClientError(null);
setRespondTo("owner-only");
setRespondToAllowlist([]);
createMutation.reset();
@@ -298,11 +317,24 @@ export function CreateAgentDialog({
// fields and config schema are only known after a successful probe.
!(isProviderMode && !probedProvider) &&
providerConfigComplete &&
// Relay-mesh mode requires the user to have picked a model from the
// availability dropdown — without a model the preset is empty.
!(useMesh && meshModelId.trim().length === 0) &&
respondToValid &&
!createMutation.isPending;
async function handleSubmit() {
setMeshClientError(null);
try {
if (useMesh) {
try {
await meshEnsureClientNode(meshModelId.trim());
} catch (err) {
setMeshClientError(err instanceof Error ? err.message : String(err));
return;
}
}
// Only send the allowlist when the mode is actually "allowlist".
// Other modes ignore it server-side, but keeping the wire clean makes
// the agent record easier to inspect.
@@ -359,6 +391,7 @@ export function CreateAgentDialog({
: undefined,
systemPrompt: systemPrompt.trim() || undefined,
envVars,
model: useMesh ? meshModelId.trim() || undefined : undefined,
spawnAfterCreate,
startOnAppLaunch,
backend: { type: "local" },
@@ -388,8 +421,46 @@ export function CreateAgentDialog({
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-5">
<CreateAgentBasicsFields name={name} onNameChange={setName} />
<RelayMeshAgentSection
current={{
acpCommand,
agentCommand,
agentArgs: agentArgs
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0),
mcpCommand,
model: meshModelId || null,
envVars,
}}
modelId={meshModelId}
onModelIdChange={(nextId, patch) => {
setMeshModelId(nextId);
if (patch == null) return;
// Fan out the preset into the existing setters so the rest
// of the dialog (and the submit branch) see normal local-mode
// values — relay-mesh is a curated local agent.
setAcpCommand(patch.acpCommand);
setAgentCommand(patch.agentCommand);
setAgentArgs(patch.agentArgs.join(","));
setMcpCommand(patch.mcpCommand);
setEnvVars(patch.envVars);
}}
onUseMeshChange={(next) => {
setUseMesh(next);
if (!next) {
// Clearing the toggle: drop the model selection so the
// submit guard doesn't fire on a stale value. The runtime
// fields keep whatever the user had — they can re-pick
// ACP runtime or stay with the preset values, their call.
setMeshModelId("");
}
}}
useMesh={useMesh}
/>
{/* Run on selector — only shown when backend providers are discovered */}
{backendProviders.length > 0 ? (
{!useMesh && backendProviders.length > 0 ? (
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-run-on">
Run on
@@ -442,7 +513,7 @@ export function CreateAgentDialog({
) : null}
{/* Local mode: show the ACP runtime selector */}
{!isProviderMode ? (
{!isProviderMode && !useMesh ? (
<CreateAgentRuntimeProviderField
onProviderChange={handleProviderChange}
providers={providers}
@@ -553,6 +624,12 @@ export function CreateAgentDialog({
value={envVars}
/>
{meshClientError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Mesh client failed to start: {meshClientError}
</p>
) : null}
{createMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{createMutation.error.message}

View File

@@ -31,6 +31,7 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { EditAgentDialog } from "./EditAgentDialog";
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
import { ModelPicker } from "./ModelPicker";
import { truncatePubkey } from "./agentUi";
@@ -86,6 +87,13 @@ export function ManagedAgentRow({
: isLocal
? "Ready to launch"
: "Managed remotely";
// When the harness recovered a meaningful error string from the agent's
// log tail (Max's seam in `managed_agents/storage.rs`), promote it to
// user-visible copy below the process detail. Specifically renders the
// friendly "Relay mesh denied this agent — check your relay membership."
// for auth failures so the user knows it's a membership thing, not a
// crash. Generic exits stay verbatim so we don't lie about other failures.
const friendlyError = friendlyAgentLastError(agent.lastError);
return (
<div
@@ -115,6 +123,7 @@ export function ManagedAgentRow({
presenceStatus={presenceStatus}
/>
<StatusBlock
friendlyError={friendlyError}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
processDetail={processDetail}
@@ -135,6 +144,7 @@ export function ManagedAgentRow({
presenceStatus={presenceStatus}
/>
<StatusBlock
friendlyError={friendlyError}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
processDetail={processDetail}
@@ -249,11 +259,13 @@ function AgentSummary({
}
function StatusBlock({
friendlyError,
presenceLoaded,
presenceStatus,
processDetail,
status,
}: {
friendlyError: ReturnType<typeof friendlyAgentLastError>;
presenceLoaded: boolean;
presenceStatus: PresenceStatus | undefined;
processDetail: string;
@@ -270,6 +282,19 @@ function StatusBlock({
status={status}
/>
<p className="text-xs text-muted-foreground">{processDetail}</p>
{friendlyError ? (
<p
className={cn(
"text-xs",
friendlyError.severity === "denied"
? "text-destructive"
: "text-muted-foreground",
)}
data-testid="managed-agent-last-error"
>
{friendlyError.copy}
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
detectMeshPresetOverrides,
meshAgentPresetPatch,
} from "./applyMeshAgentPreset.ts";
const PRESET = {
providerId: "relay-mesh",
label: "Relay mesh",
acpCommand: "sprout-acp",
agentCommand: "sprout-agent",
agentArgs: [],
mcpCommand: "sprout-dev-mcp",
model: "Qwen3-8B-Q4_K_M",
envVars: {
SPROUT_AGENT_PROVIDER: "openai",
OPENAI_COMPAT_BASE_URL: "http://127.0.0.1:9337/v1",
OPENAI_COMPAT_MODEL: "Qwen3-8B-Q4_K_M",
OPENAI_COMPAT_API_KEY: "sprout-mesh-local",
OPENAI_COMPAT_API: "chat",
},
};
// ── meshAgentPresetPatch ──────────────────────────────────────────────
test("patch carries the fields a managed-agent draft needs", () => {
const patch = meshAgentPresetPatch(PRESET);
assert.equal(patch.acpCommand, "sprout-acp");
assert.equal(patch.agentCommand, "sprout-agent");
assert.deepEqual(patch.agentArgs, []);
assert.equal(patch.mcpCommand, "sprout-dev-mcp");
assert.equal(patch.model, "Qwen3-8B-Q4_K_M");
assert.equal(patch.envVars.OPENAI_COMPAT_MODEL, "Qwen3-8B-Q4_K_M");
});
test("patch returns owned copies — caller cannot mutate the preset", () => {
const patch = meshAgentPresetPatch(PRESET);
patch.agentArgs.push("dirty");
patch.envVars.DIRTY = "1";
assert.deepEqual(PRESET.agentArgs, []);
assert.equal(PRESET.envVars.DIRTY, undefined);
});
// ── detectMeshPresetOverrides ─────────────────────────────────────────
test("empty draft has no overrides", () => {
const overrides = detectMeshPresetOverrides(
{
acpCommand: "",
agentCommand: "",
agentArgs: [],
mcpCommand: "",
model: null,
envVars: {},
},
PRESET,
);
assert.deepEqual(overrides, []);
});
test("matching draft has no overrides", () => {
const overrides = detectMeshPresetOverrides(
{
acpCommand: "sprout-acp",
agentCommand: "sprout-agent",
agentArgs: [],
mcpCommand: "sprout-dev-mcp",
model: "Qwen3-8B-Q4_K_M",
envVars: {
SPROUT_AGENT_PROVIDER: "openai",
OPENAI_COMPAT_BASE_URL: "http://127.0.0.1:9337/v1",
},
},
PRESET,
);
assert.deepEqual(overrides, []);
});
test("differing model is reported as override", () => {
const overrides = detectMeshPresetOverrides(
{
acpCommand: "sprout-acp",
agentCommand: "sprout-agent",
agentArgs: [],
mcpCommand: "sprout-dev-mcp",
model: "llama-3.2-3b-instruct",
envVars: {},
},
PRESET,
);
assert.deepEqual(overrides, ["model"]);
});
test("non-sprout-agent runtime + non-mesh model both reported", () => {
const overrides = detectMeshPresetOverrides(
{
acpCommand: "sprout-acp",
agentCommand: "goose",
agentArgs: ["acp"],
mcpCommand: "sprout-dev-mcp",
model: "gpt-4o",
envVars: {},
},
PRESET,
);
assert.deepEqual(overrides, ["agent runtime", "model"]);
});
test("overlapping env-var with differing value is reported", () => {
const overrides = detectMeshPresetOverrides(
{
acpCommand: "sprout-acp",
agentCommand: "sprout-agent",
agentArgs: [],
mcpCommand: "sprout-dev-mcp",
model: "Qwen3-8B-Q4_K_M",
envVars: {
SPROUT_AGENT_PROVIDER: "anthropic",
},
},
PRESET,
);
assert.deepEqual(overrides, ["environment variables"]);
});
test("overlapping env-var with same value is NOT reported", () => {
const overrides = detectMeshPresetOverrides(
{
acpCommand: "sprout-acp",
agentCommand: "sprout-agent",
agentArgs: [],
mcpCommand: "sprout-dev-mcp",
model: "Qwen3-8B-Q4_K_M",
envVars: {
SPROUT_AGENT_PROVIDER: "openai",
},
},
PRESET,
);
assert.deepEqual(overrides, []);
});
test("additive env-var (new key) is not an override", () => {
const overrides = detectMeshPresetOverrides(
{
acpCommand: "sprout-acp",
agentCommand: "sprout-agent",
agentArgs: [],
mcpCommand: "sprout-dev-mcp",
model: "Qwen3-8B-Q4_K_M",
envVars: {
SOME_USER_VAR: "kept",
},
},
PRESET,
);
assert.deepEqual(overrides, []);
});
test("empty model string treated like null (no override)", () => {
// ManagedAgent.model is `string | null` but a fresh draft sometimes carries
// "" instead of null. Either should be treated as "user hasn't picked yet."
const overrides = detectMeshPresetOverrides(
{
acpCommand: "sprout-acp",
agentCommand: "sprout-agent",
agentArgs: [],
mcpCommand: "sprout-dev-mcp",
model: "",
envVars: {},
},
PRESET,
);
assert.deepEqual(overrides, []);
});

View File

@@ -0,0 +1,95 @@
import type { MeshAgentPreset } from "@/shared/api/tauriMesh";
/**
* Fields a Create-Agent draft needs to overwrite when the "Run on relay mesh"
* flow is chosen. Mirror of `MeshAgentPreset` minus the picker metadata
* (`providerId`, `label`) which the *flow* owns, not the agent record.
*/
export type MeshAgentPresetPatch = {
acpCommand: string;
agentCommand: string;
agentArgs: string[];
mcpCommand: string;
model: string;
envVars: Record<string, string>;
};
/**
* Turn a `mesh_agent_preset()` response into the fields a managed-agent draft
* should carry. Idempotent and contract-stable: the same preset always yields
* the same patch. The flow integrator calls this and `Object.assign`s the
* result over the draft fields.
*
* Why a separate function: the dialog's state lives across many `useState`s
* (acpCommand, agentCommand, agentArgs, mcpCommand, model, envVars), so the
* caller fans out the patch. Doing the fan-out via a single helper keeps the
* integration commit a one-liner per setter and the override behavior
* testable without rendering.
*/
export function meshAgentPresetPatch(
preset: MeshAgentPreset,
): MeshAgentPresetPatch {
return {
acpCommand: preset.acpCommand,
agentCommand: preset.agentCommand,
agentArgs: [...preset.agentArgs],
mcpCommand: preset.mcpCommand,
model: preset.model,
envVars: { ...preset.envVars },
};
}
/**
* Detect whether applying a Relay-mesh preset would overwrite values a user
* (or persona) has already set. Drives the "Using relay mesh — overrides this
* persona's model" honest-over-silent copy that Eva named as a requirement.
*
* Returns the list of human-readable field labels that the preset *changes*
* from the current draft state, so the UI can render
* "Using Relay mesh overrides: model, agent runtime"
* rather than a silent overwrite.
*
* Empty list = no overrides; the preset is purely additive.
*/
export function detectMeshPresetOverrides(
current: {
acpCommand: string;
agentCommand: string;
agentArgs: string[];
mcpCommand: string;
model: string | null;
envVars: Record<string, string>;
},
preset: MeshAgentPreset,
): string[] {
const changes: string[] = [];
if (
current.agentCommand !== "" &&
current.agentCommand !== preset.agentCommand
) {
changes.push("agent runtime");
}
if (current.acpCommand !== "" && current.acpCommand !== preset.acpCommand) {
changes.push("ACP harness");
}
if (current.mcpCommand !== "" && current.mcpCommand !== preset.mcpCommand) {
changes.push("MCP server");
}
if (
current.model != null &&
current.model.length > 0 &&
current.model !== preset.model
) {
changes.push("model");
}
// Env-var overlap: only mention if a *value* would change (a new key being
// added is additive, not an override).
const overlappingEnvKeys = Object.keys(preset.envVars).filter(
(key) =>
key in current.envVars && current.envVars[key] !== preset.envVars[key],
);
if (overlappingEnvKeys.length > 0) {
changes.push("environment variables");
}
return changes;
}

View File

@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import test from "node:test";
import { classifyModelRef, modelRefHintLabel } from "./classifyModelRef.ts";
// ── classifyModelRef ──────────────────────────────────────────────────
test("empty string → unknown", () => {
assert.deepEqual(classifyModelRef(""), { kind: "unknown" });
assert.deepEqual(classifyModelRef(" "), { kind: "unknown" });
});
test("hf:// prefix → huggingface", () => {
assert.deepEqual(classifyModelRef("hf://meshllm/qwen3-8b@main"), {
kind: "huggingface",
ref: "hf://meshllm/qwen3-8b@main",
});
});
test("absolute path → local-path", () => {
assert.deepEqual(classifyModelRef("/Users/me/models/qwen.gguf"), {
kind: "local-path",
path: "/Users/me/models/qwen.gguf",
});
});
test("relative path with ./ → local-path", () => {
assert.deepEqual(classifyModelRef("./models/qwen.gguf"), {
kind: "local-path",
path: "./models/qwen.gguf",
});
});
test("home shortcut → local-path", () => {
assert.deepEqual(classifyModelRef("~/models/qwen.gguf"), {
kind: "local-path",
path: "~/models/qwen.gguf",
});
});
test(".gguf extension without path prefix → local-path", () => {
// Bare filename ending in .gguf — user clearly means a file.
assert.deepEqual(classifyModelRef("my-model.gguf"), {
kind: "local-path",
path: "my-model.gguf",
});
});
test("plain name → catalog", () => {
assert.deepEqual(classifyModelRef("Qwen3-8B-Q4_K_M"), {
kind: "catalog",
name: "Qwen3-8B-Q4_K_M",
});
});
test("trims whitespace before classifying", () => {
assert.deepEqual(classifyModelRef(" Qwen3-8B-Q4_K_M "), {
kind: "catalog",
name: "Qwen3-8B-Q4_K_M",
});
});
// ── modelRefHintLabel ─────────────────────────────────────────────────
test("hint labels are user-facing strings", () => {
assert.equal(
modelRefHintLabel({ kind: "catalog", name: "x" }),
"Looks like a catalog name",
);
assert.equal(
modelRefHintLabel({ kind: "huggingface", ref: "hf://x" }),
"HuggingFace ref",
);
assert.equal(
modelRefHintLabel({ kind: "local-path", path: "/x" }),
"Local file",
);
assert.equal(modelRefHintLabel({ kind: "unknown" }), null);
});

View File

@@ -0,0 +1,59 @@
/**
* Classification of a free-text model ref entered into the serve card.
* UI shows a hint inline ("Looks like a catalog name") for trust feedback.
* Mirrors mesh's own resolve logic at `runtime/mod.rs:3390`.
*/
export type ModelRefKind =
| { kind: "catalog"; name: string }
| { kind: "huggingface"; ref: string }
| { kind: "local-path"; path: string }
| { kind: "unknown" };
/**
* Classify a model-ref string the way mesh-llm's runtime does:
* - `hf://…` → HuggingFace ref
* - starts with `/` or `./` or `~`, OR ends with `.gguf` → local file
* - otherwise non-empty → catalog name
* - empty/whitespace → unknown
*
* Source: mesh runtime/mod.rs:3390 ("local file, catalog name, or HuggingFace URL").
*
* This is presentational only — the canonical resolution still happens server-
* side via `mesh_start_node`. UI uses this for the "Looks like a …" hint that
* makes the free-text field feel honest instead of opaque.
*/
export function classifyModelRef(raw: string): ModelRefKind {
const trimmed = raw.trim();
if (trimmed.length === 0) {
return { kind: "unknown" };
}
if (trimmed.startsWith("hf://")) {
return { kind: "huggingface", ref: trimmed };
}
// Local path heuristics. Conservative: only mark as path when there are
// unambiguous signals (leading separator, home shortcut, .gguf extension).
const looksLikePath =
trimmed.startsWith("/") ||
trimmed.startsWith("./") ||
trimmed.startsWith("../") ||
trimmed.startsWith("~") ||
trimmed.toLowerCase().endsWith(".gguf");
if (looksLikePath) {
return { kind: "local-path", path: trimmed };
}
return { kind: "catalog", name: trimmed };
}
/** Short label for the inline hint, e.g. "Looks like a catalog name". */
export function modelRefHintLabel(kind: ModelRefKind): string | null {
switch (kind.kind) {
case "catalog":
return "Looks like a catalog name";
case "huggingface":
return "HuggingFace ref";
case "local-path":
return "Local file";
case "unknown":
return null;
}
}

View File

@@ -0,0 +1,70 @@
import * as React from "react";
import { meshAvailability } from "@/shared/api/tauriMesh";
import type { MeshAvailability } from "@/shared/api/tauriMesh";
/**
* Polls `mesh_availability` at a slow cadence — this drives the Settings
* client-mode tile AND the Create-Agent "Relay mesh" flow gate. Both consumers
* tolerate up-to-5-second staleness; we re-fetch on focus to keep transitions
* (member added/removed, serve node started/stopped) feeling live.
*
* Returns `null` until the first successful fetch; consumers render a neutral
* loading state during that window rather than guessing at false defaults.
*/
export function useMeshAvailability(): {
availability: MeshAvailability | null;
error: string | null;
refresh: () => void;
} {
const [availability, setAvailability] =
React.useState<MeshAvailability | null>(null);
const [error, setError] = React.useState<string | null>(null);
// Single fetch routine; the latest one wins via the `cancelled` flag captured
// at call time. Returning the cleanup lets useEffect / setInterval invalidate
// in-flight fetches when they unmount or super-cede each other.
const fetchOnce = React.useCallback(() => {
let cancelled = false;
(async () => {
try {
const value = await meshAvailability();
if (!cancelled) {
setAvailability(value);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
}
})();
return () => {
cancelled = true;
};
}, []);
// Initial fetch on mount.
React.useEffect(() => fetchOnce(), [fetchOnce]);
// Slow polling — availability changes when members are admitted/removed or
// serve nodes come up/down, which is human-scale, not sub-second.
React.useEffect(() => {
const handle = window.setInterval(() => {
fetchOnce();
}, 5000);
return () => window.clearInterval(handle);
}, [fetchOnce]);
// Refresh when window regains focus — common case is user toggled
// share-compute on a peer machine and tabbed back.
React.useEffect(() => {
const onFocus = () => {
fetchOnce();
};
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [fetchOnce]);
return { availability, error, refresh: fetchOnce };
}

View File

@@ -0,0 +1,58 @@
import * as React from "react";
import { meshNodeStatus } from "@/shared/api/tauriMesh";
import type { MeshNodeStatus } from "@/shared/api/tauriMesh";
/**
* Polls `mesh_node_status` faster than availability — lifecycle transitions
* (off → starting → running, or running → failed) need to render quickly so
* the Share-compute card doesn't show a frozen "Starting…" for minutes.
*
* The poll interval steps up while transitioning and steps down when steady.
* That avoids hammering the runtime once a node is just "running ok."
*
* Returns `null` until first successful fetch.
*/
export function useMeshNodeStatus(): {
status: MeshNodeStatus | null;
error: string | null;
refresh: () => void;
} {
const [status, setStatus] = React.useState<MeshNodeStatus | null>(null);
const [error, setError] = React.useState<string | null>(null);
const fetchOnce = React.useCallback(() => {
let cancelled = false;
(async () => {
try {
const value = await meshNodeStatus();
if (!cancelled) {
setStatus(value);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
}
})();
return () => {
cancelled = true;
};
}, []);
React.useEffect(() => fetchOnce(), [fetchOnce]);
// Fast poll while in a transitioning state; slow poll while steady or off.
React.useEffect(() => {
const transitioning =
status?.state === "starting" || status?.state === "stopping";
const interval = transitioning ? 750 : 4000;
const handle = window.setInterval(() => {
fetchOnce();
}, interval);
return () => window.clearInterval(handle);
}, [status?.state, fetchOnce]);
return { status, error, refresh: fetchOnce };
}

View File

@@ -0,0 +1,307 @@
import * as React from "react";
import { ChevronDown, Cpu } from "lucide-react";
import { Input } from "@/shared/ui/input";
import { Switch } from "@/shared/ui/switch";
import { cn } from "@/shared/lib/cn";
import {
meshStartNode,
meshStopNode,
meshInstalledModels,
} from "@/shared/api/tauriMesh";
import type { MeshModelOption, MeshNodeStatus } from "@/shared/api/tauriMesh";
import { classifyModelRef, modelRefHintLabel } from "../classifyModelRef";
import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus";
/**
* Settings → Compute → Share compute.
*
* One toggle, one model field, an "Already installed" picklist, an Advanced
* group. Honest copy throughout — no kind:30621, no "endpoint id", no raw
* mesh knobs.
*
* The architectural-trust footer is load-bearing: it tells a privacy-aware
* user that *not publishing* is enforced by the build, not by a default they
* have to verify. (Source of the invariants this copy claims: Max's no-leak
* builder defaults — publish=false, no Nostr relays, no auto-discovery.)
*/
export function MeshComputeSettingsCard() {
const { status, error, refresh } = useMeshNodeStatus();
const [installedModels, setInstalledModels] = React.useState<
MeshModelOption[]
>([]);
const [modelInput, setModelInput] = React.useState("");
const [maxVramGb, setMaxVramGb] = React.useState<string>("");
const [advancedOpen, setAdvancedOpen] = React.useState(false);
const [actionInFlight, setActionInFlight] = React.useState(false);
const [actionError, setActionError] = React.useState<string | null>(null);
// Fetch installed models. Called on mount and whenever the running state
// changes (a fresh start may have downloaded a new model). Stale-tolerant —
// the picklist is a convenience, not load-bearing.
const refreshInstalled = React.useCallback(() => {
let cancelled = false;
(async () => {
try {
const list = await meshInstalledModels();
if (!cancelled) setInstalledModels(list);
} catch {
// Non-fatal — picklist just stays empty; user can still type a ref.
}
})();
return () => {
cancelled = true;
};
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: status?.state is the intentional trigger — re-fetch installed models when the node transitions (a fresh start may have downloaded a new model)
React.useEffect(() => refreshInstalled(), [refreshInstalled, status?.state]);
// Mirror the running node's modelId back into the field so the card shows
// what's actually being served, even after a fresh app load.
React.useEffect(() => {
if (status?.state === "running" && status.modelId && modelInput === "") {
setModelInput(status.modelId);
}
}, [status?.state, status?.modelId, modelInput]);
const isOn = status?.state === "running" || status?.state === "starting";
const refClass = classifyModelRef(modelInput);
const refHint = modelRefHintLabel(refClass);
const canStart =
refClass.kind !== "unknown" &&
!actionInFlight &&
status?.state !== "starting";
async function handleToggle(next: boolean) {
setActionError(null);
setActionInFlight(true);
try {
if (next) {
const maxVram =
maxVramGb.trim() === "" ? undefined : Number.parseFloat(maxVramGb);
await meshStartNode({
mode: "serve",
modelId: modelInput.trim() || undefined,
maxVramGb:
typeof maxVram === "number" && !Number.isNaN(maxVram)
? maxVram
: undefined,
});
} else {
await meshStopNode();
}
refresh();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setActionInFlight(false);
}
}
return (
<section className="min-w-0" data-testid="settings-mesh-share-compute">
<div className="mb-3 min-w-0">
<h2 className="text-sm font-semibold tracking-tight">Share compute</h2>
<p className="text-sm text-muted-foreground">
Share this machine with your relay. When on, other members can run
their agents here.
</p>
</div>
{error ? (
<p className="mb-3 rounded bg-destructive/10 px-2 py-1 text-xs text-destructive">
Couldn't load mesh status: {error}
</p>
) : null}
{actionError ? (
<p className="mb-3 rounded bg-destructive/10 px-2 py-1 text-xs text-destructive">
{actionError}
</p>
) : null}
<div className="flex flex-col gap-4">
{/* ── Master toggle + status row ─────────────────────────────── */}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<label
className="text-sm font-medium"
htmlFor="mesh-share-compute-toggle"
>
Share this machine
</label>
<StatusLine status={status} />
</div>
<Switch
checked={isOn}
data-testid="mesh-share-compute-toggle"
disabled={actionInFlight || (!isOn && !canStart)}
id="mesh-share-compute-toggle"
onCheckedChange={handleToggle}
/>
</div>
{/* ── Model field ──────────────────────────────────────────── */}
<fieldset
className="flex flex-col gap-2"
disabled={isOn || actionInFlight}
>
<legend className="flex items-center gap-2 text-sm font-medium">
<Cpu className="h-4 w-4 text-muted-foreground" />
Model
</legend>
<Input
data-testid="mesh-share-compute-model"
onChange={(e) => setModelInput(e.target.value)}
placeholder="Qwen3-8B-Q4_K_M or hf://meshllm/qwen3-8b@main"
value={modelInput}
/>
{refHint ? (
<p className="text-xs text-muted-foreground">{refHint}</p>
) : (
<p className="text-xs text-muted-foreground">
Catalog name, HuggingFace ref, or a local file path.
</p>
)}
{installedModels.length > 0 ? (
<div className="mt-1">
<p className="text-xs text-muted-foreground">
Already installed on this machine:
</p>
<ul
className="mt-1 flex flex-wrap gap-1.5"
data-testid="mesh-share-compute-installed-list"
>
{installedModels.map((m) => (
<li key={m.id}>
<button
className="rounded border border-border/60 bg-muted/20 px-2 py-0.5 text-xs hover:bg-muted/40"
onClick={() => setModelInput(m.id)}
type="button"
>
{m.name ?? m.id}
</button>
</li>
))}
</ul>
</div>
) : null}
</fieldset>
{/* ── Advanced ─────────────────────────────────────────────── */}
<details
className="rounded border border-border/40 px-3 py-2"
onToggle={(e) =>
setAdvancedOpen((e.target as HTMLDetailsElement).open)
}
open={advancedOpen}
>
<summary className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<ChevronDown
className={cn(
"h-3 w-3 transition-transform",
advancedOpen ? "rotate-0" : "-rotate-90",
)}
/>
Advanced
</summary>
<div className="mt-2 flex flex-col gap-2">
<label
className="text-xs text-muted-foreground"
htmlFor="mesh-vram"
>
Max VRAM (GB)
</label>
<Input
data-testid="mesh-share-compute-vram"
id="mesh-vram"
inputMode="decimal"
onChange={(e) => setMaxVramGb(e.target.value)}
placeholder="No limit"
value={maxVramGb}
/>
{status?.consoleUrl ? (
<p className="text-xs text-muted-foreground">
Debug console:{" "}
<a
className="underline"
href={status.consoleUrl}
rel="noreferrer"
target="_blank"
>
{status.consoleUrl}
</a>
</p>
) : null}
</div>
</details>
{/* ── Architectural-trust footer ───────────────────────────── */}
<p className="rounded bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
Sprout will not publish your machine to public Nostr relays,
auto-discover other networks, or share your endpoint outside this
relay's members. Only members of this relay can dial in.
</p>
</div>
</section>
);
}
/**
* Renders the lifecycle/health text under the toggle. Maps Max's `state` ×
* `health` matrix to honest copy — no "starting…" stuck forever when mesh
* is actually downloading weights or has failed.
*/
function StatusLine({ status }: { status: MeshNodeStatus | null }) {
if (!status) {
return <p className="text-sm text-muted-foreground">Loading…</p>;
}
const { state, health, modelId, modelName } = status;
const modelLabel = modelName ?? modelId ?? "";
if (state === "off") {
return (
<p className="text-sm text-muted-foreground">Not sharing right now.</p>
);
}
if (state === "starting") {
const reason =
health.status === "degraded" || health.status === "failed"
? health.reason
: "Starting…";
return <p className="text-sm text-muted-foreground">{reason}</p>;
}
if (state === "running") {
if (health.status === "failed") {
return (
<p className="text-sm text-destructive">
Couldn't load: {health.reason}
</p>
);
}
if (health.status === "degraded") {
return (
<p className="text-sm text-amber-600 dark:text-amber-400">
Active{modelLabel ? ` — ${modelLabel}` : ""}. {health.reason}
</p>
);
}
return (
<p className="text-sm text-muted-foreground">
Active{modelLabel ? ` — serving ${modelLabel}` : ""}.
</p>
);
}
if (state === "stopping") {
return <p className="text-sm text-muted-foreground">Stopping…</p>;
}
if (state === "failed") {
const reason =
health.status === "failed" || health.status === "degraded"
? health.reason
: "Couldn't start.";
return <p className="text-sm text-destructive">{reason}</p>;
}
return null;
}

View File

@@ -0,0 +1,155 @@
import * as React from "react";
import { AlertCircle, Network } from "lucide-react";
import { meshAgentPreset } from "@/shared/api/tauriMesh";
import { Switch } from "@/shared/ui/switch";
import {
detectMeshPresetOverrides,
meshAgentPresetPatch,
} from "../applyMeshAgentPreset";
import { useMeshAvailability } from "../hooks/useMeshAvailability";
/**
* The "Run on relay mesh" entry inside CreateAgentDialog. When enabled, the
* user picks a model from `mesh_availability().models`, the dialog's runtime
* + env-var state is fanned out from `mesh_agent_preset()`, and the existing
* runtime/backend pickers are hidden in the parent.
*
* Lives outside `CreateAgentDialog` so the dialog file's diff stays narrow —
* the dialog only renders this and reacts to its `onPatch` and `useMesh`
* callbacks. The dialog is the single source of truth for state; this
* component is purely a controller for the mesh-specific subset.
*/
export function RelayMeshAgentSection({
current,
useMesh,
modelId,
onUseMeshChange,
onModelIdChange,
}: {
/**
* Current draft state of the *fields the preset would overwrite*. Used to
* compute the override warning ("Using Relay mesh — overrides this
* persona's model") and to know what changed.
*/
current: {
acpCommand: string;
agentCommand: string;
agentArgs: string[];
mcpCommand: string;
model: string | null;
envVars: Record<string, string>;
};
useMesh: boolean;
modelId: string;
onUseMeshChange: (next: boolean) => void;
/**
* Fires when the user picks a model. The parent should fan out the
* preset to its individual setters (acpCommand, envVars, etc.) using
* `meshAgentPresetPatch`. We expose both `modelId` and the resolved patch
* so the parent can choose either eager fan-out or lazy-apply on submit.
*/
onModelIdChange: (
nextModelId: string,
patch: ReturnType<typeof meshAgentPresetPatch> | null,
) => void;
}) {
const { availability, error } = useMeshAvailability();
const [presetError, setPresetError] = React.useState<string | null>(null);
const disabled = availability == null || !availability.available;
const disabledReason =
availability == null
? (error ?? "Checking relay mesh availability…")
: (availability.reason ?? "Relay compute isn't available right now.");
// Compute overrides from the currently-selected model's preset, *not* from
// an arbitrary one — the warning must reflect what'll actually happen.
const [overrides, setOverrides] = React.useState<string[]>([]);
async function pickModel(nextModelId: string) {
if (nextModelId === "") {
onModelIdChange("", null);
setOverrides([]);
setPresetError(null);
return;
}
setPresetError(null);
try {
const preset = await meshAgentPreset(nextModelId);
const patch = meshAgentPresetPatch(preset);
setOverrides(detectMeshPresetOverrides(current, preset));
onModelIdChange(nextModelId, patch);
} catch (err) {
setPresetError(err instanceof Error ? err.message : String(err));
onModelIdChange(nextModelId, null);
}
}
return (
<div className="space-y-3 rounded-2xl border border-border/70 bg-muted/10 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<label
className="flex items-center gap-1.5 text-sm font-medium"
htmlFor="agent-relay-mesh-toggle"
>
<Network className="h-4 w-4 text-muted-foreground" />
Run on relay mesh
</label>
<p className="text-sm text-muted-foreground">
{disabled
? disabledReason
: "Use a member's shared compute — no API key needed."}
</p>
</div>
<Switch
checked={useMesh}
data-testid="agent-relay-mesh-toggle"
disabled={disabled}
id="agent-relay-mesh-toggle"
onCheckedChange={onUseMeshChange}
/>
</div>
{useMesh ? (
<div className="space-y-2">
<label
className="text-xs text-muted-foreground"
htmlFor="agent-relay-mesh-model"
>
Model
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
data-testid="agent-relay-mesh-model"
id="agent-relay-mesh-model"
onChange={(e) => void pickModel(e.target.value)}
value={modelId}
>
<option value="">Choose a model…</option>
{availability?.models.map((model) => (
<option key={model.id} value={model.id}>
{model.name ?? model.id}
</option>
))}
</select>
{presetError ? (
<p className="rounded bg-destructive/10 px-2 py-1 text-xs text-destructive">
Couldn't load model preset: {presetError}
</p>
) : null}
{overrides.length > 0 ? (
<p className="flex items-start gap-1.5 rounded bg-warning-bg/60 px-2 py-1.5 text-xs text-warning">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Using Relay mesh overrides this agent's {overrides.join(", ")}.
</span>
</p>
) : null}
</div>
) : null}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import {
BellRing,
Bot,
Check,
Cpu,
Download,
Keyboard,
LayoutTemplate,
@@ -31,6 +32,7 @@ import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard";
import { MobilePairingCard } from "./MobilePairingCard";
import { NotificationSettingsCard } from "./NotificationSettingsCard";
import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard";
@@ -42,6 +44,7 @@ export type SettingsSection =
| "notifications"
| "agents"
| "channel-templates"
| "compute"
| "appearance"
| "shortcuts"
| "relay-members"
@@ -92,6 +95,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Templates",
icon: LayoutTemplate,
},
{
value: "compute",
label: "Compute",
icon: Cpu,
},
{
value: "appearance",
label: "Appearance",
@@ -284,6 +292,8 @@ export function renderSettingsSection(
return <PreventSleepSettingsCard />;
case "channel-templates":
return <ChannelTemplatesSettingsCard />;
case "compute":
return <MeshComputeSettingsCard />;
case "appearance":
return <ThemeSettingsCard />;
case "shortcuts":

View File

@@ -0,0 +1,104 @@
import { invokeTauri } from "./tauri";
export type MeshHealth =
| { status: "ok"; reason?: null }
| { status: "degraded" | "failed"; reason: string };
export type MeshModelOption = {
id: string;
name: string | null;
};
export type MeshServeTarget = {
modelId: string;
modelName: string | null;
endpointAddr: string;
nodeName: string | null;
capacity: { vramGb: number | null } | null;
};
export type MeshAvailability = {
capable: boolean;
admitted: boolean;
available: boolean;
reason: string | null;
models: MeshModelOption[];
serveTargets: MeshServeTarget[];
};
export type MeshNodeState =
| "off"
| "starting"
| "running"
| "stopping"
| "failed";
export type MeshNodeMode = "serve" | "client";
export type StartMeshNodeRequest = {
mode: MeshNodeMode;
modelId?: string;
maxVramGb?: number;
joinToken?: string;
irohRelayUrl?: string;
irohRelayAuth?: string;
};
export type MeshNodeStatus = {
state: MeshNodeState;
mode: MeshNodeMode | null;
health: MeshHealth;
apiBaseUrl: string | null;
consoleUrl: string | null;
modelId: string | null;
modelName: string | null;
inviteToken?: string | null;
};
export type MeshAgentPreset = {
providerId: "relay-mesh";
label: string;
acpCommand: string;
agentCommand: string;
agentArgs: string[];
mcpCommand: string;
model: string;
envVars: Record<string, string>;
};
export async function meshAvailability(): Promise<MeshAvailability> {
return await invokeTauri<MeshAvailability>("mesh_availability");
}
export async function meshStartNode(
request: StartMeshNodeRequest,
): Promise<MeshNodeStatus> {
return await invokeTauri<MeshNodeStatus>("mesh_start_node", { request });
}
export async function meshEnsureClientNode(
modelId: string,
): Promise<MeshNodeStatus> {
return await invokeTauri<MeshNodeStatus>("mesh_ensure_client_node", {
request: { modelId },
});
}
export async function meshStopNode(): Promise<MeshNodeStatus> {
return await invokeTauri<MeshNodeStatus>("mesh_stop_node");
}
export async function meshNodeStatus(): Promise<MeshNodeStatus> {
return await invokeTauri<MeshNodeStatus>("mesh_node_status");
}
export async function meshInstalledModels(): Promise<MeshModelOption[]> {
return await invokeTauri<MeshModelOption[]>("mesh_installed_models");
}
export async function meshAgentPreset(
modelId: string,
): Promise<MeshAgentPreset> {
return await invokeTauri<MeshAgentPreset>("mesh_agent_preset", {
request: { modelId },
});
}

View File

@@ -464,6 +464,11 @@ declare global {
) => Promise<unknown>;
__SPROUT_E2E_PUSH_MOCK_FEED_ITEM__?: (item: RawFeedItem) => RawFeedItem;
__SPROUT_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void;
__SPROUT_E2E_SET_MESH__?: (mesh: {
admitted?: boolean;
models?: Array<{ id: string; name: string | null }>;
denyReason?: string;
}) => void;
}
}
@@ -1112,6 +1117,41 @@ const mockSockets = new Map<number, MockSocket>();
let mockWebsocketSendMutexWedged = false;
const realSockets = new Map<number, WebSocket>();
let mockManagedAgents: MockManagedAgent[] = [];
// Mesh-compute mock state — TEST-ONLY.
//
// This entire module (e2eBridge.ts) is loaded only when `window.__SPROUT_E2E__`
// is set by the Playwright harness; it never runs in a shipped build. These
// handlers stub the `mesh_*` Tauri commands with the SHAPES the UI expects
// (availability, node status, preset) so the desktop UI flow can be exercised
// in a browser. They deliberately do NOT model real admission, real inference,
// or real mesh routing — those are proven by the Rust layer-2 tests and the
// on-hardware layer-1 example. Do not port any of this into production code.
const mockMeshState: {
admitted: boolean;
models: Array<{ id: string; name: string | null }>;
denyReason: string;
nodeState: "off" | "running";
nodeMode: "serve" | "client" | null;
} = {
admitted: true,
models: [
{ id: "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M", name: "SmolLM2 135M" },
],
denyReason: "not a relay member",
nodeState: "off",
nodeMode: null,
};
function resetMockMesh() {
mockMeshState.admitted = true;
mockMeshState.models = [
{ id: "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M", name: "SmolLM2 135M" },
];
mockMeshState.denyReason = "not a relay member";
mockMeshState.nodeState = "off";
mockMeshState.nodeMode = null;
}
let mockPersonas: RawPersona[] = [];
let mockTeams: RawTeam[] = [];
let mockRelayAgents: RawRelayAgent[] = [
@@ -4736,6 +4776,7 @@ export function maybeInstallE2eTauriMocks() {
resetMockPersonas();
resetMockTeams();
resetMockWorkflows();
resetMockMesh();
mockWebsocketSendMutexWedged = false;
mockWindows("main");
window.__SPROUT_E2E_COMMANDS__ = [];
@@ -4797,12 +4838,86 @@ export function maybeInstallE2eTauriMocks() {
config.mock.stallWebsocketSends = stall;
if (!stall) mockWebsocketSendMutexWedged = false;
};
// Tests flip `admitted` to exercise the denial path: mesh_ensure_client_node
// rejects when not admitted, which proves relay membership is the gate and
// that the create flow surfaces denial copy without spawning the agent.
window.__SPROUT_E2E_SET_MESH__ = (mesh) => {
if (mesh.admitted !== undefined) mockMeshState.admitted = mesh.admitted;
if (mesh.models !== undefined) mockMeshState.models = mesh.models;
if (mesh.denyReason !== undefined)
mockMeshState.denyReason = mesh.denyReason;
};
const meshNodeStatus = (
state: "off" | "running",
mode: "serve" | "client" | null,
) => ({
state,
mode,
health: { status: "ok" as const, reason: null },
apiBaseUrl: state === "running" ? "http://127.0.0.1:9337/v1" : null,
consoleUrl: null,
modelId: mockMeshState.models[0]?.id ?? null,
modelName: mockMeshState.models[0]?.name ?? null,
inviteToken: null,
});
const handleMockCommand = async (command: string, payload: unknown) => {
const activeConfig = getConfig();
const identity = getActiveIdentity(activeConfig);
window.__SPROUT_E2E_COMMANDS__?.push(command);
switch (command) {
case "mesh_availability":
return {
capable: true,
admitted: mockMeshState.admitted,
available: mockMeshState.admitted,
reason: mockMeshState.admitted ? null : mockMeshState.denyReason,
models: mockMeshState.models,
serveTargets: [],
};
case "mesh_installed_models":
return mockMeshState.models;
case "mesh_node_status":
return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode);
case "mesh_start_node": {
const req = (
payload as { request?: { mode?: "serve" | "client" } } | null
)?.request;
mockMeshState.nodeState = "running";
mockMeshState.nodeMode = req?.mode ?? "serve";
return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode);
}
case "mesh_stop_node":
mockMeshState.nodeState = "off";
mockMeshState.nodeMode = null;
return meshNodeStatus("off", null);
case "mesh_ensure_client_node":
// The invariant under test: membership is the only factor. A
// non-admitted (non-member) caller cannot bring up the client node,
// and no extra manual auth step exists — admission alone decides.
if (!mockMeshState.admitted) {
throw new Error(mockMeshState.denyReason);
}
return meshNodeStatus("running", "client");
case "mesh_agent_preset": {
const req = (payload as { request?: { modelId?: string } } | null)
?.request;
const model = req?.modelId ?? mockMeshState.models[0]?.id ?? "";
return {
providerId: "relay-mesh" as const,
label: "Run on relay mesh",
acpCommand: "",
agentCommand: "sprout-agent",
agentArgs: [],
mcpCommand: "",
model,
envVars: {
SPROUT_AGENT_PROVIDER: "openai",
OPENAI_COMPAT_BASE_URL: "http://127.0.0.1:9337/v1",
OPENAI_COMPAT_MODEL: model,
},
};
}
case "get_identity":
if (identity) {
return {

View File

@@ -0,0 +1,157 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
// Layer 3 of the mesh e2e: the desktop UI contract for mesh-compute, driven
// through the real UI + the (mocked) Tauri mesh commands. Asserts the bridge
// CALL ORDER, not just labels — in particular the ensure-before-spawn
// invariant and the membership-denial copy.
type E2eWindow = Window & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: { invoke?: unknown };
__SPROUT_E2E_COMMANDS__?: string[];
__SPROUT_E2E_SET_MESH__?: (mesh: {
admitted?: boolean;
models?: Array<{ id: string; name: string | null }>;
denyReason?: string;
}) => void;
};
async function waitForInvokeBridge(page: import("@playwright/test").Page) {
await page.waitForFunction(() => {
const w = window as E2eWindow;
return (
typeof w.__SPROUT_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
typeof w.__TAURI_INTERNALS__?.invoke === "function"
);
}, null);
}
async function gotoApp(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForInvokeBridge(page);
await expect(page.getByTestId("open-agents-view")).toBeVisible({
timeout: 10_000,
});
}
/** Ordered command names the bridge recorded so far. */
async function commands(page: import("@playwright/test").Page) {
return page.evaluate(
() => (window as E2eWindow).__SPROUT_E2E_COMMANDS__ ?? [],
);
}
async function setMesh(
page: import("@playwright/test").Page,
mesh: { admitted?: boolean; denyReason?: string },
) {
await page.evaluate((m) => {
(window as E2eWindow).__SPROUT_E2E_SET_MESH__?.(m);
}, mesh);
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("Share compute starts and stops a serve node", async ({ page }) => {
await gotoApp(page);
await openSettings(page, "compute");
const card = page.getByTestId("settings-mesh-share-compute");
await expect(card).toBeVisible({ timeout: 10_000 });
// A model ref is required before the machine can be shared (the toggle IS
// the start/stop control).
await page
.getByTestId("mesh-share-compute-model")
.fill("hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M");
const toggle = page.getByTestId("mesh-share-compute-toggle");
await expect(toggle).toBeEnabled({ timeout: 10_000 });
// Toggle on -> starts a serve node.
await toggle.click();
await expect
.poll(async () => await commands(page))
.toContain("mesh_start_node");
// Status polling flips the toggle into the "on" state…
await expect(toggle).toBeChecked({ timeout: 10_000 });
// …toggle off -> stops the serve node.
await toggle.click();
await expect
.poll(async () => await commands(page))
.toContain("mesh_stop_node");
});
test("Run-on-relay-mesh ensures the client node BEFORE spawning the agent", async ({
page,
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "New" }).click();
await page.getByText("Custom Agent").click();
// Member is admitted -> the relay-mesh toggle becomes enabled (availability
// resolves to available). Wait for that before driving the flow.
await page.getByTestId("agent-name-input").fill("Mesh Agent");
const toggle = page.getByTestId("agent-relay-mesh-toggle");
await expect(toggle).toBeEnabled({ timeout: 10_000 });
await toggle.click();
await page
.getByTestId("agent-relay-mesh-model")
.selectOption("hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M");
await expect(page.getByTestId("create-agent-submit")).toBeEnabled({
timeout: 10_000,
});
// Snapshot the command log length right before the click so we assert the
// ensure→spawn ordering WITHIN this user action's fresh slice, not merely
// that ensure appeared somewhere earlier (e.g. an availability probe).
const before = (await commands(page)).length;
await page.getByTestId("create-agent-submit").click();
await expect
.poll(async () => (await commands(page)).slice(before))
.toContain("create_managed_agent");
// The invariant: within the Create action, ensure runs BEFORE create.
const slice = (await commands(page)).slice(before);
const ensureIdx = slice.indexOf("mesh_ensure_client_node");
const createIdx = slice.indexOf("create_managed_agent");
expect(
ensureIdx,
"ensure must occur in the Create action",
).toBeGreaterThanOrEqual(0);
expect(ensureIdx).toBeLessThan(createIdx);
});
test("a non-member cannot enable relay-mesh — membership is the gate", async ({
page,
}) => {
await gotoApp(page);
// Non-member: relay membership is the only factor, so availability reports
// unavailable. There is no manual auth action that would change this.
await setMesh(page, { admitted: false, denyReason: "not a relay member" });
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "New" }).click();
await page.getByText("Custom Agent").click();
// The relay-mesh toggle stays disabled — a non-member cannot even opt into
// running on the mesh, let alone spawn an agent against it.
const toggle = page.getByTestId("agent-relay-mesh-toggle");
await expect(toggle).toBeDisabled();
// The flow never reaches an ensure-or-spawn, because membership gates the
// entry point itself. Sanity-check we never created an agent on the mesh.
const seq = await commands(page);
expect(seq).not.toContain("create_managed_agent");
});

View File

@@ -4,6 +4,8 @@ type SettingsSection =
| "profile"
| "notifications"
| "agents"
| "channel-templates"
| "compute"
| "appearance"
| "shortcuts"
| "tokens"

View File

@@ -0,0 +1,104 @@
# Mesh LLM local build prerequisites
Sprout embeds mesh-llm through the Rust SDK pinned in Cargo. mesh-llm's native
skippy/llama layer is linked into the relay and desktop binaries.
## Local Mac demo path
For the first local milestone, use mesh-llm's default native build path. On macOS
this compiles patched llama.cpp/ggml with Metal support the first time a Sprout
binary that depends on mesh is built. The result is cached under Cargo's git
checkout of mesh-llm, so subsequent builds are much faster.
Prerequisites:
```bash
xcode-select --install # if Command Line Tools are not installed yet
brew install cmake # if cmake is not already available
```
Then build normally:
```bash
cargo build -p sprout-relay --bin sprout-relay
cargo check --manifest-path desktop/src-tauri/Cargo.toml
```
Expect the first build to take several minutes while mesh-llm prepares and builds
patched llama.cpp. This is intentional for the local demo: there is no external
binary artifact to fetch and no separate dylib path to configure.
## CI / release path
CI should not rebuild llama.cpp from scratch on every job. CI and release now
prebuild the llama native libraries in a dedicated step (`prepare-llama.sh` +
`build-llama.sh -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15`) and the Tauri build reuses
them via `SKIPPY_LLAMA_AUTO_BUILD=0` + `LLAMA_STAGE_BUILD_DIR`. That build is
cached with `actions/cache` keyed on the mesh-llm rev (resolved from
`Cargo.lock`), so a cache hit skips the rebuild and a dependency bump
invalidates the cache automatically — no workflow edit required.
A dynamic-link artifact path remains a possible future optimization. The
mesh-llm build script supports dynamic linking with:
```bash
export LLAMA_STAGE_LINK_MODE=dynamic
export LLAMA_STAGE_LIB_DIR=/path/to/prebuilt/llama/libs
```
Do not use dynamic-link locally unless you already have compatible `llama`,
`llama-common`, and `mtmd` dynamic libraries. The default static build is the
supported local path for M1.
## Current privacy limitation: public STUN
Sprout Desktop now refuses to start an embedded mesh node unless the active relay
advertises a Sprout-owned `iroh_relay_url`, and it passes a fresh NIP-98 bearer
to that relay. This prevents mesh-llm's empty-relay fallback to public iroh relay
URLs.
mesh-llm `bd16da4` still performs raw public STUN on startup to discover the
host's public IP (`stun.l.google.com`, `stun.cloudflare.com`, or
`stun.stunprotocol.org`) and may include that public address in its invite token.
That behavior is inside mesh-llm's host runtime and is not currently exposed as
an SDK option. Treat it as a v1 limitation until mesh exposes a disable-public-
STUN / relay-only-addressing knob.
## Mesh-compute e2e acceptance matrix
"Full e2e" for mesh-compute is necessarily layered: the Playwright harness
drives the **web build** of the desktop UI through a bridge, so it cannot
execute real Tauri mesh commands or real GGUF inference. We therefore split
coverage into three layers and are explicit about what is real vs mocked.
| # | What it proves | Where | Real / Mocked | Runs in CI? | How to run |
|---|----------------|-------|---------------|-------------|------------|
| 1 | serve node + client node + mesh routing + **real inference** | `crates/sprout-relay/examples/mesh_serve_client_smoke.rs` | **REAL** (loads a model, runs inference, joins a real mesh) | No — hardware-gated | `just mesh-e2e-hardware` (or `cargo run -p sprout-relay --example mesh_serve_client_smoke`) |
| 2 | admission **invariant**: relay membership is the only factor | `crates/sprout-relay/src/iroh_relay.rs` (`admission_from_membership` tests) | REAL policy logic, no I/O | **Yes** | `cargo test -p sprout-relay iroh_relay::tests::admission` |
| 2b | live db-membership admission + member/non-member status reads | `crates/sprout-test-client/tests/e2e_mesh_llm.rs` (`trust_*`) | REAL relay over ws | No — env-gated (`MEMBER_NSEC`/`STRANGER_NSEC`, live relay) | see that file's module docs |
| 3 | desktop UI contract: Share-compute start/stop, Run-on-relay-mesh preset, **ensure-before-spawn** order, membership-gated toggle | `desktop/tests/e2e/mesh-compute.spec.ts` | UI REAL, Tauri mesh commands MOCKED via the e2e bridge | **Yes** | `cd desktop && pnpm test:e2e:integration -- mesh-compute.spec.ts` |
`just mesh-e2e` runs the two CI-safe layers (2 + 3). Layer 1 is run on hardware.
### What "real" means per layer
- **Layer 1 is the only layer that proves inference.** It starts a real serve
node on the GPU, a real client node that joins via the serve node's invite
token, and asserts a chat completion *routed through the client* returns
`finish_reason=stop` with non-empty content. Verified locally with
SmolLM2-135M; point `MESH_SMOKE_MODEL` at a larger `.gguf` for scale.
Note: even with mDNS discovery the join bootstraps through mesh's public iroh
relay at the pinned rev — see the STUN limitation above.
- **Layer 2 proves the auth invariant without faking it.** The policy mapping
(`MembershipDecision` → admit/deny) is exercised directly: member → allow,
open relay → allow, non-member → deny, owner-delegation → deny (v1),
error → deny. A valid NIP-98 identity or possession of dial metadata is, by
itself, never sufficient. The membership-only gate is what matters **when
membership enforcement is enabled** (`require_relay_membership = true`); an
open relay (`OpenRelay`) intentionally admits any valid NIP-98 signer, so
`open relay → allow` is the deliberate disabled-enforcement case, not a
contradiction of the membership gate.
- **Layer 3 proves the UI contract, not inference.** The mesh Tauri commands
are mocked, but the assertions are on real UI behavior and real command
*ordering* (`mesh_ensure_client_node` is recorded before `create_managed_agent`),
and on the membership gate (a non-member cannot enable relay-mesh at all).

View File

@@ -198,6 +198,15 @@ desktop-e2e-smoke:
desktop-e2e-integration: _ensure-migrations
cd {{desktop_dir}} && pnpm test:e2e:integration
# Mesh-compute e2e: the CI-safe layers (admission invariant + Playwright UI)
mesh-e2e:
cargo test -p sprout-relay iroh_relay::tests::admission
cd {{desktop_dir}} && pnpm test:e2e:integration -- mesh-compute.spec.ts
# Mesh-compute Layer 1: REAL serve->client->inference on this machine (not CI)
mesh-e2e-hardware:
cargo run -p sprout-relay --example mesh_serve_client_smoke
# Run all checks suitable for CI / pre-push (no infra needed)
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test