diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index a29daa3e48..99f211cdb7 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -50,7 +50,8 @@ const overrides = new Map([ ["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ ["src-tauri/src/nostr_convert.rs", 870], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + 20 unit tests - ["src-tauri/src/managed_agents/runtime.rs", 780], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle + pack persona live-read + login shell PATH augmentation + observer endpoint wiring + git credential helper env injection + sprout-agent mcp_hooks wiring + tests + ["src-tauri/src/managed_agents/runtime.rs", 990], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + ["src-tauri/src/managed_agents/types.rs", 700], // ManagedAgentRecord/Summary + Create/Update request structs + RespondTo enum + validate_respond_to_allowlist + tests ["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh ["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation @@ -62,7 +63,7 @@ const overrides = new Map([ ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes ["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display ["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow - ["src/shared/api/types.ts", 580], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack + UpdateManagedAgentInput edit fields + UserStatus/UserStatusLookup (NIP-38) + RelayMember/RelayMemberRole types + ChannelTemplate/TemplateAgentEntry/TemplateTeamEntry + ["src/shared/api/types.ts", 620], // ... + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs ["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders ["src-tauri/src/huddle/kokoro.rs", 980], // Kokoro ONNX TTS engine + three-tier G2P + ARPAbet→IPA + CoreML + synth_chunk() public API + style validation + hyphenated compound splitting + 23 unit tests ["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 5a96c35b76..7db9be2879 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -171,6 +171,30 @@ pub async fn update_managed_agent( if let Some(mcp_command) = input.mcp_command { record.mcp_command = mcp_command; } + + // Inbound author gate: merge patch onto current values, then validate + // the merged state. This lets a single update switch to Allowlist AND + // supply pubkeys atomically. + let prospective_mode = input.respond_to.unwrap_or(record.respond_to); + let prospective_allowlist = match input.respond_to_allowlist.as_ref() { + Some(list) => crate::managed_agents::validate_respond_to_allowlist(list)?, + None => record.respond_to_allowlist.clone(), + }; + if prospective_mode == crate::managed_agents::RespondTo::Allowlist + && prospective_allowlist.is_empty() + { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist" + .to_string(), + ); + } + record.respond_to = prospective_mode; + // Preserve the persisted allowlist across mode toggles — only replace + // when the caller explicitly supplied a new list. + if input.respond_to_allowlist.is_some() { + record.respond_to_allowlist = prospective_allowlist; + } + record.updated_at = now_iso(); save_managed_agents(&app, &records)?; diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 521ae3bb8a..3aeb07a147 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -19,6 +19,14 @@ use crate::{ util::now_iso, }; +/// Read the workspace owner's pubkey hex from app state without holding the +/// lock for longer than necessary. Used to populate `SPROUT_ACP_AGENT_OWNER` +/// as a fallback for legacy agent records that have no NIP-OA `auth_tag`. +fn workspace_owner_hex(state: &AppState) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + Ok(keys.public_key().to_hex()) +} + /// Build the standard agent JSON payload for provider deploy calls. fn build_deploy_payload(record: &ManagedAgentRecord) -> serde_json::Value { serde_json::json!({ @@ -34,6 +42,10 @@ fn build_deploy_payload(record: &ManagedAgentRecord) -> serde_json::Value { "idle_timeout_seconds": record.idle_timeout_seconds, "max_turn_duration_seconds": record.max_turn_duration_seconds, "parallelism": record.parallelism, + // Inbound author gate. Providers that don't yet read these fall back + // to the harness default (`owner-only`) — no protocol break. + "respond_to": record.respond_to, + "respond_to_allowlist": &record.respond_to_allowlist, }) } @@ -151,6 +163,24 @@ pub async fn create_managed_agent( } } + // Validate & normalize the respond-to allowlist BEFORE any side effects. + // The harness has its own validator (sprout-acp/src/config.rs) but we want + // to catch malformed input at the boundary so the agent never tries to + // start with a list that will crash it on launch. + let respond_to_allowlist = + crate::managed_agents::validate_respond_to_allowlist(&input.respond_to_allowlist)?; + if input.respond_to == crate::managed_agents::RespondTo::Allowlist + && respond_to_allowlist.is_empty() + { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + + // Snapshot the workspace owner pubkey for the legacy-record auth_tag + // fallback. Computed outside the records lock to keep lock ordering simple. + let owner_hex = workspace_owner_hex(&state)?; + // ── Phase 1: generate keys (sync lock) ──────────────────────────────────── let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = { let _store_guard = state @@ -350,6 +380,8 @@ pub async fn create_managed_agent( last_stopped_at: None, last_exit_code: None, last_error: None, + respond_to: input.respond_to, + respond_to_allowlist: respond_to_allowlist.clone(), }; records.push(record); @@ -357,7 +389,9 @@ pub async fn create_managed_agent( let mut spawn_error = None; 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) { + if let Err(error) = + start_managed_agent_process(&app, record, &mut runtimes, Some(&owner_hex)) + { record.updated_at = now_iso(); record.last_error = Some(error.clone()); spawn_error = Some(error); @@ -455,6 +489,9 @@ pub async fn start_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { + // Snapshot the workspace owner pubkey for the legacy auth_tag fallback. + // Read outside the records lock to keep lock ordering simple. + let owner_hex = workspace_owner_hex(&state)?; // Collect backend info and handle local vs provider under lock. let (backend, cached_binary_path, agent_json) = { let _store_guard = state @@ -475,7 +512,7 @@ pub async fn start_managed_agent( if record.backend == BackendKind::Local { // Local: spawn in-process and return immediately. - start_managed_agent_process(&app, record, &mut runtimes)?; + start_managed_agent_process(&app, record, &mut runtimes, Some(&owner_hex))?; save_managed_agents(&app, &records)?; let record = records .iter() diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index a390c28bb2..e6bbc3bdae 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -83,18 +83,29 @@ pub fn restore_managed_agents_on_launch( return Ok(()); } + // Snapshot the workspace owner pubkey once for the legacy auth_tag fallback. + // Read outside the per-agent spawn loop so all parallel spawns see the same + // value and we don't lock `state.keys` repeatedly. + let owner_hex: Option = state + .keys + .lock() + .map_err(|e| e.to_string()) + .ok() + .map(|k| k.public_key().to_hex()); + // ── Phase B (no locks): resolve commands and spawn processes in parallel ── let spawn_results: Vec<( String, Result<(std::process::Child, std::path::PathBuf), String>, )> = std::thread::scope(|scope| { + let owner_hex_ref = owner_hex.as_deref(); let handles: Vec<_> = agents_to_start .iter() .filter(|_| !shutdown_started.load(Ordering::SeqCst)) .map(|record| { let pubkey = record.pubkey.clone(); let handle = scope.spawn(move || { - let result = spawn_agent_child(app, record); + let result = spawn_agent_child(app, record, owner_hex_ref); (pubkey, result) }); handle diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 7f9c34f488..99dba89445 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -427,6 +427,8 @@ pub fn build_managed_agent_summary( last_error: record.last_error.clone(), start_on_app_launch: record.start_on_app_launch, log_path, + respond_to: record.respond_to, + respond_to_allowlist: record.respond_to_allowlist.clone(), }) } @@ -440,12 +442,73 @@ pub fn find_managed_agent_mut<'a>( .ok_or_else(|| format!("agent {pubkey} not found")) } +/// Pure decision function for the inbound author gate env vars. +/// +/// Returns the env vars to **set** and the env vars to **remove**. Removal is +/// belt-and-suspenders: an inherited parent env var must not leak into a +/// child agent and silently change its security posture. +/// +/// The `owner_hex` argument is the current workspace owner pubkey. It's used +/// as a fallback for legacy records (`auth_tag.is_none()`) — without it, the +/// harness's owner cache stays empty and `owner-only` / `allowlist` modes +/// drop everything. +/// +/// Returns `Err(...)` if the record's allowlist fails validation. The harness +/// validates too, but doing it here means we never spawn a doomed process. +pub(crate) fn build_respond_to_env( + record: &ManagedAgentRecord, + owner_hex: Option<&str>, +) -> Result<(Vec<(&'static str, String)>, Vec<&'static str>), String> { + // Defensive re-validation: an on-disk record could have been hand-edited. + let normalized = super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?; + if record.respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + + let mut set: Vec<(&'static str, String)> = Vec::new(); + let mut remove: Vec<&'static str> = Vec::new(); + + set.push(( + "SPROUT_ACP_RESPOND_TO", + record.respond_to.as_str().to_string(), + )); + + if record.respond_to == super::types::RespondTo::Allowlist { + set.push(("SPROUT_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); + } else { + remove.push("SPROUT_ACP_RESPOND_TO_ALLOWLIST"); + } + + // Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without + // it the harness can't resolve the owner, and owner-dependent gate modes + // would drop every event. Forwarding the workspace owner pubkey via + // SPROUT_ACP_AGENT_OWNER keeps those records functional. Modern records + // (`auth_tag = Some(...)`) use `SPROUT_AUTH_TAG` as before. + if record.auth_tag.is_none() { + if let Some(owner) = owner_hex { + set.push(("SPROUT_ACP_AGENT_OWNER", owner.to_string())); + } else { + remove.push("SPROUT_ACP_AGENT_OWNER"); + } + } else { + remove.push("SPROUT_ACP_AGENT_OWNER"); + } + + Ok((set, remove)) +} + /// Spawn an agent process without holding any locks on records or runtimes. /// Returns the child process and log path on success. The caller is responsible /// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. +/// +/// `owner_hex`: the workspace owner's pubkey, used as a fallback for legacy +/// records that have no NIP-OA `auth_tag`. See `build_respond_to_env`. pub fn spawn_agent_child( app: &AppHandle, record: &ManagedAgentRecord, + owner_hex: Option<&str>, ) -> Result<(std::process::Child, std::path::PathBuf), String> { let log_path = managed_agent_log_path(app, &record.pubkey)?; append_log_marker( @@ -574,6 +637,18 @@ pub fn spawn_agent_child( command.env_remove("SPROUT_AUTH_TAG"); } + // Inbound author gate: who is this agent allowed to respond to? + // Validation is strict here — a malformed allowlist on disk fails before + // we spawn anything (the harness would also reject it, but we'd rather + // fail with a clear error than crash-loop the child). + let (gate_set, gate_remove) = build_respond_to_env(record, owner_hex)?; + for (key, value) in &gate_set { + command.env(key, value); + } + for key in &gate_remove { + command.env_remove(key); + } + command.env("SPROUT_ACP_RELAY_OBSERVER", "true"); // ── Git credential helper for Sprout relay ────────────────────────── @@ -643,6 +718,7 @@ pub fn start_managed_agent_process( app: &AppHandle, record: &mut ManagedAgentRecord, runtimes: &mut HashMap, + owner_hex: Option<&str>, ) -> Result<(), String> { if let Some(runtime) = runtimes.get_mut(&record.pubkey) { if runtime @@ -667,7 +743,7 @@ pub fn start_managed_agent_process( record.runtime_pid = None; } - let (child, log_path) = spawn_agent_child(app, record)?; + let (child, log_path) = spawn_agent_child(app, record, owner_hex)?; let now = now_iso(); record.updated_at = now.clone(); @@ -769,4 +845,141 @@ mod tests { fn unknown_command_returns_none() { assert!(known_acp_provider("custom-agent").is_none()); } + + // ── build_respond_to_env tests ─────────────────────────────────────── + + use super::build_respond_to_env; + use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; + + /// Construct a minimal record fixture for env-building tests. Only the + /// fields read by `build_respond_to_env` matter here. + fn fixture( + respond_to: RespondTo, + allowlist: Vec, + auth_tag: Option, + ) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "p".into(), + name: "n".into(), + persona_id: None, + private_key_nsec: "nsec1fake".into(), + auth_tag, + relay_url: "ws://localhost:3000".into(), + acp_command: "sprout-acp".into(), + agent_command: "goose".into(), + agent_args: vec![], + mcp_command: "sprout-mcp-server".into(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + mcp_toolsets: None, + start_on_app_launch: false, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + persona_pack_path: None, + persona_name_in_pack: None, + created_at: "now".into(), + updated_at: "now".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + respond_to, + respond_to_allowlist: allowlist, + } + } + + #[test] + fn build_env_owner_only_sets_mode_and_removes_others() { + let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into())); + let (set, remove) = build_respond_to_env(&rec, Some("owner")).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("SPROUT_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only") + ); + assert!(!set_map.contains_key("SPROUT_ACP_RESPOND_TO_ALLOWLIST")); + assert!(remove.contains(&"SPROUT_ACP_RESPOND_TO_ALLOWLIST")); + // auth_tag is present → no AGENT_OWNER fallback fires. + assert!(remove.contains(&"SPROUT_ACP_AGENT_OWNER")); + } + + #[test] + fn build_env_allowlist_sets_both_envs_and_joins() { + let a = "a".repeat(64); + let b = "b".repeat(64); + let rec = fixture( + RespondTo::Allowlist, + vec![a.clone(), b.clone()], + Some("tag".into()), + ); + let (set, _remove) = build_respond_to_env(&rec, Some("owner")).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("SPROUT_ACP_RESPOND_TO").map(String::as_str), + Some("allowlist") + ); + assert_eq!( + set_map + .get("SPROUT_ACP_RESPOND_TO_ALLOWLIST") + .map(String::as_str), + Some(format!("{a},{b}").as_str()), + ); + } + + #[test] + fn build_env_anyone_omits_allowlist_var() { + let rec = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + let (set, remove) = build_respond_to_env(&rec, Some("owner")).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("SPROUT_ACP_RESPOND_TO").map(String::as_str), + Some("anyone") + ); + assert!(!set_map.contains_key("SPROUT_ACP_RESPOND_TO_ALLOWLIST")); + assert!(remove.contains(&"SPROUT_ACP_RESPOND_TO_ALLOWLIST")); + } + + #[test] + fn build_env_legacy_record_without_auth_tag_emits_agent_owner() { + let rec = fixture(RespondTo::OwnerOnly, vec![], None); + let (set, remove) = build_respond_to_env(&rec, Some("ownerhex")).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("SPROUT_ACP_AGENT_OWNER").map(String::as_str), + Some("ownerhex") + ); + assert!(!remove.contains(&"SPROUT_ACP_AGENT_OWNER")); + } + + #[test] + fn build_env_legacy_record_without_owner_hex_removes_agent_owner() { + // No owner available to forward → make sure we don't inherit a leaked + // env var from the parent. + let rec = fixture(RespondTo::OwnerOnly, vec![], None); + let (_set, remove) = build_respond_to_env(&rec, None).unwrap(); + assert!(remove.contains(&"SPROUT_ACP_AGENT_OWNER")); + } + + #[test] + fn build_env_rejects_corrupted_allowlist() { + let rec = fixture( + RespondTo::Allowlist, + vec!["not-hex".into()], + Some("tag".into()), + ); + assert!(build_respond_to_env(&rec, Some("owner")).is_err()); + } + + #[test] + fn build_env_rejects_empty_allowlist_in_allowlist_mode() { + let rec = fixture(RespondTo::Allowlist, vec![], Some("tag".into())); + let err = build_respond_to_env(&rec, Some("owner")).unwrap_err(); + assert!(err.contains("at least one pubkey")); + } } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 67551ead84..dc4bafeccd 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -120,6 +120,14 @@ pub struct ManagedAgentRecord { pub last_stopped_at: Option, pub last_exit_code: Option, pub last_error: Option, + /// Inbound author gate mode. Translates to `SPROUT_ACP_RESPOND_TO`. + #[serde(default)] + pub respond_to: RespondTo, + /// Allowlist used when `respond_to == Allowlist`. Stored normalized + /// (64-char lowercase hex, deduped). Empty when mode is not Allowlist. + /// Preserved across mode toggles so users don't lose state. + #[serde(default)] + pub respond_to_allowlist: Vec, } #[derive(Debug)] @@ -157,6 +165,8 @@ pub struct ManagedAgentSummary { pub last_error: Option, pub start_on_app_launch: bool, pub log_path: String, + pub respond_to: RespondTo, + pub respond_to_allowlist: Vec, } #[derive(Debug, Deserialize)] @@ -185,6 +195,12 @@ pub struct CreateManagedAgentRequest { pub start_on_app_launch: bool, #[serde(default)] pub backend: BackendKind, + #[serde(default)] + pub respond_to: RespondTo, + /// Raw allowlist as received from the frontend. Validated and normalized + /// before being written to the record. + #[serde(default)] + pub respond_to_allowlist: Vec, } #[derive(Debug, Serialize)] @@ -295,6 +311,13 @@ pub struct UpdateManagedAgentRequest { pub agent_args: Option>, #[serde(default)] pub mcp_command: Option, + /// Absent = don't touch. Present = set mode. + #[serde(default)] + pub respond_to: Option, + /// Absent = don't touch. Present = replace the allowlist (validated & + /// normalized server-side). + #[serde(default)] + pub respond_to_allowlist: Option>, } #[derive(Debug, Serialize)] @@ -381,6 +404,68 @@ fn default_record_active() -> bool { true } +// ── Inbound author gate ────────────────────────────────────────────────────── +// +// Mirrors `sprout-acp`'s `--respond-to` CLI flag and the related +// `--respond-to-allowlist` option. Persisted per agent so the desktop can +// translate the user's choice into `SPROUT_ACP_RESPOND_TO` / +// `SPROUT_ACP_RESPOND_TO_ALLOWLIST` env vars at spawn time. +// +// Wire format is kebab-case (`owner-only`, `allowlist`, `anyone`) to match +// the harness CLI vocabulary and the strings the GUI emits. +// +// `nobody` is intentionally NOT exposed here. The harness supports it, but +// it's a heartbeat-only mode and the desktop has no surface for it. + +/// Who the agent should respond to. Defaults to `OwnerOnly`, which matches +/// the harness default → existing agents behave identically. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum RespondTo { + #[default] + OwnerOnly, + Allowlist, + Anyone, +} + +impl RespondTo { + /// CLI/env wire string (matches `sprout-acp`'s `--respond-to`). + pub fn as_str(self) -> &'static str { + match self { + Self::OwnerOnly => "owner-only", + Self::Allowlist => "allowlist", + Self::Anyone => "anyone", + } + } +} + +/// Validate and normalize a respond-to allowlist. +/// +/// Rules mirror `sprout-acp/src/config.rs::validate_allowlist`: +/// - Each entry is exactly 64 hex chars (any case in, lowercase out). +/// - Duplicates removed, insertion order preserved. +/// +/// Empty input is allowed here — the boundary check (allowlist mode requires +/// at least one entry) is the caller's job, because an `UpdateManagedAgentRequest` +/// may want to validate a list without yet knowing the final mode. +pub fn validate_respond_to_allowlist(input: &[String]) -> Result, String> { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::with_capacity(input.len()); + for entry in input { + let trimmed = entry.trim(); + if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "invalid pubkey in respond-to allowlist: '{trimmed}' (must be 64 hex chars)" + )); + } + let lower = trimmed.to_ascii_lowercase(); + if seen.insert(lower.clone()) { + out.push(lower); + } + } + Ok(out) +} + #[cfg(test)] mod tests { use super::{ManagedAgentRecord, PersonaRecord}; @@ -473,4 +558,122 @@ mod tests { serde_json::from_str(&serialized).expect("round-trip should deserialize"); assert_eq!(record.auth_tag, record2.auth_tag); } + + // ── Inbound author gate tests ──────────────────────────────────────── + + use super::{validate_respond_to_allowlist, RespondTo}; + + #[test] + fn respond_to_default_is_owner_only() { + assert_eq!(RespondTo::default(), RespondTo::OwnerOnly); + } + + #[test] + fn respond_to_serde_is_kebab_case() { + assert_eq!( + serde_json::to_string(&RespondTo::OwnerOnly).unwrap(), + "\"owner-only\"" + ); + assert_eq!( + serde_json::to_string(&RespondTo::Allowlist).unwrap(), + "\"allowlist\"" + ); + assert_eq!( + serde_json::to_string(&RespondTo::Anyone).unwrap(), + "\"anyone\"" + ); + let parsed: RespondTo = serde_json::from_str("\"owner-only\"").unwrap(); + assert_eq!(parsed, RespondTo::OwnerOnly); + let parsed: RespondTo = serde_json::from_str("\"allowlist\"").unwrap(); + assert_eq!(parsed, RespondTo::Allowlist); + let parsed: RespondTo = serde_json::from_str("\"anyone\"").unwrap(); + assert_eq!(parsed, RespondTo::Anyone); + } + + #[test] + fn respond_to_rejects_unknown_modes() { + // `nobody` is a valid harness mode but intentionally not exposed + // through the desktop request types. + assert!(serde_json::from_str::("\"nobody\"").is_err()); + assert!(serde_json::from_str::("\"OwnerOnly\"").is_err()); + } + + /// Records persisted before this feature must continue to load, + /// defaulting to OwnerOnly (the safe, matches-harness-default value). + #[test] + fn managed_agent_record_without_respond_to_fields_defaults_to_owner_only() { + let record: ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "abcd1234", + "name": "legacy-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "sprout-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "sprout-mcp-server", + "turn_timeout_seconds": 320, + "system_prompt": null, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("legacy record without respond_to fields should deserialize"); + assert_eq!(record.respond_to, RespondTo::OwnerOnly); + assert!(record.respond_to_allowlist.is_empty()); + } + + #[test] + fn validate_respond_to_allowlist_accepts_valid_hex_and_lowercases() { + let upper = "A".repeat(64); + let lower = "a".repeat(64); + let result = validate_respond_to_allowlist(&[upper.clone()]).unwrap(); + assert_eq!(result, vec![lower.clone()]); + } + + #[test] + fn validate_respond_to_allowlist_dedups_preserving_order() { + let a = "a".repeat(64); + let b = "b".repeat(64); + let a_upper = "A".repeat(64); + let input = vec![a.clone(), b.clone(), a_upper]; + let result = validate_respond_to_allowlist(&input).unwrap(); + assert_eq!(result, vec![a, b]); + } + + #[test] + fn validate_respond_to_allowlist_rejects_wrong_length() { + let too_short = "a".repeat(63); + assert!(validate_respond_to_allowlist(&[too_short]).is_err()); + let too_long = "a".repeat(65); + assert!(validate_respond_to_allowlist(&[too_long]).is_err()); + } + + #[test] + fn validate_respond_to_allowlist_rejects_non_hex() { + let bad = "z".repeat(64); + assert!(validate_respond_to_allowlist(&[bad]).is_err()); + // npub-style strings should not slip through. + let npub = format!("npub1{}", "a".repeat(59)); + assert!(validate_respond_to_allowlist(&[npub]).is_err()); + } + + #[test] + fn validate_respond_to_allowlist_trims_whitespace() { + let padded = format!(" {} ", "a".repeat(64)); + let result = validate_respond_to_allowlist(&[padded]).unwrap(); + assert_eq!(result, vec!["a".repeat(64)]); + } + + #[test] + fn validate_respond_to_allowlist_accepts_empty() { + // Empty is allowed at this layer; the boundary check + // (Allowlist mode requires ≥1 entry) is the caller's job. + let result = validate_respond_to_allowlist(&[]).unwrap(); + assert!(result.is_empty()); + } } diff --git a/desktop/src/features/agents/lib/respondToAllowlist.test.mjs b/desktop/src/features/agents/lib/respondToAllowlist.test.mjs new file mode 100644 index 0000000000..bbe07f7204 --- /dev/null +++ b/desktop/src/features/agents/lib/respondToAllowlist.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mergeAllowlist, parsePubkeyInput } from "./respondToAllowlist.ts"; + +const HEX_A = "a".repeat(64); +const HEX_B = "b".repeat(64); +const HEX_A_UPPER = "A".repeat(64); + +test("parsePubkeyInput splits on commas, whitespace, and newlines", () => { + const input = `${HEX_A}, ${HEX_B}\n${HEX_A_UPPER}`; + const result = parsePubkeyInput(input); + assert.deepEqual(result.valid, [HEX_A, HEX_B]); + assert.deepEqual(result.invalid, []); +}); + +test("parsePubkeyInput lowercases and dedupes", () => { + const result = parsePubkeyInput(`${HEX_A_UPPER} ${HEX_A}`); + assert.deepEqual(result.valid, [HEX_A]); +}); + +test("parsePubkeyInput surfaces invalid entries separately", () => { + const result = parsePubkeyInput(`notgood ${HEX_A} ${"z".repeat(64)}`); + assert.deepEqual(result.valid, [HEX_A]); + assert.deepEqual(result.invalid, ["notgood", "z".repeat(64)]); +}); + +test("parsePubkeyInput rejects npub-style strings (hex only)", () => { + const npub = `npub1${"a".repeat(59)}`; + const result = parsePubkeyInput(npub); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.invalid, [npub]); +}); + +test("parsePubkeyInput rejects wrong-length entries", () => { + const shortHex = "a".repeat(63); + const longHex = "a".repeat(65); + const result = parsePubkeyInput(`${shortHex} ${longHex}`); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.invalid, [shortHex, longHex]); +}); + +test("parsePubkeyInput handles empty and whitespace-only input", () => { + assert.deepEqual(parsePubkeyInput("").valid, []); + assert.deepEqual(parsePubkeyInput(" \n\t ").valid, []); +}); + +test("mergeAllowlist preserves existing order and appends new", () => { + const merged = mergeAllowlist([HEX_A], [HEX_B]); + assert.deepEqual(merged, [HEX_A, HEX_B]); +}); + +test("mergeAllowlist dedupes case-insensitively", () => { + const merged = mergeAllowlist([HEX_A], [HEX_A_UPPER]); + assert.deepEqual(merged, [HEX_A]); +}); + +test("mergeAllowlist skips invalid additions silently", () => { + // Invalid additions are caller-validated; merge ignores them defensively. + const merged = mergeAllowlist([HEX_A], ["not-hex", HEX_B]); + assert.deepEqual(merged, [HEX_A, HEX_B]); +}); diff --git a/desktop/src/features/agents/lib/respondToAllowlist.ts b/desktop/src/features/agents/lib/respondToAllowlist.ts new file mode 100644 index 0000000000..c376aa1d1a --- /dev/null +++ b/desktop/src/features/agents/lib/respondToAllowlist.ts @@ -0,0 +1,63 @@ +/** + * Pure helpers for the inbound author gate UI. + * + * The Rust side is the canonical validator (see + * `desktop/src-tauri/src/managed_agents/types.rs::validate_respond_to_allowlist`). + * These helpers exist to give the UI immediate, inline feedback before the + * round-trip, and to normalize input so the Rust validator sees clean data. + */ + +const HEX_64 = /^[0-9a-f]{64}$/i; + +export type ParsedAllowlist = { + /** Successfully parsed entries — lowercase hex, deduplicated, in order. */ + valid: string[]; + /** Entries that failed validation, in their raw form. */ + invalid: string[]; +}; + +/** + * Parse a free-form pubkey-paste input (one per line, comma-separated, or + * mixed whitespace) into a normalized allowlist. Matches the splitting + * pattern used by `ChannelMemberInviteCard` so users have one mental model. + * + * - Splits on `/[\s,]+/`. + * - Trims and lowercases each entry. + * - Validates each entry is exactly 64 hex chars. + * - Deduplicates while preserving insertion order. + */ +export function parsePubkeyInput(raw: string): ParsedAllowlist { + const seen = new Set(); + const valid: string[] = []; + const invalid: string[] = []; + for (const piece of raw.split(/[\s,]+/)) { + const trimmed = piece.trim(); + if (trimmed.length === 0) continue; + if (!HEX_64.test(trimmed)) { + invalid.push(trimmed); + continue; + } + const lower = trimmed.toLowerCase(); + if (!seen.has(lower)) { + seen.add(lower); + valid.push(lower); + } + } + return { valid, invalid }; +} + +/** + * Merge an existing allowlist with newly-added pubkeys, normalizing and + * deduplicating without reordering existing entries. + */ +export function mergeAllowlist(existing: string[], add: string[]): string[] { + const seen = new Set(existing.map((p) => p.toLowerCase())); + const out = [...existing.map((p) => p.toLowerCase())]; + for (const candidate of add) { + const lower = candidate.toLowerCase(); + if (!HEX_64.test(lower) || seen.has(lower)) continue; + seen.add(lower); + out.push(lower); + } + return out; +} diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 5c68900a41..019fd9ffd3 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -12,6 +12,7 @@ import type { BackendProviderProbeResult, CreateManagedAgentInput, CreateManagedAgentResponse, + RespondToMode, } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { @@ -31,6 +32,7 @@ import { coerceConfigValues, ProviderConfigFields, } from "./ProviderConfigFields"; +import { CreateAgentRespondToField } from "./RespondToField"; import { useLastRuntimeProvider } from "@/features/agents/lib/useLastRuntimeProvider"; // ── Dialog ──────────────────────────────────────────────────────────────────── @@ -66,6 +68,10 @@ export function CreateAgentDialog({ const [hasSyncedProviderSelection, setHasSyncedProviderSelection] = React.useState(false); const [showAdvanced, setShowAdvanced] = React.useState(false); + const [respondTo, setRespondTo] = React.useState("owner-only"); + const [respondToAllowlist, setRespondToAllowlist] = React.useState( + [], + ); // ── Backend provider ("Run on") state ────────────────────────────────────── const [runOn, setRunOn] = React.useState<"local" | string>("local"); @@ -213,6 +219,8 @@ export function CreateAgentDialog({ setProviderConfig({}); setProbedProvider(null); setProbeError(null); + setRespondTo("owner-only"); + setRespondToAllowlist([]); createMutation.reset(); } @@ -262,6 +270,12 @@ export function CreateAgentDialog({ ); }, [isProviderMode, probedProvider, providerConfig]); + // Allowlist mode requires at least one entry, mirroring the harness's own + // validation. If we let it through empty, the agent crash-loops at startup + // with a config error. + const respondToValid = + respondTo !== "allowlist" || respondToAllowlist.length > 0; + const canSubmit = name.trim().length > 0 && !isDiscoveryPending && @@ -275,10 +289,20 @@ export function CreateAgentDialog({ // fields and config schema are only known after a successful probe. !(isProviderMode && !probedProvider) && providerConfigComplete && + respondToValid && !createMutation.isPending; async function handleSubmit() { try { + // Only send the allowlist when the mode is actually "allowlist". + // Other modes ignore it server-side, but keeping the wire clean makes + // the agent record easier to inspect. + const respondToFields = { + respondTo, + respondToAllowlist: + respondTo === "allowlist" ? respondToAllowlist : undefined, + } as const; + const input: CreateManagedAgentInput = isProviderMode ? { name: name.trim(), @@ -302,6 +326,7 @@ export function CreateAgentDialog({ probedProvider?.config_schema, ), }, + ...respondToFields, } : { name: name.trim(), @@ -326,6 +351,7 @@ export function CreateAgentDialog({ spawnAfterCreate, startOnAppLaunch, backend: { type: "local" }, + ...respondToFields, }; const created = await createMutation.mutateAsync(input); @@ -432,6 +458,13 @@ export function CreateAgentDialog({ spawnToggleDisabled={isProviderMode || spawnToggleDisabled} /> + +
+
+ ))} + + ) : null} + {deferredQuery.length > 0 ? ( +
+ {searchIsLoading ? ( +

+ Searching… +

+ ) : searchResults.length > 0 ? ( +
+ {searchResults.map((result) => ( + + ))} +
+ ) : ( +

+ No matching users. +

+ )} +
+ ) : null} + + {searchError ? ( +

{searchError}

+ ) : null} +
+ + {isDirectEntryOpen ? ( +
+

+ One per line, or comma/space-separated. 64-char lowercase hex only + — npub decoding is not yet supported here. +

+