feat: remote agent backend providers (#134)

This commit is contained in:
tlongwell-block
2026-03-20 20:32:55 -04:00
committed by GitHub
parent cba19f8c15
commit 168c1ca8de
34 changed files with 2006 additions and 213 deletions
Generated
+10 -10
View File
@@ -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",
+74
View File
@@ -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<String> = {
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,
+50 -5
View File
@@ -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();
+4
View File
@@ -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;
+21 -8
View File
@@ -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<String>,
/// Optional JSON capabilities descriptor.
pub capabilities: Option<serde_json::Value>,
/// Comma-separated channel names (from string_agg).
pub channel_names: String,
/// Channel entries with both name and UUID, from json_agg.
pub channels: Vec<BotChannelEntry>,
}
/// 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<Vec<BotMemberRecord>> {
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<Vec<BotMemberRecord>> {
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let capabilities: Option<serde_json::Value> = row.try_get("capabilities")?;
let channels_json: serde_json::Value = row
.try_get::<serde_json::Value, _>("channels_json")
.unwrap_or(serde_json::Value::Array(vec![]));
let channels: Vec<BotChannelEntry> =
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::<Option<String>, _>("channel_names")?
.unwrap_or_default(),
channels,
});
}
Ok(out)
+3 -2
View File
@@ -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<bool> {
user::set_agent_owner(&self.pool, agent_pubkey, owner_pubkey).await
}
+66 -12
View File
@@ -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<bool> {
// 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]
+10 -7
View File
@@ -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<String> = accessible_channels
let accessible_ids: std::collections::HashSet<String> = 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<String> = 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,
}));
+109 -15
View File
@@ -128,6 +128,11 @@ pub struct MintTokenRequest {
pub channel_ids: Option<Vec<String>>,
/// Optional expiry in days (1365). Omit for no expiry.
pub expires_in_days: Option<u32>,
/// 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<String>,
}
/// 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<Vec<u8>> = 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<String> = 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<u8> = Sha256::digest(raw_token.as_bytes()).to_vec();
let scope_strings: Vec<String> = 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<String> = validated_channel_ids
.as_deref()
+25 -7
View File
@@ -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)),
})))
}
+3
View File
@@ -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.
+5 -1
View File
@@ -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) {
+1
View File
@@ -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()),
}
}
+335 -32
View File
@@ -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<String> = 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<ManagedAgentSummary, String> {
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<bool>,
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<BackendProviderInfo> {
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<serde_json::Value, String> {
// 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.
+1
View File
@@ -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)
+9 -2
View File
@@ -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::<Vec<_>>();
@@ -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,
@@ -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<serde_json::Value, String> {
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::<Vec<u8>>();
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::<Vec<u8>>(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<String> {
let mut words = Vec::new();
let mut current = String::new();
let chars: Vec<char> = 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<String, String> {
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<PathBuf, String> {
// 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}"
),
}
}
}
@@ -275,6 +275,7 @@ pub fn default_token_scopes() -> Vec<String> {
"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<String, String> {
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}"))?;
@@ -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::*;
+50 -22
View File
@@ -137,29 +137,55 @@ pub fn build_managed_agent_summary(
record: &ManagedAgentRecord,
runtimes: &HashMap<String, ManagedAgentProcess>,
) -> Result<ManagedAgentSummary, String> {
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(),
@@ -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<u32>,
#[serde(default)]
pub backend: BackendKind,
#[serde(default)]
pub backend_agent_id: Option<String>,
#[serde(default)]
pub provider_binary_path: Option<String>,
pub created_at: String,
pub updated_at: String,
pub last_started_at: Option<String>,
@@ -79,6 +101,8 @@ pub struct ManagedAgentSummary {
pub system_prompt: Option<String>,
pub model: Option<String>,
pub has_api_token: bool,
pub backend: BackendKind,
pub backend_agent_id: Option<String>,
pub status: String,
pub pid: Option<u32>,
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)]
+4
View File
@@ -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<u32>,
/// 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)]
+17 -4
View File
@@ -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;
}
+20 -1
View File
@@ -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 });
@@ -97,9 +97,9 @@ export function AddAgentToChannelDialog({
<DialogTitle>Add agent to channel</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
+110 -5
View File
@@ -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);
@@ -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<string, string>,
schema: Record<string, unknown> | undefined,
): Record<string, unknown> {
if (!schema) return { ...config };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const properties = (schema as any)?.properties ?? {};
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(config)) {
const prop = properties[key] as Record<string, unknown> | 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<string, unknown>;
config: Record<string, string>;
onChange: (config: Record<string, string>) => 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<string>((schema as any)?.required ?? []);
const entries = Object.entries(properties) as [
string,
Record<string, unknown>,
][];
if (entries.length === 0) {
return null;
}
return (
<div className="space-y-3">
{entries.map(([key, prop]) => (
<div key={key} className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor={`provider-cfg-${key}`}
>
{typeof prop.title === "string" ? prop.title : key}
{required.has(key) ? (
<span className="ml-1 text-destructive">*</span>
) : null}
</label>
<Input
id={`provider-cfg-${key}`}
onChange={(e) => 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" ? (
<p className="text-xs text-muted-foreground">{prop.description}</p>
) : null}
</div>
))}
</div>
);
}
// ── 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<string, string>
>({});
const [probedProvider, setProbedProvider] =
React.useState<BackendProviderProbeResult | null>(null);
const [probeError, setProbeError] = React.useState<string | null>(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<string, string> = {};
for (const [key, prop] of Object.entries(props) as [
string,
Record<string, unknown>,
][]) {
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({
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-5">
<CreateAgentBasicsFields name={name} onNameChange={setName} />
<CreateAgentRuntimeProviderField
onProviderChange={handleProviderChange}
providers={providers}
providersLoading={providersQuery.isLoading}
selectedProvider={selectedProvider}
selectedProviderId={selectedProviderId}
/>
{/* Run on selector — only shown when backend providers are discovered */}
{backendProviders.length > 0 ? (
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-run-on">
Run on
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
id="agent-run-on"
onChange={(e) => handleRunOnChange(e.target.value)}
value={runOn}
>
<option value="local">This computer</option>
{backendProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
</option>
))}
</select>
</div>
) : null}
{/* Provider mode: trust warning + config fields */}
{isProviderMode && selectedBackendProvider ? (
<div className="space-y-4">
<div className="flex gap-3 rounded-2xl border border-amber-400/60 bg-amber-50/60 px-4 py-3 dark:border-amber-500/40 dark:bg-amber-950/30">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<p className="text-sm text-amber-800 dark:text-amber-300">
This provider at{" "}
<span className="font-mono font-medium">
{selectedBackendProvider.binaryPath}
</span>{" "}
will receive your agent&apos;s private key. Only use
providers from trusted sources.
</p>
</div>
{probeError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Could not probe provider: {probeError}
</p>
) : null}
{probedProvider?.config_schema ? (
<ProviderConfigFields
config={providerConfig}
onChange={setProviderConfig}
schema={probedProvider.config_schema}
/>
) : null}
</div>
) : null}
{/* Local mode: show the ACP runtime selector */}
{!isProviderMode ? (
<CreateAgentRuntimeProviderField
onProviderChange={handleProviderChange}
providers={providers}
providersLoading={providersQuery.isLoading}
selectedProvider={selectedProvider}
selectedProviderId={selectedProviderId}
/>
) : null}
<CreateAgentOptionToggles
isMintSupported={isMintSupported}
isSpawnSupported={isSpawnSupported}
mintToken={mintToken}
mintToggleDisabled={mintToggleDisabled}
mintToken={effectiveMintToken}
mintToggleDisabled={isProviderMode || mintToggleDisabled}
onToggleMintToken={() => {
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 ? (
<CreateAgentTokenSection
lockedScopes={
isProviderMode
? new Set<TokenScope>(PROVIDER_REQUIRED_SCOPES)
: undefined
}
onScopeToggle={toggleScope}
onTokenNameChange={setTokenName}
selectedScopes={selectedScopes}
@@ -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
</p>
<p className="mt-1 text-sm text-foreground/70">
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."}
</p>
</button>
@@ -352,11 +358,14 @@ export function CreateAgentOptionToggles({
export function CreateAgentTokenSection({
selectedScopes,
tokenName,
lockedScopes,
onScopeToggle,
onTokenNameChange,
}: {
selectedScopes: Set<TokenScope>;
tokenName: string;
/** Scopes that cannot be removed (e.g. required for remote agent controllability). */
lockedScopes?: Set<TokenScope>;
onScopeToggle: (scope: TokenScope) => void;
onTokenNameChange: (value: string) => void;
}) {
@@ -379,6 +388,7 @@ export function CreateAgentTokenSection({
<div className="grid grid-cols-2 gap-2">
{MANAGED_AGENT_SCOPE_OPTIONS.map((scope) => {
const selected = selectedScopes.has(scope.value);
const locked = lockedScopes?.has(scope.value) && selected;
return (
<button
className={cn(
@@ -386,12 +396,24 @@ export function CreateAgentTokenSection({
selected
? "border-primary bg-primary/10 text-foreground"
: "border-border/60 text-muted-foreground hover:bg-accent",
locked && "cursor-not-allowed opacity-70",
)}
disabled={locked}
key={scope.value}
onClick={() => onScopeToggle(scope.value)}
title={
locked
? "Required for remote agent controllability"
: undefined
}
type="button"
>
{scope.label}
{locked ? (
<span className="ml-1 text-xs text-muted-foreground">
(required)
</span>
) : null}
<span className="block text-xs text-foreground/60">
{scope.description}
</span>
@@ -11,8 +11,9 @@ import {
UserPlus,
} from "lucide-react";
import type { ManagedAgent } from "@/shared/api/types";
import type { ManagedAgent, PresenceLookup } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -34,6 +35,7 @@ export function ManagedAgentsSection({
isActionPending,
isLoading,
personaLabelsById,
presenceLookup,
onAddToChannel,
onCreate,
onDelete,
@@ -50,6 +52,7 @@ export function ManagedAgentsSection({
isActionPending: boolean;
isLoading: boolean;
personaLabelsById: Record<string, string>;
presenceLookup: PresenceLookup;
onAddToChannel: (agent: ManagedAgent) => void;
onCreate: () => void;
onDelete: (pubkey: string) => void;
@@ -64,10 +67,10 @@ export function ManagedAgentsSection({
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold tracking-tight">
Managed locally
Managed agents
</h3>
<p className="text-sm text-muted-foreground">
Saved agent profiles and local ACP process state.
Agent profiles and process state local and remote.
</p>
</div>
<Tooltip>
@@ -137,6 +140,7 @@ export function ManagedAgentsSection({
isActionPending={isActionPending}
key={agent.pubkey}
personaLabelsById={personaLabelsById}
presenceLookup={presenceLookup}
onAddToChannel={onAddToChannel}
onDelete={onDelete}
onMintToken={onMintToken}
@@ -177,6 +181,7 @@ function ManagedAgentRow({
agent,
isActionPending,
personaLabelsById,
presenceLookup,
onAddToChannel,
onDelete,
onMintToken,
@@ -188,6 +193,7 @@ function ManagedAgentRow({
agent: ManagedAgent;
isActionPending: boolean;
personaLabelsById: Record<string, string>;
presenceLookup: PresenceLookup;
onAddToChannel: (agent: ManagedAgent) => void;
onDelete: (pubkey: string) => void;
onMintToken: (pubkey: string, name: string) => void;
@@ -196,19 +202,32 @@ function ManagedAgentRow({
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
onViewLogs: (pubkey: string) => void;
}) {
const isRunning = agent.status === "running";
const isActive = agent.status === "running" || agent.status === "deployed";
const personaLabel = agent.personaId
? (personaLabelsById[agent.personaId] ?? null)
: null;
const presenceStatus = presenceLookup[agent.pubkey.trim().toLowerCase()];
return (
<tr
className="cursor-pointer border-b border-border/60 last:border-b-0 hover:bg-muted/30"
className={cn(
"border-b border-border/60 last:border-b-0 hover:bg-muted/30",
agent.backend.type === "local" && "cursor-pointer",
)}
data-testid={`managed-agent-${agent.pubkey}`}
onClick={() => onViewLogs(agent.pubkey)}
onClick={() => {
if (agent.backend.type === "local") {
onViewLogs(agent.pubkey);
}
}}
>
<td className="px-4 py-3">
<p className="truncate font-medium text-foreground">{agent.name}</p>
<div className="flex items-center gap-1.5">
{presenceStatus ? (
<PresenceDot className="shrink-0" status={presenceStatus} />
) : null}
<p className="truncate font-medium text-foreground">{agent.name}</p>
</div>
{personaLabel ? (
<p className="mt-1">
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
@@ -224,7 +243,7 @@ function ManagedAgentRow({
<span
className={cn(
"inline-flex rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em]",
isRunning
isActive
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
@@ -248,7 +267,7 @@ function ManagedAgentRow({
<AgentActionsMenu
agent={agent}
isActionPending={isActionPending}
isRunning={isRunning}
isActive={isActive}
onAddToChannel={onAddToChannel}
onDelete={onDelete}
onMintToken={onMintToken}
@@ -265,7 +284,7 @@ function ManagedAgentRow({
function AgentActionsMenu({
agent,
isActionPending,
isRunning,
isActive,
onAddToChannel,
onDelete,
onMintToken,
@@ -276,7 +295,7 @@ function AgentActionsMenu({
}: {
agent: ManagedAgent;
isActionPending: boolean;
isRunning: boolean;
isActive: boolean;
onAddToChannel: (agent: ManagedAgent) => void;
onDelete: (pubkey: string) => void;
onMintToken: (pubkey: string, name: string) => void;
@@ -299,7 +318,24 @@ function AgentActionsMenu({
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
{isRunning ? (
{agent.backend.type === "provider" ? (
<>
<DropdownMenuItem
disabled={isActionPending}
onClick={() => onStart(agent.pubkey)}
>
<Play className="h-4 w-4" />
{isActive ? "Redeploy" : "Deploy"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={isActionPending}
onClick={() => onStop(agent.pubkey)}
>
<Square className="h-4 w-4" />
Shutdown
</DropdownMenuItem>
</>
) : isActive ? (
<DropdownMenuItem
disabled={isActionPending}
onClick={() => onStop(agent.pubkey)}
@@ -340,20 +376,26 @@ function AgentActionsMenu({
Copy pubkey
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onViewLogs(agent.pubkey)}>
<FileText className="h-4 w-4" />
View logs
</DropdownMenuItem>
{agent.backend.type === "local" ? (
<DropdownMenuItem onClick={() => onViewLogs(agent.pubkey)}>
<FileText className="h-4 w-4" />
View logs
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={isActionPending}
onClick={() =>
onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch)
}
>
<Power className="h-4 w-4" />
{agent.startOnAppLaunch ? "Disable auto-start" : "Enable auto-start"}
</DropdownMenuItem>
{agent.backend.type === "local" ? (
<DropdownMenuItem
disabled={isActionPending}
onClick={() =>
onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch)
}
>
<Power className="h-4 w-4" />
{agent.startOnAppLaunch
? "Disable auto-start"
: "Enable auto-start"}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
@@ -68,7 +68,7 @@ export function ModelPicker({
pubkey: agent.pubkey,
model: modelId === modelsData?.agentDefaultModel ? null : modelId,
});
if (agent.status === "running") {
if (agent.status === "running" || agent.status === "deployed") {
setNeedsRestart(true);
}
onModelChanged?.();
@@ -84,7 +84,11 @@ export function SecretRevealDialog({
) : (
<p className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
{created.agent.name} is ready
{created.agent.status === "running" ? " and running." : "."}
{created.agent.status === "running"
? " and running."
: created.agent.status === "deployed"
? " and deployed."
: "."}
</p>
)}
</>
+34 -2
View File
@@ -3,6 +3,8 @@ import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import type {
AddChannelMembersInput,
AddChannelMembersResult,
BackendProviderCandidate,
BackendProviderProbeResult,
CanvasResponse,
Channel,
ChannelDetail,
@@ -16,6 +18,7 @@ import type {
MintTokenResponse,
MintManagedAgentTokenInput,
ManagedAgent,
ManagedAgentBackend,
RelayAgent,
PresenceLookup,
PresenceStatus,
@@ -215,6 +218,7 @@ type RawRelayAgent = {
name: string;
agent_type: string;
channels: string[];
channel_ids: string[];
capabilities: string[];
status: RelayAgent["status"];
};
@@ -243,6 +247,8 @@ export type RawManagedAgent = {
last_error: string | null;
log_path: string;
start_on_app_launch: boolean;
backend: ManagedAgentBackend;
backend_agent_id: string | null;
};
type RawCreateManagedAgentResponse = {
@@ -775,6 +781,7 @@ function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent {
name: agent.name,
agentType: agent.agent_type,
channels: agent.channels,
channelIds: agent.channel_ids ?? [],
capabilities: agent.capabilities,
status: agent.status,
};
@@ -805,6 +812,8 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
lastError: agent.last_error,
logPath: agent.log_path,
startOnAppLaunch: agent.start_on_app_launch,
backend: agent.backend,
backendAgentId: agent.backend_agent_id,
};
}
@@ -898,6 +907,7 @@ export async function createManagedAgent(input: CreateManagedAgentInput) {
tokenName: input.tokenName,
spawnAfterCreate: input.spawnAfterCreate,
startOnAppLaunch: input.startOnAppLaunch,
backend: input.backend,
},
},
);
@@ -925,8 +935,14 @@ export async function stopManagedAgent(pubkey: string): Promise<ManagedAgent> {
return fromRawManagedAgent(response);
}
export async function deleteManagedAgent(pubkey: string): Promise<void> {
await invokeTauri("delete_managed_agent", { pubkey });
export async function deleteManagedAgent(
pubkey: string,
forceRemoteDelete?: boolean,
): Promise<void> {
await invokeTauri("delete_managed_agent", {
pubkey,
forceRemoteDelete: forceRemoteDelete ?? null,
});
}
export async function mintManagedAgentToken(input: MintManagedAgentTokenInput) {
@@ -1003,3 +1019,19 @@ export async function updateManagedAgent(
});
return fromRawManagedAgent(response);
}
// ── Backend provider discovery ────────────────────────────────────────────────
export async function discoverBackendProviders(): Promise<
BackendProviderCandidate[]
> {
return invokeTauri<BackendProviderCandidate[]>("discover_backend_providers");
}
export async function probeBackendProvider(
binaryPath: string,
): Promise<BackendProviderProbeResult> {
return invokeTauri<BackendProviderProbeResult>("probe_backend_provider", {
binaryPath,
});
}
+22 -1
View File
@@ -267,10 +267,15 @@ export type RelayAgent = {
name: string;
agentType: string;
channels: string[];
channelIds: string[];
capabilities: string[];
status: "online" | "away" | "offline";
};
export type ManagedAgentBackend =
| { type: "local" }
| { type: "provider"; id: string; config: Record<string, unknown> };
export type ManagedAgent = {
pubkey: string;
name: string;
@@ -285,7 +290,7 @@ export type ManagedAgent = {
systemPrompt: string | null;
model: string | null;
hasApiToken: boolean;
status: "running" | "stopped";
status: "running" | "stopped" | "deployed" | "not_deployed";
pid: number | null;
createdAt: string;
updatedAt: string;
@@ -295,6 +300,21 @@ export type ManagedAgent = {
lastError: string | null;
logPath: string;
startOnAppLaunch: boolean;
backend: ManagedAgentBackend;
backendAgentId: string | null;
};
export type BackendProviderCandidate = {
id: string;
binaryPath: string;
};
export type BackendProviderProbeResult = {
ok: boolean;
name?: string;
version?: string;
description?: string;
config_schema?: Record<string, unknown>;
};
export type CreateManagedAgentInput = {
@@ -315,6 +335,7 @@ export type CreateManagedAgentInput = {
tokenName?: string;
spawnAfterCreate?: boolean;
startOnAppLaunch?: boolean;
backend?: ManagedAgentBackend;
};
export type CreateManagedAgentResponse = {
+30 -1
View File
@@ -230,7 +230,7 @@ type RawManagedAgent = {
system_prompt: string | null;
model: string | null;
has_api_token: boolean;
status: "running" | "stopped";
status: "running" | "stopped" | "deployed" | "not_deployed";
pid: number | null;
created_at: string;
updated_at: string;
@@ -240,6 +240,10 @@ type RawManagedAgent = {
last_error: string | null;
log_path: string;
start_on_app_launch: boolean;
backend:
| { type: "local" }
| { type: "provider"; id: string; config: Record<string, unknown> };
backend_agent_id: string | null;
};
type RawCreateManagedAgentResponse = {
@@ -550,6 +554,8 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
last_error: agent.last_error,
log_path: agent.log_path,
start_on_app_launch: agent.start_on_app_launch,
backend: agent.backend ?? { type: "local" as const },
backend_agent_id: agent.backend_agent_id ?? null,
};
}
@@ -2452,6 +2458,9 @@ async function handleCreateManagedAgent(args: {
tokenName?: string;
spawnAfterCreate?: boolean;
startOnAppLaunch?: boolean;
backend?:
| { type: "local" }
| { type: "provider"; id: string; config: Record<string, unknown> };
};
}): Promise<RawCreateManagedAgentResponse> {
const name = args.input.name.trim();
@@ -2492,6 +2501,8 @@ async function handleCreateManagedAgent(args: {
last_error: null,
log_path: `/tmp/mock-agent-${pubkey}.log`,
start_on_app_launch: args.input.startOnAppLaunch ?? true,
backend: args.input.backend ?? { type: "local" as const },
backend_agent_id: null,
private_key_nsec: `nsec1mock${pubkey.slice(0, 20)}`,
api_token: token,
log_lines: [
@@ -2559,7 +2570,21 @@ async function handleStopManagedAgent(args: {
async function handleDeleteManagedAgent(args: {
pubkey: string;
forceRemoteDelete?: boolean | null;
}): Promise<void> {
// Model the backend invariant: reject deletion of deployed remote agents
// unless force_remote_delete is true.
const agent = mockManagedAgents.find((a) => a.pubkey === args.pubkey);
if (
agent &&
agent.backend.type === "provider" &&
agent.backend_agent_id != null &&
!args.forceRemoteDelete
) {
throw new Error(
"cannot delete a deployed remote agent without force_remote_delete: true",
);
}
mockManagedAgents = mockManagedAgents.filter(
(candidate) => candidate.pubkey !== args.pubkey,
);
@@ -3155,6 +3180,10 @@ export function maybeInstallE2eTauriMocks() {
return getRelayWsUrl(activeConfig);
case "discover_acp_providers":
return handleDiscoverAcpProviders();
case "discover_backend_providers":
return [];
case "probe_backend_provider":
return { ok: false, error: "mock: no providers available" };
case "discover_managed_agent_prereqs":
return handleDiscoverManagedAgentPrereqs(
payload as Parameters<typeof handleDiscoverManagedAgentPrereqs>[0],