mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user