diff --git a/crates/buzz-cli/src/agent_management.rs b/crates/buzz-cli/src/agent_management.rs index ce4059f821..119f9bc968 100644 --- a/crates/buzz-cli/src/agent_management.rs +++ b/crates/buzz-cli/src/agent_management.rs @@ -35,6 +35,8 @@ pub struct UpdateAgentDraft { pub model: Option, #[serde(skip_serializing_if = "Option::is_none")] pub respond_to: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub respond_to_allowlist: Vec, } #[derive(Debug, Serialize)] @@ -84,6 +86,28 @@ fn optional(value: Option, label: &str) -> Result, CliErr value.map(|value| required(value, label, 300)).transpose() } +/// Validate and normalize a respond-to allowlist: each entry must be exactly +/// 64 hex chars (any case in, lowercase out); duplicates are removed, +/// insertion order preserved. Mirrors the desktop-side validation in +/// `managed_agents::types::validate_respond_to_allowlist`. +fn validate_respond_to_allowlist(input: &[String]) -> Result, CliError> { + 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(CliError::Usage(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) +} + fn build( keys: &Keys, owner: &PublicKey, @@ -152,10 +176,21 @@ pub fn build_update( let respond_to = optional(draft.respond_to, "respond-to")?; if respond_to .as_deref() - .is_some_and(|value| value != "owner-only" && value != "anyone") + .is_some_and(|value| value != "owner-only" && value != "allowlist" && value != "anyone") { return Err(CliError::Usage( - "respond-to must be owner-only or anyone".into(), + "respond-to must be owner-only, allowlist, or anyone".into(), + )); + } + let respond_to_allowlist = validate_respond_to_allowlist(&draft.respond_to_allowlist)?; + if respond_to.as_deref() == Some("allowlist") && respond_to_allowlist.is_empty() { + return Err(CliError::Usage( + "--respond-to allowlist requires at least one --respond-to-allowlist pubkey".into(), + )); + } + if !respond_to_allowlist.is_empty() && respond_to.as_deref() != Some("allowlist") { + return Err(CliError::Usage( + "--respond-to-allowlist requires --respond-to allowlist".into(), )); } let request = UpdateAgentDraft { @@ -170,6 +205,7 @@ pub fn build_update( provider: optional(draft.provider, "provider")?, model: optional(draft.model, "model")?, respond_to, + respond_to_allowlist, }; if request.display_name.is_none() && request.system_prompt.is_none() @@ -254,12 +290,110 @@ mod tests { provider: None, model: None, respond_to: None, + respond_to_allowlist: Vec::new(), }, ) .unwrap_err(); assert!(error.to_string().contains("at least one field")); } + #[test] + fn update_allowlist_mode_requires_at_least_one_pubkey() { + let error = build_update( + &Keys::generate(), + &Keys::generate().public_key(), + UpdateAgentDraft { + channel_id: CHANNEL.into(), + agent_name: "Scout".into(), + display_name: None, + system_prompt: None, + runtime: None, + provider: None, + model: None, + respond_to: Some("allowlist".into()), + respond_to_allowlist: Vec::new(), + }, + ) + .unwrap_err(); + assert!(error + .to_string() + .contains("at least one --respond-to-allowlist")); + } + + #[test] + fn update_allowlist_without_mode_is_rejected() { + let pubkey = "a".repeat(64); + let error = build_update( + &Keys::generate(), + &Keys::generate().public_key(), + UpdateAgentDraft { + channel_id: CHANNEL.into(), + agent_name: "Scout".into(), + display_name: None, + system_prompt: None, + runtime: None, + provider: None, + model: None, + respond_to: None, + respond_to_allowlist: vec![pubkey], + }, + ) + .unwrap_err(); + assert!(error + .to_string() + .contains("--respond-to-allowlist requires --respond-to allowlist")); + } + + #[test] + fn update_allowlist_mode_is_included_in_encrypted_payload_and_dedupes() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let pubkey = "B".repeat(64); + let built = build_update( + &agent, + &owner.public_key(), + UpdateAgentDraft { + channel_id: CHANNEL.into(), + agent_name: "Scout".into(), + display_name: None, + system_prompt: None, + runtime: None, + provider: None, + model: None, + respond_to: Some("allowlist".into()), + respond_to_allowlist: vec![pubkey.clone(), pubkey.to_ascii_uppercase()], + }, + ) + .unwrap(); + let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap(); + assert_eq!(payload["payload"]["request"]["respondTo"], "allowlist"); + assert_eq!( + payload["payload"]["request"]["respondToAllowlist"], + serde_json::json!([pubkey.to_ascii_lowercase()]) + ); + } + + #[test] + fn update_rejects_invalid_allowlist_pubkey() { + let error = build_update( + &Keys::generate(), + &Keys::generate().public_key(), + UpdateAgentDraft { + channel_id: CHANNEL.into(), + agent_name: "Scout".into(), + display_name: None, + system_prompt: None, + runtime: None, + provider: None, + model: None, + respond_to: Some("allowlist".into()), + respond_to_allowlist: vec!["not-hex".into()], + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("must be 64 hex chars")); + } + #[test] fn create_rejects_invalid_channel() { let error = build_create( diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 58564a45c2..6b362b8a89 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -52,6 +52,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli provider, model, respond_to, + respond_to_allowlist, } => { let owner = require_owner(client)?; let built = build_update( @@ -66,6 +67,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli provider, model, respond_to: respond_to.map(RespondToArg::to_wire), + respond_to_allowlist, }, )?; let response = client.publish_ephemeral_event(built.event).await?; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index f745e7b280..2bd290c04f 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -245,6 +245,8 @@ enum Cmd { pub enum RespondToArg { #[value(name = "owner-only")] OwnerOnly, + #[value(name = "allowlist")] + Allowlist, #[value(name = "anyone")] Anyone, } @@ -253,6 +255,7 @@ impl RespondToArg { fn to_wire(self) -> String { match self { Self::OwnerOnly => "owner-only", + Self::Allowlist => "allowlist", Self::Anyone => "anyone", } .to_string() @@ -294,6 +297,9 @@ pub enum AgentsCmd { model: Option, #[arg(long, value_enum)] respond_to: Option, + /// Pubkey allowed to trigger the agent when --respond-to=allowlist (hex, repeatable) + #[arg(long = "respond-to-allowlist")] + respond_to_allowlist: Vec, }, /// Submit a NIP-IA archive request for an identity (kind 9035) #[command( diff --git a/desktop/src/features/agents/agentManagement.test.mjs b/desktop/src/features/agents/agentManagement.test.mjs index 0fa9176c74..7ab58d70bf 100644 --- a/desktop/src/features/agents/agentManagement.test.mjs +++ b/desktop/src/features/agents/agentManagement.test.mjs @@ -90,6 +90,57 @@ test("uses an agent's current name, never an internal profile ID", () => { assert.deepEqual(parseAgentManagementRequest(payload), payload); }); +test("parses an allowlist-mode update and carries the pubkey list", () => { + const payload = { + type: AGENT_MANAGEMENT_REQUEST, + action: "update", + requestId: "request-4", + request: { + channelId: CHANNEL_ID, + agentName: "Review helper", + respondTo: "allowlist", + respondToAllowlist: ["a".repeat(64), "b".repeat(64)], + }, + }; + + assert.deepEqual(parseAgentManagementRequest(payload), payload); +}); + +test("drops respondToAllowlist when respondTo isn't allowlist mode", () => { + const payload = { + type: AGENT_MANAGEMENT_REQUEST, + action: "update", + requestId: "request-5", + request: { + channelId: CHANNEL_ID, + agentName: "Review helper", + respondTo: "anyone", + respondToAllowlist: ["a".repeat(64)], + }, + }; + + const parsed = parseAgentManagementRequest(payload); + assert.ok(parsed && parsed.action === "update"); + assert.equal(parsed.request.respondTo, "anyone"); + assert.equal(parsed.request.respondToAllowlist, undefined); +}); + +test("rejects a malformed respondToAllowlist", () => { + const payload = { + type: AGENT_MANAGEMENT_REQUEST, + action: "update", + requestId: "request-6", + request: { + channelId: CHANNEL_ID, + agentName: "Review helper", + respondTo: "allowlist", + respondToAllowlist: [42], + }, + }; + + assert.equal(parseAgentManagementRequest(payload), null); +}); + test("allows agents to update only personal, editable profiles", () => { assert.equal( requestTargetsEditablePersona({ isBuiltIn: false, sourceTeam: null }), diff --git a/desktop/src/features/agents/agentManagement.ts b/desktop/src/features/agents/agentManagement.ts index 5b5e18d872..e82a0d1a4f 100644 --- a/desktop/src/features/agents/agentManagement.ts +++ b/desktop/src/features/agents/agentManagement.ts @@ -30,6 +30,8 @@ export type AgentManagementUpdateRequest = { provider?: string; model?: string; respondTo?: RespondToMode; + /** Present only when `respondTo === "allowlist"`; validated server-side. */ + respondToAllowlist?: string[]; }; }; @@ -42,7 +44,16 @@ function isText(value: unknown): value is string { } function isRespondTo(value: unknown): value is RespondToMode | undefined { - return value === undefined || value === "owner-only" || value === "anyone"; + return ( + value === undefined || + value === "owner-only" || + value === "allowlist" || + value === "anyone" + ); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => isText(item)); } function hasOnlyKeys( @@ -94,6 +105,8 @@ export function parseAgentManagementRequest( if ( !isRespondTo(request.respondTo) || + (request.respondToAllowlist !== undefined && + !isStringArray(request.respondToAllowlist)) || !hasOnlyKeys(request, [ "channelId", "agentName", @@ -103,6 +116,7 @@ export function parseAgentManagementRequest( "provider", "model", "respondTo", + "respondToAllowlist", ]) || !isText(request.channelId) || !isText(request.agentName) @@ -120,6 +134,12 @@ export function parseAgentManagementRequest( ...(isText(request.provider) ? { provider: request.provider } : {}), ...(isText(request.model) ? { model: request.model } : {}), ...(request.respondTo ? { respondTo: request.respondTo } : {}), + // Mode and list travel together — only carry the allowlist when the + // request actually selects allowlist mode. + ...(request.respondTo === "allowlist" && + isStringArray(request.respondToAllowlist) + ? { respondToAllowlist: request.respondToAllowlist } + : {}), }; if (Object.keys(changes).length === 0) return null; return { diff --git a/desktop/src/features/agents/useAgentManagement.ts b/desktop/src/features/agents/useAgentManagement.ts index 066f7949a9..c6bd876718 100644 --- a/desktop/src/features/agents/useAgentManagement.ts +++ b/desktop/src/features/agents/useAgentManagement.ts @@ -49,7 +49,10 @@ function updateInputFromRequest( ? { behavior: { respondTo: changes.respondTo, - respondToAllowlist: [], + respondToAllowlist: + changes.respondTo === "allowlist" + ? (changes.respondToAllowlist ?? []) + : [], parallelism: current.behavior?.parallelism, }, }