feat: support remote relay deployments (no sprout-admin CLI required) (#123)

Co-authored-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-03-19 21:13:20 -04:00
committed by GitHub
co-authored by Wes
parent 1db0da585d
commit 94203d730c
9 changed files with 289 additions and 204 deletions
Generated
+2
View File
@@ -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",
+1
View File
@@ -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)
+4
View File
@@ -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.
+3
View File
@@ -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 }
+2
View File
@@ -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(
@@ -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)),
}
+211 -191
View File
@@ -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<CreateManagedAgentResponse, String> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<String> = 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::<Vec<_>>(),
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<MintManagedAgentTokenResponse, String> {
let scopes = input
.scopes
.into_iter()
.map(|scope| scope.trim().to_string())
.filter(|scope| !scope.is_empty())
.collect::<Vec<_>>();
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::<Vec<_>>();
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]
@@ -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<String> {
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<String> {
vec![
"messages:read".to_string(),
@@ -275,6 +278,59 @@ pub fn default_token_scopes() -> Vec<String> {
]
}
/// 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<String, String> {
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::{
@@ -191,7 +191,6 @@ pub struct DiscoverManagedAgentPrereqsRequest {
#[derive(Debug, Clone, Serialize)]
pub struct ManagedAgentPrereqsInfo {
pub admin: CommandAvailabilityInfo,
pub acp: CommandAvailabilityInfo,
pub mcp: CommandAvailabilityInfo,
}