mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): GUI for agent respond-to gate (owner-only / anyone / allowlist) (#557)
This commit is contained in:
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user