From 168c1ca8de1b77016a2474b35f9f673d54426eb0 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:32:55 -0400 Subject: [PATCH] feat: remote agent backend providers (#134) --- Cargo.lock | 20 +- crates/sprout-acp/src/main.rs | 74 +++ crates/sprout-admin/src/main.rs | 55 +- crates/sprout-core/src/kind.rs | 4 + crates/sprout-db/src/channel.rs | 29 +- crates/sprout-db/src/lib.rs | 5 +- crates/sprout-db/src/user.rs | 78 ++- crates/sprout-relay/src/api/agents.rs | 17 +- crates/sprout-relay/src/api/tokens.rs | 124 ++++- crates/sprout-relay/src/api/users.rs | 32 +- deny.toml | 3 + desktop/scripts/check-file-sizes.mjs | 6 +- desktop/src-tauri/src/app_state.rs | 1 + desktop/src-tauri/src/commands/agents.rs | 367 ++++++++++-- desktop/src-tauri/src/commands/tokens.rs | 1 + desktop/src-tauri/src/lib.rs | 11 +- .../src-tauri/src/managed_agents/backend.rs | 522 ++++++++++++++++++ .../src-tauri/src/managed_agents/discovery.rs | 3 + desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../src-tauri/src/managed_agents/runtime.rs | 72 ++- desktop/src-tauri/src/managed_agents/types.rs | 26 + desktop/src-tauri/src/models.rs | 4 + desktop/src/features/agents/channelAgents.ts | 21 +- desktop/src/features/agents/hooks.ts | 21 +- .../agents/ui/AddAgentToChannelDialog.tsx | 6 +- desktop/src/features/agents/ui/AgentsView.tsx | 115 +++- .../features/agents/ui/CreateAgentDialog.tsx | 384 +++++++++++-- .../agents/ui/CreateAgentDialogSections.tsx | 26 +- .../agents/ui/ManagedAgentsSection.tsx | 92 ++- .../src/features/agents/ui/ModelPicker.tsx | 2 +- .../features/agents/ui/SecretRevealDialog.tsx | 6 +- desktop/src/shared/api/tauri.ts | 36 +- desktop/src/shared/api/types.ts | 23 +- desktop/src/testing/e2eBridge.ts | 31 +- 34 files changed, 2006 insertions(+), 213 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/backend.rs diff --git a/Cargo.lock b/Cargo.lock index e304d2308..3c0e58ac1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -496,9 +496,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -1742,9 +1742,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" @@ -3979,9 +3979,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -5133,18 +5133,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs index cb43ad5a6..a4b5afac8 100644 --- a/crates/sprout-acp/src/main.rs +++ b/crates/sprout-acp/src/main.rs @@ -123,6 +123,32 @@ async fn main() -> Result<()> { } } + // ── Step 2d: Query agent owner (with retry) ───────────────────────────── + // Owner lookup is critical for !shutdown — a transient failure here would + // permanently disable remote shutdown for this process. Retry a few times + // with backoff so a brief relay hiccup doesn't leave us uncontrollable. + // Owner lookup: try at startup, but if it fails the shutdown handler will + // retry lazily when a candidate !shutdown message arrives. This means a + // relay outage during startup doesn't permanently disable remote shutdown. + let mut owner_pubkey: Option = { + let profile_url = format!("/api/users/{pubkey_hex}/profile"); + match rest_client_for_presence.get_json(&profile_url).await { + Ok(v) => v + .get("agent_owner_pubkey") + .and_then(|v| v.as_str()) + .map(String::from), + Err(e) => { + tracing::warn!("startup owner lookup failed (will retry lazily): {e}"); + None + } + } + }; + if let Some(ref owner) = owner_pubkey { + tracing::info!("agent owner: {owner}"); + } else { + tracing::info!("no agent owner set at startup — will resolve lazily on !shutdown"); + } + // ── Step 3: Discover channels and build subscription rules ──────────────── let channel_info_map = relay .discover_channels() @@ -432,6 +458,54 @@ async fn main() -> Result<()> { tracing::debug!(channel_id = %sprout_event.channel_id, "dropping self-authored event"); continue; } + + // ── Shutdown command handling ───────────────────── + // Check: kind:9, content "!shutdown", from owner, mentions THIS agent. + let is_shutdown = kind_u32 == KIND_STREAM_MESSAGE + && sprout_event.event.content.trim() == "!shutdown" + && sprout_event.event.tags.iter().any(|t| { + t.as_slice().first().map(|s| s.as_str()) == Some("p") + && t.as_slice().get(1).map(|s| s.as_str()) == Some(pubkey_hex.as_str()) + }); + if is_shutdown { + // Lazy owner resolution: if we don't have the owner + // yet (startup lookup failed), try now. This ensures + // a relay outage during startup doesn't permanently + // disable remote shutdown. + if owner_pubkey.is_none() { + let profile_url = format!("/api/users/{pubkey_hex}/profile"); + match rest_client_for_presence.get_json(&profile_url).await { + Ok(v) => { + owner_pubkey = v + .get("agent_owner_pubkey") + .and_then(|v| v.as_str()) + .map(String::from); + if let Some(ref o) = owner_pubkey { + tracing::info!("lazy owner resolution succeeded: {o}"); + } + } + Err(e) => { + tracing::warn!("lazy owner lookup failed: {e}"); + } + } + } + if let Some(ref owner) = owner_pubkey { + if sprout_event.event.pubkey.to_hex() == *owner { + tracing::info!( + channel_id = %sprout_event.channel_id, + sender = %sprout_event.event.pubkey.to_hex(), + "shutdown command from owner — exiting gracefully" + ); + let _ = shutdown_tx.send(()); + continue; + } + } + // Not from owner — fall through to normal prompt handling. + // Don't drop it — it's a regular message that happens to + // contain "!shutdown" from a non-owner. + } + // ── End shutdown command handling ────────────────── + let matched = filter::match_event(&sprout_event.event, sprout_event.channel_id, &rules, &pubkey_hex).await; let prompt_tag = match matched { Some(m) => m.prompt_tag, diff --git a/crates/sprout-admin/src/main.rs b/crates/sprout-admin/src/main.rs index 8bc36dd5f..7d983e286 100644 --- a/crates/sprout-admin/src/main.rs +++ b/crates/sprout-admin/src/main.rs @@ -88,18 +88,63 @@ async fn mint_token( let pubkey_bytes = pubkey.serialize().to_vec(); - db.ensure_user(&pubkey_bytes).await?; + // ── Enforce shutdown-required scopes (before any DB writes) ───────────── + // Two triggers, same as the relay path: + // 1. Explicit --owner-pubkey (bootstrap mint) + // 2. Agent already has an owner in the DB (re-mint must preserve controllability) + // Fail closed: DB lookup error → assume owned → enforce scopes. + let has_existing_owner = match db.get_agent_channel_policy(&pubkey_bytes).await { + Ok(Some((_, Some(_)))) => true, + Ok(_) => false, + Err(e) => { + eprintln!("warning: owner lookup failed (assuming owned): {e}"); + true // fail closed + } + }; + if owner_pubkey.is_some() || has_existing_owner { + let required = [ + "users:read", + "messages:read", + "messages:write", + "channels:read", + ]; + for r in &required { + if !scopes.iter().any(|s| s == r) { + anyhow::bail!("owned agents require the '{r}' scope for agent controllability"); + } + } + } - // Set agent owner if --owner-pubkey was provided - if let Some(ref owner_hex) = owner_pubkey { + // ── Validate owner_pubkey (before any DB writes) ───────────────────────── + let validated_owner = if let Some(ref owner_hex) = owner_pubkey { let owner_bytes = hex::decode(owner_hex).map_err(|e| anyhow::anyhow!("invalid owner pubkey hex: {e}"))?; if owner_bytes.len() != 32 { anyhow::bail!("owner pubkey must be 32 bytes (64 hex chars)"); } - // Ensure owner's user row exists (FK constraint requires it) + Some(owner_bytes) + } else { + None + }; + + // ── DB writes (all validation passed) ──────────────────────────────────── + db.ensure_user(&pubkey_bytes).await?; + + if let Some(owner_bytes) = validated_owner { db.ensure_user(&owner_bytes).await?; - db.set_agent_owner(&pubkey_bytes, &owner_bytes).await?; + let was_set = db.set_agent_owner(&pubkey_bytes, &owner_bytes).await?; + if !was_set { + let existing = db + .get_agent_channel_policy(&pubkey_bytes) + .await? + .and_then(|(_, owner)| owner); + if existing.as_deref() != Some(owner_bytes.as_slice()) { + anyhow::bail!( + "agent already has a different owner — refusing to mint token for non-owner" + ); + } + eprintln!("note: agent already owned by the requested pubkey — proceeding"); + } } let raw_token = generate_token(); diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index 93705c8f5..25bb78423 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -71,6 +71,10 @@ pub const KIND_TYPING_INDICATOR: u32 = 20002; // Stream messaging /// NIP-29 group chat message kind. V1 used kind:10001 (replaceable range — wrong), then 40001. +/// +/// Agent shutdown convention: the agent's owner sends a kind:9 message with content +/// `"!shutdown"` and a `#p` tag mentioning the agent. The harness exits gracefully. +/// This is a convention, not a new event kind — uses regular stream messages. pub const KIND_STREAM_MESSAGE: u32 = 9; /// V1 used kind:10002 (replaceable range — wrong). pub const KIND_STREAM_MESSAGE_V2: u32 = 40002; diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs index cfb4eda41..798b96377 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -646,6 +646,15 @@ async fn get_channel_tx( row_to_channel_record(row) } +/// A channel entry returned as part of a bot member record. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct BotChannelEntry { + /// Channel display name. + pub name: String, + /// Channel UUID (as string from the DB). + pub id: String, +} + /// Bot member record — a user with role=bot, with their channel memberships aggregated. #[derive(Debug, Clone)] pub struct BotMemberRecord { @@ -657,8 +666,8 @@ pub struct BotMemberRecord { pub agent_type: Option, /// Optional JSON capabilities descriptor. pub capabilities: Option, - /// Comma-separated channel names (from string_agg). - pub channel_names: String, + /// Channel entries with both name and UUID, from json_agg. + pub channels: Vec, } /// User record for bulk lookup. @@ -747,15 +756,16 @@ pub async fn get_accessible_channels( .collect() } -/// Returns all bot-role members with their aggregated channel names. +/// Returns all bot-role members with their channel memberships. /// -/// Channel names are returned as a comma-separated string from string_agg. +/// Channels are returned as a JSON array of `{name, id}` objects via `json_agg`, +/// preserving the 1:1 name↔UUID pairing. No separate string_agg ordering issues. /// Members with no active channel memberships are excluded (INNER JOIN on channels). pub async fn get_bot_members(pool: &PgPool) -> Result> { let rows = sqlx::query( r#" SELECT cm.pubkey, u.display_name, u.agent_type, u.capabilities, - string_agg(DISTINCT c.name, ',' ORDER BY c.name) AS channel_names + COALESCE(json_agg(DISTINCT jsonb_build_object('name', c.name, 'id', c.id::text)), '[]') AS channels_json FROM channel_members cm LEFT JOIN users u ON cm.pubkey = u.pubkey JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL @@ -770,14 +780,17 @@ pub async fn get_bot_members(pool: &PgPool) -> Result> { let mut out = Vec::with_capacity(rows.len()); for row in rows { let capabilities: Option = row.try_get("capabilities")?; + let channels_json: serde_json::Value = row + .try_get::("channels_json") + .unwrap_or(serde_json::Value::Array(vec![])); + let channels: Vec = + serde_json::from_value(channels_json).unwrap_or_default(); out.push(BotMemberRecord { pubkey: row.try_get("pubkey")?, display_name: row.try_get("display_name")?, agent_type: row.try_get("agent_type")?, capabilities, - channel_names: row - .try_get::, _>("channel_names")? - .unwrap_or_default(), + channels, }); } Ok(out) diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index 0e579de65..ca01f76f2 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -491,8 +491,9 @@ impl Db { user::search_users(&self.pool, query, limit).await } - /// Set the owner pubkey for an agent user. - pub async fn set_agent_owner(&self, agent_pubkey: &[u8], owner_pubkey: &[u8]) -> Result<()> { + /// Atomically set agent owner — only if no owner is currently assigned. + /// Returns Ok(true) if set, Ok(false) if an owner already exists. + pub async fn set_agent_owner(&self, agent_pubkey: &[u8], owner_pubkey: &[u8]) -> Result { user::set_agent_owner(&self.pool, agent_pubkey, owner_pubkey).await } diff --git a/crates/sprout-db/src/user.rs b/crates/sprout-db/src/user.rs index cb8bce4fc..d28a3ba5a 100644 --- a/crates/sprout-db/src/user.rs +++ b/crates/sprout-db/src/user.rs @@ -251,22 +251,42 @@ pub async fn search_users( /// Set the owner pubkey for an agent user. /// The owner pubkey must already exist in the users table (FK constraint). /// Returns an error if the agent pubkey is not found (rows_affected == 0). +/// Atomically set agent owner — only if no owner is currently assigned. +/// +/// Returns Ok(true) if ownership was set, Ok(false) if an owner already exists +/// (caller should check whether the existing owner matches). Returns Err if the +/// agent pubkey doesn't exist in the users table. pub async fn set_agent_owner( pool: &PgPool, agent_pubkey: &[u8], owner_pubkey: &[u8], -) -> Result<()> { - let result = sqlx::query(r#"UPDATE users SET agent_owner_pubkey = $1 WHERE pubkey = $2"#) - .bind(owner_pubkey) - .bind(agent_pubkey) - .execute(pool) - .await?; +) -> Result { + // Conditional UPDATE: only set owner if currently NULL. This makes + // "first mint wins" atomic — no TOCTOU race between concurrent mints. + let result = sqlx::query( + r#"UPDATE users SET agent_owner_pubkey = $1 WHERE pubkey = $2 AND agent_owner_pubkey IS NULL"#, + ) + .bind(owner_pubkey) + .bind(agent_pubkey) + .execute(pool) + .await?; + if result.rows_affected() == 0 { - return Err(crate::error::DbError::NotFound( - "agent pubkey not found in users table".into(), - )); + // Could be: (a) pubkey not found, or (b) owner already set. + // Check which case by querying the row. + let exists = sqlx::query(r#"SELECT 1 FROM users WHERE pubkey = $1"#) + .bind(agent_pubkey) + .fetch_optional(pool) + .await?; + if exists.is_none() { + return Err(crate::error::DbError::NotFound( + "agent pubkey not found in users table".into(), + )); + } + // Row exists but owner already set — return false (not an error). + return Ok(false); } - Ok(()) + Ok(true) } /// Get the channel_add_policy and agent_owner_pubkey for a user. @@ -350,9 +370,10 @@ mod tests { .await .expect("ensure owner"); - set_agent_owner(&db.pool, &agent_pk, &owner_pk) + let was_set = set_agent_owner(&db.pool, &agent_pk, &owner_pk) .await .expect("set_agent_owner"); + assert!(was_set, "first set_agent_owner should return true"); let result = get_agent_channel_policy(&db.pool, &agent_pk) .await @@ -425,7 +446,7 @@ mod tests { } /// set_agent_owner should return Err when the agent pubkey does not exist - /// in the users table (0 rows affected -> NotFound). + /// in the users table. #[tokio::test] #[ignore = "requires Postgres"] async fn test_set_agent_owner_nonexistent_agent() { @@ -445,6 +466,39 @@ mod tests { ); } + /// set_agent_owner should return Ok(false) when the agent already has an owner. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_set_agent_owner_already_owned() { + let db = setup_db().await; + let agent_pk = random_pubkey(); + let owner1 = random_pubkey(); + let owner2 = random_pubkey(); + + ensure_user(&db.pool, &agent_pk) + .await + .expect("ensure agent"); + ensure_user(&db.pool, &owner1).await.expect("ensure owner1"); + ensure_user(&db.pool, &owner2).await.expect("ensure owner2"); + + let first = set_agent_owner(&db.pool, &agent_pk, &owner1) + .await + .expect("first set"); + assert!(first, "first set should succeed"); + + let second = set_agent_owner(&db.pool, &agent_pk, &owner2) + .await + .expect("second set should not error"); + assert!(!second, "second set should return false (already owned)"); + + // Verify original owner is preserved. + let (_, owner) = get_agent_channel_policy(&db.pool, &agent_pk) + .await + .expect("get policy") + .expect("should be Some"); + assert_eq!(owner, Some(owner1), "original owner should be preserved"); + } + /// set_channel_add_policy should return Err when the pubkey does not exist /// in the users table (0 rows affected -> NotFound). #[tokio::test] diff --git a/crates/sprout-relay/src/api/agents.rs b/crates/sprout-relay/src/api/agents.rs index 3221d3dcb..3bbe7c8ca 100644 --- a/crates/sprout-relay/src/api/agents.rs +++ b/crates/sprout-relay/src/api/agents.rs @@ -36,9 +36,9 @@ pub async fn agents_handler( tracing::error!("agents: failed to load accessible channels: {e}"); internal_error("presence lookup failed") })?; - let accessible_names: std::collections::HashSet = accessible_channels + let accessible_ids: std::collections::HashSet = accessible_channels .iter() - .map(|ac| ac.channel.name.clone()) + .map(|ac| ac.channel.id.to_string()) .collect(); let bots = state @@ -97,12 +97,14 @@ pub async fn agents_handler( format!("agent-{}", &hex[..end]) }); - let channels: Vec<&str> = bot - .channel_names - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty() && accessible_names.contains(*s)) + // Filter by accessible channel IDs — each entry has a paired name+UUID. + let visible: Vec<&sprout_db::channel::BotChannelEntry> = bot + .channels + .iter() + .filter(|entry| accessible_ids.contains(&entry.id)) .collect(); + let channels: Vec<&str> = visible.iter().map(|e| e.name.as_str()).collect(); + let channel_ids: Vec<&str> = visible.iter().map(|e| e.id.as_str()).collect(); let capabilities: Vec = bot .capabilities @@ -126,6 +128,7 @@ pub async fn agents_handler( "name": name, "agent_type": bot.agent_type.clone().unwrap_or_default(), "channels": channels, + "channel_ids": channel_ids, "capabilities": capabilities, "status": status, })); diff --git a/crates/sprout-relay/src/api/tokens.rs b/crates/sprout-relay/src/api/tokens.rs index af52bd288..ee4bf82b3 100644 --- a/crates/sprout-relay/src/api/tokens.rs +++ b/crates/sprout-relay/src/api/tokens.rs @@ -128,6 +128,11 @@ pub struct MintTokenRequest { pub channel_ids: Option>, /// Optional expiry in days (1–365). Omit for no expiry. pub expires_in_days: Option, + /// Optional owner pubkey (hex). Only accepted via NIP-98 auth (bootstrap mint). + /// Sets `agent_owner_pubkey` on the agent's user record. This proves the caller + /// holds the agent's private key and is designating another pubkey as the owner. + /// Rejected if auth is Bearer (child token minting cannot reassign ownership). + pub owner_pubkey: Option, } /// Response body for `POST /api/tokens` (token shown once only). @@ -480,6 +485,27 @@ pub async fn post_tokens( None }; + // ── Validate owner_pubkey (before token insert) ───────────────────────── + let validated_owner_bytes: Option> = if let Some(ref owner_hex) = req.owner_pubkey { + if ctx.auth_method != super::RestAuthMethod::Nip98 { + return Err(api_error( + StatusCode::FORBIDDEN, + "owner_pubkey can only be set via NIP-98 auth (bootstrap mint)", + )); + } + let bytes = nostr::util::hex::decode(owner_hex) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid owner_pubkey hex"))?; + if bytes.len() != 32 { + return Err(api_error( + StatusCode::BAD_REQUEST, + "owner_pubkey must be 32 bytes (64 hex chars)", + )); + } + Some(bytes) + } else { + None + }; + // ── Validate expires_in_days ────────────────────────────────────────────── if let Some(days) = req.expires_in_days { if days == 0 || days > 365 { @@ -494,7 +520,89 @@ pub async fn post_tokens( .expires_in_days .map(|days| Utc::now() + chrono::Duration::days(days as i64)); - // ── Generate token ──────────────────────────────────────────────────────── + // ── Set agent owner BEFORE token creation ──────────────────────────────── + // Ownership must be settled before the token is inserted. This eliminates + // the orphaned-token failure mode: if ownership fails, no token exists to + // revoke. set_agent_owner is atomic (UPDATE ... WHERE agent_owner_pubkey + // IS NULL) — concurrent bootstrap mints are serialized by the DB. + let owner_bytes = validated_owner_bytes.unwrap_or_else(|| ctx.pubkey_bytes.clone()); + + // ── Enforce shutdown-required scopes ───────────────────────────────────── + // Check BEFORE any side effects (ownership, token creation). Two triggers: + // 1. Explicit owner_pubkey in request (bootstrap mint) + // 2. Agent already has an owner in the DB (re-mint must preserve controllability) + // Fail closed: if the DB lookup errors, assume owned and enforce scopes. + // A transient DB error must not open a bypass for stripping shutdown scopes. + let has_existing_owner = match state.db.get_agent_channel_policy(&ctx.pubkey_bytes).await { + Ok(Some((_, Some(_)))) => true, + Ok(_) => false, + Err(e) => { + tracing::warn!("owner lookup failed (assuming owned, enforcing scopes): {e}"); + true // fail closed + } + }; + let needs_scope_check = req.owner_pubkey.is_some() || has_existing_owner; + if needs_scope_check { + let required = [ + "users:read", + "messages:read", + "messages:write", + "channels:read", + ]; + let scope_strs: Vec = parsed_scopes.iter().map(|s| s.to_string()).collect(); + for r in &required { + if !scope_strs.iter().any(|s| s == r) { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!("owned agents require the '{r}' scope for controllability"), + )); + } + } + } + + // ── Set agent owner (only when explicitly requested) ───────────────────── + // Self-mints without owner_pubkey do NOT assign ownership. Only bootstrap + // mints with an explicit owner_pubkey write the ownership relationship. + // This preserves the semantics that omitting owner_pubkey means "don't + // set agent owner" — important because self-ownership would force + // controllability scopes on all future re-mints. + if req.owner_pubkey.is_some() { + state + .db + .ensure_user(&owner_bytes) + .await + .map_err(|e| internal_error(&format!("ensure_user for owner failed: {e}")))?; + + match state + .db + .set_agent_owner(&ctx.pubkey_bytes, &owner_bytes) + .await + { + Ok(true) => { + tracing::debug!("agent owner set successfully"); + } + Ok(false) => { + let existing = state + .db + .get_agent_channel_policy(&ctx.pubkey_bytes) + .await + .map_err(|e| internal_error(&format!("db error checking owner: {e}")))? + .and_then(|(_, owner)| owner); + if existing.as_deref() != Some(owner_bytes.as_slice()) { + return Err(api_error( + StatusCode::CONFLICT, + "agent already has a different owner", + )); + } + tracing::debug!("agent already owned by the requested pubkey — no change needed"); + } + Err(e) => { + return Err(internal_error(&format!("failed to set agent owner: {e}"))); + } + } + } + + // ── Generate token (after scope validation + ownership settled) ─────────── let raw_token = sprout_auth::generate_token(); let token_hash: Vec = Sha256::digest(raw_token.as_bytes()).to_vec(); let scope_strings: Vec = parsed_scopes.iter().map(|s| s.to_string()).collect(); @@ -527,20 +635,6 @@ pub async fn post_tokens( } }; - // ── Set agent owner ───────────────────────────────────────────────────── - // Self-minted tokens always set the caller as the agent owner. This is the - // same field that `sprout-admin mint-token --owner-pubkey` sets, ensuring - // self-minted agents have the same ownership semantics as admin-minted ones. - if let Err(e) = state - .db - .set_agent_owner(&ctx.pubkey_bytes, &ctx.pubkey_bytes) - .await - { - tracing::warn!("set_agent_owner failed for self-mint: {e}"); - // Non-fatal — token was already created. Owner field is a convenience, - // not a security gate. Log and continue. - } - // ── Build response ──────────────────────────────────────────────────────── let channel_ids_response: Vec = validated_channel_ids .as_deref() diff --git a/crates/sprout-relay/src/api/users.rs b/crates/sprout-relay/src/api/users.rs index fcc676e16..431f21cfd 100644 --- a/crates/sprout-relay/src/api/users.rs +++ b/crates/sprout-relay/src/api/users.rs @@ -122,13 +122,23 @@ pub async fn get_profile( .map_err(|e| internal_error(&format!("db error: {e}")))?; match profile { - Some(p) => Ok(Json(serde_json::json!({ - "pubkey": nostr_hex::encode(&p.pubkey), - "display_name": p.display_name, - "avatar_url": p.avatar_url, - "about": p.about, - "nip05_handle": p.nip05_handle, - }))), + Some(p) => { + let (_, owner_pk) = state + .db + .get_agent_channel_policy(&pubkey_bytes) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))? + .unwrap_or_else(|| ("anyone".to_string(), None)); + + Ok(Json(serde_json::json!({ + "pubkey": nostr_hex::encode(&p.pubkey), + "display_name": p.display_name, + "avatar_url": p.avatar_url, + "about": p.about, + "nip05_handle": p.nip05_handle, + "agent_owner_pubkey": owner_pk.map(|b| nostr_hex::encode(&b)), + }))) + } None => Err(api_error(StatusCode::NOT_FOUND, "user not found")), } } @@ -158,12 +168,20 @@ pub async fn get_user_profile( .map_err(|e| internal_error(&format!("db error: {e}")))? .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "user not found"))?; + let (_, owner_pk) = state + .db + .get_agent_channel_policy(&pubkey_bytes) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))? + .unwrap_or_else(|| ("anyone".to_string(), None)); + Ok(Json(serde_json::json!({ "pubkey": nostr_hex::encode(&profile.pubkey), "display_name": profile.display_name, "avatar_url": profile.avatar_url, "about": profile.about, "nip05_handle": profile.nip05_handle, + "agent_owner_pubkey": owner_pk.map(|b| nostr_hex::encode(&b)), }))) } diff --git a/deny.toml b/deny.toml index 7a2489fee..5735f2e3a 100644 --- a/deny.toml +++ b/deny.toml @@ -3,6 +3,9 @@ ignore = [ # instant 0.1.13 — unmaintained crate. Transitive dep: nostr → instant. # Will be resolved when nostr crate updates its dependencies. { id = "RUSTSEC-2024-0384", reason = "transitive dep via nostr; no upstream fix available" }, + # rustls-webpki 0.101.7 — CRL distribution point matching bug. Transitive dep pinned by + # old rustls 0.21 stack (via rust-s3). 0.103 branch updated; 0.101 has no fix available. + { id = "RUSTSEC-2026-0049", reason = "transitive dep via rust-s3 → old rustls stack; 0.101 branch has no fix" }, # rustls-pemfile 1.0.4 — unmaintained crate. Transitive dep: rust-s3 → rustls-native-certs 0.6 → rustls-pemfile. # The tokio-rustls-tls feature of rust-s3 pins an old rustls 0.21 stack internally. # Not a security vulnerability — just an unmaintained notice. No fix until rust-s3 updates. diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index c1354956e..6fdf65212 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -40,7 +40,11 @@ const overrides = new Map([ ["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], ["src/shared/api/relayClientSession.ts", 725], // durable websocket session manager with reconnect/replay/recovery state - ["src/shared/api/tauri.ts", 1025], // canvas API functions + ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions + ["src-tauri/src/commands/agents.rs", 820], // remote agent lifecycle routing (local + provider branches) + scope enforcement + ["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests + ["src/features/agents/ui/AgentsView.tsx", 730], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona import + ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes ]); async function walkFiles(directory) { diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index f8c773d83..81ebc01d8 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -55,6 +55,7 @@ pub fn build_app_state() -> AppState { session_token: Mutex::new(None), managed_agents_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), + } } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 6fd2cebc7..c277e1aee 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -4,11 +4,13 @@ use tauri::{AppHandle, State}; use crate::{ app_state::AppState, 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, - 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, + build_managed_agent_summary, default_token_scopes, discover_provider_candidates, + find_managed_agent_mut, invoke_provider, load_managed_agents, load_personas, + managed_agent_avatar_url, managed_agent_log_path, mint_token_via_api, provider_deploy, + read_log_tail, resolve_provider_binary, save_managed_agents, start_managed_agent_process, + stop_managed_agent_process, sync_managed_agent_processes, validate_provider_config, + BackendKind, BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse, + ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, MintManagedAgentTokenRequest, MintManagedAgentTokenResponse, DEFAULT_AGENT_ARG, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND, @@ -17,6 +19,90 @@ use crate::{ util::now_iso, }; +/// Build the standard agent JSON payload for provider deploy calls. +fn build_deploy_payload(record: &ManagedAgentRecord) -> serde_json::Value { + serde_json::json!({ + "name": &record.name, + "relay_url": &record.relay_url, + "private_key_nsec": &record.private_key_nsec, + "api_token": &record.api_token, + "agent_command": &record.agent_command, + "agent_args": &record.agent_args, + "system_prompt": &record.system_prompt, + "model": &record.model, + "turn_timeout_seconds": record.turn_timeout_seconds, + "parallelism": record.parallelism, + }) +} + +/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via +/// spawn_blocking, and persists the result (backend_agent_id or last_error). +/// +/// Idempotency: calling deploy on an already-deployed agent sends the same payload +/// again. Providers are expected to handle this as an update-in-place or no-op — +/// the protocol does not include an explicit `undeploy` operation (deferred to v2). +/// +/// Returns Ok(()) on success, Err(message) on failure. Either way the record is +/// updated and saved before returning. +async fn deploy_to_provider( + app: &AppHandle, + state: &AppState, + pubkey: &str, + provider_id: &str, + config: &serde_json::Value, + agent_json: serde_json::Value, + cached_binary_path: Option<&str>, +) -> Result<(), String> { + // Resolve via discovered candidates only. Cached path must match BOTH + // "is a discovered candidate" AND "belongs to this provider_id". A tampered + // record cannot redirect deploys to a different provider's binary. + let bin_path = cached_binary_path + .map(std::path::PathBuf::from) + .filter(|p| p.exists()) + .map(|p| p.canonicalize().unwrap_or(p)) + .filter(|canonical| { + discover_provider_candidates() + .iter() + .any(|(id, cp)| id == provider_id && cp.canonicalize().ok().as_ref() == Some(canonical)) + }) + .map_or_else(|| resolve_provider_binary(provider_id), Ok)?; + + let config_clone = config.clone(); + let deploy_result = tokio::task::spawn_blocking(move || { + provider_deploy(&bin_path, &agent_json, &config_clone) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))?; + + // Persist result under lock. + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + let rec = records + .iter_mut() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + + match deploy_result { + Ok(backend_agent_id) => { + rec.backend_agent_id = Some(backend_agent_id); + rec.last_started_at = Some(now_iso()); + rec.updated_at = now_iso(); + rec.last_error = None; + } + Err(ref e) => { + rec.last_error = Some(e.clone()); + rec.updated_at = now_iso(); + save_managed_agents(app, &records)?; + return Err(e.clone()); + } + } + save_managed_agents(app, &records)?; + Ok(()) +} + #[tauri::command] pub fn list_managed_agents( app: AppHandle, @@ -132,7 +218,40 @@ pub async fn create_managed_agent( (keys, private_key_nsec, pubkey, resolved_relay_url, token_scopes, token_name, mint_token, input) }; + // ── Pre-Phase 2: validate provider config BEFORE any side effects ──────── + if let BackendKind::Provider { ref config, ref id } = input.backend { + // Provider agents MUST mint a token so the relay establishes ownership. + // Without ownership the harness ignores !shutdown — the agent becomes + // uncontrollable. Reject early rather than deploy an unstoppable agent. + if !mint_token { + return Err( + "provider-backed agents require a minted token (ownership is established during mint)" + .to_string(), + ); + } + // Enforce minimum scopes for remote agents. The harness needs users:read + // to query its owner (for !shutdown). Without it, the agent is unstoppable. + const REQUIRED_PROVIDER_SCOPES: &[&str] = + &["messages:read", "messages:write", "channels:read", "users:read"]; + for required in REQUIRED_PROVIDER_SCOPES { + if !token_scopes.iter().any(|s| s == required) { + return Err(format!( + "provider-backed agents require the '{required}' scope" + )); + } + } + validate_provider_config(config)?; + // Validate via discovered candidates — not raw resolve_command. + resolve_provider_binary(id)?; + } + // ── Phase 2: mint token via REST API (async, outside lock) ─────────────── + // Pass the desktop user's pubkey as the agent owner so the relay records + // the ownership chain. Only NIP-98 bootstrap mints can set owner_pubkey. + let user_pubkey_hex = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; let api_token: Option = if mint_token { let token = mint_token_via_api( &state, @@ -140,6 +259,7 @@ pub async fn create_managed_agent( &resolved_relay_url, &token_name, &token_scopes, + Some(&user_pubkey_hex), ) .await?; Some(token) @@ -147,6 +267,9 @@ pub async fn create_managed_agent( None }; + // Agent ownership is set atomically during token mint via owner_pubkey + // in the request body — no separate API call needed. + // ── Phase 3: save record and optionally spawn (sync lock) ───────────────── let (agent, spawn_error) = { let _store_guard = state @@ -168,6 +291,15 @@ pub async fn create_managed_agent( if records.iter().any(|record| record.pubkey == pubkey) { return Err(format!("agent {pubkey} already exists")); } + // Provider config was already validated in Pre-Phase 2. + // Cache the discovered binary path for deploy_to_provider. + let provider_binary_path = if let BackendKind::Provider { ref id, .. } = input.backend { + // Use resolve_provider_binary (discovered candidates only). + resolve_provider_binary(id).ok().map(|p| p.display().to_string()) + } else { + None + }; + let mut record = crate::managed_agents::ManagedAgentRecord { pubkey: pubkey.clone(), name: name.clone(), @@ -222,8 +354,18 @@ pub async fn create_managed_agent( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), - start_on_app_launch: input.start_on_app_launch, + // Provider agents don't auto-start with the desktop — they're + // managed externally. Force false to avoid persisting a flag the + // app will never honor. + start_on_app_launch: if input.backend != BackendKind::Local { + false + } else { + input.start_on_app_launch + }, runtime_pid: None, + backend: input.backend.clone(), + backend_agent_id: None, + provider_binary_path, created_at: now_iso(), updated_at: now_iso(), last_started_at: None, @@ -239,7 +381,7 @@ pub async fn create_managed_agent( records.push(record); let mut spawn_error = None; - if input.spawn_after_create { + if input.spawn_after_create && input.backend == BackendKind::Local { let record = find_managed_agent_mut(&mut records, &pubkey)?; if let Err(error) = start_managed_agent_process(&app, record, &mut runtimes) { record.updated_at = now_iso(); @@ -281,8 +423,43 @@ pub async fn create_managed_agent( Err(error) => Some(error), }; + // ── Phase 5: provider deploy (async, outside lock) ─────────────────────── + let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local { + if let BackendKind::Provider { ref id, ref config } = input.backend { + // Read the saved record to build the deploy payload (record has the + // canonical field values after Phase 3 normalization). + let agent_json = { + let _g = state.managed_agents_store_lock.lock().map_err(|e| e.to_string())?; + let records = load_managed_agents(&app)?; + let rec = records.iter().find(|r| r.pubkey == pubkey) + .ok_or_else(|| "agent disappeared".to_string())?; + build_deploy_payload(rec) + }; + match deploy_to_provider(&app, &state, &pubkey, id, config, agent_json, None).await { + Ok(()) => spawn_error, + Err(e) => Some(e), + } + } else { + spawn_error + } + } else { + spawn_error + }; + + // Rebuild summary if provider deploy may have updated backend_agent_id. + let final_agent = if input.backend != BackendKind::Local && spawn_error.is_none() { + let _store_guard = state.managed_agents_store_lock.lock().map_err(|e| e.to_string())?; + let records = load_managed_agents(&app)?; + let runtimes = state.managed_agent_processes.lock().map_err(|e| e.to_string())?; + let record = records.iter().find(|r| r.pubkey == pubkey) + .ok_or_else(|| "agent disappeared".to_string())?; + build_managed_agent_summary(&app, record, &runtimes)? + } else { + agent + }; + Ok(CreateManagedAgentResponse { - agent, + agent: final_agent, private_key_nsec, api_token, profile_sync_error, @@ -291,35 +468,79 @@ pub async fn create_managed_agent( } #[tauri::command] -pub fn start_managed_agent( +pub async fn start_managed_agent( pubkey: String, app: AppHandle, state: State<'_, AppState>, ) -> Result { - 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())?; + // Collect backend info and handle local vs provider under lock. + let (backend, cached_binary_path, agent_json) = { + 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)?; - } + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } - { let record = find_managed_agent_mut(&mut records, &pubkey)?; - start_managed_agent_process(&app, record, &mut runtimes)?; + + if record.backend == BackendKind::Local { + // Local: spawn in-process and return immediately. + start_managed_agent_process(&app, record, &mut runtimes)?; + save_managed_agents(&app, &records)?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + return build_managed_agent_summary(&app, record, &runtimes); + } + + let payload = build_deploy_payload(record); + ( + record.backend.clone(), + record.provider_binary_path.clone(), + payload, + ) + }; + + // Provider backend: deploy via shared helper (async, outside lock). + if let BackendKind::Provider { ref id, ref config } = backend { + deploy_to_provider( + &app, + &state, + &pubkey, + id, + config, + agent_json, + cached_binary_path.as_deref(), + ) + .await?; + + // Return updated summary. + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(&app)?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + return 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"))?; - build_managed_agent_summary(&app, record, &runtimes) + + Err(format!("agent {pubkey} has unsupported backend kind")) } #[tauri::command] @@ -344,6 +565,13 @@ pub fn stop_managed_agent( { let record = find_managed_agent_mut(&mut records, &pubkey)?; + // Remote agents are stopped via !shutdown @mention from the frontend, + // not via this backend command. Reject the call. + if record.backend != BackendKind::Local { + return Err( + "remote agents are stopped via !shutdown message, not this command".to_string(), + ); + } stop_managed_agent_process(record, &mut runtimes)?; } save_managed_agents(&app, &records)?; @@ -357,6 +585,7 @@ pub fn stop_managed_agent( #[tauri::command] pub fn delete_managed_agent( pubkey: String, + force_remote_delete: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { @@ -373,7 +602,27 @@ pub fn delete_managed_agent( if sync_managed_agent_processes(&mut records, &mut runtimes) { save_managed_agents(&app, &records)?; } + + // Guard: reject deletion of deployed remote agents unless explicitly forced. + // This turns "don't orphan remote infra" from a UI convention into a backend + // invariant — a buggy or compromised IPC caller cannot silently orphan a live + // remote deployment. The frontend sends force_remote_delete: true only after + // the user confirms the orphan warning. + if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { + if record.backend != BackendKind::Local + && record.backend_agent_id.is_some() + && !force_remote_delete.unwrap_or(false) + { + return Err( + "cannot delete a deployed remote agent without force_remote_delete: true" + .to_string(), + ); + } + } + if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { + // For local agents: kills the process. For remote agents: no-op (the frontend + // sends !shutdown via WebSocket before calling delete). Either way, safe. stop_managed_agent_process(record, &mut runtimes)?; } let initial_len = records.len(); @@ -438,7 +687,10 @@ pub async fn mint_managed_agent_token( }; // ── 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?; + // Re-minting: do NOT send owner_pubkey. Ownership was established during + // the first mint (create flow). Sending it again would be rejected by the + // relay if the owner is already set to a different pubkey. + let minted_token = mint_token_via_api(&state, &agent_keys, &relay_url, &token_name, &scopes, None).await?; // ── Phase 3: persist new token to agent record (sync lock) ─────────────── let (agent, api_token) = { @@ -487,8 +739,10 @@ pub fn get_managed_agent_log( .lock() .map_err(|error| error.to_string())?; let records = load_managed_agents(&app)?; - if !records.iter().any(|record| record.pubkey == pubkey) { - return Err(format!("agent {pubkey} not found")); + let record = records.iter().find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + if record.backend != BackendKind::Local { + return Err("logs are not available for remote agents".to_string()); } let log_path = managed_agent_log_path(&app, &pubkey)?; @@ -497,3 +751,52 @@ pub fn get_managed_agent_log( log_path: log_path.display().to_string(), }) } + +// ── New backend-provider commands ──────────────────────────────────────────── + +#[tauri::command] +pub fn discover_backend_providers() -> Vec { + discover_provider_candidates() + .into_iter() + .map(|(id, path)| BackendProviderInfo { + id, + binary_path: path.display().to_string(), + }) + .collect() +} + +#[tauri::command] +pub async fn probe_backend_provider(binary_path: String) -> Result { + // Validate that the requested path is actually a discovered sprout-backend-* binary. + // This prevents arbitrary binary execution via a compromised frontend or IPC. + let candidates = discover_provider_candidates(); + let path = std::path::PathBuf::from(&binary_path); + let canonical = path + .canonicalize() + .map_err(|e| format!("binary not found: {binary_path}: {e}"))?; + let is_known = candidates + .iter() + .any(|(_, p)| p.canonicalize().ok().as_ref() == Some(&canonical)); + if !is_known { + return Err(format!( + "binary '{binary_path}' is not a discovered sprout-backend-* provider" + )); + } + // request_id is for provider-side logging — not validated in the response + // (stdin→stdout is 1:1 per process invocation). + let request = serde_json::json!({ + "op": "info", + "request_id": uuid::Uuid::new_v4().to_string(), + }); + tokio::task::spawn_blocking(move || { + invoke_provider(&canonical, &request, std::time::Duration::from_secs(10)) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +// Remote agent shutdown is handled entirely by the frontend: +// 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key) +// 2. Harness sees it, exits gracefully, sets presence to "offline" +// 3. Desktop's existing presence polling sees "offline" — UI updates automatically +// No backend Tauri command needed. Presence IS the status. diff --git a/desktop/src-tauri/src/commands/tokens.rs b/desktop/src-tauri/src/commands/tokens.rs index 057f3dda6..0b7668fc0 100644 --- a/desktop/src-tauri/src/commands/tokens.rs +++ b/desktop/src-tauri/src/commands/tokens.rs @@ -30,6 +30,7 @@ pub async fn mint_token( scopes: &scopes, channel_ids: channel_ids.as_deref(), expires_in_days, + owner_pubkey: None, // User-minted tokens don't set agent owner }; let request = if state.configured_api_token.is_some() { build_authed_request(&state.http_client, Method::POST, "/api/tokens", &state)?.json(&body) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7a5438adc..f554ce967 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,7 +9,7 @@ use app_state::{build_app_state, resolve_persisted_identity, AppState}; use commands::*; use managed_agents::{ find_managed_agent_mut, load_managed_agents, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, sync_managed_agent_processes, + stop_managed_agent_process, sync_managed_agent_processes, BackendKind, }; use tauri::{Manager, RunEvent}; use tauri_plugin_window_state::StateFlags; @@ -28,7 +28,7 @@ fn restore_managed_agents_on_launch(app: &tauri::AppHandle) -> Result<(), String let mut changed = sync_managed_agent_processes(&mut records, &mut runtimes); let pubkeys_to_restore = records .iter() - .filter(|record| record.start_on_app_launch) + .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) .map(|record| record.pubkey.clone()) .collect::>(); @@ -67,6 +67,10 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> { let mut changed = sync_managed_agent_processes(&mut records, &mut runtimes); for record in records.iter_mut() { + // Only stop Local agents — Provider agents are managed externally. + if record.backend != BackendKind::Local { + continue; + } if record.runtime_pid.is_none() && !runtimes.contains_key(&record.pubkey) { continue; } @@ -170,6 +174,9 @@ pub fn run() { get_managed_agent_log, get_agent_models, update_managed_agent, + discover_backend_providers, + probe_backend_provider, + list_personas, create_persona, update_persona, diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs new file mode 100644 index 000000000..301ad13ef --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -0,0 +1,522 @@ +use std::io::{BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::Duration; + +const STDERR_CAP: usize = 65536; +/// Provider responses should be small JSON objects. Cap stdout to prevent a +/// buggy or malicious provider from OOM-ing the desktop process. +const STDOUT_CAP: usize = 1_048_576; // 1 MB + +/// Invoke a provider binary: write JSON to stdin, read JSON from stdout. +/// +/// Reader threads stream lines/chunks over channels so the caller can receive +/// data as it arrives and time-box the wait. No `read_to_end` — if a provider +/// daemonizes or leaves descendants holding pipes open, the caller still gets +/// all data written before the child exited and returns without leaking threads +/// (the readers drop naturally when the sender is gone and the pipe closes or +/// the desktop process exits). +pub fn invoke_provider( + binary: &Path, + request: &serde_json::Value, + timeout: Duration, +) -> Result { + let request_bytes = + format!("{}\n", serde_json::to_string(request).map_err(|e| e.to_string())?); + + let mut child = std::process::Command::new(binary) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("failed to spawn {}: {e}", binary.display()))?; + + // Write request and close stdin immediately so the provider sees EOF. + let stdin_result = if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(request_bytes.as_bytes()) + } else { + Ok(()) + }; + + // Stream stdout as raw chunks over a channel. The caller appends chunks + // to a buffer and attempts incremental JSON parsing — no dependency on + // newlines or EOF. If a descendant holds the pipe open after the provider + // exits, the thread blocks on the next read — but the caller already has + // the response data and proceeds. The thread is not joined; it terminates + // when the pipe eventually closes or the process exits. + let (stdout_tx, stdout_rx) = mpsc::channel::>(); + if let Some(stdout) = child.stdout.take() { + std::thread::spawn(move || { + let mut buf = vec![0u8; 8192]; + let mut reader = BufReader::new(stdout); + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if stdout_tx.send(buf[..n].to_vec()).is_err() { + break; // receiver dropped + } + } + Err(_) => break, + } + } + }); + } + + // Drain stderr into a bounded channel. sync_channel(8) caps in-flight + // chunks — the producer blocks when the buffer is full, applying natural + // backpressure. The consumer drains during the try_wait loop and caps + // total bytes at STDERR_CAP, so memory is bounded even for long-running + // or malicious providers. + let (stderr_tx, stderr_rx) = mpsc::sync_channel::>(8); + if let Some(stderr) = child.stderr.take() { + std::thread::spawn(move || { + let mut buf = vec![0u8; 8192]; + let mut reader = BufReader::new(stderr); + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if stderr_tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + } + + // Bail early if stdin write failed — child may be in a bad state. + if let Err(e) = stdin_result { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("stdin write failed: {e}")); + } + + // Poll try_wait with a deadline, collecting stdout chunks and draining + // stderr as data arrives. Incremental JSON parsing on stdout means we + // capture the response even without a trailing newline or EOF. + let timeout_secs = timeout.as_secs(); + let deadline = std::time::Instant::now() + timeout; + let mut stdout_buf = Vec::new(); + let mut stderr_bytes = Vec::new(); + let mut exit_status = None; + + loop { + // Drain stdout chunks (non-blocking), enforce byte cap. + while stdout_buf.len() < STDOUT_CAP { + match stdout_rx.try_recv() { + Ok(chunk) => stdout_buf.extend_from_slice(&chunk), + Err(_) => break, + } + } + // Drain stderr chunks (non-blocking), enforce byte cap. + while stderr_bytes.len() < STDERR_CAP { + match stderr_rx.try_recv() { + Ok(chunk) => stderr_bytes.extend_from_slice(&chunk), + Err(_) => break, + } + } + + match child.try_wait() { + Ok(Some(status)) => { + exit_status = Some(status); + break; + } + Ok(None) => { + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("provider timed out after {timeout_secs}s")); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("wait error: {e}")); + } + } + } + + // Drain remaining stdout chunks buffered between last poll and child exit. + // Keep draining until the channel disconnects (reader finished) or the + // 2s deadline expires (descendant holding pipe open). Do NOT break on the + // first timeout — a slightly delayed final chunk should still be captured. + let drain_deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + if stdout_buf.len() >= STDOUT_CAP { + break; + } + let remaining = drain_deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + break; + } + match stdout_rx.recv_timeout(remaining.min(Duration::from_millis(100))) { + Ok(chunk) => stdout_buf.extend_from_slice(&chunk), + Err(mpsc::RecvTimeoutError::Disconnected) => break, // reader done + Err(mpsc::RecvTimeoutError::Timeout) => { + // Keep waiting until the full drain deadline expires. + if std::time::Instant::now() >= drain_deadline { + break; + } + } + } + } + + // Final stderr drain (non-blocking, cap already enforced). + while stderr_bytes.len() < STDERR_CAP { + match stderr_rx.try_recv() { + Ok(chunk) => stderr_bytes.extend_from_slice(&chunk), + Err(_) => break, + } + } + stderr_bytes.truncate(STDERR_CAP); + stdout_buf.truncate(STDOUT_CAP); + + let stderr = String::from_utf8_lossy(&stderr_bytes); + let stderr_redacted = redact_secrets(&stderr); + + let exit_info = exit_status + .map(|s| { + s.code() + .map(|c| format!("exit code {c}")) + .unwrap_or_else(|| "killed by signal".to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()); + + // Fail on non-zero exit regardless of stdout content. A provider that + // crashes mid-deploy may flush partial JSON before dying — trusting that + // output would be worse than surfacing the failure. + let exited_ok = exit_status.map_or(false, |s| s.success()); + if !exited_ok { + let stderr_snippet = &stderr_redacted[..stderr_redacted.len().min(4096)]; + if stderr_snippet.is_empty() { + return Err(format!("provider failed ({exit_info}, empty stderr)")); + } else { + return Err(format!( + "provider failed ({exit_info}). stderr: {stderr_snippet}" + )); + } + } + + // Incremental JSON parse: try each line, then try the entire buffer. + // Handles providers that emit JSON on a single line (common) as well as + // providers that write JSON without a trailing newline. + let stdout_str = String::from_utf8_lossy(&stdout_buf); + let response: serde_json::Value = stdout_str + .lines() + .find_map(|line| serde_json::from_str(line).ok()) + .or_else(|| serde_json::from_str(stdout_str.trim()).ok()) + .ok_or_else(|| { + let stderr_snippet = &stderr_redacted[..stderr_redacted.len().min(4096)]; + if stderr_snippet.is_empty() { + format!("provider produced no JSON response ({exit_info}, empty stderr)") + } else { + format!( + "provider produced no JSON response ({exit_info}). stderr: {stderr_snippet}" + ) + } + })?; + + if response.get("ok").and_then(|v| v.as_bool()) == Some(false) { + let error = response["error"].as_str().unwrap_or("unknown error"); + return Err(redact_secrets(error)); + } + + Ok(response) +} + +/// Split a config key into lowercase words on `_`, `-`, `.`, and camelCase boundaries. +/// +/// Handles acronyms: consecutive uppercase runs stay together until a lowercase follows. +/// "apiKey" → ["api", "key"], "apiKEY" → ["api", "key"], "APIKey" → ["api", "key"], +/// "access_token" → ["access", "token"], "keyboard" → ["keyboard"], +/// "clientSecret" → ["client", "secret"]. +fn split_config_key(key: &str) -> Vec { + let mut words = Vec::new(); + let mut current = String::new(); + let chars: Vec = key.chars().collect(); + for (i, &ch) in chars.iter().enumerate() { + if ch == '_' || ch == '-' || ch == '.' { + if !current.is_empty() { + words.push(current.to_lowercase()); + current.clear(); + } + } else if ch.is_uppercase() { + // Start a new word on: (a) transition from lowercase to uppercase, or + // (b) uppercase followed by lowercase (end of acronym run, e.g. "APIKey" → "API" + "Key"). + let prev_lower = !current.is_empty() && current.chars().last().map_or(false, |c| c.is_lowercase()); + let acronym_end = !current.is_empty() + && current.chars().last().map_or(false, |c| c.is_uppercase()) + && chars.get(i + 1).map_or(false, |c| c.is_lowercase()); + if prev_lower || acronym_end { + words.push(current.to_lowercase()); + current.clear(); + } + current.push(ch); + } else { + current.push(ch); + } + } + if !current.is_empty() { + words.push(current.to_lowercase()); + } + words +} + +fn redact_secrets(s: &str) -> String { + let mut result = s.to_string(); + for prefix in &["nsec1", "sprt_tok_"] { + while let Some(pos) = result.find(prefix) { + let end = result[pos..] + .find(|c: char| c.is_whitespace() || c == '"' || c == '\'') + .map(|i| pos + i) + .unwrap_or(result.len()); + result.replace_range(pos..end, "[REDACTED]"); + } + } + result +} + +/// Deploy an agent via provider binary. Returns the provider-assigned agent_id. +/// +/// `request_id` is included for provider-side logging/correlation but is not +/// validated in the response — the stdin→stdout exchange is 1:1 per process. +pub fn provider_deploy( + binary: &Path, + agent: &serde_json::Value, + provider_config: &serde_json::Value, +) -> Result { + let request = serde_json::json!({ + "op": "deploy", + "request_id": uuid::Uuid::new_v4().to_string(), + "agent": agent, + "provider_config": provider_config, + }); + let resp = invoke_provider(binary, &request, Duration::from_secs(600))?; + resp["agent_id"] + .as_str() + .map(String::from) + .ok_or_else(|| "deploy response missing agent_id".to_string()) +} + +/// Validate provider_config: flat object, scalar values, no secret-like keys. +pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String> { + let obj = config + .as_object() + .ok_or("provider_config must be a JSON object")?; + if obj.len() > 20 { + return Err("provider_config: max 20 fields".to_string()); + } + let json_str = serde_json::to_string(config).unwrap_or_default(); + if json_str.len() > 65536 { + return Err("provider_config: max 64KB".to_string()); + } + // Split on separators AND camelCase boundaries, then check each word. + // Catches: api_key, apiKey, access-token, clientSecret, etc. + // Allows: keyboard, monkey_wrench (no forbidden word as a segment). + let forbidden = ["secret", "password", "token", "key", "credential"]; + for (k, v) in obj { + let words = split_config_key(k); + for f in &forbidden { + if words.iter().any(|w| w == f) { + return Err(format!("provider_config: key '{}' looks like a secret", k)); + } + } + if v.is_object() || v.is_array() { + return Err(format!( + "provider_config: value for '{}' must be a scalar", + k + )); + } + } + Ok(()) +} + +/// Enumerate PATH for sprout-backend-* executables. Returns (id, path) pairs. +/// Only includes files that are executable. Does NOT execute any binaries. +pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> { + let prefix = "sprout-backend-"; + let mut seen = std::collections::HashSet::new(); + let mut results = Vec::new(); + + let path_var = std::env::var_os("PATH").unwrap_or_default(); + for dir in std::env::split_paths(&path_var) { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if let Some(id) = name.strip_prefix(prefix) { + if !id.is_empty() && !seen.contains(&name) && is_executable(&entry.path()) { + seen.insert(name.clone()); + results.push((id.to_string(), entry.path())); + } + } + } + } + results +} + +/// Resolve a provider ID to a discovered, executable binary path. +/// +/// This is the ONLY way to resolve provider binaries for execution. It: +/// 1. Validates the ID against `^[a-z0-9][a-z0-9_-]*$` (no path traversal) +/// 2. Looks up the ID in `discover_provider_candidates()` (PATH-discovered only) +/// 3. Returns the canonical path of the discovered binary +/// +/// All deploy, start, and create paths MUST use this instead of raw +/// `resolve_command(format!("sprout-backend-{id}"))` to prevent a compromised +/// frontend/IPC caller from steering execution to an arbitrary binary. +pub fn resolve_provider_binary(provider_id: &str) -> Result { + // Reject IDs that could be path components or shell metacharacters. + let valid_id = provider_id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + && !provider_id.is_empty() + && provider_id.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()); + if !valid_id { + return Err(format!( + "invalid provider ID '{provider_id}': must match [a-z0-9][a-z0-9_-]*" + )); + } + + let candidates = discover_provider_candidates(); + let found = candidates + .into_iter() + .find(|(id, _)| id == provider_id) + .map(|(_, path)| path); + + match found { + Some(path) => path + .canonicalize() + .map_err(|e| format!("provider binary not accessible: {e}")), + None => Err(format!( + "provider 'sprout-backend-{provider_id}' not found on PATH" + )), + } +} + +/// Check if a file is executable (Unix: mode bits; other platforms: always true). +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + let _ = path; + true + } +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackendProviderInfo { + pub id: String, + pub binary_path: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redact_secrets_replaces_nsec() { + let s = "key=nsec1abc123def456 other"; + let r = redact_secrets(s); + assert!(r.contains("[REDACTED]")); + assert!(!r.contains("nsec1abc123def456")); + } + + #[test] + fn redact_secrets_replaces_token() { + let s = r#"{"token":"sprt_tok_xyz789"}"#; + let r = redact_secrets(s); + assert!(r.contains("[REDACTED]")); + assert!(!r.contains("sprt_tok_xyz789")); + } + + #[test] + fn validate_provider_config_rejects_secret_key() { + let cfg = serde_json::json!({"api_key": "val"}); + assert!(validate_provider_config(&cfg).is_err()); + } + + #[test] + fn validate_provider_config_rejects_nested() { + let cfg = serde_json::json!({"region": {"us": "east"}}); + assert!(validate_provider_config(&cfg).is_err()); + } + + #[test] + fn validate_provider_config_accepts_scalars() { + let cfg = serde_json::json!({"region": "us-east-1", "tier": "standard"}); + assert!(validate_provider_config(&cfg).is_ok()); + } + + #[test] + fn validate_provider_config_allows_key_as_substring() { + // "keyboard", "monkey" contain "key" as substring but not as a word segment. + let cfg = serde_json::json!({"keyboard_layout": "us", "monkey_wrench": "tight"}); + assert!(validate_provider_config(&cfg).is_ok()); + } + + #[test] + fn validate_provider_config_rejects_camel_case_secrets() { + assert!(validate_provider_config(&serde_json::json!({"apiKey": "val"})).is_err()); + assert!(validate_provider_config(&serde_json::json!({"accessToken": "val"})).is_err()); + assert!(validate_provider_config(&serde_json::json!({"clientSecret": "val"})).is_err()); + // ALL-CAPS variants + assert!(validate_provider_config(&serde_json::json!({"apiKEY": "val"})).is_err()); + assert!(validate_provider_config(&serde_json::json!({"accessTOKEN": "val"})).is_err()); + } + + #[test] + fn split_config_key_handles_all_styles() { + assert_eq!(split_config_key("apiKey"), vec!["api", "key"]); + assert_eq!(split_config_key("access_token"), vec!["access", "token"]); + assert_eq!(split_config_key("keyboard"), vec!["keyboard"]); + assert_eq!(split_config_key("client-secret"), vec!["client", "secret"]); + // Acronym runs stay together + assert_eq!(split_config_key("APIKey"), vec!["api", "key"]); + assert_eq!(split_config_key("apiKEY"), vec!["api", "key"]); + assert_eq!(split_config_key("accessTOKEN"), vec!["access", "token"]); + assert_eq!(split_config_key("MyAPIKey"), vec!["my", "api", "key"]); + } + + #[test] + fn resolve_provider_binary_rejects_invalid_ids() { + // Path traversal + assert!(resolve_provider_binary("../evil").is_err()); + // Empty + assert!(resolve_provider_binary("").is_err()); + // Uppercase + assert!(resolve_provider_binary("MyProvider").is_err()); + // Spaces + assert!(resolve_provider_binary("my provider").is_err()); + // Shell metacharacters + assert!(resolve_provider_binary("foo;rm -rf /").is_err()); + // Valid format but not on PATH — should fail with "not found" + assert!(resolve_provider_binary("nonexistent-test-id-12345").is_err()); + } + + #[test] + fn resolve_provider_binary_accepts_valid_id_format() { + // Valid ID format should pass validation. If the binary happens to + // exist on PATH, Ok is returned; otherwise Err contains "not found" + // (not "invalid provider ID"). Either outcome proves validation passed. + match resolve_provider_binary("zzz-nonexistent-test-provider") { + Ok(_) => {} // unlikely but fine — binary exists + Err(e) => assert!( + e.contains("not found"), + "expected 'not found' error, got: {e}" + ), + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 4f5fd5962..1156aead0 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -275,6 +275,7 @@ pub fn default_token_scopes() -> Vec { "messages:read".to_string(), "messages:write".to_string(), "channels:read".to_string(), + "users:read".to_string(), "users:write".to_string(), ] } @@ -291,6 +292,7 @@ pub async fn mint_token_via_api( relay_url: &str, name: &str, scopes: &[String], + owner_pubkey: Option<&str>, ) -> Result { let http_base = relay_http_base_url(relay_url); let url = format!("{http_base}/api/tokens"); @@ -300,6 +302,7 @@ pub async fn mint_token_via_api( scopes, channel_ids: None, expires_in_days: None, + owner_pubkey, }; let body_bytes = serde_json::to_vec(&body).map_err(|e| format!("serialize mint body failed: {e}"))?; diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index f4e234a44..544ccfc3f 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,3 +1,4 @@ +mod backend; mod discovery; mod persona_card; mod personas; @@ -6,6 +7,7 @@ mod storage; mod teams; mod types; +pub use backend::*; pub use discovery::*; pub use persona_card::*; pub use personas::*; diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index d521b2239..21618c5b2 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -137,29 +137,55 @@ pub fn build_managed_agent_summary( record: &ManagedAgentRecord, runtimes: &HashMap, ) -> Result { - let persisted_pid = record.runtime_pid.filter(|pid| process_is_running(*pid)); - let (status, pid, log_path) = if let Some(runtime) = runtimes.get(&record.pubkey) { - ( - "running".to_string(), - Some(runtime.child.id()), - runtime.log_path.display().to_string(), - ) - } else if let Some(pid) = persisted_pid { - ( - "running".to_string(), - Some(pid), - managed_agent_log_path(app, &record.pubkey)? - .display() - .to_string(), - ) + use crate::managed_agents::BackendKind; + + let (status, pid, log_path) = if record.backend != BackendKind::Local { + // Two-axis status model for remote agents: + // + // Control-plane (this field): "deployed" = provider has been invoked and + // returned a backend_agent_id. "not_deployed" = no deploy call yet (or it + // failed). This axis tracks whether infrastructure *exists*, not whether + // the process is currently running. + // + // Live axis (relay presence, polled by frontend): online/away/offline. + // Shown as a PresenceDot next to the agent name. This is the real-time + // signal for whether the harness is connected. + // + // After !shutdown the agent goes offline (presence) but stays "deployed" + // (infrastructure still exists). This is intentional — the provider may + // have allocated a VM/container that persists across process restarts. + // A future provider `undeploy` operation (v2) will handle teardown. + let status = if record.backend_agent_id.is_some() { + "deployed".to_string() + } else { + "not_deployed".to_string() + }; + (status, None, String::new()) } else { - ( - "stopped".to_string(), - None, - managed_agent_log_path(app, &record.pubkey)? - .display() - .to_string(), - ) + let persisted_pid = record.runtime_pid.filter(|pid| process_is_running(*pid)); + if let Some(runtime) = runtimes.get(&record.pubkey) { + ( + "running".to_string(), + Some(runtime.child.id()), + runtime.log_path.display().to_string(), + ) + } else if let Some(pid) = persisted_pid { + ( + "running".to_string(), + Some(pid), + managed_agent_log_path(app, &record.pubkey)? + .display() + .to_string(), + ) + } else { + ( + "stopped".to_string(), + None, + managed_agent_log_path(app, &record.pubkey)? + .display() + .to_string(), + ) + } }; Ok(ManagedAgentSummary { @@ -176,6 +202,8 @@ pub fn build_managed_agent_summary( system_prompt: record.system_prompt.clone(), model: record.model.clone(), has_api_token: record.api_token.is_some(), + backend: record.backend.clone(), + backend_agent_id: record.backend_agent_id.clone(), status, pid, created_at: record.created_at.clone(), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 3f674c520..e588715e6 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -2,6 +2,22 @@ use std::{path::PathBuf, process::Child}; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum BackendKind { + Local, + Provider { + id: String, + config: serde_json::Value, + }, +} + +impl Default for BackendKind { + fn default() -> Self { + Self::Local + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PersonaRecord { pub id: String, @@ -50,6 +66,12 @@ pub struct ManagedAgentRecord { pub start_on_app_launch: bool, #[serde(default)] pub runtime_pid: Option, + #[serde(default)] + pub backend: BackendKind, + #[serde(default)] + pub backend_agent_id: Option, + #[serde(default)] + pub provider_binary_path: Option, pub created_at: String, pub updated_at: String, pub last_started_at: Option, @@ -79,6 +101,8 @@ pub struct ManagedAgentSummary { pub system_prompt: Option, pub model: Option, pub has_api_token: bool, + pub backend: BackendKind, + pub backend_agent_id: Option, pub status: String, pub pid: Option, pub created_at: String, @@ -117,6 +141,8 @@ pub struct CreateManagedAgentRequest { pub spawn_after_create: bool, #[serde(default = "default_start_on_app_launch")] pub start_on_app_launch: bool, + #[serde(default)] + pub backend: BackendKind, } #[derive(Debug, Serialize)] diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index be93767be..6a5812364 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -220,6 +220,10 @@ pub struct MintTokenBody<'a> { pub channel_ids: Option<&'a [String]>, #[serde(skip_serializing_if = "Option::is_none")] pub expires_in_days: Option, + /// Owner pubkey (hex). Only accepted via NIP-98 auth. + /// Sets agent_owner_pubkey on the agent's user record. + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_pubkey: Option<&'a str>, } #[derive(Serialize, Deserialize)] diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 73d4c5349..5dd742c00 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -124,11 +124,22 @@ export async function attachManagedAgentToChannel( let restarted = false; if (ensureRunning) { - if (membershipAdded && input.agent.status === "running") { + // Remote (provider-backed) agents don't need restart — the harness + // auto-discovers new channels via membership notifications. + const isRemote = input.agent.backend.type === "provider"; + if (isRemote) { + // No-op: remote agents pick up channel membership changes automatically. + } else if ( + membershipAdded && + (input.agent.status === "running" || input.agent.status === "deployed") + ) { await stopManagedAgent(input.agent.pubkey); agent = await startManagedAgent(input.agent.pubkey); restarted = true; - } else if (input.agent.status !== "running") { + } else if ( + input.agent.status !== "running" && + input.agent.status !== "deployed" + ) { agent = await startManagedAgent(input.agent.pubkey); started = true; } @@ -144,8 +155,10 @@ export async function attachManagedAgentToChannel( function pickPreferredManagedAgent(agents: ManagedAgent[]) { return [...agents].sort((left, right) => { - const leftRunningScore = left.status === "running" ? 1 : 0; - const rightRunningScore = right.status === "running" ? 1 : 0; + const leftRunningScore = + left.status === "running" || left.status === "deployed" ? 1 : 0; + const rightRunningScore = + right.status === "running" || right.status === "deployed" ? 1 : 0; if (leftRunningScore !== rightRunningScore) { return rightRunningScore - leftRunningScore; } diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 78623f2c5..075ac24a1 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -11,6 +11,7 @@ import { createManagedAgent, deleteManagedAgent, discoverAcpProviders, + discoverBackendProviders, discoverManagedAgentPrereqs, getManagedAgentLog, listManagedAgents, @@ -70,6 +71,7 @@ export const personasQueryKey = ["personas"] as const; export const teamsQueryKey = ["teams"] as const; export const acpProvidersQueryKey = ["acp-providers"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; +export const backendProvidersQueryKey = ["backend-providers"] as const; export type EnsureGooseInChannelResult = AttachManagedAgentToChannelResult & { created: boolean; @@ -101,6 +103,14 @@ export function useAcpProvidersQuery() { }); } +export function useBackendProvidersQuery() { + return useQuery({ + queryKey: backendProvidersQueryKey, + queryFn: discoverBackendProviders, + staleTime: 30_000, + }); +} + export function usePersonasQuery() { return useQuery({ queryKey: personasQueryKey, @@ -148,6 +158,9 @@ export function useManagedAgentsQuery() { staleTime: 1_000, refetchInterval: (query) => { const agents = query.state.data as ManagedAgent[] | undefined; + // Only local "running" agents need fast polling (process state can + // change). "deployed" is static control-plane state — presence polling + // handles the live signal for remote agents separately. return agents?.some((agent) => agent.status === "running") ? 2_000 : 10_000; @@ -274,7 +287,13 @@ export function useDeleteManagedAgentMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (pubkey: string) => deleteManagedAgent(pubkey), + mutationFn: ({ + pubkey, + forceRemoteDelete, + }: { + pubkey: string; + forceRemoteDelete?: boolean; + }) => deleteManagedAgent(pubkey, forceRemoteDelete), onSettled: async () => { await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); diff --git a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx index 96dcd88f1..cd60b6961 100644 --- a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx @@ -97,9 +97,9 @@ export function AddAgentToChannelDialog({ Add agent to channel Add {agent?.name ?? "this agent"} to a channel so desktop chat can - `@mention` it. Running agents are restarted automatically when - they join a new channel so the harness picks up the new - subscription immediately. + `@mention` it. Running local agents are restarted automatically + when they join a new channel. Remote agents pick up new channels + automatically via membership notifications. diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index f3fb172d8..426783d59 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -18,6 +18,9 @@ import { useStopManagedAgentMutation, useUpdatePersonaMutation, } from "@/features/agents/hooks"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { usePresenceQuery } from "@/features/presence/hooks"; +import { sendChannelMessage } from "@/shared/api/tauri"; import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; import type { AgentPersona, @@ -56,6 +59,7 @@ export function AgentsView() { const queryClient = useQueryClient(); const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); + const channelsQuery = useChannelsQuery(); const personasQuery = usePersonasQuery(); const startMutation = useStartManagedAgentMutation(); const stopMutation = useStopManagedAgentMutation(); @@ -99,9 +103,12 @@ export function AgentsView() { const managedAgents = React.useMemo( () => [...(managedAgentsQuery.data ?? [])].sort((left, right) => { - if (left.status !== right.status) { - return left.status === "running" ? -1 : 1; - } + // Active agents (running or deployed) sort before inactive ones. + // Both "running" and "deployed" are equivalent for sorting purposes. + const activeScore = (s: string) => + s === "running" || s === "deployed" ? 1 : 0; + const diff = activeScore(right.status) - activeScore(left.status); + if (diff !== 0) return diff; return left.name.localeCompare(right.name); }), @@ -125,6 +132,32 @@ export function AgentsView() { () => new Set(managedAgents.map((agent) => agent.pubkey)), [managedAgents], ); + const managedPubkeyList = React.useMemo( + () => managedAgents.map((agent) => agent.pubkey), + [managedAgents], + ); + const managedPresenceQuery = usePresenceQuery(managedPubkeyList); + + /** Resolve a relay-agent's first channel UUID for sending !shutdown. */ + function resolveAgentChannelId(pubkey: string): string | null { + const relayAgents = relayAgentsQuery.data ?? []; + const relayAgent = relayAgents.find((ra) => ra.pubkey === pubkey); + // Prefer channelIds (new relay with json_agg). Fall back to resolving + // channel names via the channels query (old relay without channel_ids). + if (relayAgent?.channelIds?.length) { + return relayAgent.channelIds[0]; + } + // Fallback: resolve channel name → UUID via the channels query. + // Only use this when the match is unambiguous — if multiple channels + // share the same name (e.g. across teams), we can't be sure which one + // the agent is in, and sending !shutdown to the wrong channel would + // silently miss the agent. Return null to surface the error to the user. + const channelName = relayAgent?.channels?.[0]; + if (!channelName) return null; + const channels = channelsQuery.data ?? []; + const matches = channels.filter((ch) => ch.name === channelName); + return matches.length === 1 ? matches[0].id : null; + } // Clear log selection if the agent was removed React.useEffect(() => { @@ -154,7 +187,26 @@ export function AgentsView() { setActionErrorMessage(null); try { - await stopMutation.mutateAsync(pubkey); + const agent = managedAgents.find((a) => a.pubkey === pubkey); + if (!agent) return; + + if (agent.backend.type === "provider") { + // Remote agent: send !shutdown mention via relay REST API. + const channelId = resolveAgentChannelId(pubkey); + if (!channelId) { + setActionErrorMessage("Cannot stop: agent is not in any channel"); + return; + } + await sendChannelMessage(channelId, "!shutdown", undefined, undefined, [ + pubkey, + ]); + setActionNoticeMessage( + "Shutdown command sent. Agent will stop shortly.", + ); + } else { + // Local agent: existing stop flow + await stopMutation.mutateAsync(pubkey); + } } catch (error) { setActionErrorMessage( error instanceof Error ? error.message : "Failed to stop agent.", @@ -167,7 +219,59 @@ export function AgentsView() { setActionErrorMessage(null); try { - await deleteMutation.mutateAsync(pubkey); + // For remote agents, send !shutdown before deleting to avoid orphaning. + const agent = managedAgents.find((a) => a.pubkey === pubkey); + if (agent?.backend.type === "provider" && agent.backendAgentId) { + const presence = + managedPresenceQuery.data?.[pubkey.trim().toLowerCase()]; + const channelId = resolveAgentChannelId(pubkey); + if (channelId) { + // If the agent is still online, send !shutdown and warn that + // deletion proceeds without waiting for confirmed exit. + if (presence === "online" || presence === "away") { + await sendChannelMessage( + channelId, + "!shutdown", + undefined, + undefined, + [pubkey], + ); + // eslint-disable-next-line no-alert + const confirmed = window.confirm( + "Shutdown command sent, but the agent may still be running. " + + "Deleting now removes the local record — the remote deployment " + + "will be orphaned if shutdown hasn't completed. Continue?", + ); + if (!confirmed) return; + } else { + // Offline presence means the process isn't connected, but the + // remote infrastructure (VM/container) may still exist. Confirm + // before removing the local record — it's the only management handle. + // eslint-disable-next-line no-alert + const confirmed = window.confirm( + "This agent is offline but the remote deployment may still exist. " + + "Deleting removes the local management record. Continue?", + ); + if (!confirmed) return; + } + } else { + // Can't send shutdown — warn user about orphaning. + // eslint-disable-next-line no-alert + const confirmed = window.confirm( + "This agent is deployed but not in any channel. " + + "Deleting will orphan the remote deployment (it will keep running). Continue?", + ); + if (!confirmed) return; + } + } + // Pass forceRemoteDelete for deployed provider agents — the backend + // rejects deletion of deployed remote agents without this flag. + const isDeployedRemote = + agent?.backend.type === "provider" && agent?.backendAgentId; + await deleteMutation.mutateAsync({ + pubkey, + forceRemoteDelete: isDeployedRemote ? true : undefined, + }); if (logAgentPubkey === pubkey) { setLogAgentPubkey(null); } @@ -420,6 +524,7 @@ export function AgentsView() { isActionPending={isActionPending} isLoading={managedAgentsQuery.isLoading} personaLabelsById={personaLabelsById} + presenceLookup={managedPresenceQuery.data ?? {}} onAddToChannel={(agent) => { setActionNoticeMessage(null); setActionErrorMessage(null); diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index a77a32fc3..719e85f45 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -1,13 +1,16 @@ -import { ChevronDown } from "lucide-react"; +import { AlertTriangle, ChevronDown } from "lucide-react"; import * as React from "react"; import { useAcpProvidersQuery, + useBackendProvidersQuery, useCreateManagedAgentMutation, useManagedAgentPrereqsQuery, } from "@/features/agents/hooks"; import { DEFAULT_MANAGED_AGENT_SCOPES } from "@/features/tokens/lib/scopeOptions"; +import { probeBackendProvider } from "@/shared/api/tauri"; import type { + BackendProviderProbeResult, CreateManagedAgentInput, CreateManagedAgentResponse, TokenScope, @@ -20,6 +23,7 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; import { CreateAgentBasicsFields, CreateAgentOptionToggles, @@ -28,6 +32,92 @@ import { CreateAgentTokenSection, } from "./CreateAgentDialogSections"; +/// Coerce string config values to their schema-declared types (number, boolean). +/// Providers receive JSON — sending "3" instead of 3 for an integer field breaks +/// typed config parsing on the provider side. +function coerceConfigValues( + config: Record, + schema: Record | undefined, +): Record { + if (!schema) return { ...config }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const properties = (schema as any)?.properties ?? {}; + const result: Record = {}; + for (const [key, value] of Object.entries(config)) { + const prop = properties[key] as Record | undefined; + const schemaType = prop?.type; + if ((schemaType === "integer" || schemaType === "number") && value !== "") { + const num = Number(value); + result[key] = Number.isNaN(num) ? value : num; + } else if (schemaType === "boolean") { + result[key] = value === "true"; + } else { + result[key] = value; + } + } + return result; +} + +// ── Provider config form ────────────────────────────────────────────────────── + +function ProviderConfigFields({ + schema, + config, + onChange, +}: { + schema: Record; + config: Record; + onChange: (config: Record) => void; +}) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const properties = (schema as any)?.properties ?? {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const required = new Set((schema as any)?.required ?? []); + + const entries = Object.entries(properties) as [ + string, + Record, + ][]; + + if (entries.length === 0) { + return null; + } + + return ( +
+ {entries.map(([key, prop]) => ( +
+ + onChange({ ...config, [key]: e.target.value })} + placeholder={ + typeof prop.description === "string" ? prop.description : "" + } + value={ + config[key] ?? + (typeof prop.default === "string" ? prop.default : "") + } + /> + {typeof prop.description === "string" ? ( +

{prop.description}

+ ) : null} +
+ ))} +
+ ); +} + +// ── Dialog ──────────────────────────────────────────────────────────────────── + export function CreateAgentDialog({ open, onCreated, @@ -39,6 +129,7 @@ export function CreateAgentDialog({ }) { const createMutation = useCreateManagedAgentMutation(); const providersQuery = useAcpProvidersQuery(); + const backendProvidersQuery = useBackendProvidersQuery(); const [acpCommand, setAcpCommand] = React.useState("sprout-acp"); const [agentCommand, setAgentCommand] = React.useState("goose"); const [agentArgs, setAgentArgs] = React.useState("acp"); @@ -61,13 +152,33 @@ export function CreateAgentDialog({ const [hasSyncedProviderSelection, setHasSyncedProviderSelection] = React.useState(false); const [showAdvanced, setShowAdvanced] = React.useState(false); + + // ── Backend provider ("Run on") state ────────────────────────────────────── + const [runOn, setRunOn] = React.useState<"local" | string>("local"); + const [providerConfig, setProviderConfig] = React.useState< + Record + >({}); + const [probedProvider, setProbedProvider] = + React.useState(null); + const [probeError, setProbeError] = React.useState(null); + const providers = providersQuery.data ?? []; + const backendProviders = backendProvidersQuery.data ?? []; const prereqs = prereqsQuery.data ?? null; const selectedProvider = React.useMemo( () => providers.find((provider) => provider.id === selectedProviderId) ?? null, [providers, selectedProviderId], ); + const selectedBackendProvider = React.useMemo( + () => backendProviders.find((p) => p.id === runOn) ?? null, + [backendProviders, runOn], + ); + const isProviderMode = runOn !== "local"; + // Provider agents always mint — ownership is established during mint. + // Use this everywhere instead of raw `mintToken` for validation/rendering. + const effectiveMintToken = isProviderMode || mintToken; + const isMintSupported = prereqs?.admin.available ?? false; const isSpawnSupported = prereqs?.acp.available === true && prereqs?.mcp.available === true; @@ -96,12 +207,13 @@ export function CreateAgentDialog({ ]); React.useEffect(() => { - if (!prereqs || prereqs.admin.available || !mintToken) { + // Don't auto-disable minting in provider mode — it's required. + if (!prereqs || prereqs.admin.available || !mintToken || isProviderMode) { return; } setMintToken(false); - }, [mintToken, prereqs]); + }, [mintToken, prereqs, isProviderMode]); React.useEffect(() => { if ( @@ -124,6 +236,51 @@ export function CreateAgentDialog({ } }, [prereqsQuery.error, providersQuery.error]); + // Probe the backend provider when runOn changes to a non-local value + React.useEffect(() => { + if (!isProviderMode || !selectedBackendProvider) { + setProbedProvider(null); + setProbeError(null); + return; + } + + let cancelled = false; + setProbeError(null); + setProbedProvider(null); + + probeBackendProvider(selectedBackendProvider.binaryPath) + .then((result) => { + if (!cancelled) { + setProbedProvider(result); + // Initialize config from schema defaults so unchanged defaults + // are included in the submit payload (not silently dropped). + if (result.config_schema) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const props = (result.config_schema as any)?.properties ?? {}; + const defaults: Record = {}; + for (const [key, prop] of Object.entries(props) as [ + string, + Record, + ][]) { + if (prop.default != null) { + defaults[key] = String(prop.default); + } + } + setProviderConfig(defaults); + } + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setProbeError(err instanceof Error ? err.message : String(err)); + } + }); + + return () => { + cancelled = true; + }; + }, [isProviderMode, selectedBackendProvider]); + function reset() { setName(""); setRelayUrl(""); @@ -142,6 +299,10 @@ export function CreateAgentDialog({ setSelectedProviderId("custom"); setHasSyncedProviderSelection(false); setShowAdvanced(false); + setRunOn("local"); + setProviderConfig({}); + setProbedProvider(null); + setProbeError(null); createMutation.reset(); } @@ -153,7 +314,24 @@ export function CreateAgentDialog({ onOpenChange(next); } + // Scopes required for remote agent controllability (!shutdown path). + // These cannot be removed in provider mode. + const PROVIDER_REQUIRED_SCOPES: TokenScope[] = [ + "users:read" as TokenScope, + "messages:read" as TokenScope, + "messages:write" as TokenScope, + "channels:read" as TokenScope, + ]; + function toggleScope(scope: TokenScope) { + // Prevent removing required scopes in provider mode. + if ( + isProviderMode && + PROVIDER_REQUIRED_SCOPES.includes(scope) && + selectedScopes.has(scope) + ) { + return; // locked — required for remote agent controllability + } setSelectedScopes((previous) => { const next = new Set(previous); if (next.has(scope)) { @@ -184,41 +362,97 @@ export function CreateAgentDialog({ setAgentArgs(provider.defaultArgs.join(",")); } + function handleRunOnChange(value: string) { + setRunOn(value); + setProviderConfig({}); + setProbedProvider(null); + setProbeError(null); + } + + // Check provider config required fields are filled. + const providerConfigComplete = React.useMemo(() => { + if (!isProviderMode || !probedProvider?.config_schema) return true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schema = probedProvider.config_schema as any; + const required: string[] = schema?.required ?? []; + return required.every( + (key) => (providerConfig[key] ?? "").trim().length > 0, + ); + }, [isProviderMode, probedProvider, providerConfig]); + const canSubmit = name.trim().length > 0 && - (!mintToken || selectedScopes.size > 0) && + (!effectiveMintToken || selectedScopes.size > 0) && !isDiscoveryPending && - !(mintToken && prereqs !== null && !isMintSupported) && - !(spawnAfterCreate && prereqs !== null && !isSpawnSupported) && + !(effectiveMintToken && prereqs !== null && !isMintSupported) && + !( + !isProviderMode && + spawnAfterCreate && + prereqs !== null && + !isSpawnSupported + ) && + // Block submission until probe succeeds in provider mode — required + // fields and config schema are only known after a successful probe. + !(isProviderMode && !probedProvider) && + providerConfigComplete && !createMutation.isPending; async function handleSubmit() { try { - const input: CreateManagedAgentInput = { - name: name.trim(), - relayUrl: relayUrl.trim() || undefined, - acpCommand: acpCommand.trim() || undefined, - agentCommand: agentCommand.trim() || undefined, - agentArgs: agentArgs - .split(",") - .map((value) => value.trim()) - .filter((value) => value.length > 0), - mcpCommand: mcpCommand.trim() || undefined, - turnTimeoutSeconds: - Number.parseInt(turnTimeoutSeconds, 10) > 0 - ? Number.parseInt(turnTimeoutSeconds, 10) - : undefined, - parallelism: - Number.parseInt(parallelism, 10) > 0 - ? Number.parseInt(parallelism, 10) - : undefined, - systemPrompt: systemPrompt.trim() || undefined, - mintToken, - tokenName: tokenName.trim() || undefined, - tokenScopes: [...selectedScopes], - spawnAfterCreate, - startOnAppLaunch, - }; + const input: CreateManagedAgentInput = isProviderMode + ? { + name: name.trim(), + relayUrl: relayUrl.trim() || undefined, + turnTimeoutSeconds: + Number.parseInt(turnTimeoutSeconds, 10) > 0 + ? Number.parseInt(turnTimeoutSeconds, 10) + : undefined, + parallelism: + Number.parseInt(parallelism, 10) > 0 + ? Number.parseInt(parallelism, 10) + : undefined, + systemPrompt: systemPrompt.trim() || undefined, + mintToken: true, // Required: ownership established during mint + tokenName: tokenName.trim() || undefined, + tokenScopes: [...selectedScopes], + spawnAfterCreate: true, + startOnAppLaunch: false, // Remote agents don't auto-start with the desktop + backend: { + type: "provider", + id: runOn, + config: coerceConfigValues( + providerConfig, + probedProvider?.config_schema, + ), + }, + } + : { + name: name.trim(), + relayUrl: relayUrl.trim() || undefined, + acpCommand: acpCommand.trim() || undefined, + agentCommand: agentCommand.trim() || undefined, + agentArgs: agentArgs + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0), + mcpCommand: mcpCommand.trim() || undefined, + turnTimeoutSeconds: + Number.parseInt(turnTimeoutSeconds, 10) > 0 + ? Number.parseInt(turnTimeoutSeconds, 10) + : undefined, + parallelism: + Number.parseInt(parallelism, 10) > 0 + ? Number.parseInt(parallelism, 10) + : undefined, + systemPrompt: systemPrompt.trim() || undefined, + mintToken: effectiveMintToken, + tokenName: tokenName.trim() || undefined, + tokenScopes: [...selectedScopes], + spawnAfterCreate, + startOnAppLaunch, + backend: { type: "local" }, + }; + const created = await createMutation.mutateAsync(input); handleOpenChange(false); onCreated(created); @@ -243,19 +477,75 @@ export function CreateAgentDialog({
- + {/* Run on selector — only shown when backend providers are discovered */} + {backendProviders.length > 0 ? ( +
+ + +
+ ) : null} + + {/* Provider mode: trust warning + config fields */} + {isProviderMode && selectedBackendProvider ? ( +
+
+ +

+ This provider at{" "} + + {selectedBackendProvider.binaryPath} + {" "} + will receive your agent's private key. Only use + providers from trusted sources. +

+
+ + {probeError ? ( +

+ Could not probe provider: {probeError} +

+ ) : null} + + {probedProvider?.config_schema ? ( + + ) : null} +
+ ) : null} + + {/* Local mode: show the ACP runtime selector */} + {!isProviderMode ? ( + + ) : null} { if (!mintToggleDisabled) { setMintToken((current) => !current); @@ -270,13 +560,19 @@ export function CreateAgentDialog({ } }} prereqs={prereqs} - startOnAppLaunch={startOnAppLaunch} - spawnAfterCreate={spawnAfterCreate} - spawnToggleDisabled={spawnToggleDisabled} + startOnAppLaunch={isProviderMode ? false : startOnAppLaunch} + startOnAppLaunchDisabled={isProviderMode} + spawnAfterCreate={isProviderMode ? true : spawnAfterCreate} + spawnToggleDisabled={isProviderMode || spawnToggleDisabled} /> - {mintToken ? ( + {effectiveMintToken ? ( (PROVIDER_REQUIRED_SCOPES) + : undefined + } onScopeToggle={toggleScope} onTokenNameChange={setTokenName} selectedScopes={selectedScopes} diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx index 1677d2f71..f0af718dd 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx @@ -262,6 +262,7 @@ export function CreateAgentOptionToggles({ mintToggleDisabled, prereqs, startOnAppLaunch, + startOnAppLaunchDisabled, spawnAfterCreate, spawnToggleDisabled, onToggleMintToken, @@ -274,6 +275,8 @@ export function CreateAgentOptionToggles({ mintToggleDisabled: boolean; prereqs: ManagedAgentPrereqs | null; startOnAppLaunch: boolean; + /** When true, the toggle is disabled (e.g. remote agents don't support auto-start). */ + startOnAppLaunchDisabled?: boolean; spawnAfterCreate: boolean; spawnToggleDisabled: boolean; onToggleMintToken: () => void; @@ -307,10 +310,12 @@ export function CreateAgentOptionToggles({ aria-pressed={startOnAppLaunch} className={cn( "rounded-2xl border px-4 py-3 text-left transition-colors", + startOnAppLaunchDisabled && "cursor-not-allowed opacity-60", startOnAppLaunch ? "border-primary bg-primary/10" : "border-border/70 bg-background/70", )} + disabled={startOnAppLaunchDisabled} onClick={onToggleStartOnAppLaunch} type="button" > @@ -318,8 +323,9 @@ export function CreateAgentOptionToggles({ Start on app launch

- Reopen this local ACP harness automatically when the desktop app - starts. + {startOnAppLaunchDisabled + ? "Remote agents are managed by their provider and don\u2019t auto-start with the desktop app." + : "Reopen this local ACP harness automatically when the desktop app starts."}

@@ -352,11 +358,14 @@ export function CreateAgentOptionToggles({ export function CreateAgentTokenSection({ selectedScopes, tokenName, + lockedScopes, onScopeToggle, onTokenNameChange, }: { selectedScopes: Set; tokenName: string; + /** Scopes that cannot be removed (e.g. required for remote agent controllability). */ + lockedScopes?: Set; onScopeToggle: (scope: TokenScope) => void; onTokenNameChange: (value: string) => void; }) { @@ -379,6 +388,7 @@ export function CreateAgentTokenSection({
{MANAGED_AGENT_SCOPE_OPTIONS.map((scope) => { const selected = selectedScopes.has(scope.value); + const locked = lockedScopes?.has(scope.value) && selected; return (