fix(desktop): harden relay mesh connect p-tag (#834)

This commit is contained in:
tlongwell-block
2026-06-04 11:50:49 -04:00
committed by GitHub
parent b3aefae152
commit 34ac3ba1d1
18 changed files with 750 additions and 148 deletions
+7 -10
View File
@@ -1,8 +1,6 @@
use nostr::{Keys, ToBech32};
use tauri::{AppHandle, State};
#[cfg(feature = "mesh-llm")]
use crate::managed_agents::relay_mesh_model_id;
use crate::{
app_state::AppState,
managed_agents::{
@@ -33,18 +31,16 @@ fn workspace_owner_hex(state: &AppState) -> Result<String, String> {
async fn ensure_relay_mesh_for_record(
state: &AppState,
record: &ManagedAgentRecord,
allow_fresh_create_start: bool,
) -> Result<(), String> {
let Some(model_id) = relay_mesh_model_id(record) else {
return Ok(());
};
crate::commands::mesh_llm::ensure_client_node_for_model(state, model_id, None).await?;
Ok(())
crate::commands::ensure_relay_mesh_for_record(state, record, allow_fresh_create_start).await
}
#[cfg(not(feature = "mesh-llm"))]
async fn ensure_relay_mesh_for_record(
_state: &AppState,
_record: &ManagedAgentRecord,
_allow_fresh_create_start: bool,
) -> Result<(), String> {
Ok(())
}
@@ -54,6 +50,7 @@ async fn start_local_agent_with_preflight(
state: &AppState,
pubkey: &str,
owner_hex: &str,
allow_fresh_create_start: bool,
) -> Result<ManagedAgentSummary, String> {
let record_snapshot = {
let _store_guard = state
@@ -72,7 +69,7 @@ async fn start_local_agent_with_preflight(
return Err(format!("agent {pubkey} is not a local agent"));
}
ensure_relay_mesh_for_record(state, &record_snapshot).await?;
ensure_relay_mesh_for_record(state, &record_snapshot, allow_fresh_create_start).await?;
let _store_guard = state
.managed_agents_store_lock
@@ -519,7 +516,7 @@ pub async fn create_managed_agent(
// ── Phase 3b: local spawn (async preflight outside store lock) ───────────
let mut spawn_error = None;
let agent = if input.spawn_after_create && input.backend == BackendKind::Local {
match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex).await {
match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, true).await {
Ok(agent) => agent,
Err(error) => {
let _store_guard = state
@@ -693,7 +690,7 @@ pub async fn start_managed_agent(
match target {
StartTarget::Local => {
start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex).await
start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, false).await
}
StartTarget::Provider {
backend: BackendKind::Provider { id, config },
+132 -27
View File
@@ -1,6 +1,9 @@
use tauri::{AppHandle, State};
use crate::{app_state::AppState, mesh_llm, relay};
use crate::{app_state::AppState, managed_agents::RELAY_MESH_API_BASE_URL, mesh_llm, relay};
const RELAY_MESH_RUNTIME_NO_TARGET: &str =
"relay mesh client start requires a concrete serve target; reopen the agent with Run on relay mesh selected to refresh its target";
pub type CmdResult<T> = Result<T, String>;
@@ -93,27 +96,7 @@ pub(crate) async fn ensure_client_node_for_model(
.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
}
None => return Err(RELAY_MESH_RUNTIME_NO_TARGET.to_string()),
};
let start = mesh_llm::StartMeshNodeRequest {
@@ -137,6 +120,85 @@ pub(crate) async fn ensure_client_node_for_model(
Ok(status)
}
/// Re-resolve a live serve target's dial pointer for a saved relay-mesh agent.
///
/// The serve target's `endpoint_addr` is live discovery state — it comes from
/// the peer's replaceable kind:30621 status event and rotates when the peer's
/// iroh endpoint changes — so it is never persisted onto the agent record.
/// Instead, a saved agent re-resolves a current bootstrap target at start time
/// by matching its configured model against the targets the relay is gossiping
/// right now. We only need *any* live target for the model to bootstrap the
/// client node; mesh-llm's router picks the per-request host afterwards.
///
/// `Err` means the relay query itself failed (relay down, auth, network) — we
/// could not refresh targets at all and must not pretend the peer is offline.
/// `Ok(None)` means the relay answered but no live target currently serves this
/// model (genuine peer-offline). `Ok(Some(addr))` is a dialable bootstrap
/// target.
pub(crate) async fn resolve_mesh_bootstrap_target(
state: &AppState,
model_id: &str,
) -> Result<Option<String>, String> {
let model_id = model_id.trim();
if model_id.is_empty() {
return Ok(None);
}
let events = relay::query_relay(state, &[mesh_llm::mesh_status_filter()]).await?;
Ok(pick_serve_target_for_model(
mesh_llm::availability_from_events(events).serve_targets,
model_id,
))
}
/// Pure target-selection used by `resolve_mesh_bootstrap_target`: the first
/// gossiped serve target that hosts `model_id`. Split out so the matching rule
/// is unit-testable without a relay round-trip.
fn pick_serve_target_for_model(
targets: Vec<mesh_llm::MeshServeTarget>,
model_id: &str,
) -> Option<String> {
targets
.into_iter()
.find(|target| target.model_id == model_id)
.map(|target| target.endpoint_addr)
}
/// Decide whether a relay-mesh agent may start, and bring up its local mesh
/// client when needed.
///
/// Fresh create (`allow_fresh_create_start`) has just run the client-start flow
/// from the dialog, so it spawns as-is. For a saved/manual start the serve
/// target's dial pointer was never persisted (it is live discovery state), so
/// re-resolve a current bootstrap target from the relay's gossiped targets and
/// dial it. The two failure modes get distinct, actionable copy: a relay query
/// failure ("could not refresh targets") is not the same as a relay that
/// answered with no live target for this model ("peer offline"). Non relay-mesh
/// records are a no-op.
pub(crate) async fn ensure_relay_mesh_for_record(
state: &AppState,
record: &crate::managed_agents::ManagedAgentRecord,
allow_fresh_create_start: bool,
) -> Result<(), String> {
if allow_fresh_create_start {
return Ok(());
}
let Some(model_id) = crate::managed_agents::relay_mesh_model_id(record) else {
return Ok(());
};
match resolve_mesh_bootstrap_target(state, &model_id).await {
Ok(Some(endpoint_addr)) => {
ensure_client_node_for_model(state, &model_id, Some(endpoint_addr)).await?;
Ok(())
}
Ok(None) => Err(format!(
"relay mesh agents cannot be started from saved state because no live serve target is available for this model. Start serving on a mesh peer, or create a new agent with Run on relay mesh selected to refresh the target for {RELAY_MESH_API_BASE_URL}."
)),
Err(error) => Err(format!(
"could not refresh relay mesh serve targets to start this agent: {error}"
)),
}
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeshDialEndpointRequest {
@@ -222,6 +284,50 @@ mod tests {
use super::*;
use crate::app_state::build_app_state;
fn target(model_id: &str, endpoint_addr: &str) -> mesh_llm::MeshServeTarget {
mesh_llm::MeshServeTarget {
model_id: model_id.to_string(),
model_name: None,
endpoint_addr: endpoint_addr.to_string(),
node_name: None,
capacity: None,
reporter_pubkey: None,
endpoint_id: None,
device_id: None,
device_name: None,
}
}
#[test]
fn pick_serve_target_returns_first_match_for_model() {
let targets = vec![
target("model-a", "addr-a"),
target("model-b", "addr-b1"),
target("model-b", "addr-b2"),
];
// Matches by model id, returns the first such target's dial pointer.
assert_eq!(
pick_serve_target_for_model(targets, "model-b"),
Some("addr-b1".to_string())
);
}
#[test]
fn pick_serve_target_none_when_model_not_hosted() {
let targets = vec![target("model-a", "addr-a")];
// No live target serves this model -> caller falls closed.
assert_eq!(pick_serve_target_for_model(targets, "model-missing"), None);
}
#[tokio::test]
async fn cold_client_preflight_requires_explicit_target() {
let state = build_app_state();
let error = ensure_client_node_for_model(&state, "demo/model", None)
.await
.expect_err("cold relay-mesh preflight must not auto-pick a target");
assert_eq!(error, RELAY_MESH_RUNTIME_NO_TARGET);
}
/// Acceptance-critical regression for dropping the serve-vs-client guard.
///
/// Before this change, `ensure_client_node_for_model` hard-errored whenever
@@ -231,11 +337,10 @@ mod tests {
/// through the same `9337` ingress.
///
/// This test starts a real serve runtime and asserts that a follow-up
/// preflight for a *different* model:
/// 1. does NOT reject on mode, and
/// 2. returns the existing runtime's status (same `9337` ingress), so the
/// agent keeps talking to the running node and mesh-llm's router
/// resolves the model per request.
/// preflight for a *different* model and no explicit target still reuses the
/// existing runtime. Cold starts without a target are rejected before mesh-llm
/// startup; running runtimes are already joined to whatever target the
/// frontend selected earlier.
///
/// Hardware-gated (`#[ignore]`): loads a real model. Run with:
/// cargo test -p sprout-desktop --features mesh-llm \
@@ -5,6 +5,8 @@ mod nest;
mod persona_avatars;
mod persona_card;
mod personas;
#[cfg(feature = "mesh-llm")]
mod relay_mesh;
mod restore;
mod runtime;
mod storage;
@@ -17,6 +19,8 @@ pub use env_vars::*;
pub use nest::*;
pub use persona_card::*;
pub use personas::*;
#[cfg(feature = "mesh-llm")]
pub use relay_mesh::*;
pub use restore::*;
pub use runtime::*;
pub use storage::*;
@@ -0,0 +1,127 @@
use super::ManagedAgentRecord;
pub const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1";
pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "sprout-mesh-local";
/// Returns the relay-mesh model id for agents whose provider env points at the
/// local mesh client endpoint created by Sprout's relay-mesh preset.
#[cfg(feature = "mesh-llm")]
pub fn relay_mesh_model_id(record: &ManagedAgentRecord) -> Option<String> {
let base_url = record.env_vars.get("OPENAI_COMPAT_BASE_URL")?.trim();
if base_url.trim_end_matches('/') != RELAY_MESH_API_BASE_URL {
return None;
}
let provider = record.env_vars.get("SPROUT_AGENT_PROVIDER")?.trim();
if provider != "openai" {
return None;
}
let api_key = record.env_vars.get("OPENAI_COMPAT_API_KEY")?.trim();
if api_key != RELAY_MESH_API_KEY_PLACEHOLDER {
return None;
}
record
.env_vars
.get("OPENAI_COMPAT_MODEL")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::managed_agents::{BackendKind, RespondTo};
fn fixture() -> ManagedAgentRecord {
ManagedAgentRecord {
pubkey: "p".into(),
name: "n".into(),
persona_id: None,
private_key_nsec: "nsec1fake".into(),
auth_tag: Some("tag".into()),
relay_url: "ws://localhost:3000".into(),
acp_command: "sprout-acp".into(),
agent_command: "goose".into(),
agent_args: vec![],
mcp_command: "sprout-mcp-server".into(),
turn_timeout_seconds: 320,
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
parallelism: 1,
system_prompt: None,
model: None,
mcp_toolsets: None,
env_vars: BTreeMap::new(),
start_on_app_launch: false,
runtime_pid: None,
backend: BackendKind::Local,
backend_agent_id: None,
provider_binary_path: None,
persona_pack_path: None,
persona_name_in_pack: None,
created_at: "now".into(),
updated_at: "now".into(),
last_started_at: None,
last_stopped_at: None,
last_exit_code: None,
last_error: None,
respond_to: RespondTo::OwnerOnly,
respond_to_allowlist: vec![],
}
}
#[cfg(feature = "mesh-llm")]
#[test]
fn relay_mesh_model_id_detects_mesh_preset_env() {
let mut rec = fixture();
rec.env_vars = BTreeMap::from([
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
(
"OPENAI_COMPAT_BASE_URL".to_string(),
"http://127.0.0.1:9337/v1/".to_string(),
),
("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()),
(
"OPENAI_COMPAT_API_KEY".to_string(),
RELAY_MESH_API_KEY_PLACEHOLDER.to_string(),
),
]);
assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3"));
}
#[cfg(feature = "mesh-llm")]
#[test]
fn relay_mesh_model_id_ignores_non_mesh_openai_env() {
let mut rec = fixture();
rec.env_vars = BTreeMap::from([
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
(
"OPENAI_COMPAT_BASE_URL".to_string(),
"https://api.openai.com/v1".to_string(),
),
("OPENAI_COMPAT_MODEL".to_string(), "gpt-5".to_string()),
]);
assert_eq!(relay_mesh_model_id(&rec), None);
}
#[cfg(feature = "mesh-llm")]
#[test]
fn relay_mesh_model_id_ignores_user_openai_on_same_local_port() {
let mut rec = fixture();
rec.env_vars = BTreeMap::from([
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
(
"OPENAI_COMPAT_BASE_URL".to_string(),
"http://127.0.0.1:9337/v1".to_string(),
),
("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()),
("OPENAI_COMPAT_API_KEY".to_string(), "real-key".to_string()),
]);
assert_eq!(relay_mesh_model_id(&rec), None);
}
}
@@ -108,11 +108,14 @@ pub async fn restore_managed_agents_on_launch(
let agents_to_start = {
let mut mesh_preflight_failures = std::collections::HashSet::new();
for record in &agents_to_start {
let Some(model_id) = relay_mesh_model_id(record) else {
if relay_mesh_model_id(record).is_none() {
continue;
};
}
// Auto-start after relaunch: re-resolve a live bootstrap target and
// dial it. Skip (with an actionable error) only when no live target
// serves this model right now.
if let Err(error) =
crate::commands::ensure_client_node_for_model(&state, model_id, None).await
crate::commands::ensure_relay_mesh_for_record(&state, record, false).await
{
persist_restore_error(app, &state, &record.pubkey, error)?;
mesh_preflight_failures.insert(record.pubkey.clone());
@@ -1043,27 +1043,6 @@ fn child_rust_log_filter() -> String {
}
}
/// Returns the relay-mesh model id for agents whose provider env points at the
/// local mesh client endpoint. The caller is responsible for ensuring that
/// endpoint is alive before spawning the agent process.
#[cfg(feature = "mesh-llm")]
pub fn relay_mesh_model_id(record: &ManagedAgentRecord) -> Option<String> {
let base_url = record.env_vars.get("OPENAI_COMPAT_BASE_URL")?.trim();
if base_url.trim_end_matches('/') != "http://127.0.0.1:9337/v1" {
return None;
}
let provider = record.env_vars.get("SPROUT_AGENT_PROVIDER")?.trim();
if provider != "openai" {
return None;
}
record
.env_vars
.get("OPENAI_COMPAT_MODEL")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(str::to_string)
}
pub fn start_managed_agent_process(
app: &AppHandle,
record: &mut ManagedAgentRecord,
@@ -1170,8 +1149,6 @@ pub fn stop_managed_agent_process(
#[cfg(test)]
mod tests {
#[cfg(feature = "mesh-llm")]
use super::relay_mesh_model_id;
use crate::managed_agents::known_acp_provider;
#[test]
@@ -1263,38 +1240,6 @@ mod tests {
}
}
#[cfg(feature = "mesh-llm")]
#[test]
fn relay_mesh_model_id_detects_mesh_preset_env() {
let mut rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
rec.env_vars = std::collections::BTreeMap::from([
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
(
"OPENAI_COMPAT_BASE_URL".to_string(),
"http://127.0.0.1:9337/v1/".to_string(),
),
("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()),
]);
assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3"));
}
#[cfg(feature = "mesh-llm")]
#[test]
fn relay_mesh_model_id_ignores_non_mesh_openai_env() {
let mut rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
rec.env_vars = std::collections::BTreeMap::from([
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
(
"OPENAI_COMPAT_BASE_URL".to_string(),
"https://api.openai.com/v1".to_string(),
),
("OPENAI_COMPAT_MODEL".to_string(), "gpt-5".to_string()),
]);
assert_eq!(relay_mesh_model_id(&rec), None);
}
#[test]
fn build_env_owner_only_sets_mode_and_removes_others() {
let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
+9 -48
View File
@@ -4,6 +4,9 @@ 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};
mod preset;
pub use preset::{agent_preset, MeshAgentPreset, MeshAgentPresetRequest};
use mesh_llm_sdk::{client, serve, EmbeddedNodeHandle, MeshDiscoveryMode};
use serde::{Deserialize, Serialize};
@@ -13,6 +16,12 @@ const MESH_STATUS_KIND: u64 = 30_621;
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";
/// ACP provider relay-mesh agents run on. Sources of truth for its command +
/// MCP live in the provider catalog (`known_acp_provider_exact`); these are
/// only the fallbacks. `sprout-agent` reads the `SPROUT_AGENT_PROVIDER` /
/// `OPENAI_COMPAT_*` env vars below — goose (the global default) does not.
const MESH_AGENT_PROVIDER_ID: &str = "sprout-agent";
const MESH_AGENT_MCP_COMMAND: &str = "sprout-dev-mcp";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -442,54 +451,6 @@ pub(super) fn dedupe_models(models: Vec<MeshModelOption>) -> Vec<MeshModelOption
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MeshAgentPresetRequest {
pub model_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MeshAgentPreset {
pub provider_id: String,
pub label: String,
pub acp_command: String,
pub agent_command: String,
pub agent_args: Vec<String>,
pub mcp_command: String,
pub model: String,
pub env_vars: BTreeMap<String, String>,
}
pub fn agent_preset(request: MeshAgentPresetRequest) -> Result<MeshAgentPreset, String> {
let model = request.model_id.trim();
if model.is_empty() {
return Err("modelId is required".to_string());
}
Ok(MeshAgentPreset {
provider_id: "relay-mesh".to_string(),
label: "Relay mesh".to_string(),
acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(),
agent_command: crate::managed_agents::DEFAULT_AGENT_COMMAND.to_string(),
agent_args: Vec::new(),
mcp_command: crate::managed_agents::DEFAULT_MCP_COMMAND.to_string(),
model: model.to_string(),
env_vars: BTreeMap::from([
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
(
"OPENAI_COMPAT_BASE_URL".to_string(),
relay_mesh_api_base_url()?,
),
("OPENAI_COMPAT_MODEL".to_string(), model.to_string()),
(
"OPENAI_COMPAT_API_KEY".to_string(),
RELAY_MESH_API_KEY_PLACEHOLDER.to_string(),
),
("OPENAI_COMPAT_API".to_string(), "chat".to_string()),
]),
})
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod mod_tests;
@@ -32,3 +32,35 @@ fn model_ref_is_family_agnostic() {
assert!(!looks_like_model_ref("Qwen3-35B"));
assert!(!looks_like_model_ref(""));
}
#[test]
fn agent_preset_runs_on_sprout_agent_not_goose() {
// Regression (Tyler): the relay-mesh preset used to hand the agent the
// global default runtime (goose), which ignores the OpenAI-compat env
// vars and falls back to its own provider. Mesh agents must run on
// sprout-agent, which reads those vars.
let preset = super::agent_preset(super::MeshAgentPresetRequest {
model_id: "Qwen3-8B-Q4_K_M".to_string(),
})
.expect("preset for a valid model id");
assert_eq!(preset.agent_command, "sprout-agent");
assert_ne!(preset.agent_command, "goose");
assert_eq!(preset.mcp_command, "sprout-dev-mcp");
// The env vars sprout-agent's config layer reads (crates/sprout-agent).
assert_eq!(
preset
.env_vars
.get("SPROUT_AGENT_PROVIDER")
.map(String::as_str),
Some("openai")
);
assert_eq!(
preset
.env_vars
.get("OPENAI_COMPAT_MODEL")
.map(String::as_str),
Some("Qwen3-8B-Q4_K_M")
);
}
+69
View File
@@ -0,0 +1,69 @@
//! Relay-mesh "Run on relay mesh" agent preset. Kept in a sibling file so
//! `mod.rs` stays under the 500-line budget; `#[path]`-included from there.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::{
relay_mesh_api_base_url, MESH_AGENT_MCP_COMMAND, MESH_AGENT_PROVIDER_ID,
RELAY_MESH_API_KEY_PLACEHOLDER,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MeshAgentPresetRequest {
pub model_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MeshAgentPreset {
pub provider_id: String,
pub label: String,
pub acp_command: String,
pub agent_command: String,
pub agent_args: Vec<String>,
pub mcp_command: String,
pub model: String,
pub env_vars: BTreeMap<String, String>,
}
pub fn agent_preset(request: MeshAgentPresetRequest) -> Result<MeshAgentPreset, String> {
let model = request.model_id.trim();
if model.is_empty() {
return Err("modelId is required".to_string());
}
// Run on sprout-agent, not the global default (goose). Source command +
// MCP from the catalog so this can't drift from the provider definition.
let sprout_agent = crate::managed_agents::known_acp_provider_exact(MESH_AGENT_PROVIDER_ID);
let agent_command = sprout_agent
.and_then(|p| p.commands.first().copied())
.unwrap_or(MESH_AGENT_PROVIDER_ID)
.to_string();
let mcp_command = sprout_agent
.and_then(|p| p.mcp_command)
.unwrap_or(MESH_AGENT_MCP_COMMAND)
.to_string();
Ok(MeshAgentPreset {
provider_id: "relay-mesh".to_string(),
label: "Relay mesh".to_string(),
acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(),
agent_command,
agent_args: Vec::new(),
mcp_command,
model: model.to_string(),
env_vars: BTreeMap::from([
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
(
"OPENAI_COMPAT_BASE_URL".to_string(),
relay_mesh_api_base_url()?,
),
("OPENAI_COMPAT_MODEL".to_string(), model.to_string()),
(
"OPENAI_COMPAT_API_KEY".to_string(),
RELAY_MESH_API_KEY_PLACEHOLDER.to_string(),
),
("OPENAI_COMPAT_API".to_string(), "chat".to_string()),
]),
})
}
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import { startManagedAgentWithRules } from "./managedAgentControlActions.ts";
function agent(overrides = {}) {
return {
pubkey: "deadbeef".repeat(8),
name: "Mesh Agent",
personaId: null,
relayUrl: "ws://localhost:3000",
acpCommand: "sprout-acp",
agentCommand: "goose",
agentArgs: [],
mcpCommand: "sprout-mcp-server",
turnTimeoutSeconds: 320,
idleTimeoutSeconds: null,
maxTurnDurationSeconds: null,
parallelism: 1,
systemPrompt: null,
model: "hf://demo/model.gguf",
mcpToolsets: null,
envVars: {},
status: "stopped",
pid: null,
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
lastStartedAt: null,
lastStoppedAt: null,
lastExitCode: null,
lastError: null,
logPath: null,
startOnAppLaunch: false,
backend: { type: "local" },
backendAgentId: null,
respondTo: "owner-only",
respondToAllowlist: [],
...overrides,
};
}
test("relay-mesh agents cannot be manually started without a fresh target", async () => {
let called = false;
await assert.rejects(
startManagedAgentWithRules({
agent: agent({
envVars: {
SPROUT_AGENT_PROVIDER: "openai",
OPENAI_COMPAT_BASE_URL: "http://127.0.0.1:9337/v1/",
},
}),
startManagedAgent: async () => {
called = true;
},
}),
/Relay-mesh agents need a fresh serve target/,
);
assert.equal(called, false);
});
test("ordinary local agents still start normally", async () => {
let calledWith = null;
await startManagedAgentWithRules({
agent: agent(),
startManagedAgent: async (pubkey) => {
calledWith = pubkey;
},
});
assert.equal(calledWith, "deadbeef".repeat(8));
});
@@ -75,6 +75,18 @@ export function resolveManagedAgentChannelId(
return matches.length === 1 ? matches[0].id : null;
}
function relayMeshAgentError(agent: ManagedAgent): string | null {
if (agent.backend.type !== "local") return null;
if (agent.envVars.SPROUT_AGENT_PROVIDER !== "openai") return null;
if (
agent.envVars.OPENAI_COMPAT_BASE_URL?.replace(/\/+$/, "") !==
"http://127.0.0.1:9337/v1"
) {
return null;
}
return "Relay-mesh agents need a fresh serve target before start. Create a new agent with Run on relay mesh selected.";
}
export async function startManagedAgentWithRules({
agent,
startManagedAgent,
@@ -82,6 +94,8 @@ export async function startManagedAgentWithRules({
agent: ManagedAgent;
startManagedAgent: StartManagedAgent;
}) {
const relayMeshError = relayMeshAgentError(agent);
if (relayMeshError) throw new Error(relayMeshError);
await startManagedAgent(agent.pubkey);
}
@@ -94,6 +108,8 @@ export async function respawnManagedAgentWithRules({
startManagedAgent: StartManagedAgent;
stopManagedAgent: StopManagedAgent;
}) {
const relayMeshError = relayMeshAgentError(agent);
if (relayMeshError) throw new Error(relayMeshError);
if (agent.backend.type === "local" && isManagedAgentActive(agent)) {
await stopManagedAgent(agent.pubkey);
}
@@ -396,7 +396,9 @@ export function CreateAgentDialog({
envVars,
model: useMesh ? meshModelId.trim() || undefined : undefined,
spawnAfterCreate,
startOnAppLaunch,
// Relay-mesh agents need a freshly selected serve target to start;
// do not auto-restore them later with only the saved model/env.
startOnAppLaunch: useMesh ? false : startOnAppLaunch,
backend: { type: "local" },
...respondToFields,
};
@@ -352,7 +352,9 @@ function AgentActionsMenu({
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-label={`Agent actions for ${agent.name}`}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
data-testid={`managed-agent-actions-${agent.pubkey}`}
type="button"
>
<Ellipsis className="h-4 w-4" />
@@ -1,15 +1,27 @@
import { publishMeshConnectRequest } from "@/shared/api/relayMeshSignaling";
import { getIdentity } from "@/shared/api/tauri";
import {
meshEnsureClientNode,
type MeshServeTarget,
} from "@/shared/api/tauriMesh";
function normalizePubkey(value: string | null | undefined): string | null {
const normalized = value?.trim().toLowerCase() ?? "";
return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null;
}
export async function startRelayMeshClientForTarget(
modelId: string,
target: MeshServeTarget | null,
): Promise<void> {
const status = await meshEnsureClientNode(modelId, target);
if (!target?.reporterPubkey) {
if (!target) {
throw new Error(
"Selected relay mesh target is missing its reporter pubkey.",
);
}
const targetPubkey = normalizePubkey(target.reporterPubkey);
if (!targetPubkey) {
throw new Error(
"Selected relay mesh target is missing its reporter pubkey.",
);
@@ -17,8 +29,18 @@ export async function startRelayMeshClientForTarget(
if (!status.inviteToken) {
throw new Error("Local mesh client did not publish an endpoint address.");
}
const selfPubkey = normalizePubkey((await getIdentity()).pubkey);
if (selfPubkey === targetPubkey) {
// The selected serve target belongs to this desktop. `meshEnsureClientNode`
// has already ensured the local mesh ingress is usable; a relay
// connect-request to ourselves would be rejected as self-targeting and is
// unnecessary for local routing.
return;
}
await publishMeshConnectRequest({
targetPubkey: target.reporterPubkey,
targetPubkey,
selfEndpointAddr: status.inviteToken,
peerEndpointAddr: target.endpointAddr,
attemptId: crypto.randomUUID(),
+13 -1
View File
@@ -23,6 +23,11 @@ export async function publishMeshStatusReport(
);
}
function normalizePubkey(value: string): string | null {
const trimmed = value.trim().toLowerCase();
return /^[0-9a-f]{64}$/.test(trimmed) ? trimmed : null;
}
export async function publishMeshConnectRequest(input: {
targetPubkey: string;
selfEndpointAddr: string;
@@ -40,10 +45,17 @@ export async function publishMeshConnectRequest(input: {
};
if (input.selfEndpointId) content.self_endpoint_id = input.selfEndpointId;
if (input.peerEndpointId) content.peer_endpoint_id = input.peerEndpointId;
const targetPubkey = normalizePubkey(input.targetPubkey);
if (!targetPubkey) {
throw new Error(
"Selected relay mesh target has an invalid reporter pubkey.",
);
}
const event = await signRelayEvent({
kind: KIND_MESH_CONNECT_REQUEST,
content: JSON.stringify(content),
tags: [["p", input.targetPubkey]],
tags: [["p", targetPubkey]],
});
await relayClient.publishEvent(
event,
+47 -1
View File
@@ -55,6 +55,7 @@ type E2eConfig = {
// `upload_media_bytes` commands. Lets a spec drive the attachment flow
// (e.g. a generic PDF) without a real upload pipeline. See
// tests/helpers/bridge.ts:MockBridgeOptions.uploadDescriptors.
meshReporterPubkey?: string;
uploadDescriptors?: RawBlobDescriptor[];
};
relayHttpUrl?: string;
@@ -514,6 +515,11 @@ declare global {
payload?: Record<string, unknown>,
) => Promise<unknown>;
__SPROUT_E2E_PUSH_MOCK_FEED_ITEM__?: (item: RawFeedItem) => RawFeedItem;
__SPROUT_E2E_SIGNED_EVENTS__?: Array<{
content: string;
kind: number;
tags: string[][];
}>;
__SPROUT_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void;
__SPROUT_E2E_SET_MESH__?: (mesh: {
admitted?: boolean;
@@ -4257,10 +4263,27 @@ function getMockManagedAgent(pubkey: string): MockManagedAgent {
return agent;
}
function isRelayMeshManagedAgent(agent: MockManagedAgent): boolean {
const env = agent.env_vars ?? {};
return (
agent.backend.type === "local" &&
env.SPROUT_AGENT_PROVIDER === "openai" &&
env.OPENAI_COMPAT_BASE_URL?.replace(/\/+$/, "") ===
"http://127.0.0.1:9337/v1" &&
env.OPENAI_COMPAT_API_KEY === "sprout-mesh-local"
);
}
async function handleStartManagedAgent(args: {
pubkey: string;
}): Promise<RawManagedAgent> {
const agent = getMockManagedAgent(args.pubkey);
if (isRelayMeshManagedAgent(agent)) {
throw new Error(
"relay mesh agents cannot be started from saved state because the selected serve target is not persisted. Create a new agent with Run on relay mesh selected to refresh the target for http://127.0.0.1:9337/v1.",
);
}
const now = new Date().toISOString();
agent.status = "running";
agent.pid = agent.pid ?? 42000 + mockManagedAgents.indexOf(agent);
@@ -5059,6 +5082,18 @@ function sendToMockSocket(args: {
// 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) {
if (
event.kind === 24621 &&
!event.tags.some((tag) => tag[0] === "p" && typeof tag[1] === "string")
) {
sendWsText(socket.handler, [
"OK",
event.id,
false,
"invalid: mesh connect request missing #p target",
]);
return;
}
sendWsText(socket.handler, ["OK", event.id, true, ""]);
return;
}
@@ -5109,6 +5144,7 @@ export function maybeInstallE2eTauriMocks() {
mockWebsocketSendMutexWedged = false;
mockWindows("main");
window.__SPROUT_E2E_COMMANDS__ = [];
window.__SPROUT_E2E_SIGNED_EVENTS__ = [];
window.__SPROUT_E2E_WEBVIEW_ZOOM__ = 1;
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__ = ({
channelName,
@@ -5215,7 +5251,10 @@ export function maybeInstallE2eTauriMocks() {
endpointAddr: "mock-endpoint-addr",
nodeName: "Mock desktop",
capacity: { vramGb: null },
reporterPubkey: identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey,
reporterPubkey:
activeConfig?.mock?.meshReporterPubkey ??
identity?.pubkey ??
DEFAULT_MOCK_IDENTITY.pubkey,
endpointId: "mock-endpoint-id",
deviceId: "mock-endpoint-id",
deviceName: "Mock desktop",
@@ -5276,6 +5315,8 @@ export function maybeInstallE2eTauriMocks() {
SPROUT_AGENT_PROVIDER: "openai",
OPENAI_COMPAT_BASE_URL: "http://127.0.0.1:9337/v1",
OPENAI_COMPAT_MODEL: model,
OPENAI_COMPAT_API_KEY: "sprout-mesh-local",
OPENAI_COMPAT_API: "chat",
},
};
}
@@ -5586,6 +5627,11 @@ export function maybeInstallE2eTauriMocks() {
activeConfig,
);
case "sign_event":
window.__SPROUT_E2E_SIGNED_EVENTS__?.push({
content: (payload as { content: string }).content,
kind: (payload as { kind: number }).kind,
tags: (payload as { tags: string[][] }).tags,
});
if (identity) {
return JSON.stringify(
await signWithIdentity(identity, {
+184
View File
@@ -9,9 +9,15 @@ import { openSettings } from "../helpers/settings";
// invariant and the membership-denial copy.
type E2eWindow = Window & {
__SPROUT_E2E__?: { mock?: { meshReporterPubkey?: string } };
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: { invoke?: unknown };
__SPROUT_E2E_COMMANDS__?: string[];
__SPROUT_E2E_SIGNED_EVENTS__?: Array<{
content: string;
kind: number;
tags: string[][];
}>;
__SPROUT_E2E_SET_MESH__?: (mesh: {
admitted?: boolean;
models?: Array<{ id: string; name: string | null }>;
@@ -44,6 +50,13 @@ async function commands(page: import("@playwright/test").Page) {
);
}
/** Signed event templates the bridge recorded so far. */
async function signedEvents(page: import("@playwright/test").Page) {
return page.evaluate(
() => (window as E2eWindow).__SPROUT_E2E_SIGNED_EVENTS__ ?? [],
);
}
async function setMesh(
page: import("@playwright/test").Page,
mesh: { admitted?: boolean; denyReason?: string },
@@ -53,6 +66,17 @@ async function setMesh(
}, mesh);
}
async function openManagedAgentActions(
page: import("@playwright/test").Page,
pubkey: string,
) {
const trigger = page.getByTestId(`managed-agent-actions-${pubkey}`);
await trigger.scrollIntoViewIfNeeded();
await trigger.focus();
await trigger.press("Enter");
await expect(trigger).toHaveAttribute("data-state", "open");
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
@@ -157,6 +181,80 @@ test("Run-on-relay-mesh ensures the client node BEFORE spawning the agent", asyn
expect(ensureIdx).toBeLessThan(createIdx);
});
test("Run-on-relay-mesh skips connect signaling for own serve target", async ({
page,
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "New" }).click();
await page.getByText("Custom Agent").click();
await page.getByTestId("agent-name-input").fill("Own Mesh Agent");
const toggle = page.getByTestId("agent-relay-mesh-toggle");
await expect(toggle).toBeEnabled({ timeout: 10_000 });
await toggle.click();
await page
.getByTestId("agent-relay-mesh-model")
.selectOption({ label: "SmolLM2 135M — Mock desktop" });
const before = (await commands(page)).length;
await page.getByTestId("create-agent-submit").click();
await expect
.poll(async () => (await commands(page)).slice(before))
.toContain("create_managed_agent");
const slice = (await commands(page)).slice(before);
expect(slice).toContain("mesh_ensure_client_node");
expect(
(await signedEvents(page)).filter((event) => event.kind === 24621),
).toHaveLength(0);
});
test("Run-on-relay-mesh canonicalizes the mesh connect #p target", async ({
page,
}) => {
await page.addInitScript(() => {
const w = window as E2eWindow;
w.__SPROUT_E2E__ = {
...(w.__SPROUT_E2E__ ?? {}),
mock: {
...(w.__SPROUT_E2E__?.mock ?? {}),
meshReporterPubkey:
" CAFEBABECAFEBABECAFEBABECAFEBABECAFEBABECAFEBABECAFEBABECAFEBABE ",
},
};
});
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "New" }).click();
await page.getByText("Custom Agent").click();
await page.getByTestId("agent-name-input").fill("Mesh Agent");
const toggle = page.getByTestId("agent-relay-mesh-toggle");
await expect(toggle).toBeEnabled({ timeout: 10_000 });
await toggle.click();
await page
.getByTestId("agent-relay-mesh-model")
.selectOption({ label: "SmolLM2 135M — Mock desktop" });
await page.getByTestId("create-agent-submit").click();
await expect
.poll(async () =>
(await signedEvents(page)).find((event) => event.kind === 24621),
)
.toMatchObject({
tags: [
[
"p",
"cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe",
],
],
});
await expect
.poll(async () => await commands(page))
.toContain("create_managed_agent");
});
test("a non-member cannot enable relay-mesh — membership is the gate", async ({
page,
}) => {
@@ -179,3 +277,89 @@ test("a non-member cannot enable relay-mesh — membership is the gate", async (
const seq = await commands(page);
expect(seq).not.toContain("create_managed_agent");
});
test("saved relay-mesh agents require a fresh serve target before manual start", async ({
page,
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "New" }).click();
await page.getByText("Custom Agent").click();
await page.getByTestId("agent-name-input").fill("Saved relay mesh agent");
const toggle = page.getByTestId("agent-relay-mesh-toggle");
await expect(toggle).toBeEnabled({ timeout: 10_000 });
await toggle.click();
await page
.getByTestId("agent-relay-mesh-model")
.selectOption({ label: "SmolLM2 135M — Mock desktop" });
await page.getByTestId("create-agent-submit").click();
await expect
.poll(async () => await commands(page))
.toContain("create_managed_agent");
const agents = await page.evaluate(async () => {
const w = window as E2eWindow & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<Array<{ name: string; pubkey: string }>>;
};
const invoke = w.__SPROUT_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
return invoke("list_managed_agents");
});
const pubkey = agents.find(
(agent) => agent.name === "Saved relay mesh agent",
)?.pubkey;
expect(pubkey).toBeTruthy();
const row = page.getByTestId(`managed-agent-${pubkey}`);
await expect(row).toContainText("Saved relay mesh agent");
await expect(row).toContainText("running");
await page.getByRole("button", { name: "Done" }).click();
await expect(page.getByRole("dialog", { name: "Agent created" })).toHaveCount(
0,
);
await openManagedAgentActions(page, pubkey);
await page.getByRole("menuitem", { name: "Stop" }).click();
await expect
.poll(async () => await commands(page))
.toContain("stop_managed_agent");
await expect(row).toContainText("stopped");
const before = (await commands(page)).length;
await openManagedAgentActions(page, pubkey);
await page.getByRole("menuitem", { name: "Spawn" }).click();
await expect(
page
.locator("[data-sonner-toast]")
.filter({ hasText: "Relay-mesh agents need a fresh serve target" }),
).toBeVisible();
expect((await commands(page)).slice(before)).not.toContain(
"start_managed_agent",
);
await expect(row).toContainText("stopped");
await expect(
page.evaluate(async (agentPubkey) => {
const invoke = (window as E2eWindow).__SPROUT_E2E_INVOKE_MOCK_COMMAND__ as
| ((
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>)
| undefined;
if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
try {
await invoke("start_managed_agent", { pubkey: agentPubkey });
return "started";
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
}, pubkey),
).resolves.toContain("selected serve target is not persisted");
await expect(row).toContainText("stopped");
});
+5
View File
@@ -70,6 +70,11 @@ type MockBridgeOptions = {
* evaluates false).
*/
relayRole?: "owner" | "admin" | "member" | null;
/**
* Reporter pubkey injected into mocked mesh serve targets. Defaults to the
* active identity; specs can override to catch malformed/missing #p handling.
*/
meshReporterPubkey?: string;
/**
* Descriptors returned by the mocked `pick_and_upload_media` /
* `upload_media_bytes` commands. When omitted, the bridge returns a single