feat(desktop): GUI for agent respond-to gate (owner-only / anyone / allowlist) (#557)

This commit is contained in:
tlongwell-block
2026-05-13 12:48:42 -04:00
committed by GitHub
parent 5db47a84b1
commit 9c258e4beb
14 changed files with 1120 additions and 7 deletions
+3 -2
View File
@@ -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
@@ -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)?;
+39 -2
View File
@@ -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<String, String> {
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<ManagedAgentSummary, String> {
// 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()
@@ -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<String> = 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
+214 -1
View File
@@ -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<String, ManagedAgentProcess>,
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<String>,
auth_tag: Option<String>,
) -> 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"));
}
}
@@ -120,6 +120,14 @@ pub struct ManagedAgentRecord {
pub last_stopped_at: Option<String>,
pub last_exit_code: Option<i32>,
pub last_error: Option<String>,
/// 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<String>,
}
#[derive(Debug)]
@@ -157,6 +165,8 @@ pub struct ManagedAgentSummary {
pub last_error: Option<String>,
pub start_on_app_launch: bool,
pub log_path: String,
pub respond_to: RespondTo,
pub respond_to_allowlist: Vec<String>,
}
#[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<String>,
}
#[derive(Debug, Serialize)]
@@ -295,6 +311,13 @@ pub struct UpdateManagedAgentRequest {
pub agent_args: Option<Vec<String>>,
#[serde(default)]
pub mcp_command: Option<String>,
/// Absent = don't touch. Present = set mode.
#[serde(default)]
pub respond_to: Option<RespondTo>,
/// Absent = don't touch. Present = replace the allowlist (validated &
/// normalized server-side).
#[serde(default)]
pub respond_to_allowlist: Option<Vec<String>>,
}
#[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<Vec<String>, 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::<RespondTo>("\"nobody\"").is_err());
assert!(serde_json::from_str::<RespondTo>("\"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());
}
}
@@ -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]);
});
@@ -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<string>();
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;
}
@@ -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<RespondToMode>("owner-only");
const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>(
[],
);
// ── 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}
/>
<CreateAgentRespondToField
allowlist={respondToAllowlist}
mode={respondTo}
onAllowlistChange={setRespondToAllowlist}
onModeChange={setRespondTo}
/>
<div className="rounded-2xl border border-border/70 bg-muted/20">
<button
aria-expanded={showAdvanced}
@@ -1,7 +1,11 @@
import * as React from "react";
import { useUpdateManagedAgentMutation } from "@/features/agents/hooks";
import type { ManagedAgent, UpdateManagedAgentInput } from "@/shared/api/types";
import type {
ManagedAgent,
RespondToMode,
UpdateManagedAgentInput,
} from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -14,6 +18,7 @@ import {
CreateAgentBasicsFields,
CreateAgentRuntimeFields,
} from "./CreateAgentDialogSections";
import { CreateAgentRespondToField } from "./RespondToField";
export function EditAgentDialog({
agent,
@@ -44,6 +49,12 @@ export function EditAgentDialog({
const [systemPrompt, setSystemPrompt] = React.useState(
agent.systemPrompt ?? "",
);
const [respondTo, setRespondTo] = React.useState<RespondToMode>(
agent.respondTo,
);
const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>(
agent.respondToAllowlist,
);
// Reset form state only when the dialog opens or when switching to a different
// agent. Omitting the full agent object and its array fields from deps prevents
@@ -62,6 +73,8 @@ export function EditAgentDialog({
setTurnTimeoutSeconds(String(agent.turnTimeoutSeconds));
setParallelism(String(agent.parallelism));
setSystemPrompt(agent.systemPrompt ?? "");
setRespondTo(agent.respondTo);
setRespondToAllowlist(agent.respondToAllowlist);
updateMutation.reset();
}
}, [open, agent.pubkey]);
@@ -81,12 +94,19 @@ export function EditAgentDialog({
// cause a runtime failure.
const acpCommandValid = !(agent.acpCommand && acpCommand.trim() === "");
const mcpCommandValid = !(agent.mcpCommand && mcpCommand.trim() === "");
// Allowlist mode requires at least one entry — mirrors the harness's own
// validation. The backend would reject the request anyway; we block early
// so the user sees the disabled button instead of a round-tripped error.
const respondToValid =
respondTo !== "allowlist" || respondToAllowlist.length > 0;
const canSubmit =
name.trim().length > 0 &&
parallelismValid &&
timeoutValid &&
acpCommandValid &&
mcpCommandValid &&
respondToValid &&
!updateMutation.isPending;
async function handleSubmit() {
@@ -136,6 +156,18 @@ export function EditAgentDialog({
(systemPrompt.trim() || null) !== agent.systemPrompt
? systemPrompt.trim() || null
: undefined,
respondTo: respondTo !== agent.respondTo ? respondTo : undefined,
// The allowlist is preserved across mode toggles in local UI state
// (so a user can flip away from allowlist and back without losing
// their entries), but we only send it on the wire when (a) it
// actually changed, AND (b) the saved mode will need it. Sending
// an allowlist while switching to a non-allowlist mode would be
// harmless server-side, but it's noise in the persisted record.
respondToAllowlist:
respondTo === "allowlist" &&
respondToAllowlist.join(",") !== agent.respondToAllowlist.join(",")
? respondToAllowlist
: undefined,
};
const result = await updateMutation.mutateAsync(input);
@@ -165,6 +197,13 @@ export function EditAgentDialog({
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-5">
<CreateAgentBasicsFields name={name} onNameChange={setName} />
<CreateAgentRespondToField
allowlist={respondToAllowlist}
mode={respondTo}
onAllowlistChange={setRespondToAllowlist}
onModeChange={setRespondTo}
/>
<CreateAgentRuntimeFields
acpCommand={acpCommand}
agentArgs={agentArgs}
@@ -0,0 +1,371 @@
import * as React from "react";
import { ChevronDown, Search, X } from "lucide-react";
import {
mergeAllowlist,
parsePubkeyInput,
} from "@/features/agents/lib/respondToAllowlist";
import { formatPubkey } from "@/features/channels/lib/memberUtils";
import { useUserSearchQuery } from "@/features/profile/hooks";
import type { RespondToMode, UserSearchResult } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { UserAvatar } from "@/shared/ui/UserAvatar";
/**
* Inbound author gate UI for create/edit agent dialogs.
*
* Dropdown:
* - Owner only (default; matches `sprout-acp --respond-to=owner-only`)
* - Anyone (`--respond-to=anyone` — fully open bot)
* - Allowlist (`--respond-to=allowlist`, plus the chip list as
* `--respond-to-allowlist`)
*
* `nobody` is intentionally not surfaced — it pairs with a heartbeat-only
* setup that has no meaningful GUI use case.
*
* Validation is duplicated lightly here for inline UX feedback only; the
* authoritative validator is `validate_respond_to_allowlist` in
* `desktop/src-tauri/src/managed_agents/types.rs`.
*/
function formatSearchUserName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
formatPubkey(user.pubkey)
);
}
function formatSearchUserSecondary(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return formatPubkey(user.pubkey);
}
export function CreateAgentRespondToField({
mode,
allowlist,
onModeChange,
onAllowlistChange,
ownerPubkey,
disabled,
}: {
mode: RespondToMode;
allowlist: string[];
onModeChange: (mode: RespondToMode) => void;
onAllowlistChange: (allowlist: string[]) => void;
/**
* The agent's own owner pubkey, when known (Edit dialog). The owner is
* always implicitly included by the harness — surfaced here as a small
* informational note so it doesn't look like a missing entry.
*/
ownerPubkey?: string | null;
disabled?: boolean;
}) {
const [query, setQuery] = React.useState("");
const [isDirectEntryOpen, setIsDirectEntryOpen] = React.useState(false);
const [pasteText, setPasteText] = React.useState("");
const deferredQuery = React.useDeferredValue(query.trim());
const allowlistSet = React.useMemo(
() => new Set(allowlist.map((p) => p.toLowerCase())),
[allowlist],
);
const userSearchQuery = useUserSearchQuery(deferredQuery, {
enabled: mode === "allowlist" && deferredQuery.length > 0,
limit: 8,
});
const searchResults = React.useMemo(
() =>
(userSearchQuery.data ?? []).filter(
(user) => !allowlistSet.has(user.pubkey.toLowerCase()),
),
[allowlistSet, userSearchQuery.data],
);
const pasteParsed = React.useMemo(
() => parsePubkeyInput(pasteText),
[pasteText],
);
function handleAddSearchResult(user: UserSearchResult) {
onAllowlistChange(mergeAllowlist(allowlist, [user.pubkey]));
setQuery("");
}
function handleRemove(pubkey: string) {
onAllowlistChange(
allowlist.filter((p) => p.toLowerCase() !== pubkey.toLowerCase()),
);
}
function handleAddFromPaste() {
if (pasteParsed.valid.length === 0) return;
onAllowlistChange(mergeAllowlist(allowlist, pasteParsed.valid));
setPasteText("");
}
return (
<div className="space-y-2" data-testid="agent-respond-to">
<label className="text-sm font-medium" htmlFor="agent-respond-to">
Who can talk to this agent
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
data-testid="agent-respond-to-select"
disabled={disabled}
id="agent-respond-to"
onChange={(e) => onModeChange(e.target.value as RespondToMode)}
value={mode}
>
<option value="owner-only">Owner only (default)</option>
<option value="anyone">Anyone</option>
<option value="allowlist">Allowlist</option>
</select>
<p className="text-xs text-muted-foreground">
Controls which Nostr authors the agent listens to (@mentions, DMs,
thread replies). The agent&apos;s owner can always shut it down with
<span className="font-mono"> !shutdown</span>.
</p>
{mode === "allowlist" ? (
<AllowlistPicker
allowlist={allowlist}
deferredQuery={deferredQuery}
disabled={disabled}
isDirectEntryOpen={isDirectEntryOpen}
onAddFromPaste={handleAddFromPaste}
onAddSearchResult={handleAddSearchResult}
onPasteTextChange={setPasteText}
onQueryChange={setQuery}
onRemove={handleRemove}
onToggleDirectEntry={() => setIsDirectEntryOpen((v) => !v)}
ownerPubkey={ownerPubkey ?? null}
pasteInvalid={pasteParsed.invalid}
pasteText={pasteText}
pasteValidCount={pasteParsed.valid.length}
query={query}
searchError={
userSearchQuery.error instanceof Error
? userSearchQuery.error.message
: null
}
searchIsLoading={userSearchQuery.isLoading}
searchResults={searchResults}
/>
) : null}
</div>
);
}
function AllowlistPicker({
allowlist,
deferredQuery,
disabled,
isDirectEntryOpen,
onAddFromPaste,
onAddSearchResult,
onPasteTextChange,
onQueryChange,
onRemove,
onToggleDirectEntry,
ownerPubkey,
pasteInvalid,
pasteText,
pasteValidCount,
query,
searchError,
searchIsLoading,
searchResults,
}: {
allowlist: string[];
deferredQuery: string;
disabled?: boolean;
isDirectEntryOpen: boolean;
onAddFromPaste: () => void;
onAddSearchResult: (user: UserSearchResult) => void;
onPasteTextChange: (value: string) => void;
onQueryChange: (value: string) => void;
onRemove: (pubkey: string) => void;
onToggleDirectEntry: () => void;
ownerPubkey: string | null;
pasteInvalid: string[];
pasteText: string;
pasteValidCount: number;
query: string;
searchError: string | null;
searchIsLoading: boolean;
searchResults: UserSearchResult[];
}) {
return (
<div
className="space-y-2.5 rounded-xl border border-border/80 bg-muted/15 p-3"
data-testid="agent-respond-to-allowlist"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Allowed pubkeys</span>
<span className="rounded-full bg-background px-2 py-1 text-[11px] font-medium leading-none text-muted-foreground">
{allowlist.length} selected
</span>
</div>
{ownerPubkey ? (
<p className="text-xs text-muted-foreground">
Owner (<span className="font-mono">{formatPubkey(ownerPubkey)}</span>)
is always implicitly allowed by the harness — no need to add it here.
</p>
) : (
<p className="text-xs text-muted-foreground">
The agent&apos;s owner is always implicitly allowed.
</p>
)}
<div className="rounded-lg border border-border/80 bg-background">
<div className="flex items-center gap-2 px-2.5 py-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
className="h-auto border-0 px-0 py-0 shadow-none focus-visible:ring-0"
data-testid="agent-respond-to-search"
disabled={disabled}
onChange={(event) => onQueryChange(event.target.value)}
placeholder="Search by name or NIP-05."
value={query}
/>
</div>
{allowlist.length > 0 ? (
<div className="flex flex-wrap gap-1.5 border-t border-border/70 px-2.5 py-2">
{allowlist.map((pubkey) => (
<div
className="inline-flex items-center gap-1.5 rounded-full border border-border/80 bg-muted/60 px-2.5 py-1 text-[11px] leading-none"
data-testid={`agent-respond-to-chip-${pubkey}`}
key={pubkey}
>
<UserAvatar
avatarUrl={null}
displayName={formatPubkey(pubkey)}
size="xs"
/>
<span className="font-mono">{formatPubkey(pubkey)}</span>
<button
aria-label={`Remove ${formatPubkey(pubkey)}`}
className="text-muted-foreground transition-colors hover:text-foreground"
disabled={disabled}
onClick={() => onRemove(pubkey)}
type="button"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
) : null}
{deferredQuery.length > 0 ? (
<div className="border-t border-border/70 px-2 py-2">
{searchIsLoading ? (
<p className="px-2 py-1 text-sm text-muted-foreground">
Searching…
</p>
) : searchResults.length > 0 ? (
<div className="max-h-44 space-y-1 overflow-y-auto">
{searchResults.map((result) => (
<button
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
data-testid={`agent-respond-to-result-${result.pubkey}`}
key={result.pubkey}
onClick={() => onAddSearchResult(result)}
type="button"
>
<div className="flex items-center gap-2 min-w-0">
<UserAvatar
avatarUrl={result.avatarUrl}
displayName={formatSearchUserName(result)}
size="xs"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium leading-5">
{formatSearchUserName(result)}
</p>
<p className="truncate text-xs text-muted-foreground">
{formatSearchUserSecondary(result)}
</p>
</div>
</div>
<span className="text-xs text-muted-foreground">Add</span>
</button>
))}
</div>
) : (
<p className="px-2 py-1 text-sm text-muted-foreground">
No matching users.
</p>
)}
</div>
) : null}
</div>
{searchError ? (
<p className="text-sm text-destructive">{searchError}</p>
) : null}
<div className="space-y-2">
<button
aria-controls="agent-respond-to-direct-panel"
aria-expanded={isDirectEntryOpen}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
data-testid="agent-respond-to-toggle-direct"
onClick={onToggleDirectEntry}
type="button"
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
isDirectEntryOpen && "rotate-180",
)}
/>
<span>Paste pubkeys</span>
</button>
{isDirectEntryOpen ? (
<div
className="space-y-2 rounded-lg border border-dashed border-border/80 bg-background/70 p-2.5"
id="agent-respond-to-direct-panel"
>
<p className="text-xs text-muted-foreground">
One per line, or comma/space-separated. 64-char lowercase hex only
— npub decoding is not yet supported here.
</p>
<Textarea
className="min-h-20 font-mono text-xs"
data-testid="agent-respond-to-paste"
disabled={disabled}
onChange={(event) => onPasteTextChange(event.target.value)}
placeholder="abcdef0123…"
value={pasteText}
/>
{pasteInvalid.length > 0 ? (
<p className="text-xs text-destructive">
{pasteInvalid.length} entr
{pasteInvalid.length === 1 ? "y is" : "ies are"} not 64-char hex
and will be ignored.
</p>
) : null}
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{pasteValidCount > 0
? `${pasteValidCount} valid pubkey${pasteValidCount === 1 ? "" : "s"} ready.`
: "No valid pubkeys yet."}
</span>
<button
className="rounded-md border border-border/80 bg-background px-2.5 py-1 text-xs font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50"
data-testid="agent-respond-to-paste-add"
disabled={disabled || pasteValidCount === 0}
onClick={onAddFromPaste}
type="button"
>
Add to allowlist
</button>
</div>
</div>
) : null}
</div>
</div>
);
}
+10
View File
@@ -226,6 +226,10 @@ export type RawManagedAgent = {
start_on_app_launch: boolean;
backend: ManagedAgentBackend;
backend_agent_id: string | null;
// Optional: pre-feature mock fixtures may omit these. Mapped to
// `"owner-only"` / `[]` in `fromRawManagedAgent`.
respond_to?: ManagedAgent["respondTo"];
respond_to_allowlist?: string[];
};
type RawCreateManagedAgentResponse = {
@@ -833,6 +837,10 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
startOnAppLaunch: agent.start_on_app_launch,
backend: agent.backend,
backendAgentId: agent.backend_agent_id,
// Fallbacks for pre-feature mocks/fixtures that don't carry these fields.
// Real agent records always include them (defaulted server-side).
respondTo: agent.respond_to ?? "owner-only",
respondToAllowlist: agent.respond_to_allowlist ?? [],
};
}
@@ -945,6 +953,8 @@ export async function createManagedAgent(input: CreateManagedAgentInput) {
spawnAfterCreate: input.spawnAfterCreate,
startOnAppLaunch: input.startOnAppLaunch,
backend: input.backend,
respondTo: input.respondTo,
respondToAllowlist: input.respondToAllowlist,
},
},
);
+28
View File
@@ -290,8 +290,22 @@ export type ManagedAgent = {
startOnAppLaunch: boolean;
backend: ManagedAgentBackend;
backendAgentId: string | null;
/** Who the agent should respond to. Maps to `sprout-acp --respond-to`. */
respondTo: RespondToMode;
/**
* Normalized 64-char lowercase hex pubkeys. Used only when `respondTo` is
* `"allowlist"`. Preserved across mode toggles.
*/
respondToAllowlist: string[];
};
/**
* Inbound author gate mode. Mirrors `sprout-acp`'s `--respond-to` CLI flag.
* `"nobody"` is supported by the harness but not surfaced through this API —
* it's a heartbeat-only mode without a meaningful GUI use case.
*/
export type RespondToMode = "owner-only" | "allowlist" | "anyone";
export type BackendProviderCandidate = {
id: string;
binaryPath: string;
@@ -324,6 +338,13 @@ export type CreateManagedAgentInput = {
spawnAfterCreate?: boolean;
startOnAppLaunch?: boolean;
backend?: ManagedAgentBackend;
/** Inbound author gate mode. Omitted = `"owner-only"` (server default). */
respondTo?: RespondToMode;
/**
* Hex pubkeys to allow when `respondTo === "allowlist"`. Validated &
* normalized server-side (must be 64 hex chars each).
*/
respondToAllowlist?: string[];
};
export type CreateManagedAgentResponse = {
@@ -389,6 +410,13 @@ export type UpdateManagedAgentInput = {
agentCommand?: string;
agentArgs?: string[];
mcpCommand?: string;
/** Absent = don't touch. Present = set the mode. */
respondTo?: RespondToMode;
/**
* Absent = don't touch. Present = replace the allowlist with this list
* (validated & normalized server-side).
*/
respondToAllowlist?: string[];
};
export type AgentPersona = {
id: string;
+18
View File
@@ -300,6 +300,8 @@ type RawManagedAgent = {
| { type: "local" }
| { type: "provider"; id: string; config: Record<string, unknown> };
backend_agent_id: string | null;
respond_to: "owner-only" | "allowlist" | "anyone";
respond_to_allowlist: string[];
};
type RawCreateManagedAgentResponse = {
@@ -672,6 +674,10 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
start_on_app_launch: agent.start_on_app_launch,
backend: agent.backend ?? { type: "local" as const },
backend_agent_id: agent.backend_agent_id ?? null,
respond_to: agent.respond_to ?? "owner-only",
respond_to_allowlist: agent.respond_to_allowlist
? [...agent.respond_to_allowlist]
: [],
};
}
@@ -3789,6 +3795,8 @@ async function handleCreateManagedAgent(args: {
backend?:
| { type: "local" }
| { type: "provider"; id: string; config: Record<string, unknown> };
respondTo?: "owner-only" | "allowlist" | "anyone";
respondToAllowlist?: string[];
};
}): Promise<RawCreateManagedAgentResponse> {
if (args.input.personaId) {
@@ -3831,6 +3839,8 @@ async function handleCreateManagedAgent(args: {
start_on_app_launch: args.input.startOnAppLaunch ?? true,
backend: args.input.backend ?? { type: "local" as const },
backend_agent_id: null,
respond_to: args.input.respondTo ?? "owner-only",
respond_to_allowlist: args.input.respondToAllowlist ?? [],
private_key_nsec: `nsec1mock${pubkey.slice(0, 20)}`,
log_lines: [
`sprout-acp starting: relay=${args.input.relayUrl ?? DEFAULT_RELAY_WS_URL} agent_pubkey=${pubkey} parallelism=${args.input.parallelism ?? 1}`,
@@ -3945,6 +3955,8 @@ async function handleUpdateManagedAgent(args: {
name?: string;
model?: string | null;
systemPrompt?: string | null;
respondTo?: "owner-only" | "allowlist" | "anyone";
respondToAllowlist?: string[];
};
}): Promise<{ agent: RawManagedAgent; profile_sync_error: string | null }> {
const agent = getMockManagedAgent(args.input.pubkey);
@@ -3957,6 +3969,12 @@ async function handleUpdateManagedAgent(args: {
if (args.input.systemPrompt !== undefined) {
agent.system_prompt = args.input.systemPrompt;
}
if (args.input.respondTo !== undefined) {
agent.respond_to = args.input.respondTo;
}
if (args.input.respondToAllowlist !== undefined) {
agent.respond_to_allowlist = args.input.respondToAllowlist;
}
agent.updated_at = new Date().toISOString();
return { agent: cloneManagedAgent(agent), profile_sync_error: null };
}