Mesh-LLM v1: relay-gated direct-iroh inference between users (WAN) (#822)

Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-06-03 10:49:09 -04:00
committed by GitHub
parent 7c896d339d
commit 33cfc85293
32 changed files with 1565 additions and 552 deletions

85
Cargo.lock generated
View File

@@ -3884,7 +3884,7 @@ dependencies = [
[[package]]
name = "mesh-llm-api-client"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"hex",
"mesh-llm-client",
@@ -3894,7 +3894,7 @@ dependencies = [
[[package]]
name = "mesh-llm-api-server"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"mesh-llm-api-client",
@@ -3905,7 +3905,7 @@ dependencies = [
[[package]]
name = "mesh-llm-client"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"async-trait",
@@ -3937,11 +3937,12 @@ dependencies = [
[[package]]
name = "mesh-llm-config"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"dirs",
"mesh-llm-types",
"semver",
"serde",
"skippy-protocol",
"toml 0.9.12+spec-1.1.0",
@@ -3951,7 +3952,7 @@ dependencies = [
[[package]]
name = "mesh-llm-gpu-bench"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"cc",
@@ -3964,7 +3965,7 @@ dependencies = [
[[package]]
name = "mesh-llm-guardrails"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"serde",
"serde_json",
@@ -3973,13 +3974,14 @@ dependencies = [
[[package]]
name = "mesh-llm-host-runtime"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"ansi-to-tui",
"anyhow",
"arboard",
"argon2",
"async-trait",
"axum",
"base64",
"bytes",
"chacha20poly1305",
@@ -3989,6 +3991,8 @@ dependencies = [
"crypto_box",
"dirs",
"ed25519-dalek",
"flate2",
"futures-util",
"hex",
"hf-hub",
"http",
@@ -4004,6 +4008,7 @@ dependencies = [
"mesh-llm-config",
"mesh-llm-guardrails",
"mesh-llm-identity",
"mesh-llm-native-runtime",
"mesh-llm-node",
"mesh-llm-plugin",
"mesh-llm-plugin-manager",
@@ -4042,6 +4047,7 @@ dependencies = [
"skippy-server",
"skippy-topology",
"tabwriter",
"tar",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -4058,7 +4064,7 @@ dependencies = [
[[package]]
name = "mesh-llm-identity"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"crypto_box",
"ed25519-dalek",
@@ -4070,10 +4076,21 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "mesh-llm-native-runtime"
version = "0.68.0"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"serde",
"serde_json",
"sha2 0.10.9",
]
[[package]]
name = "mesh-llm-node"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"mesh-llm-types",
@@ -4087,7 +4104,7 @@ dependencies = [
[[package]]
name = "mesh-llm-plugin"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"async-trait",
@@ -4104,7 +4121,7 @@ dependencies = [
[[package]]
name = "mesh-llm-plugin-manager"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"dirs",
@@ -4122,7 +4139,7 @@ dependencies = [
[[package]]
name = "mesh-llm-protocol"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"hex",
@@ -4135,7 +4152,7 @@ dependencies = [
[[package]]
name = "mesh-llm-routing"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"iroh",
]
@@ -4143,7 +4160,7 @@ dependencies = [
[[package]]
name = "mesh-llm-sdk"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"mesh-llm-host-runtime",
@@ -4153,7 +4170,7 @@ dependencies = [
[[package]]
name = "mesh-llm-skills"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"dirs",
@@ -4164,7 +4181,7 @@ dependencies = [
[[package]]
name = "mesh-llm-system"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"chrono",
@@ -4186,7 +4203,7 @@ dependencies = [
[[package]]
name = "mesh-llm-types"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"hex",
"serde",
@@ -4197,12 +4214,12 @@ dependencies = [
[[package]]
name = "mesh-llm-ui"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
[[package]]
name = "mesh-mixture-of-agents"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"async-trait",
"mesh-llm-guardrails",
@@ -4319,7 +4336,7 @@ dependencies = [
[[package]]
name = "model-artifact"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"async-trait",
@@ -4330,7 +4347,7 @@ dependencies = [
[[package]]
name = "model-hf"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"async-trait",
@@ -4344,7 +4361,7 @@ dependencies = [
[[package]]
name = "model-package"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"bytes",
@@ -4364,7 +4381,7 @@ dependencies = [
[[package]]
name = "model-ref"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"serde",
]
@@ -4372,7 +4389,7 @@ dependencies = [
[[package]]
name = "model-resolver"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"model-artifact",
@@ -5136,7 +5153,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openai-frontend"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"async-trait",
"axum",
@@ -6610,7 +6627,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -7242,7 +7259,7 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
[[package]]
name = "skippy-cache"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"blake3",
@@ -7252,7 +7269,7 @@ dependencies = [
[[package]]
name = "skippy-coordinator"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"thiserror 2.0.18",
]
@@ -7260,17 +7277,17 @@ dependencies = [
[[package]]
name = "skippy-ffi"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
[[package]]
name = "skippy-metrics"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
[[package]]
name = "skippy-protocol"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"prost",
"prost-build",
@@ -7281,7 +7298,7 @@ dependencies = [
[[package]]
name = "skippy-runtime"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"libc",
@@ -7295,7 +7312,7 @@ dependencies = [
[[package]]
name = "skippy-server"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"async-trait",
@@ -7323,7 +7340,7 @@ dependencies = [
[[package]]
name = "skippy-topology"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"serde",
"serde_json",

View File

@@ -180,6 +180,22 @@ pub const KIND_PAIRING: u32 = 24134;
pub const KIND_TYPING_INDICATOR: u32 = 20002;
/// Ephemeral: owner-scoped encrypted agent observer telemetry and control frame.
pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200;
/// Ephemeral: mesh status report (desktop → relay). A relay member reports its
/// current mesh serve availability + EndpointAddr(s) so the relay can project a
/// sanitized, relay-signed kind:30621 discovery note keyed per reporter. Tagged
/// `["p", <self>]` optional; never stored — the durable record is the relay's
/// 30621, not this transient input.
pub const KIND_MESH_STATUS_REPORT: u32 = 24620;
/// Ephemeral: mesh connect request (desktop → relay). A relay member asks the
/// relay to coordinate a direct iroh hole-punch to a peer it discovered via
/// kind:30621. Tagged `["p", <target_pubkey>]`. Never stored; the relay
/// validates membership of both ends, then emits paired KIND_MESH_CALL_ME_NOW.
pub const KIND_MESH_CONNECT_REQUEST: u32 = 24621;
/// Ephemeral: mesh call-me-now signal (relay → desktop, relay-signed). The live
/// dial trigger for a direct iroh hole-punch — carries the peer's EndpointAddr
/// so both ends dial near-simultaneously. Tagged `["p", <recipient_pubkey>]`.
/// Never stored; seconds expiry.
pub const KIND_MESH_CALL_ME_NOW: u32 = 24622;
// Stream messaging
/// NIP-29 group chat message kind. V1 used kind:10001 (replaceable range — wrong), then 40001.
@@ -372,6 +388,9 @@ pub const ALL_KINDS: &[u32] = &[
KIND_NIP29_GROUP_ROLES,
KIND_PRESENCE_UPDATE,
KIND_TYPING_INDICATOR,
KIND_MESH_STATUS_REPORT,
KIND_MESH_CONNECT_REQUEST,
KIND_MESH_CALL_ME_NOW,
KIND_BLOSSOM_AUTH,
KIND_PAIRING,
KIND_AGENT_OBSERVER_FRAME,

View File

@@ -65,7 +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"] }
mesh-llm-sdk = { git = "https://github.com/tlongwell-block/mesh-llm.git", rev = "bc2f110625ccad2c3f8557b125c063bdc030fd21", 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

@@ -7,7 +7,8 @@ use tracing::{debug, error, info, warn};
use nostr::{Event, PublicKey};
use sprout_core::event::StoredEvent;
use sprout_core::kind::{
event_kind_u32, is_ephemeral, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE,
event_kind_u32, is_ephemeral, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP,
KIND_MESH_CONNECT_REQUEST, KIND_MESH_STATUS_REPORT, KIND_PRESENCE_UPDATE,
};
use sprout_core::observer::{
content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG,
@@ -249,6 +250,22 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
));
return;
}
// Mesh signaling kinds are direct desktop-user actions: the resulting
// call-me-now routes to the *authenticated connection's* pubkey, so a
// proxy-submitted mesh request would point the dial at the proxy, not the
// user's desktop. Reject mesh kinds under proxy scope — they must come
// from the member's own session.
if has_proxy_scope
&& (kind_u32 == KIND_MESH_CONNECT_REQUEST || kind_u32 == KIND_MESH_STATUS_REPORT)
{
reject("scope");
conn.send(RelayMessage::ok(
&event_id_hex,
false,
"restricted: mesh signaling cannot be proxy-submitted",
));
return;
}
handle_ephemeral_event(
event,
conn_id,
@@ -384,6 +401,69 @@ async fn handle_ephemeral_event(
return;
}
// Mesh status report (kind:24620). An authenticated relay member reports its
// current mesh serve availability; the relay projects it into a relay-signed,
// per-reporter kind:30621 discovery note. The report is ephemeral input; the
// 30621 is the durable, relay-owned record.
if event_kind_u32(&event) == KIND_MESH_STATUS_REPORT {
let reporter_hex = auth_pubkey.to_hex();
match super::mesh_signaling::handle_status_report(&state, &reporter_hex, &event).await {
Ok(()) => {
conn.send(RelayMessage::ok(event_id_hex, true, ""));
}
Err(reason) => {
conn.send(RelayMessage::ok(event_id_hex, false, &reason));
}
}
return;
}
// Mesh hole-punch signaling (kind:24621). An authenticated relay member
// asks the relay to coordinate a direct iroh hole-punch to a peer it found
// via kind:30621. The relay validates the target is also a member, then
// emits the paired call-me-now (kind:24622). This is the relay's ONLY role
// in the v1 direct-iroh mesh — validate membership + pair + fan out. It
// never carries iroh traffic and stores no endpoint state.
if event_kind_u32(&event) == KIND_MESH_CONNECT_REQUEST {
// Generous per-requester rate limit: each accepted 24621 makes the relay
// sign + fan TWO 24622s, so bound the amplification. 20/sec is far above
// any real interactive use; a buggy desktop loop can't storm the relay.
{
let key: [u8; 32] = auth_pubkey.to_bytes();
let now = std::time::Instant::now();
let mut entry = state
.mesh_connect_rate_limiter
.entry(key)
.or_insert((0, now));
let (count, window_start) = entry.value_mut();
if now.duration_since(*window_start).as_secs() >= 1 {
*count = 1;
*window_start = now;
} else {
*count += 1;
if *count > 20 {
drop(entry);
conn.send(RelayMessage::ok(
event_id_hex,
false,
"rate-limited: mesh connect request rate exceeded (20/sec)",
));
return;
}
}
}
let requester_hex = auth_pubkey.to_hex();
match super::mesh_signaling::handle_connect_request(&state, &requester_hex, &event).await {
Ok(()) => {
conn.send(RelayMessage::ok(event_id_hex, true, ""));
}
Err(reason) => {
conn.send(RelayMessage::ok(event_id_hex, false, &reason));
}
}
return;
}
// Check channel membership before publishing other ephemeral events.
if let Some(ch_id) = super::ingest::extract_channel_id(&event) {
if let Err(msg) =

View File

@@ -0,0 +1,612 @@
//! Mesh hole-punch signaling: the relay's only role in the v1 direct-iroh mesh.
//!
//! v1 mesh is "Sprout-coordinated direct iroh" — no server-side iroh relay/proxy.
//! The relay's entire job for connectivity is: when a member asks to dial a peer
//! it discovered via kind:30621, validate that BOTH ends are relay members, then
//! emit a *paired* live "call-me-now" so both desktops hole-punch at the same
//! moment. The relay never sees or stores the bulk iroh traffic — only this tiny
//! control-plane exchange.
//!
//! Flow:
//! desktop A (member) reads 30621 → already holds B's `EndpointAddr` →
//! publishes KIND_MESH_CONNECT_REQUEST (24621) `#p=B` with both endpoint addrs →
//! relay validates B is a member → mints two relay-signed KIND_MESH_CALL_ME_NOW
//! (24622): one `#p=A` carrying B's addr, one `#p=B` carrying A's addr →
//! both fan out over the existing channel-less ephemeral path (local + Redis) →
//! both desktops dial simultaneously → direct QUIC.
//!
//! The relay is ENDPOINT-STATELESS here: the requester supplies both dial hints
//! (it read them from the relay-signed 30621), so the relay only validates
//! membership and pairs. Endpoint addrs are dial hints, never auth — membership
//! is the gate.
use std::sync::Arc;
use nostr::{EventBuilder, Kind, Tag};
use sprout_core::event::StoredEvent;
use sprout_core::kind::KIND_MESH_CALL_ME_NOW;
use crate::api::relay_members::{check_relay_membership, MembershipDecision};
use crate::state::AppState;
/// Parsed `KIND_MESH_CONNECT_REQUEST` (24621) content.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConnectRequest {
/// Requester's own iroh EndpointAddr (base64 invite token) — sent to the peer.
pub self_endpoint_addr: String,
/// Peer's iroh EndpointAddr (base64 invite token), read by the requester from
/// the peer's kind:30621 serve target — sent back to the requester.
pub peer_endpoint_addr: String,
/// Requester's own iroh endpoint id (optional) — correlation/instrumentation
/// only, never trusted for auth. Copied into the peer's call-me-now.
pub self_endpoint_id: Option<String>,
/// Peer's iroh endpoint id (optional) — correlation/instrumentation only.
/// Copied into the requester's call-me-now so the desktop can target the
/// exact peer endpoint it picked from 30621 (multi-endpoint disambiguation).
pub peer_endpoint_id: Option<String>,
/// Correlates the two halves of one punch attempt.
pub attempt_id: String,
}
/// Outcome of validating + parsing a connect request, before any relay state is touched.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequestError {
/// Content was not valid JSON in the expected shape.
Malformed(String),
/// No `#p` tag naming the target peer.
MissingTarget,
}
/// Parse the JSON content of a 24621 connect request. Pure — no I/O.
pub fn parse_connect_request(content: &str) -> Result<ConnectRequest, RequestError> {
let v: serde_json::Value = serde_json::from_str(content)
.map_err(|e| RequestError::Malformed(format!("not JSON: {e}")))?;
let get = |k: &str| {
v.get(k)
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let self_endpoint_addr = get("self_endpoint_addr")
.ok_or_else(|| RequestError::Malformed("self_endpoint_addr".into()))?;
let peer_endpoint_addr = get("peer_endpoint_addr")
.ok_or_else(|| RequestError::Malformed("peer_endpoint_addr".into()))?;
let attempt_id =
get("attempt_id").ok_or_else(|| RequestError::Malformed("attempt_id".into()))?;
Ok(ConnectRequest {
self_endpoint_addr,
peer_endpoint_addr,
self_endpoint_id: get("self_endpoint_id"),
peer_endpoint_id: get("peer_endpoint_id"),
attempt_id,
})
}
/// Extract the single `#p` target pubkey (hex) from the request event's tags.
/// If multiple `#p` tags are present, the first wins; multi-target mesh-connect
/// is not supported in v1.
pub fn extract_target_pubkey(event: &nostr::Event) -> Option<String> {
event.tags.iter().find_map(|t| {
let s = t.as_slice();
if s.len() >= 2 && s[0] == "p" {
Some(s[1].clone())
} else {
None
}
})
}
/// Build the JSON content for one call-me-now (24622) directed at `recipient`,
/// telling it to dial `peer_endpoint_addr` (optionally `peer_endpoint_id` for
/// multi-endpoint disambiguation). Pure — no I/O.
pub fn call_me_now_content(
peer_endpoint_addr: &str,
peer_endpoint_id: Option<&str>,
attempt_id: &str,
expires_at: u64,
) -> String {
let mut obj = serde_json::json!({
"v": 1,
"type": "sprout-iroh-call-me-now",
"peer_endpoint_addr": peer_endpoint_addr,
"attempt_id": attempt_id,
"expires_at": expires_at,
});
if let Some(eid) = peer_endpoint_id {
obj["peer_endpoint_id"] = serde_json::Value::String(eid.to_string());
}
obj.to_string()
}
/// Pure: does a membership decision admit a peer into the v1 mesh? Direct relay
/// members (or open relays) only — `ViaOwner` (NIP-OA delegated) is intentionally
/// NOT admitted in v1, keeping the mesh trust boundary tighter and legible. This
/// is applied identically to BOTH the requester and the target, so the two ends
/// are symmetric. Isolated as a pure fn so the trust gate is unit-testable
/// without an `AppState`.
pub fn membership_admits_mesh(decision: &MembershipDecision) -> bool {
matches!(
decision,
MembershipDecision::OpenRelay | MembershipDecision::Member
)
}
/// Seconds a call-me-now is valid; iroh's punch loop runs ~60s, so this bounds
/// how stale a signal a desktop should act on.
pub const CALL_ME_NOW_TTL_SECS: u64 = 60;
/// Handle a verified KIND_MESH_CONNECT_REQUEST (24621) from an authenticated
/// relay member. Validates the target is also a member, then emits the paired
/// call-me-now to both ends. Returns Ok(()) on success or an Err(reason) string
/// suitable for an OK(false) reply (reason is for the requester, not secret).
pub async fn handle_connect_request(
state: &Arc<AppState>,
requester_pubkey_hex: &str,
event: &nostr::Event,
) -> Result<(), String> {
let req = parse_connect_request(&event.content)
.map_err(|e| format!("invalid: malformed mesh connect request ({e:?})"))?;
let target_hex = extract_target_pubkey(event)
.ok_or_else(|| "invalid: mesh connect request missing #p target".to_string())?;
if target_hex == requester_pubkey_hex {
return Err("invalid: cannot mesh-connect to self".to_string());
}
// Membership gate, applied SYMMETRICALLY to both ends — direct relay members
// only, gated purely by relay access. The requester reached this handler via
// a NIP-42-authed WS, but that auth can be ViaOwner (NIP-OA delegated) when
// SPROUT_ALLOW_NIP_OA_AUTH is on; v1 mesh excludes delegated identities, so we
// re-check the requester here with no auth tag (which makes ViaOwner
// unreachable — only Member/OpenRelay/Denied) to match the target check.
require_mesh_member(state, requester_pubkey_hex)
.await
.map_err(|_| "restricted: delegated identities cannot initiate mesh in v1".to_string())?;
require_mesh_member(state, &target_hex)
.await
.map_err(|_| "restricted: target is not a relay member".to_string())?;
let expires_at = (chrono::Utc::now().timestamp().max(0) as u64) + CALL_ME_NOW_TTL_SECS;
// Pair: tell the requester to dial the peer's addr, and the peer to dial the
// requester's addr. Each is a relay-signed ephemeral #p-addressed event.
// endpoint_id (if supplied) is copied through for desktop multi-endpoint
// disambiguation — it is correlation metadata, never trusted for auth.
let to_requester = build_call_me_now(
state,
requester_pubkey_hex,
&req.peer_endpoint_addr,
req.peer_endpoint_id.as_deref(),
&req.attempt_id,
expires_at,
)?;
let to_target = build_call_me_now(
state,
&target_hex,
&req.self_endpoint_addr,
req.self_endpoint_id.as_deref(),
&req.attempt_id,
expires_at,
)?;
publish_channelless_ephemeral(state, &to_requester).await;
publish_channelless_ephemeral(state, &to_target).await;
Ok(())
}
/// Async: confirm `pubkey_hex` is a direct relay member admissible to the mesh.
/// `None` auth_tag → ViaOwner is unreachable, so only Member/OpenRelay admit;
/// everything else (Denied, ViaOwner-if-it-somehow-appeared, or a check error)
/// FAILS CLOSED. Used symmetrically for requester and target.
async fn require_mesh_member(state: &Arc<AppState>, pubkey_hex: &str) -> Result<(), ()> {
let bytes = hex::decode(pubkey_hex).map_err(|_| ())?;
match check_relay_membership(state, &bytes, None).await {
Ok(d) if membership_admits_mesh(&d) => Ok(()),
Ok(_) => Err(()),
Err(e) => {
tracing::warn!("mesh connect: membership check failed (fail-closed): {e}");
Err(())
}
}
}
/// Mint one relay-signed call-me-now (24622) addressed to `recipient_hex`.
fn build_call_me_now(
state: &Arc<AppState>,
recipient_hex: &str,
peer_endpoint_addr: &str,
peer_endpoint_id: Option<&str>,
attempt_id: &str,
expires_at: u64,
) -> Result<nostr::Event, String> {
let content = call_me_now_content(peer_endpoint_addr, peer_endpoint_id, attempt_id, expires_at);
let p_tag = Tag::parse(["p", recipient_hex])
.map_err(|e| format!("error: failed to build p tag: {e}"))?;
EventBuilder::new(Kind::Custom(KIND_MESH_CALL_ME_NOW as u16), content)
.tags([p_tag])
.sign_with_keys(&state.relay_keypair)
.map_err(|e| format!("error: failed to sign call-me-now: {e}"))
}
/// Publish a channel-less ephemeral event over the same path NIP-AB pairing uses:
/// Redis fan-out (nil-UUID global routing key) for cross-pod, plus direct local
/// WS fan-out. The recipient's desktop receives it via a REQ on `#p=self kind:24622`.
///
/// Observability note: call-me-now is delivered via `#p` subscription matching,
/// so it is NOT private to the recipient — any member can REQ `kind:24622
/// #p=<other_member>` and observe who that member is mesh-connecting to (and the
/// endpoint addrs). This matches presence/typing being broadly observable and is
/// intentional for v1. A conn-scoped private delivery channel would be needed to
/// hide it.
async fn publish_channelless_ephemeral(state: &Arc<AppState>, event: &nostr::Event) {
state.mark_local_event(&event.id);
if let Err(e) = state.pubsub.publish_event(uuid::Uuid::nil(), event).await {
state.local_event_ids.invalidate(&event.id.to_bytes());
tracing::warn!(event_id = %event.id, "mesh call-me-now global publish failed: {e}");
}
let stored = StoredEvent::new(event.clone(), None);
let matches = state.sub_registry.fan_out(&stored);
metrics::histogram!("sprout_fanout_recipients").record(matches.len() as f64);
if let Ok(event_json) = serde_json::to_string(event) {
for (target_conn_id, sub_id) in &matches {
let msg = format!(r#"["EVENT","{sub_id}",{event_json}]"#);
let _ = state.conn_manager.send_to(*target_conn_id, msg);
}
}
}
/// Handle a verified KIND_MESH_STATUS_REPORT (24620) from an authenticated relay
/// member. The member reports its current mesh `/api/status` JSON; the relay
/// sanitizes it and republishes a relay-signed kind:30621 discovery note keyed
/// to the reporter (so members' notes never clobber each other). The report
/// itself is ephemeral — only the relay's projection is durable. Membership is
/// already enforced (the reporter is authenticated on a member-gated WS).
pub async fn handle_status_report(
state: &Arc<AppState>,
reporter_pubkey_hex: &str,
event: &nostr::Event,
) -> Result<(), String> {
// Same membership symmetry as handle_connect_request: a NIP-OA delegated
// (ViaOwner) identity is authed on the WS but is NOT a v1 mesh participant.
// If we let it report, the relay would advertise a serve_target under that
// pubkey that the connect path (which denies ViaOwner) then refuses — broken
// discovery. So gate the reporter the same way: direct members only, fail
// closed. Keeps all three desktop-facing mesh kinds consistent on delegation.
require_mesh_member(state, reporter_pubkey_hex)
.await
.map_err(|_| {
"restricted: delegated identities cannot report mesh status in v1".to_string()
})?;
let payload: serde_json::Value = serde_json::from_str(&event.content)
.map_err(|e| format!("invalid: mesh status report content is not JSON ({e})"))?;
crate::mesh_status_publisher::publish_mesh_status_from_payload(
state,
reporter_pubkey_hex,
&payload,
)
.await
.map_err(|e| {
tracing::warn!("mesh status report publish failed: {e}");
"error: failed to publish mesh status".to_string()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_request() {
let c = r#"{"self_endpoint_addr":"AAA","peer_endpoint_addr":"BBB","attempt_id":"x1"}"#;
let r = parse_connect_request(c).unwrap();
assert_eq!(r.self_endpoint_addr, "AAA");
assert_eq!(r.peer_endpoint_addr, "BBB");
assert_eq!(r.attempt_id, "x1");
}
#[test]
fn parse_rejects_missing_field() {
let c = r#"{"self_endpoint_addr":"AAA","attempt_id":"x1"}"#;
assert!(matches!(
parse_connect_request(c),
Err(RequestError::Malformed(_))
));
}
#[test]
fn parse_rejects_empty_field() {
let c = r#"{"self_endpoint_addr":"","peer_endpoint_addr":"BBB","attempt_id":"x1"}"#;
assert!(matches!(
parse_connect_request(c),
Err(RequestError::Malformed(_))
));
}
#[test]
fn parse_rejects_non_json() {
assert!(matches!(
parse_connect_request("not json"),
Err(RequestError::Malformed(_))
));
}
#[test]
fn call_me_now_content_shape() {
let s = call_me_now_content("ENDPOINT", None, "att-1", 1234);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["type"], "sprout-iroh-call-me-now");
assert_eq!(v["peer_endpoint_addr"], "ENDPOINT");
assert_eq!(v["attempt_id"], "att-1");
assert_eq!(v["expires_at"], 1234);
assert_eq!(v["v"], 1);
// No endpoint id supplied → field omitted entirely.
assert!(v.get("peer_endpoint_id").is_none());
}
#[test]
fn call_me_now_content_includes_endpoint_id_when_present() {
let s = call_me_now_content("ENDPOINT", Some("EID-7"), "att-1", 1234);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["peer_endpoint_id"], "EID-7");
}
#[test]
fn parse_round_trips_optional_endpoint_ids() {
let c = r#"{"self_endpoint_addr":"A","peer_endpoint_addr":"B","self_endpoint_id":"SI","peer_endpoint_id":"PI","attempt_id":"x"}"#;
let r = parse_connect_request(c).unwrap();
assert_eq!(r.self_endpoint_id.as_deref(), Some("SI"));
assert_eq!(r.peer_endpoint_id.as_deref(), Some("PI"));
// endpoint ids are optional — absent is fine.
let c2 = r#"{"self_endpoint_addr":"A","peer_endpoint_addr":"B","attempt_id":"x"}"#;
let r2 = parse_connect_request(c2).unwrap();
assert_eq!(r2.self_endpoint_id, None);
assert_eq!(r2.peer_endpoint_id, None);
}
// ── Trust gate: membership_admits_mesh ──────────────────────────────────
// This is the single pure predicate behind the requester, target, AND
// reporter gates. v1 admits only direct relay members (or open relays);
// NIP-OA-delegated (ViaOwner) and Denied are excluded, symmetrically.
#[test]
fn member_and_open_relay_are_admitted() {
assert!(membership_admits_mesh(&MembershipDecision::Member));
assert!(membership_admits_mesh(&MembershipDecision::OpenRelay));
}
#[test]
fn denied_is_not_admitted() {
assert!(!membership_admits_mesh(&MembershipDecision::Denied));
}
#[test]
fn via_owner_is_not_admitted_in_v1() {
// Delegated identities are excluded from v1 mesh on every desktop-facing
// path (requester / target / reporter all run through this predicate).
let owner = nostr::Keys::generate().public_key();
assert!(!membership_admits_mesh(&MembershipDecision::ViaOwner(
owner
)));
}
#[test]
fn extract_target_takes_the_p_tag() {
let keys = nostr::Keys::generate();
let target = nostr::Keys::generate().public_key().to_hex();
let event = nostr::EventBuilder::new(nostr::Kind::Custom(24621), "{}")
.tags([nostr::Tag::parse(["p", &target]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
assert_eq!(
extract_target_pubkey(&event).as_deref(),
Some(target.as_str())
);
}
#[test]
fn extract_target_none_without_p_tag() {
let keys = nostr::Keys::generate();
let event = nostr::EventBuilder::new(nostr::Kind::Custom(24621), "{}")
.sign_with_keys(&keys)
.unwrap();
assert_eq!(extract_target_pubkey(&event), None);
}
fn p_tag() -> nostr::SingleLetterTag {
nostr::SingleLetterTag::lowercase(nostr::Alphabet::P)
}
fn test_config() -> crate::config::Config {
let mut config = crate::config::Config::from_env().expect("default config loads");
config.require_relay_membership = false;
config.redis_url = "redis://127.0.0.1:1".to_string();
config
}
async fn test_state() -> std::sync::Arc<AppState> {
let config = test_config();
let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool");
let db = sprout_db::Db::from_pool(pool.clone());
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.expect("redis pool");
let pubsub = std::sync::Arc::new(
sprout_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
.await
.expect("pubsub manager"),
);
let audit = sprout_audit::AuditService::new(pool);
let auth = sprout_auth::AuthService::new(config.auth.clone());
let search = sprout_search::SearchService::new(sprout_search::SearchConfig {
url: config.typesense_url.clone(),
api_key: config.typesense_key.clone(),
collection: "events".to_string(),
});
let workflow_engine = std::sync::Arc::new(sprout_workflow::WorkflowEngine::new(
db.clone(),
sprout_workflow::WorkflowConfig::default(),
));
let media_storage = sprout_media::MediaStorage::new(&config.media).expect("media storage");
let (state, _audit_shutdown) = crate::state::AppState::new(
config,
db,
redis_pool,
audit,
pubsub,
auth,
search,
workflow_engine,
nostr::Keys::generate(),
media_storage,
);
std::sync::Arc::new(state)
}
fn register_call_me_now_sub(
state: &AppState,
recipient_hex: &str,
sub_id: &str,
) -> (
uuid::Uuid,
tokio::sync::mpsc::Receiver<axum::extract::ws::Message>,
) {
let conn_id = uuid::Uuid::new_v4();
let (tx, rx) = tokio::sync::mpsc::channel(10);
state.conn_manager.register(
conn_id,
tx,
tokio_util::sync::CancellationToken::new(),
std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)),
std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
);
state.sub_registry.register(
conn_id,
sub_id.to_string(),
vec![nostr::Filter::new()
.kind(nostr::Kind::Custom(KIND_MESH_CALL_ME_NOW as u16))
.custom_tags(p_tag(), [recipient_hex])],
None,
);
(conn_id, rx)
}
fn connect_request_event(target_hex: &str) -> nostr::Event {
let content = serde_json::json!({
"self_endpoint_addr": "SELF_ADDR",
"peer_endpoint_addr": "PEER_ADDR",
"self_endpoint_id": "SELF_ID",
"peer_endpoint_id": "PEER_ID",
"attempt_id": "attempt-1"
})
.to_string();
nostr::EventBuilder::new(nostr::Kind::Custom(24621), content)
.tags([nostr::Tag::parse(["p", target_hex]).unwrap()])
.sign_with_keys(&nostr::Keys::generate())
.unwrap()
}
fn event_from_ws_message(msg: axum::extract::ws::Message) -> nostr::Event {
let axum::extract::ws::Message::Text(text) = msg else {
panic!("expected text ws message");
};
let v: serde_json::Value = serde_json::from_str(&text).expect("EVENT frame JSON");
assert_eq!(v[0], "EVENT");
serde_json::from_value(v[2].clone()).expect("nostr event")
}
#[tokio::test]
async fn accepted_connect_request_emits_two_relay_signed_call_me_now_events() {
let state = test_state().await;
let requester_hex = nostr::Keys::generate().public_key().to_hex();
let target_hex = nostr::Keys::generate().public_key().to_hex();
let (_requester_conn, mut requester_rx) =
register_call_me_now_sub(&state, &requester_hex, "mesh_requester");
let (_target_conn, mut target_rx) =
register_call_me_now_sub(&state, &target_hex, "mesh_target");
handle_connect_request(&state, &requester_hex, &connect_request_event(&target_hex))
.await
.expect("open relay admits both peers and emits pair");
let requester_event = event_from_ws_message(
requester_rx
.try_recv()
.expect("requester receives call-me-now"),
);
let target_event =
event_from_ws_message(target_rx.try_recv().expect("target receives call-me-now"));
assert!(
requester_rx.try_recv().is_err(),
"requester gets exactly one event"
);
assert!(
target_rx.try_recv().is_err(),
"target gets exactly one event"
);
for event in [&requester_event, &target_event] {
assert_eq!(
event.kind,
nostr::Kind::Custom(KIND_MESH_CALL_ME_NOW as u16)
);
assert_eq!(event.pubkey, state.relay_keypair.public_key());
event.verify().expect("relay-signed event verifies");
}
assert_eq!(
extract_target_pubkey(&requester_event).as_deref(),
Some(requester_hex.as_str())
);
assert_eq!(
extract_target_pubkey(&target_event).as_deref(),
Some(target_hex.as_str())
);
let requester_content: serde_json::Value =
serde_json::from_str(&requester_event.content).expect("requester content JSON");
assert_eq!(requester_content["type"], "sprout-iroh-call-me-now");
assert_eq!(requester_content["peer_endpoint_addr"], "PEER_ADDR");
assert_eq!(requester_content["peer_endpoint_id"], "PEER_ID");
assert_eq!(requester_content["attempt_id"], "attempt-1");
assert!(requester_content["expires_at"].as_u64().is_some());
let target_content: serde_json::Value =
serde_json::from_str(&target_event.content).expect("target content JSON");
assert_eq!(target_content["type"], "sprout-iroh-call-me-now");
assert_eq!(target_content["peer_endpoint_addr"], "SELF_ADDR");
assert_eq!(target_content["peer_endpoint_id"], "SELF_ID");
assert_eq!(target_content["attempt_id"], "attempt-1");
assert_eq!(
target_content["expires_at"],
requester_content["expires_at"]
);
}
#[tokio::test]
async fn self_target_connect_request_emits_no_call_me_now_events() {
let state = test_state().await;
let requester_hex = nostr::Keys::generate().public_key().to_hex();
let target_hex = nostr::Keys::generate().public_key().to_hex();
let (_requester_conn, mut requester_rx) =
register_call_me_now_sub(&state, &requester_hex, "mesh_requester");
let (_target_conn, mut target_rx) =
register_call_me_now_sub(&state, &target_hex, "mesh_target");
let err = handle_connect_request(
&state,
&requester_hex,
&connect_request_event(&requester_hex),
)
.await
.expect_err("self-target is rejected before emitting");
assert!(err.contains("self"), "unexpected error: {err}");
assert!(
requester_rx.try_recv().is_err(),
"requester receives no event"
);
assert!(target_rx.try_recv().is_err(), "target receives no event");
}
}

View File

@@ -14,6 +14,8 @@ pub mod identity_archive;
pub mod imeta;
/// Transport-neutral event ingestion pipeline.
pub mod ingest;
/// Mesh hole-punch signaling: validate membership + emit paired call-me-now.
pub mod mesh_signaling;
/// NIP-43 relay membership admin command handler (kinds 9030–9032).
pub mod relay_admin;
/// REQ handler — subscribe, deliver historical events, then EOSE.

View File

@@ -1,236 +0,0 @@
//! 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,8 +14,6 @@ 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.

View File

@@ -17,8 +17,18 @@ 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";
/// d-tag prefix for the relay's mesh status events. The full d-tag is
/// `"<prefix>:<reporter_pubkey_hex>"` so each reporting member gets an isolated,
/// independently-replaceable kind:30621 note (NIP-33 keys on `(kind, pubkey,
/// d_tag)`, and the author is always the relay key here). This avoids two
/// members' reports clobbering each other and sidesteps any read-modify-write
/// race on a single shared note. Discovery reads all notes with the `k` tag.
pub const MESH_STATUS_D_TAG_PREFIX: &str = "sprout-relay-mesh";
/// Build the per-reporter d-tag for a mesh status note.
pub fn mesh_status_d_tag(reporter_pubkey_hex: &str) -> String {
format!("{MESH_STATUS_D_TAG_PREFIX}:{reporter_pubkey_hex}")
}
/// Content schema discriminator.
pub const MESH_STATUS_TYPE: &str = "sprout-mesh-status";
@@ -178,26 +188,29 @@ pub fn sanitize_mesh_status(payload: &Value, now_unix: u64) -> SproutMeshStatus
}
}
/// Publish sanitized mesh status as a relay-signed kind:30621 event.
/// Publish sanitized mesh status as a relay-signed kind:30621 event, keyed to
/// the reporting member's pubkey so each member's note is isolated.
pub async fn publish_mesh_status_from_payload(
state: &Arc<AppState>,
reporter_pubkey_hex: &str,
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_mesh_status(state, reporter_pubkey_hex, &status).await
}
/// Publish a pre-sanitized mesh status. Exposed for tests and integration seams.
pub async fn publish_mesh_status(
state: &Arc<AppState>,
reporter_pubkey_hex: &str,
status: &SproutMeshStatus,
) -> anyhow::Result<()> {
let content = serde_json::to_string(status)?;
let d_tag = mesh_status_d_tag(reporter_pubkey_hex);
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(["d", &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}"))?,
];
@@ -209,7 +222,7 @@ pub async fn publish_mesh_status(
let (stored, was_inserted) = state
.db
.replace_parameterized_event(&event, MESH_STATUS_D_TAG, None)
.replace_parameterized_event(&event, &d_tag, None)
.await?;
if was_inserted {
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
@@ -278,6 +291,22 @@ fn push_target(targets: &mut Vec<MeshServeTarget>, target: MeshServeTarget) {
mod tests {
use super::*;
#[test]
fn d_tag_is_per_reporter_so_members_never_clobber() {
let a = "aa".repeat(32);
let b = "bb".repeat(32);
let d_a = mesh_status_d_tag(&a);
let d_b = mesh_status_d_tag(&b);
// Two different members → two different d-tags → isolated 30621 notes
// (NIP-33 keys on (kind, pubkey, d); the relay is always the author, so
// the d-tag is the only thing distinguishing reporters).
assert_ne!(d_a, d_b, "distinct reporters must get distinct d-tags");
assert!(d_a.starts_with(MESH_STATUS_D_TAG_PREFIX));
assert!(d_a.ends_with(&a));
// Same reporter → same d-tag → its later report replaces its own note.
assert_eq!(d_a, mesh_status_d_tag(&a));
}
#[test]
fn sanitizer_projects_models_and_endpoint_addr_without_raw_runtime() {
let payload = serde_json::json!({

View File

@@ -231,6 +231,12 @@ pub struct AppState {
/// Key: agent pubkey bytes (32). Value: (count, window_start).
/// 100 events/sec per agent — prevents relay/DB pressure from bursty telemetry.
pub observer_rate_limiter: Arc<DashMap<[u8; 32], (u32, Instant)>>,
/// Per-requester sliding-window rate limiter for mesh connect requests
/// (kind 24621). Key: requester pubkey bytes (32). Value: (count,
/// window_start). Bounds the 1→2 call-me-now amplification: a member is
/// trusted, but a buggy desktop loop shouldn't make the relay sign+fan
/// unboundedly. 20/sec is far above any real interactive use.
pub mesh_connect_rate_limiter: Arc<DashMap<[u8; 32], (u32, Instant)>>,
/// Cache for observer agent-owner authorization (kind 24200).
/// Key: (agent_pubkey_bytes, owner_pubkey_bytes). Value: is_owner.
/// agent_owner_pubkey is immutable so a long TTL (5 min) is safe.
@@ -376,6 +382,7 @@ impl AppState {
.build(),
),
observer_rate_limiter: Arc::new(DashMap::new()),
mesh_connect_rate_limiter: Arc::new(DashMap::new()),
observer_owner_cache: Arc::new(
moka::sync::Cache::builder()
.max_capacity(1_000)

View File

@@ -6,7 +6,7 @@
//! 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.
//! `mesh_signaling`); this file is the opt-in full-stack acceptance layer.
//!
//! # Running (manual / runbook)
//!
@@ -32,7 +32,7 @@
//! |---|-----------|-----------|-----------------|
//! | 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 |
//! | 3 | non-member iroh dial denied (NIP-98→membership) | runbook (needs iroh dial) | relay `mesh_signaling` 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 |

View File

@@ -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", 835], // + mesh_llm_stubs module (feature-gated no-op Tauri commands) + 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-tauri/src/lib.rs", 850], // + mesh_llm_stubs module (feature-gated no-op Tauri commands; incl mesh_dial_endpoint_addr + mesh_status_report_payload required for the default no-feature build's generate_handler! list) + 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
]);

View File

@@ -164,7 +164,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -175,7 +175,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -218,7 +218,7 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
"x11rb",
]
@@ -1234,7 +1234,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1873,7 +1873,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
dependencies = [
"data-encoding",
"syn 1.0.109",
"syn 2.0.117",
]
[[package]]
@@ -2809,8 +2809,8 @@ dependencies = [
"libc",
"log",
"rustversion",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-link 0.2.1",
"windows-result 0.4.1",
]
[[package]]
@@ -3520,7 +3520,7 @@ dependencies = [
"tokio",
"tower-service",
"tracing",
"windows-registry 0.6.1",
"windows-registry 0.5.3",
]
[[package]]
@@ -4542,7 +4542,7 @@ dependencies = [
[[package]]
name = "mesh-llm-api-client"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"hex",
"mesh-llm-client",
@@ -4552,7 +4552,7 @@ dependencies = [
[[package]]
name = "mesh-llm-api-server"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"mesh-llm-api-client",
@@ -4563,7 +4563,7 @@ dependencies = [
[[package]]
name = "mesh-llm-client"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"async-trait",
@@ -4595,11 +4595,12 @@ dependencies = [
[[package]]
name = "mesh-llm-config"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"dirs",
"mesh-llm-types",
"semver",
"serde",
"skippy-protocol",
"toml 0.9.12+spec-1.1.0",
@@ -4609,7 +4610,7 @@ dependencies = [
[[package]]
name = "mesh-llm-gpu-bench"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"cc",
@@ -4622,7 +4623,7 @@ dependencies = [
[[package]]
name = "mesh-llm-guardrails"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"serde",
"serde_json",
@@ -4631,13 +4632,14 @@ dependencies = [
[[package]]
name = "mesh-llm-host-runtime"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"ansi-to-tui",
"anyhow",
"arboard",
"argon2",
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"chacha20poly1305",
@@ -4647,6 +4649,8 @@ dependencies = [
"crypto_box",
"dirs",
"ed25519-dalek",
"flate2",
"futures-util",
"hex",
"hf-hub",
"http",
@@ -4662,6 +4666,7 @@ dependencies = [
"mesh-llm-config",
"mesh-llm-guardrails",
"mesh-llm-identity",
"mesh-llm-native-runtime",
"mesh-llm-node",
"mesh-llm-plugin",
"mesh-llm-plugin-manager",
@@ -4700,6 +4705,7 @@ dependencies = [
"skippy-server",
"skippy-topology",
"tabwriter",
"tar",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -4716,7 +4722,7 @@ dependencies = [
[[package]]
name = "mesh-llm-identity"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"crypto_box",
"ed25519-dalek",
@@ -4728,10 +4734,21 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "mesh-llm-native-runtime"
version = "0.68.0"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"serde",
"serde_json",
"sha2 0.10.9",
]
[[package]]
name = "mesh-llm-node"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"mesh-llm-types",
@@ -4745,7 +4762,7 @@ dependencies = [
[[package]]
name = "mesh-llm-plugin"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"async-trait",
@@ -4762,7 +4779,7 @@ dependencies = [
[[package]]
name = "mesh-llm-plugin-manager"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"dirs",
@@ -4780,7 +4797,7 @@ dependencies = [
[[package]]
name = "mesh-llm-protocol"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"hex",
@@ -4793,7 +4810,7 @@ dependencies = [
[[package]]
name = "mesh-llm-routing"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"iroh",
]
@@ -4801,7 +4818,7 @@ dependencies = [
[[package]]
name = "mesh-llm-sdk"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"mesh-llm-host-runtime",
@@ -4811,7 +4828,7 @@ dependencies = [
[[package]]
name = "mesh-llm-skills"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"dirs",
@@ -4822,7 +4839,7 @@ dependencies = [
[[package]]
name = "mesh-llm-system"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"chrono",
@@ -4844,7 +4861,7 @@ dependencies = [
[[package]]
name = "mesh-llm-types"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"hex",
"serde",
@@ -4855,12 +4872,12 @@ dependencies = [
[[package]]
name = "mesh-llm-ui"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
[[package]]
name = "mesh-mixture-of-agents"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"async-trait",
"mesh-llm-guardrails",
@@ -4924,7 +4941,7 @@ dependencies = [
[[package]]
name = "model-artifact"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"async-trait",
@@ -4935,7 +4952,7 @@ dependencies = [
[[package]]
name = "model-hf"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"async-trait",
@@ -4949,7 +4966,7 @@ dependencies = [
[[package]]
name = "model-package"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"bytes",
@@ -4969,7 +4986,7 @@ dependencies = [
[[package]]
name = "model-ref"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"serde",
]
@@ -4977,7 +4994,7 @@ dependencies = [
[[package]]
name = "model-resolver"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"model-artifact",
@@ -5515,7 +5532,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5625,7 +5642,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro-crate 1.3.1",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -6001,7 +6018,7 @@ dependencies = [
[[package]]
name = "openai-frontend"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"async-trait",
"axum",
@@ -6871,7 +6888,7 @@ version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
dependencies = [
"heck 0.4.1",
"heck 0.5.0",
"itertools",
"log",
"multimap",
@@ -7664,7 +7681,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -7973,7 +7990,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8410,7 +8427,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "skippy-cache"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"blake3",
@@ -8420,7 +8437,7 @@ dependencies = [
[[package]]
name = "skippy-coordinator"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"thiserror 2.0.18",
]
@@ -8428,17 +8445,17 @@ dependencies = [
[[package]]
name = "skippy-ffi"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
[[package]]
name = "skippy-metrics"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
[[package]]
name = "skippy-protocol"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"prost",
"prost-build",
@@ -8449,7 +8466,7 @@ dependencies = [
[[package]]
name = "skippy-runtime"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"libc",
@@ -8463,7 +8480,7 @@ dependencies = [
[[package]]
name = "skippy-server"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"anyhow",
"async-trait",
@@ -8491,7 +8508,7 @@ dependencies = [
[[package]]
name = "skippy-topology"
version = "0.68.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
dependencies = [
"serde",
"serde_json",
@@ -11839,8 +11856,8 @@ dependencies = [
"log",
"serde",
"thiserror 2.0.18",
"windows 0.61.3",
"windows-core 0.61.2",
"windows 0.62.2",
"windows-core 0.62.2",
]
[[package]]

View File

@@ -61,7 +61,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"], optional = true }
mesh-llm-sdk = { git = "https://github.com/tlongwell-block/mesh-llm.git", rev = "bc2f110625ccad2c3f8557b125c063bdc030fd21", package = "mesh-llm-sdk", default-features = false, features = ["client", "serve"], optional = true }
base64 = "0.22"
sha2 = "0.11"
tar = "0.4"

View File

@@ -37,7 +37,7 @@ async fn ensure_relay_mesh_for_record(
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?;
crate::commands::mesh_llm::ensure_client_node_for_model(state, model_id, None).await?;
Ok(())
}

View File

@@ -1,6 +1,3 @@
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};
@@ -21,15 +18,13 @@ pub async fn mesh_availability(
pub async fn mesh_start_node(
_app: AppHandle,
state: State<'_, AppState>,
mut request: mesh_llm::StartMeshNodeRequest,
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())?;
@@ -46,12 +41,13 @@ 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
ensure_client_node_for_model(&state, request.model_id, request.endpoint_addr).await
}
pub(crate) async fn ensure_client_node_for_model(
state: &AppState,
model_id: impl AsRef<str>,
endpoint_addr: Option<String>,
) -> CmdResult<mesh_llm::MeshNodeStatus> {
let requested_model = model_id.as_ref().trim();
if requested_model.is_empty() {
@@ -73,31 +69,40 @@ pub(crate) async fn ensure_client_node_for_model(
}
}
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}")),
let join_token = match endpoint_addr
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
Some(value) => value,
None => {
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)
.cloned()
.ok_or_else(|| {
format!("relay mesh has no serve target for model {requested_model}")
})?;
target.endpoint_addr
}
};
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 {
let 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,
join_token: Some(join_token),
};
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());
@@ -113,80 +118,45 @@ pub(crate) async fn ensure_client_node_for_model(
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(())
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeshDialEndpointRequest {
pub endpoint_addr: String,
}
async fn fetch_iroh_relay_url(state: &AppState) -> Result<String, String> {
#[derive(serde::Deserialize)]
struct Nip11Info {
iroh_relay_url: Option<String>,
#[tauri::command]
pub async fn mesh_dial_endpoint_addr(
state: State<'_, AppState>,
request: MeshDialEndpointRequest,
) -> CmdResult<mesh_llm::MeshNodeStatus> {
let endpoint_addr = request.endpoint_addr.trim();
if endpoint_addr.is_empty() {
return Err("endpointAddr is required".to_string());
}
let url = relay::relay_api_base_url_with_override(state);
let response = state
.http_client
.get(&url)
.header("Accept", "application/nostr+json")
.send()
let runtime = state.mesh_llm_runtime.lock().await;
let Some(runtime) = runtime.as_ref() else {
return Err("mesh node is not running".to_string());
};
runtime
.dial_endpoint_addr(endpoint_addr)
.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())
.map_err(|error| format!("mesh dial failed: {error}"))?;
runtime.status().await.map_err(|error| error.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");
}
#[tauri::command]
pub async fn mesh_status_report_payload(
state: State<'_, AppState>,
) -> CmdResult<Option<serde_json::Value>> {
let runtime = state.mesh_llm_runtime.lock().await;
match runtime.as_ref() {
Some(runtime) => runtime
.status_report_payload()
.await
.map(Some)
.map_err(|error| error.to_string()),
None => Ok(None),
}
parsed.set_path(&path);
Ok(parsed.to_string().trim_end_matches('/').to_string())
}
#[tauri::command]

View File

@@ -65,6 +65,21 @@ mod mesh_llm_stubs {
pub fn mesh_agent_preset(_request: serde_json::Value) -> CmdResult<serde_json::Value> {
Err("mesh-llm feature not enabled".to_string())
}
#[tauri::command]
pub async fn mesh_dial_endpoint_addr(
_state: State<'_, AppState>,
_request: serde_json::Value,
) -> CmdResult<serde_json::Value> {
Err("mesh-llm feature not enabled".to_string())
}
#[tauri::command]
pub async fn mesh_status_report_payload(
_state: State<'_, AppState>,
) -> CmdResult<Option<serde_json::Value>> {
Err("mesh-llm feature not enabled".to_string())
}
}
#[cfg(not(feature = "mesh-llm"))]
@@ -640,6 +655,8 @@ pub fn run() {
mesh_availability,
mesh_start_node,
mesh_ensure_client_node,
mesh_dial_endpoint_addr,
mesh_status_report_payload,
mesh_stop_node,
mesh_node_status,
mesh_installed_models,

View File

@@ -112,7 +112,7 @@ pub async fn restore_managed_agents_on_launch(
continue;
};
if let Err(error) =
crate::commands::ensure_client_node_for_model(&state, model_id).await
crate::commands::ensure_client_node_for_model(&state, model_id, None).await
{
persist_restore_error(app, &state, &record.pubkey, error)?;
mesh_preflight_failures.insert(record.pubkey.clone());

View File

@@ -0,0 +1,176 @@
use std::collections::BTreeMap;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use super::{dedupe_models, MeshAvailability, MeshModelOption, MeshServeTarget, MESH_STATUS_KIND};
fn dedupe_targets(targets: Vec<MeshServeTarget>) -> Vec<MeshServeTarget> {
let mut by_endpoint = BTreeMap::<String, MeshServeTarget>::new();
for target in targets {
by_endpoint
.entry(target.endpoint_addr.clone())
.or_insert(target);
}
by_endpoint.into_values().collect()
}
pub fn availability_from_events(events: Vec<nostr::Event>) -> MeshAvailability {
if events.is_empty() {
return MeshAvailability::unavailable("relay mesh status is not published yet");
}
// Relay status is now per reporter (d=sprout-relay-mesh:<pubkey>), so a
// query returns multiple replaceable events. Aggregate them; do not pick the
// newest single event or one member's machines hide everyone else's.
let mut all_targets = Vec::<MeshServeTarget>::new();
let mut all_models = Vec::<MeshModelOption>::new();
let mut saw_valid_status = false;
for event in events {
let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
continue;
};
saw_valid_status = true;
let reporter_pubkey = reporter_pubkey_from_status_event(&event);
let mut 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()
.into_iter()
.map(|mut target| {
if target.reporter_pubkey.is_none() {
target.reporter_pubkey = reporter_pubkey.clone();
}
if target.endpoint_id.is_none() {
target.endpoint_id = endpoint_id_from_invite_token(&target.endpoint_addr);
}
if target.device_id.is_none() {
target.device_id = target.endpoint_id.clone();
}
if target.device_name.is_none() {
target.device_name = target
.node_name
.clone()
.or_else(|| target.endpoint_id.as_deref().map(short_endpoint_label));
}
target
})
.collect::<Vec<_>>();
let mut 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(),
)
});
all_targets.append(&mut serve_targets);
all_models.append(&mut models);
}
if !saw_valid_status {
return MeshAvailability::unavailable("relay mesh status is malformed");
}
let serve_targets = dedupe_targets(all_targets);
let models = dedupe_models(all_models);
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],
"#k": ["sprout-mesh-status"],
"limit": 100
})
}
fn reporter_pubkey_from_status_event(event: &nostr::Event) -> Option<String> {
event.tags.iter().find_map(|tag| {
let slice = tag.as_slice();
let d = slice.get(1)?;
if slice.first().is_some_and(|name| name == "d") {
d.strip_prefix("sprout-relay-mesh:")
.map(ToString::to_string)
} else {
None
}
})
}
pub(super) fn enrich_status_payload_identity(
payload: &mut serde_json::Value,
invite_token: Option<&str>,
) {
let endpoint_id = endpoint_id_from_status(payload, invite_token);
let device_name = device_name_from_status(payload, endpoint_id.as_deref());
if let Some(endpoint_id) = endpoint_id {
payload["endpointId"] = serde_json::Value::String(endpoint_id.clone());
payload["deviceId"] = serde_json::Value::String(endpoint_id);
}
if let Some(device_name) = device_name {
payload["deviceName"] = serde_json::Value::String(device_name);
}
}
pub(super) fn endpoint_id_from_status(
payload: &serde_json::Value,
invite_token: Option<&str>,
) -> Option<String> {
string_value(payload, "endpointId")
.or_else(|| string_value(payload, "endpoint_id"))
.or_else(|| string_value(payload, "node_id"))
.or_else(|| invite_token.and_then(endpoint_id_from_invite_token))
}
pub(super) fn device_name_from_status(
payload: &serde_json::Value,
endpoint_id: Option<&str>,
) -> Option<String> {
string_value(payload, "deviceName")
.or_else(|| string_value(payload, "device_name"))
.or_else(|| string_value(payload, "my_hostname"))
.or_else(|| string_value(payload, "hostname"))
.or_else(|| endpoint_id.map(short_endpoint_label))
}
fn endpoint_id_from_invite_token(invite_token: &str) -> Option<String> {
let json = URL_SAFE_NO_PAD.decode(invite_token).ok()?;
let value = serde_json::from_slice::<serde_json::Value>(&json).ok()?;
string_value(&value, "id")
}
fn string_value(value: &serde_json::Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn short_endpoint_label(endpoint_id: &str) -> String {
endpoint_id.chars().take(12).collect()
}

View File

@@ -1,12 +1,17 @@
use std::collections::BTreeMap;
mod discovery;
pub use discovery::{availability_from_events, mesh_status_filter};
use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity};
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 MESH_API_PORT_ENV: &str = "SPROUT_MESH_API_PORT";
const MESH_CONSOLE_PORT_ENV: &str = "SPROUT_MESH_CONSOLE_PORT";
const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "sprout-mesh-local";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -24,6 +29,14 @@ pub struct MeshServeTarget {
pub endpoint_addr: String,
pub node_name: Option<String>,
pub capacity: Option<MeshTargetCapacity>,
#[serde(default)]
pub reporter_pubkey: Option<String>,
#[serde(default)]
pub endpoint_id: Option<String>,
#[serde(default)]
pub device_id: Option<String>,
#[serde(default)]
pub device_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -122,16 +135,14 @@ pub struct StartMeshNodeRequest {
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,
#[serde(default)]
pub endpoint_addr: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -146,6 +157,12 @@ pub struct MeshNodeStatus {
pub model_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub invite_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_name: Option<String>,
}
pub fn stopped_status() -> MeshNodeStatus {
@@ -158,6 +175,9 @@ pub fn stopped_status() -> MeshNodeStatus {
model_id: None,
model_name: None,
invite_token: None,
endpoint_id: None,
device_id: None,
device_name: None,
}
}
@@ -183,10 +203,11 @@ impl DesktopMeshRuntime {
.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)
.api_port(mesh_api_port()?)
.console_port(mesh_console_port()?)
.publish(false)
.auto_join(false)
.disable_iroh_relays(true)
.discovery_mode(MeshDiscoveryMode::Nostr)
.console_ui(true);
if let Some(max_vram_gb) = request.max_vram_gb {
@@ -195,31 +216,20 @@ impl DesktopMeshRuntime {
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)
.api_port(mesh_api_port()?)
.console_port(mesh_console_port()?)
.publish(false)
.auto_join(false)
.disable_iroh_relays(true)
.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?
}
};
@@ -237,6 +247,17 @@ impl DesktopMeshRuntime {
self.status_from_sdk(status)
}
pub async fn status_report_payload(&self) -> anyhow::Result<serde_json::Value> {
let status = self.handle.status().await?;
let mut payload = status.payload;
enrich_status_payload_identity(&mut payload, status.invite_token.as_deref());
Ok(payload)
}
pub async fn dial_endpoint_addr(&self, endpoint_addr: impl Into<String>) -> anyhow::Result<()> {
self.handle.join_token(endpoint_addr).await
}
pub async fn installed_models(&self) -> anyhow::Result<Vec<MeshModelOption>> {
let status = self.handle.status().await?;
Ok(models_from_status_payload(Some(&status.payload)))
@@ -247,6 +268,9 @@ impl DesktopMeshRuntime {
status: mesh_llm_sdk::EmbeddedNodeStatus,
) -> anyhow::Result<MeshNodeStatus> {
let health = health_from_payload(&status.payload);
let endpoint_id = endpoint_id_from_status(&status.payload, status.invite_token.as_deref());
let device_name = device_name_from_status(&status.payload, endpoint_id.as_deref());
let device_id = endpoint_id.clone();
Ok(MeshNodeStatus {
state: if matches!(health.status, MeshHealthStatus::Failed) {
MeshNodeState::Failed
@@ -260,6 +284,9 @@ impl DesktopMeshRuntime {
model_id: self.model_id.clone(),
model_name: self.model_name.clone(),
invite_token: status.invite_token,
endpoint_id,
device_id,
device_name,
})
}
@@ -268,16 +295,40 @@ impl DesktopMeshRuntime {
}
}
fn mesh_api_port() -> anyhow::Result<u16> {
mesh_port_from_env(MESH_API_PORT_ENV, DEFAULT_MESH_API_PORT)
}
fn mesh_console_port() -> anyhow::Result<u16> {
mesh_port_from_env(MESH_CONSOLE_PORT_ENV, DEFAULT_MESH_CONSOLE_PORT)
}
fn mesh_port_from_env(name: &str, default: u16) -> anyhow::Result<u16> {
let Some(raw) = std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
else {
return Ok(default);
};
let port = raw
.parse::<u16>()
.map_err(|error| anyhow::anyhow!("{name} must be a TCP port (got {raw:?}): {error}"))?;
if port == 0 {
anyhow::bail!("{name} must be a non-zero TCP port");
}
Ok(port)
}
fn relay_mesh_api_base_url() -> Result<String, String> {
let port = mesh_api_port().map_err(|error| error.to_string())?;
Ok(format!("http://127.0.0.1:{port}/v1"))
}
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(())
}
@@ -373,7 +424,7 @@ fn push_model(out: &mut Vec<MeshModelOption>, id: &str, name: Option<String>) {
});
}
fn dedupe_models(models: Vec<MeshModelOption>) -> Vec<MeshModelOption> {
pub(super) fn dedupe_models(models: Vec<MeshModelOption>) -> Vec<MeshModelOption> {
let mut by_id = BTreeMap::<String, Option<String>>::new();
for model in models {
by_id
@@ -427,7 +478,7 @@ pub fn agent_preset(request: MeshAgentPresetRequest) -> Result<MeshAgentPreset,
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
(
"OPENAI_COMPAT_BASE_URL".to_string(),
RELAY_MESH_API_BASE_URL.to_string(),
relay_mesh_api_base_url()?,
),
("OPENAI_COMPAT_MODEL".to_string(), model.to_string()),
(
@@ -439,60 +490,6 @@ pub fn agent_preset(request: MeshAgentPresetRequest) -> Result<MeshAgentPreset,
})
}
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

@@ -53,6 +53,7 @@ import {
type SettingsSection,
} from "@/features/settings/ui/SettingsPanels";
import { HuddleBar, HuddleProvider } from "@/features/huddle";
import { useMeshRelayOrchestrator } from "@/features/mesh-compute/hooks/useMeshRelayOrchestrator";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate";
@@ -198,6 +199,7 @@ export function AppShell() {
const startupReady = useDeferredStartup();
const identityQuery = useIdentityQuery();
useMeshRelayOrchestrator(identityQuery.data?.pubkey);
const profileQuery = useProfileQuery();
const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
usePresenceSubscription();

View File

@@ -36,7 +36,8 @@ import {
} from "./ProviderConfigFields";
import { CreateAgentRespondToField } from "./RespondToField";
import { RelayMeshAgentSection } from "@/features/mesh-compute/ui/RelayMeshAgentSection";
import { meshEnsureClientNode } from "@/shared/api/tauriMesh";
import type { MeshServeTarget } from "@/shared/api/tauriMesh";
import { startRelayMeshClientForTarget } from "@/features/mesh-compute/startRelayMeshClientForTarget";
import { useLastRuntimeProvider } from "@/features/agents/lib/useLastRuntimeProvider";
// ── Dialog ────────────────────────────────────────────────────────────────────
@@ -95,6 +96,9 @@ export function CreateAgentDialog({
// input carries `model: meshModelId`.
const [useMesh, setUseMesh] = React.useState(false);
const [meshModelId, setMeshModelId] = React.useState("");
const [meshTarget, setMeshTarget] = React.useState<MeshServeTarget | null>(
null,
);
const [meshClientError, setMeshClientError] = React.useState<string | null>(
null,
);
@@ -317,9 +321,8 @@ 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) &&
// Relay-mesh mode requires a concrete serve target, not just a model name.
!(useMesh && (meshModelId.trim().length === 0 || meshTarget == null)) &&
respondToValid &&
!createMutation.isPending;
@@ -328,7 +331,7 @@ export function CreateAgentDialog({
try {
if (useMesh) {
try {
await meshEnsureClientNode(meshModelId.trim());
await startRelayMeshClientForTarget(meshModelId.trim(), meshTarget);
} catch (err) {
setMeshClientError(err instanceof Error ? err.message : String(err));
return;
@@ -434,6 +437,7 @@ export function CreateAgentDialog({
envVars,
}}
modelId={meshModelId}
targetEndpointAddr={meshTarget?.endpointAddr ?? ""}
onModelIdChange={(nextId, patch) => {
setMeshModelId(nextId);
if (patch == null) return;
@@ -446,6 +450,7 @@ export function CreateAgentDialog({
setMcpCommand(patch.mcpCommand);
setEnvVars(patch.envVars);
}}
onTargetChange={setMeshTarget}
onUseMeshChange={(next) => {
setUseMesh(next);
if (!next) {
@@ -454,6 +459,7 @@ export function CreateAgentDialog({
// fields keep whatever the user had — they can re-pick
// ACP runtime or stay with the preset values, their call.
setMeshModelId("");
setMeshTarget(null);
}
}}
useMesh={useMesh}

View File

@@ -0,0 +1,94 @@
import * as React from "react";
import {
meshDialEndpointAddr,
meshStatusReportPayload,
type MeshCallMeNow,
} from "@/shared/api/tauriMesh";
import type { RelayEvent } from "@/shared/api/types";
import {
publishMeshStatusReport,
subscribeToMeshCallMeNow,
} from "@/shared/api/relayMeshSignaling";
const STATUS_REPORT_INTERVAL_MS = 15_000;
function parseCallMeNow(event: RelayEvent): MeshCallMeNow | null {
try {
const payload = JSON.parse(event.content) as Partial<MeshCallMeNow>;
if (
payload.v !== 1 ||
payload.type !== "sprout-iroh-call-me-now" ||
typeof payload.peer_endpoint_addr !== "string" ||
typeof payload.attempt_id !== "string" ||
typeof payload.expires_at !== "number"
) {
return null;
}
return payload as MeshCallMeNow;
} catch {
return null;
}
}
async function publishCurrentMeshStatus() {
const payload = await meshStatusReportPayload();
if (payload == null) return;
await publishMeshStatusReport(payload);
}
/**
* Keeps Sprout's relay-owned mesh discovery and punch signaling in sync with the
* local embedded mesh node. The relay only coordinates tiny control-plane
* messages; all iroh traffic remains desktop-to-desktop.
*/
export function useMeshRelayOrchestrator(pubkey: string | undefined) {
React.useEffect(() => {
if (!pubkey) return;
let disposed = false;
let unsubscribeCallMeNow: (() => Promise<void>) | null = null;
const handleCallMeNow = (event: RelayEvent) => {
const payload = parseCallMeNow(event);
if (!payload) return;
const now = Math.floor(Date.now() / 1_000);
if (payload.expires_at < now) return;
void meshDialEndpointAddr(payload.peer_endpoint_addr).catch((error) => {
console.warn("mesh call-me-now dial failed", {
attemptId: payload.attempt_id,
peerEndpointId: payload.peer_endpoint_id,
error,
});
});
};
subscribeToMeshCallMeNow(pubkey, handleCallMeNow)
.then((unsubscribe) => {
if (disposed) {
void unsubscribe();
return;
}
unsubscribeCallMeNow = unsubscribe;
})
.catch((error) => {
console.warn("mesh call-me-now subscription failed", error);
});
const report = () => {
void publishCurrentMeshStatus().catch((error) => {
// Status reports are best-effort; a stale report expires relay-side.
console.debug("mesh status report skipped", error);
});
};
report();
const interval = window.setInterval(report, STATUS_REPORT_INTERVAL_MS);
return () => {
disposed = true;
window.clearInterval(interval);
if (unsubscribeCallMeNow) void unsubscribeCallMeNow();
};
}, [pubkey]);
}

View File

@@ -0,0 +1,28 @@
import { publishMeshConnectRequest } from "@/shared/api/relayMeshSignaling";
import {
meshEnsureClientNode,
type MeshServeTarget,
} from "@/shared/api/tauriMesh";
export async function startRelayMeshClientForTarget(
modelId: string,
target: MeshServeTarget | null,
): Promise<void> {
const status = await meshEnsureClientNode(modelId, target);
if (!target?.reporterPubkey) {
throw new Error(
"Selected relay mesh target is missing its reporter pubkey.",
);
}
if (!status.inviteToken) {
throw new Error("Local mesh client did not publish an endpoint address.");
}
await publishMeshConnectRequest({
targetPubkey: target.reporterPubkey,
selfEndpointAddr: status.inviteToken,
peerEndpointAddr: target.endpointAddr,
attemptId: crypto.randomUUID(),
selfEndpointId: status.endpointId ?? null,
peerEndpointId: target.endpointId ?? null,
});
}

View File

@@ -1,7 +1,7 @@
import * as React from "react";
import { AlertCircle, Network } from "lucide-react";
import { meshAgentPreset } from "@/shared/api/tauriMesh";
import { meshAgentPreset, type MeshServeTarget } from "@/shared/api/tauriMesh";
import { Switch } from "@/shared/ui/switch";
import {
@@ -24,9 +24,10 @@ import { useMeshAvailability } from "../hooks/useMeshAvailability";
export function RelayMeshAgentSection({
current,
useMesh,
modelId,
targetEndpointAddr,
onUseMeshChange,
onModelIdChange,
onTargetChange,
}: {
/**
* Current draft state of the *fields the preset would overwrite*. Used to
@@ -42,7 +43,8 @@ export function RelayMeshAgentSection({
envVars: Record<string, string>;
};
useMesh: boolean;
modelId: string;
modelId: string; // Parent-owned selected model id; retained for API symmetry with onModelIdChange.
targetEndpointAddr: string;
onUseMeshChange: (next: boolean) => void;
/**
* Fires when the user picks a model. The parent should fan out the
@@ -54,6 +56,7 @@ export function RelayMeshAgentSection({
nextModelId: string,
patch: ReturnType<typeof meshAgentPresetPatch> | null,
) => void;
onTargetChange: (target: MeshServeTarget | null) => void;
}) {
const { availability, error } = useMeshAvailability();
const [presetError, setPresetError] = React.useState<string | null>(null);
@@ -68,25 +71,46 @@ export function RelayMeshAgentSection({
// an arbitrary one — the warning must reflect what'll actually happen.
const [overrides, setOverrides] = React.useState<string[]>([]);
async function pickModel(nextModelId: string) {
if (nextModelId === "") {
const targets = availability?.serveTargets ?? [];
const selectedValue = targetEndpointAddr;
async function pickTarget(endpointAddr: string) {
if (endpointAddr === "") {
onTargetChange(null);
onModelIdChange("", null);
setOverrides([]);
setPresetError(null);
return;
}
const target = targets.find(
(candidate) => candidate.endpointAddr === endpointAddr,
);
if (!target) {
onTargetChange(null);
onModelIdChange("", null);
setPresetError("Selected relay mesh target is no longer available.");
return;
}
setPresetError(null);
try {
const preset = await meshAgentPreset(nextModelId);
const preset = await meshAgentPreset(target.modelId);
const patch = meshAgentPresetPatch(preset);
setOverrides(detectMeshPresetOverrides(current, preset));
onModelIdChange(nextModelId, patch);
onTargetChange(target);
onModelIdChange(target.modelId, patch);
} catch (err) {
setPresetError(err instanceof Error ? err.message : String(err));
onModelIdChange(nextModelId, null);
onTargetChange(target);
onModelIdChange(target.modelId, null);
}
}
function targetLabel(target: MeshServeTarget) {
const model = target.modelName ?? target.modelId;
const device = target.deviceName ?? target.nodeName ?? target.endpointId;
return device ? `${model} — ${device}` : model;
}
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">
@@ -125,13 +149,13 @@ export function RelayMeshAgentSection({
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}
onChange={(e) => void pickTarget(e.target.value)}
value={selectedValue}
>
<option value="">Choose a model…</option>
{availability?.models.map((model) => (
<option key={model.id} value={model.id}>
{model.name ?? model.id}
<option value="">Choose a target…</option>
{targets.map((target) => (
<option key={target.endpointAddr} value={target.endpointAddr}>
{targetLabel(target)}
</option>
))}
</select>

View File

@@ -0,0 +1,68 @@
import { signRelayEvent } from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import { relayClient } from "@/shared/api/relayClient";
import {
KIND_MESH_CALL_ME_NOW,
KIND_MESH_CONNECT_REQUEST,
KIND_MESH_STATUS_REPORT,
} from "@/shared/constants/kinds";
export async function publishMeshStatusReport(
payload: Record<string, unknown>,
): Promise<void> {
await relayClient.preconnect();
const event = await signRelayEvent({
kind: KIND_MESH_STATUS_REPORT,
content: JSON.stringify(payload),
tags: [],
});
await relayClient.publishEvent(
event,
"Timed out publishing mesh status.",
"Failed to publish mesh status.",
);
}
export async function publishMeshConnectRequest(input: {
targetPubkey: string;
selfEndpointAddr: string;
peerEndpointAddr: string;
attemptId: string;
selfEndpointId?: string | null;
peerEndpointId?: string | null;
}): Promise<void> {
await relayClient.preconnect();
const content: Record<string, unknown> = {
v: 1,
self_endpoint_addr: input.selfEndpointAddr,
peer_endpoint_addr: input.peerEndpointAddr,
attempt_id: input.attemptId,
};
if (input.selfEndpointId) content.self_endpoint_id = input.selfEndpointId;
if (input.peerEndpointId) content.peer_endpoint_id = input.peerEndpointId;
const event = await signRelayEvent({
kind: KIND_MESH_CONNECT_REQUEST,
content: JSON.stringify(content),
tags: [["p", input.targetPubkey]],
});
await relayClient.publishEvent(
event,
"Timed out requesting mesh connection.",
"Failed to request mesh connection.",
);
}
export function subscribeToMeshCallMeNow(
pubkey: string,
onEvent: (event: RelayEvent) => void,
) {
return relayClient.subscribeLive(
{
kinds: [KIND_MESH_CALL_ME_NOW],
"#p": [pubkey],
limit: 0,
since: Math.max(0, Math.floor(Date.now() / 1_000) - 5),
},
onEvent,
);
}

View File

@@ -15,6 +15,10 @@ export type MeshServeTarget = {
endpointAddr: string;
nodeName: string | null;
capacity: { vramGb: number | null } | null;
reporterPubkey?: string | null;
endpointId?: string | null;
deviceId?: string | null;
deviceName?: string | null;
};
export type MeshAvailability = {
@@ -39,8 +43,6 @@ export type StartMeshNodeRequest = {
modelId?: string;
maxVramGb?: number;
joinToken?: string;
irohRelayUrl?: string;
irohRelayAuth?: string;
};
export type MeshNodeStatus = {
@@ -52,6 +54,18 @@ export type MeshNodeStatus = {
modelId: string | null;
modelName: string | null;
inviteToken?: string | null;
endpointId?: string | null;
deviceId?: string | null;
deviceName?: string | null;
};
export type MeshCallMeNow = {
v: 1;
type: "sprout-iroh-call-me-now";
peer_endpoint_addr: string;
peer_endpoint_id?: string;
attempt_id: string;
expires_at: number;
};
export type MeshAgentPreset = {
@@ -77,12 +91,35 @@ export async function meshStartNode(
export async function meshEnsureClientNode(
modelId: string,
target?: MeshServeTarget | null,
): Promise<MeshNodeStatus> {
return await invokeTauri<MeshNodeStatus>("mesh_ensure_client_node", {
request: { modelId },
request: {
modelId,
endpointAddr: target?.endpointAddr,
reporterPubkey: target?.reporterPubkey,
peerEndpointId: target?.endpointId,
},
});
}
export async function meshDialEndpointAddr(
endpointAddr: string,
): Promise<MeshNodeStatus> {
return await invokeTauri<MeshNodeStatus>("mesh_dial_endpoint_addr", {
request: { endpointAddr },
});
}
export async function meshStatusReportPayload(): Promise<Record<
string,
unknown
> | null> {
return await invokeTauri<Record<string, unknown> | null>(
"mesh_status_report_payload",
);
}
export async function meshStopNode(): Promise<MeshNodeStatus> {
return await invokeTauri<MeshNodeStatus>("mesh_stop_node");
}

View File

@@ -22,6 +22,9 @@ export const KIND_READ_STATE = 30078;
export const KIND_CHANNEL_SECTIONS = 30078;
export const KIND_USER_STATUS = 30315;
export const KIND_AGENT_OBSERVER_FRAME = 24200;
export const KIND_MESH_STATUS_REPORT = 24620;
export const KIND_MESH_CONNECT_REQUEST = 24621;
export const KIND_MESH_CALL_ME_NOW = 24622;
export const KIND_REPO_ANNOUNCEMENT = 30617;
// Human-visible "new content" message kinds. Used as the unread trigger set

View File

@@ -4827,6 +4827,16 @@ function sendToMockSocket(args: {
return;
}
// Mesh control events (24620 status report, 24621 connect request) are not
// channel messages — they carry a `p` tag, not an `h` tag. The real relay
// accepts them after membership/shape checks; the mock just ACKs so the
// desktop mesh flow (publishMeshConnectRequest) can proceed. We do not model
// the paired 24622 here; that belongs in a dedicated call-me-now test.
if (event.kind === 24620 || event.kind === 24621) {
sendWsText(socket.handler, ["OK", event.id, true, ""]);
return;
}
const channelId = getChannelIdFromTags(event.tags);
if (!channelId) {
sendWsText(socket.handler, [
@@ -4951,7 +4961,10 @@ export function maybeInstallE2eTauriMocks() {
consoleUrl: null,
modelId: mockMeshState.models[0]?.id ?? null,
modelName: mockMeshState.models[0]?.name ?? null,
inviteToken: null,
inviteToken: state === "running" ? "mock-endpoint-addr" : null,
endpointId: state === "running" ? "mock-endpoint-id" : null,
deviceId: state === "running" ? "mock-endpoint-id" : null,
deviceName: state === "running" ? "Mock desktop" : null,
});
const handleMockCommand = async (command: string, payload: unknown) => {
const activeConfig = getConfig();
@@ -4966,7 +4979,17 @@ export function maybeInstallE2eTauriMocks() {
available: mockMeshState.admitted,
reason: mockMeshState.admitted ? null : mockMeshState.denyReason,
models: mockMeshState.models,
serveTargets: [],
serveTargets: mockMeshState.models.map((model) => ({
modelId: model.id,
modelName: model.name,
endpointAddr: "mock-endpoint-addr",
nodeName: "Mock desktop",
capacity: { vramGb: null },
reporterPubkey: identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey,
endpointId: "mock-endpoint-id",
deviceId: "mock-endpoint-id",
deviceName: "Mock desktop",
})),
};
case "mesh_installed_models":
return mockMeshState.models;
@@ -4991,7 +5014,22 @@ export function maybeInstallE2eTauriMocks() {
if (!mockMeshState.admitted) {
throw new Error(mockMeshState.denyReason);
}
mockMeshState.nodeState = "running";
mockMeshState.nodeMode = "client";
return meshNodeStatus("running", "client");
case "mesh_dial_endpoint_addr":
return meshNodeStatus("running", mockMeshState.nodeMode ?? "client");
case "mesh_status_report_payload":
return mockMeshState.nodeState === "running"
? {
token: "mock-endpoint-addr",
node_id: "mock-endpoint-id",
endpointId: "mock-endpoint-id",
deviceId: "mock-endpoint-id",
deviceName: "Mock desktop",
hosted_models: mockMeshState.models.map((model) => model.id),
}
: null;
case "mesh_agent_preset": {
const req = (payload as { request?: { modelId?: string } } | null)
?.request;

View File

@@ -106,7 +106,7 @@ test("Run-on-relay-mesh ensures the client node BEFORE spawning the agent", asyn
await toggle.click();
await page
.getByTestId("agent-relay-mesh-model")
.selectOption("hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M");
.selectOption({ label: "SmolLM2 135M — Mock desktop" });
await expect(page.getByTestId("create-agent-submit")).toBeEnabled({
timeout: 10_000,

View File

@@ -50,19 +50,26 @@ 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
## Connectivity model: public iroh relays off, raw STUN on (WAN)
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.
Sprout Desktop starts the embedded mesh node with `disable_iroh_relays(true)`
(mesh-llm fork rev `bc2f1106`, `RelayPolicy::ExplicitlyDisabled`): **no public
iroh relays** (no `*.iroh.link` traffic, no relay transport), so there is no
empty-relay fallback to public infrastructure.
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.
Raw STUN **remains on** under this policy: the node discovers its public address
(via `stun.l.google.com` / `stun.cloudflare.com`) and injects it into the invite
token / `EndpointAddr`. That address rides the relay-signed `24621`/`24622`
call-me-now exchange, and peers hole-punch directly over UDP — so this works over
**WAN**, not just LAN. STUN is a "what's my public IP" lookup, not a relay or a
data path; the Sprout relay performs the address-exchange coordination.
Residual limit (intentional v1): with iroh relays off there is **no relay
transport fallback**, so two peers both behind **symmetric NATs** may fail to
hole-punch. Works for the common cases (≥1 side cone-NAT / port-forwarded /
server). A hosted private-iroh-relay fallback (relay advertises an
`iroh_relay_url` + NIP-98 auth) is the planned follow-up once the relay side is
wired end-to-end.
## Mesh-compute e2e acceptance matrix
@@ -74,7 +81,7 @@ 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` |
| 2 | admission **invariant**: relay membership is the only factor | `crates/sprout-relay/src/handlers/mesh_signaling.rs` (`*_admitted` / `denied_is_not_admitted` tests) | REAL policy logic, no I/O | **Yes** | `cargo test -p sprout-relay mesh_signaling` |
| 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` |
@@ -87,8 +94,9 @@ coverage into three layers and are explicit about what is real vs mocked.
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.
Note: with `disable_iroh_relays(true)` the join bootstraps via STUN-discovered
public addresses + relay-signed call-me-now (no public iroh relay) — see the
connectivity model 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),

View File

@@ -198,9 +198,9 @@ 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-compute e2e: the CI-safe layers (relay mesh signaling invariants + Playwright UI)
mesh-e2e:
cargo test -p sprout-relay iroh_relay::tests::admission
cargo test -p sprout-relay mesh_signaling
cd {{desktop_dir}} && pnpm test:e2e:integration -- mesh-compute.spec.ts
# Mesh-compute Layer 1: REAL serve->client->inference on this machine (not CI)