Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 136 additions & 2 deletions crates/buzz-cli/src/agent_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub struct UpdateAgentDraft {
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub respond_to: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub respond_to_allowlist: Vec<String>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new public allowlist API

The new public UpdateAgentDraft::respond_to_allowlist field has no Rust doc comment; the same is true of the newly public RespondToArg::Allowlist variant at crates/buzz-cli/src/lib.rs:249. Add API documentation for these newly exposed items as required by the repository contributor contract.

AGENTS.md reference: AGENTS.md:L113-L116

Useful? React with 👍 / 👎.

}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -84,6 +86,28 @@ fn optional(value: Option<String>, label: &str) -> Result<Option<String>, 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<Vec<String>, 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<T: Serialize>(
keys: &Keys,
owner: &PublicKey,
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions crates/buzz-cli/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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?;
Expand Down
6 changes: 6 additions & 0 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ enum Cmd {
pub enum RespondToArg {
#[value(name = "owner-only")]
OwnerOnly,
#[value(name = "allowlist")]
Allowlist,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required Signed-off-by trailer

The reviewed commit message contains no Signed-off-by trailer, so the repository's required DCO check will reject this commit regardless of the code and test results; recreate or rebase the commit with signoff.

AGENTS.md reference: AGENTS.md:L111-L111

Useful? React with 👍 / 👎.

#[value(name = "anyone")]
Anyone,
}
Expand All @@ -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()
Expand Down Expand Up @@ -294,6 +297,9 @@ pub enum AgentsCmd {
model: Option<String>,
#[arg(long, value_enum)]
respond_to: Option<RespondToArg>,
/// Pubkey allowed to trigger the agent when --respond-to=allowlist (hex, repeatable)
#[arg(long = "respond-to-allowlist")]
respond_to_allowlist: Vec<String>,
},
/// Submit a NIP-IA archive request for an identity (kind 9035)
#[command(
Expand Down
51 changes: 51 additions & 0 deletions desktop/src/features/agents/agentManagement.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
22 changes: 21 additions & 1 deletion desktop/src/features/agents/agentManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export type AgentManagementUpdateRequest = {
provider?: string;
model?: string;
respondTo?: RespondToMode;
/** Present only when `respondTo === "allowlist"`; validated server-side. */
respondToAllowlist?: string[];
};
};

Expand All @@ -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(
Expand Down Expand Up @@ -94,6 +105,8 @@ export function parseAgentManagementRequest(

if (
!isRespondTo(request.respondTo) ||
(request.respondToAllowlist !== undefined &&
!isStringArray(request.respondToAllowlist)) ||
!hasOnlyKeys(request, [
"channelId",
"agentName",
Expand All @@ -103,6 +116,7 @@ export function parseAgentManagementRequest(
"provider",
"model",
"respondTo",
"respondToAllowlist",
]) ||
!isText(request.channelId) ||
!isText(request.agentName)
Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion desktop/src/features/agents/useAgentManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ function updateInputFromRequest(
? {
behavior: {
respondTo: changes.respondTo,
respondToAllowlist: [],
respondToAllowlist:
changes.respondTo === "allowlist"
? (changes.respondToAllowlist ?? [])
: [],
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record the config-behavior change in AGENTS.md

This changes how allowlist configuration is applied to the owner-reviewed edit draft, but the commit neither updates the scoped AGENTS.md nor explicitly states that no contributor rules changed. The scoped guide requires one of those whenever agent configuration modeling, application, persistence, or clearing changes, so record the new behavior or add the explicit no-rules-changed note.

AGENTS.md reference: desktop/src/features/agents/AGENTS.md:L184-L191

Useful? React with 👍 / 👎.

parallelism: current.behavior?.parallelism,
},
}
Expand Down