From 86f2a5f944f28b150fc6968c3cf3954df8a084bb Mon Sep 17 00:00:00 2001 From: Draugmar Date: Mon, 27 Jul 2026 17:08:14 +0200 Subject: [PATCH] fix(cli): merge set-add-policy into existing kind:10100 content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kind:10100 is plain-replaceable, so `buzz channels set-add-policy` — the only CLI path that publishes an agent profile — replaced the whole event with {"channel_add_policy": ...}, silently clobbering any richer profile (respond_to, respond_to_allowlist, channel_ids). Since mention eligibility reads those fields, running the documented command made external agents invisible to @-mention autocomplete. Fetch the identity's current kind:10100 and merge the policy into its content instead. Query errors propagate — failing the command is safer than silently replacing the profile with a minimal one. Absent or unparsable existing content degrades to the previous behavior. Fixes the CLI side of #2987. Signed-off-by: Draugmar --- crates/buzz-cli/src/commands/channels.rs | 73 +++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 42844bf1e0..08756e6da2 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -1001,6 +1001,22 @@ pub async fn cmd_remove_channel_member( Ok(()) } +/// Merge `channel_add_policy` into an existing kind:10100 content object. +/// +/// Returns the existing content with the policy field set, preserving every +/// other field. Absent or unparsable existing content degrades to an object +/// holding only the policy (the previous behavior). +fn merge_channel_add_policy(existing_content: Option<&str>, policy: &str) -> String { + let mut obj = existing_content + .and_then(|c| serde_json::from_str::>(c).ok()) + .unwrap_or_default(); + obj.insert( + "channel_add_policy".to_string(), + serde_json::Value::String(policy.to_string()), + ); + serde_json::Value::Object(obj).to_string() +} + /// Set the channel addition policy — sign and submit a kind:10100 (agent profile) event. pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(), CliError> { match policy { @@ -1032,7 +1048,26 @@ pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(), } } - let content = serde_json::json!({ "channel_add_policy": policy }).to_string(); + // kind:10100 is plain-replaceable: publishing replaces the whole event, so a + // bare {"channel_add_policy"} would clobber any richer agent profile the + // identity has published (respond_to, respond_to_allowlist, channel_ids, ...) + // — which also makes the agent invisible to mention-eligibility logic that + // reads those fields. Merge the policy into the existing content instead. + // Query errors propagate: failing the command is safer than silently + // replacing the profile with a minimal one. + let my_pk = client.keys().public_key().to_hex(); + let filter = serde_json::json!({ "kinds": [10100], "authors": [my_pk], "limit": 1 }); + let resp = client.query(&filter).await?; + let existing = serde_json::from_str::(&resp) + .ok() + .and_then(|v| { + v.as_array() + .and_then(|a| a.first()) + .and_then(|e| e.get("content")) + .and_then(|c| c.as_str()) + .map(str::to_string) + }); + let content = merge_channel_add_policy(existing.as_deref(), policy); use nostr::{EventBuilder, Kind}; let builder = EventBuilder::new( Kind::Custom(buzz_sdk::kind::KIND_AGENT_PROFILE as u16), @@ -1349,6 +1384,42 @@ mod tests { // If the BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES check were removed from cmd_set_add_policy, // this test would fail (it would proceed to sign_event and return a different error). + #[test] + fn merge_add_policy_preserves_existing_profile_fields() { + // Regression: set-add-policy used to publish {"channel_add_policy"} as the + // ENTIRE content, clobbering richer agent profiles (kind:10100 is + // plain-replaceable). See #2987. + let existing = r#"{"name":"scout","agent_type":"agent","respond_to":"anyone","channel_ids":["11111111-1111-1111-1111-111111111111"]}"#; + let merged = super::merge_channel_add_policy(Some(existing), "owner_only"); + let v: serde_json::Value = serde_json::from_str(&merged).expect("valid JSON"); + assert_eq!(v["channel_add_policy"], "owner_only"); + assert_eq!(v["name"], "scout"); + assert_eq!(v["respond_to"], "anyone"); + assert_eq!(v["channel_ids"][0], "11111111-1111-1111-1111-111111111111"); + } + + #[test] + fn merge_add_policy_overwrites_previous_policy() { + let existing = r#"{"channel_add_policy":"anyone","respond_to":"anyone"}"#; + let merged = super::merge_channel_add_policy(Some(existing), "nobody"); + let v: serde_json::Value = serde_json::from_str(&merged).expect("valid JSON"); + assert_eq!(v["channel_add_policy"], "nobody"); + assert_eq!(v["respond_to"], "anyone"); + } + + #[test] + fn merge_add_policy_without_existing_content_matches_previous_behavior() { + let merged = super::merge_channel_add_policy(None, "owner_only"); + assert_eq!(merged, r#"{"channel_add_policy":"owner_only"}"#); + } + + #[test] + fn merge_add_policy_tolerates_unparsable_existing_content() { + let merged = super::merge_channel_add_policy(Some("not-json"), "anyone"); + let v: serde_json::Value = serde_json::from_str(&merged).expect("valid JSON"); + assert_eq!(v["channel_add_policy"], "anyone"); + } + fn make_test_client() -> BuzzClient { // Scalar = 1 is the smallest valid secp256k1 private key. let keys =