diff --git a/Cargo.lock b/Cargo.lock index ec19606c6..d6cddc68f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3398,6 +3398,7 @@ dependencies = [ "futures-util", "nostr", "reqwest", + "rustls", "serde", "serde_json", "sprout-core", @@ -3525,6 +3526,7 @@ dependencies = [ "nostr", "reqwest", "rmcp", + "rustls", "schemars", "serde", "serde_json", diff --git a/crates/sprout-acp/Cargo.toml b/crates/sprout-acp/Cargo.toml index 92b6ab962..121256d03 100644 --- a/crates/sprout-acp/Cargo.toml +++ b/crates/sprout-acp/Cargo.toml @@ -23,6 +23,7 @@ tokio = { workspace = true } # WebSocket tokio-tungstenite = { workspace = true } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } futures-util = { workspace = true } # HTTP (channel discovery REST API) diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs index 414a20a6e..cb43ad5a6 100644 --- a/crates/sprout-acp/src/main.rs +++ b/crates/sprout-acp/src/main.rs @@ -48,6 +48,10 @@ const MODELS_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::main] async fn main() -> Result<()> { + // Install the ring crypto provider for rustls (required for wss:// connections). + rustls::crypto::ring::default_provider() + .install_default() + .expect("failed to install rustls crypto provider"); // ── Subcommand dispatch — before Config::from_cli() or any harness setup ── if is_subcommand("models") { // Strip the "models" token so clap doesn't reject it as a positional. diff --git a/crates/sprout-mcp/Cargo.toml b/crates/sprout-mcp/Cargo.toml index 6d8482eec..92880f57f 100644 --- a/crates/sprout-mcp/Cargo.toml +++ b/crates/sprout-mcp/Cargo.toml @@ -34,6 +34,9 @@ serde_json = { workspace = true } # HTTP client (for relay REST API calls) reqwest = { workspace = true } +# TLS crypto provider (required for wss:// connections) +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } + # Utilities uuid = { workspace = true } tracing = { workspace = true } diff --git a/crates/sprout-mcp/src/main.rs b/crates/sprout-mcp/src/main.rs index 77bad25a5..a8729cd09 100644 --- a/crates/sprout-mcp/src/main.rs +++ b/crates/sprout-mcp/src/main.rs @@ -9,6 +9,8 @@ use sprout_mcp::toolsets::ToolsetConfig; #[tokio::main] async fn main() -> Result<()> { + // Install the ring crypto provider for rustls (required for wss:// connections). + let _ = rustls::crypto::ring::default_provider().install_default(); // Log to stderr — stdout is the MCP JSON-RPC channel. tracing_subscriber::fmt() .with_env_filter( diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 888270ed6..156c20248 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -4,7 +4,7 @@ use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ - admin_command, command_availability, discover_local_acp_providers, AcpProviderInfo, + command_availability, discover_local_acp_providers, AcpProviderInfo, DiscoverManagedAgentPrereqsRequest, ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND, }, @@ -33,10 +33,8 @@ pub fn discover_managed_agent_prereqs( .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(DEFAULT_MCP_COMMAND); - let admin_command = admin_command(); ManagedAgentPrereqsInfo { - admin: command_availability(&admin_command, Some(&app)), acp: command_availability(acp_command, Some(&app)), mcp: command_availability(mcp_command, Some(&app)), } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index f5d232b5a..6fd2cebc7 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,15 +6,14 @@ use crate::{ managed_agents::{ build_managed_agent_summary, default_token_scopes, find_managed_agent_mut, load_managed_agents, load_personas, managed_agent_avatar_url, managed_agent_log_path, - read_log_tail, save_managed_agents, - start_managed_agent_process, stop_managed_agent_process, sync_managed_agent_processes, - CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse, - ManagedAgentSummary, MintManagedAgentTokenRequest, MintManagedAgentTokenResponse, - DEFAULT_ACP_COMMAND, DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND, - DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, - DEFAULT_MCP_COMMAND, + mint_token_via_api, read_log_tail, save_managed_agents, start_managed_agent_process, + stop_managed_agent_process, sync_managed_agent_processes, CreateManagedAgentRequest, + CreateManagedAgentResponse, ManagedAgentLogResponse, ManagedAgentSummary, + MintManagedAgentTokenRequest, MintManagedAgentTokenResponse, DEFAULT_AGENT_ARG, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, + DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND, }, - relay::{mint_managed_agent_api_token, relay_ws_url, sync_managed_agent_profile}, + relay::{relay_ws_url, sync_managed_agent_profile}, util::now_iso, }; @@ -49,7 +48,7 @@ pub async fn create_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { - let name = input.name.trim(); + let name = input.name.trim().to_string(); if name.is_empty() { return Err("agent name is required".to_string()); } @@ -65,108 +64,9 @@ pub async fn create_managed_agent( } } - let resolved_relay_url = input - .relay_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(relay_ws_url); - let keys = Keys::generate(); - let pubkey = keys.public_key().to_hex(); - let private_key_nsec = keys - .secret_key() - .to_bech32() - .map_err(|error| format!("failed to encode private key: {error}"))?; - let token_scopes = if input.mint_token { - let requested = input - .token_scopes - .iter() - .map(|scope| scope.trim().to_string()) - .filter(|scope| !scope.is_empty()) - .collect::>(); - if requested.is_empty() { - default_token_scopes() - } else { - requested - } - } else { - Vec::new() - }; - let token_name = input - .token_name - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(name) - .to_string(); - let api_token = if input.mint_token { - Some( - mint_managed_agent_api_token( - &state.http_client, - &resolved_relay_url, - &keys, - &token_name, - &token_scopes, - ) - .await? - .token, - ) - } else { - None - }; - let mut agent_args = input - .agent_args - .into_iter() - .map(|arg| arg.trim().to_string()) - .filter(|arg| !arg.is_empty()) - .collect::>(); - if agent_args.is_empty() { - agent_args.push(DEFAULT_AGENT_ARG.to_string()); - } - let system_prompt = input - .system_prompt - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let model = input - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let acp_command = input - .acp_command - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(DEFAULT_ACP_COMMAND) - .to_string(); - let agent_command = input - .agent_command - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(DEFAULT_AGENT_COMMAND) - .to_string(); - let mcp_command = input - .mcp_command - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(DEFAULT_MCP_COMMAND) - .to_string(); - let turn_timeout_seconds = input - .turn_timeout_seconds - .filter(|seconds| *seconds > 0) - .unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS); - let parallelism = input - .parallelism - .filter(|count| (1..=32).contains(count)) - .unwrap_or(DEFAULT_AGENT_PARALLELISM); - - let (agent, spawn_error) = { + // ── Phase 1: generate keys and collect mint parameters (sync lock) ──────── + // We do NOT mint here — minting is async and must happen outside the lock. + let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, token_scopes, token_name, mint_token, input) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -186,25 +86,142 @@ pub async fn create_managed_agent( return Err(format!("persona {persona_id} not found")); } } + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); if records.iter().any(|record| record.pubkey == pubkey) { return Err(format!("agent {pubkey} already exists")); } + let private_key_nsec = keys + .secret_key() + .to_bech32() + .map_err(|error| format!("failed to encode private key: {error}"))?; - records.push(crate::managed_agents::ManagedAgentRecord { + let token_scopes = if input.mint_token { + let requested = input + .token_scopes + .iter() + .map(|scope| scope.trim().to_string()) + .filter(|scope| !scope.is_empty()) + .collect::>(); + if requested.is_empty() { + default_token_scopes() + } else { + requested + } + } else { + Vec::new() + }; + + let token_name = input + .token_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(name.as_str()) + .to_string(); + + let resolved_relay_url = input + .relay_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(relay_ws_url); + + let mint_token = input.mint_token; + (keys, private_key_nsec, pubkey, resolved_relay_url, token_scopes, token_name, mint_token, input) + }; + + // ── Phase 2: mint token via REST API (async, outside lock) ─────────────── + let api_token: Option = if mint_token { + let token = mint_token_via_api( + &state, + &agent_keys, + &resolved_relay_url, + &token_name, + &token_scopes, + ) + .await?; + Some(token) + } else { + None + }; + + // ── Phase 3: save record and optionally spawn (sync lock) ───────────────── + let (agent, spawn_error) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } + + // Guard against a duplicate pubkey appearing between phase 1 and phase 3 + // (extremely unlikely but safe to check). + if records.iter().any(|record| record.pubkey == pubkey) { + return Err(format!("agent {pubkey} already exists")); + } + let mut record = crate::managed_agents::ManagedAgentRecord { pubkey: pubkey.clone(), - name: name.to_string(), + name: name.clone(), persona_id: requested_persona_id.clone(), private_key_nsec: private_key_nsec.clone(), api_token: api_token.clone(), relay_url: resolved_relay_url.clone(), - acp_command, - agent_command, - agent_args, - mcp_command, - turn_timeout_seconds, - parallelism, - system_prompt, - model, + acp_command: input + .acp_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_ACP_COMMAND) + .to_string(), + agent_command: input + .agent_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_AGENT_COMMAND) + .to_string(), + agent_args: input + .agent_args + .into_iter() + .map(|arg| arg.trim().to_string()) + .filter(|arg| !arg.is_empty()) + .collect::>(), + mcp_command: input + .mcp_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_MCP_COMMAND) + .to_string(), + turn_timeout_seconds: input + .turn_timeout_seconds + .filter(|seconds| *seconds > 0) + .unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS), + parallelism: input + .parallelism + .filter(|count| (1..=32).contains(count)) + .unwrap_or(DEFAULT_AGENT_PARALLELISM), + system_prompt: input + .system_prompt + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + model: input + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), start_on_app_launch: input.start_on_app_launch, runtime_pid: None, created_at: now_iso(), @@ -213,7 +230,13 @@ pub async fn create_managed_agent( last_stopped_at: None, last_exit_code: None, last_error: None, - }); + }; + + if record.agent_args.is_empty() { + record.agent_args.push(DEFAULT_AGENT_ARG.to_string()); + } + + records.push(record); let mut spawn_error = None; if input.spawn_after_create { @@ -224,7 +247,6 @@ pub async fn create_managed_agent( spawn_error = Some(error); } } - save_managed_agents(&app, &records)?; let record = records @@ -233,9 +255,10 @@ pub async fn create_managed_agent( .ok_or_else(|| "created agent disappeared unexpectedly".to_string())?; let agent = build_managed_agent_summary(&app, record, &runtimes)?; - Ok::<_, String>((agent, spawn_error)) - }?; + (agent, spawn_error) + }; + // ── Phase 4: sync agent profile on relay (async, outside lock) ─────────── let avatar_url = input .avatar_url .as_deref() @@ -249,7 +272,7 @@ pub async fn create_managed_agent( &pubkey, api_token.as_deref(), &token_scopes, - name, + &name, avatar_url.as_deref(), ) .await @@ -291,7 +314,6 @@ pub fn start_managed_agent( let record = find_managed_agent_mut(&mut records, &pubkey)?; start_managed_agent_process(&app, record, &mut runtimes)?; } - save_managed_agents(&app, &records)?; let record = records .iter() @@ -324,7 +346,6 @@ pub fn stop_managed_agent( let record = find_managed_agent_mut(&mut records, &pubkey)?; stop_managed_agent_process(record, &mut runtimes)?; } - save_managed_agents(&app, &records)?; let record = records .iter() @@ -352,17 +373,14 @@ pub fn delete_managed_agent( if sync_managed_agent_processes(&mut records, &mut runtimes) { save_managed_agents(&app, &records)?; } - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { stop_managed_agent_process(record, &mut runtimes)?; } - let initial_len = records.len(); records.retain(|record| record.pubkey != pubkey); if records.len() == initial_len { return Err(format!("agent {pubkey} not found")); } - save_managed_agents(&app, &records) } @@ -372,18 +390,8 @@ pub async fn mint_managed_agent_token( app: AppHandle, state: State<'_, AppState>, ) -> Result { - let scopes = input - .scopes - .into_iter() - .map(|scope| scope.trim().to_string()) - .filter(|scope| !scope.is_empty()) - .collect::>(); - let scopes = if scopes.is_empty() { - default_token_scopes() - } else { - scopes - }; - let (pubkey, relay_url, private_key_nsec, token_name) = { + // ── Phase 1: load agent record and collect mint parameters (sync lock) ──── + let (agent_keys, relay_url, scopes, token_name) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -397,62 +405,74 @@ pub async fn mint_managed_agent_token( if sync_managed_agent_processes(&mut records, &mut runtimes) { save_managed_agents(&app, &records)?; } - let record = find_managed_agent_mut(&mut records, &input.pubkey)?; - ( - record.pubkey.clone(), - record.relay_url.clone(), - record.private_key_nsec.clone(), - input - .token_name - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("{}-token", record.name)), - ) + + let scopes = { + let requested = input + .scopes + .into_iter() + .map(|scope| scope.trim().to_string()) + .filter(|scope| !scope.is_empty()) + .collect::>(); + if requested.is_empty() { + default_token_scopes() + } else { + requested + } + }; + + let token_name = input + .token_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{}-token", record.name)); + + // Reconstruct the agent's keypair from the stored nsec so we can sign + // the NIP-98 auth event as the agent (not the desktop user). + let agent_keys = Keys::parse(&record.private_key_nsec) + .map_err(|e| format!("failed to parse agent secret key: {e}"))?; + + (agent_keys, record.relay_url.clone(), scopes, token_name) }; - let keys = Keys::parse(private_key_nsec.trim()) - .map_err(|error| format!("failed to parse managed agent private key: {error}"))?; - let minted = mint_managed_agent_api_token( - &state.http_client, - &relay_url, - &keys, - &token_name, - &scopes, - ) - .await?; - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|error| error.to_string())?; + // ── Phase 2: mint token via REST API (async, outside lock) ─────────────── + let minted_token = mint_token_via_api(&state, &agent_keys, &relay_url, &token_name, &scopes).await?; + + // ── Phase 3: persist new token to agent record (sync lock) ─────────────── + let (agent, api_token) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } + let record = find_managed_agent_mut(&mut records, &input.pubkey)?; + record.api_token = Some(minted_token.clone()); + record.updated_at = now_iso(); + record.last_error = None; + let pubkey = record.pubkey.clone(); - if sync_managed_agent_processes(&mut records, &mut runtimes) { save_managed_agents(&app, &records)?; - } - let record = find_managed_agent_mut(&mut records, &pubkey)?; - record.api_token = Some(minted.token.clone()); - record.updated_at = now_iso(); - record.last_error = None; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + let agent = build_managed_agent_summary(&app, record, &runtimes)?; - save_managed_agents(&app, &records)?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - let agent = build_managed_agent_summary(&app, record, &runtimes)?; + (agent, minted_token) + }; - Ok(MintManagedAgentTokenResponse { - agent, - token: minted.token, - }) + Ok(MintManagedAgentTokenResponse { agent, token: api_token }) } #[tauri::command] diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 7a6e048ee..ceccb5638 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1,11 +1,18 @@ -use std::{ - path::{Path, PathBuf}, - process::Command, -}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use reqwest::Method; +use sha2::{Digest, Sha256}; use tauri::AppHandle; -use crate::managed_agents::{AcpProviderInfo, CommandAvailabilityInfo, DEFAULT_AGENT_ARG}; +use crate::{ + app_state::AppState, + managed_agents::{AcpProviderInfo, CommandAvailabilityInfo, DEFAULT_AGENT_ARG}, + models::MintTokenBody, + relay::{relay_http_base_url, send_json_request}, +}; struct KnownAcpProvider { id: &'static str, @@ -263,10 +270,6 @@ pub fn managed_agent_avatar_url(command: &str) -> Option { Some(provider.avatar_url.to_string()) } -pub fn admin_command() -> String { - std::env::var("SPROUT_ADMIN_COMMAND").unwrap_or_else(|_| "sprout-admin".to_string()) -} - pub fn default_token_scopes() -> Vec { vec![ "messages:read".to_string(), @@ -275,6 +278,59 @@ pub fn default_token_scopes() -> Vec { ] } +/// Mint an API token for `agent_keys` by signing a NIP-98 auth event with the +/// agent's own keypair and posting to `POST /api/tokens` on the relay. +/// +/// The relay mints the token for the signing pubkey, so using the agent's keys +/// here ensures the token is bound to the agent's identity — not the desktop +/// user's. No `sprout-admin` binary or database access is required. +pub async fn mint_token_via_api( + state: &AppState, + agent_keys: &Keys, + relay_url: &str, + name: &str, + scopes: &[String], +) -> Result { + let http_base = relay_http_base_url(relay_url); + let url = format!("{http_base}/api/tokens"); + + let body = MintTokenBody { + name, + scopes, + channel_ids: None, + expires_in_days: None, + }; + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("serialize mint body failed: {e}"))?; + + // Build NIP-98 auth header signed by the AGENT's keys (not the desktop user's). + let payload_hash = format!("{:x}", Sha256::digest(&body_bytes)); + let forwarded_proto = if url.starts_with("http://") { "http" } else { "https" }; + let tags = vec![ + Tag::parse(vec!["u", &url]).map_err(|e| format!("url tag failed: {e}"))?, + Tag::parse(vec!["method", "POST"]).map_err(|e| format!("method tag failed: {e}"))?, + Tag::parse(vec!["payload", &payload_hash]) + .map_err(|e| format!("payload tag failed: {e}"))?, + ]; + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(agent_keys) + .map_err(|e| format!("sign failed: {e}"))?; + let auth_header = format!("Nostr {}", BASE64.encode(event.as_json().as_bytes())); + + let request = state + .http_client + .request(Method::POST, &url) + .header("Authorization", auth_header) + .header("Content-Type", "application/json") + .header("X-Forwarded-Proto", forwarded_proto) + .body(body_bytes); + + let response: crate::models::MintTokenResponse = send_json_request(request).await?; + + Ok(response.token) +} + #[cfg(test)] mod tests { use super::{ diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 65e8965c9..223da10df 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -191,7 +191,6 @@ pub struct DiscoverManagedAgentPrereqsRequest { #[derive(Debug, Clone, Serialize)] pub struct ManagedAgentPrereqsInfo { - pub admin: CommandAvailabilityInfo, pub acp: CommandAvailabilityInfo, pub mcp: CommandAvailabilityInfo, }