mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
85
Cargo.lock
generated
85
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) =
|
||||
|
||||
612
crates/sprout-relay/src/handlers/mesh_signaling.rs
Normal file
612
crates/sprout-relay/src/handlers/mesh_signaling.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
|
||||
|
||||
113
desktop/src-tauri/Cargo.lock
generated
113
desktop/src-tauri/Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
176
desktop/src-tauri/src/mesh_llm/discovery.rs
Normal file
176
desktop/src-tauri/src/mesh_llm/discovery.rs
Normal 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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
68
desktop/src/shared/api/relayMeshSignaling.ts
Normal file
68
desktop/src/shared/api/relayMeshSignaling.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
4
justfile
4
justfile
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user