Skip to content
Open
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
182 changes: 182 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,138 @@ async fn publish_presence(
Ok(())
}

/// Default `channel_add_policy` published when the operator sets none.
///
/// Matches the relay's `users.channel_add_policy` column default.
const DEFAULT_CHANNEL_ADD_POLICY: &str = "anyone";

/// Resolve the `channel_add_policy` this agent should advertise.
///
/// kind:10100 is *replaceable*, and `buzz channels set-agent-policy` writes the
/// same kind carrying only this field. Republishing a hardcoded value would
/// silently reset an operator's stricter policy (`owner_only` / `nobody`) back
/// to `anyone` on the next restart or membership change — a security-loosening
/// regression. Operators who set a non-default policy set this env var so the
/// harness re-advertises it instead of clobbering it.
fn resolve_channel_add_policy() -> String {
std::env::var("BUZZ_ACP_CHANNEL_ADD_POLICY")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_CHANNEL_ADD_POLICY.to_string())
}

/// Build the kind:10100 agent-profile content JSON.
///
/// Field names are the cross-client contract: desktop reads them in
/// `agents_from_events` and mobile in `AgentDirectoryEntry.fromEvent`, and the
/// relay's ingest side effect requires `channel_add_policy`. Collections are
/// normalized (lowercased pubkeys) and sorted so republishes are
/// deterministic.
fn agent_profile_content(
respond_to: &RespondTo,
allowlist: &HashSet<String>,
channel_ids: &HashSet<Uuid>,
channel_add_policy: &str,
) -> String {
let mut allow: Vec<String> = allowlist.iter().map(|pk| pk.to_ascii_lowercase()).collect();
allow.sort();
let mut channels: Vec<String> = channel_ids.iter().map(Uuid::to_string).collect();
channels.sort();
serde_json::json!({
"respond_to": respond_to.to_string(),
"respond_to_allowlist": allow,
"channel_ids": channels,
"channel_add_policy": channel_add_policy,
})
.to_string()
}

/// Publish/refresh the agent's kind:10100 directory profile (replaceable).
///
/// Self-attested discovery metadata for client UX — mention pickers,
/// "managed by" labels, and respond_to-aware eligibility. Without it,
/// clients cannot see that `respond_to=anyone` and hide the agent from
/// non-owners' mention autocomplete. Enforcement stays in `author_allowed`;
/// clients must treat this as advisory.
async fn publish_agent_profile(
publisher: &relay::RelayEventPublisher,
keys: &nostr::Keys,
respond_to: &RespondTo,
allowlist: &HashSet<String>,
channel_ids: &HashSet<Uuid>,
) -> Result<(), relay::RelayError> {
use buzz_core::kind::KIND_AGENT_PROFILE;
use nostr::{EventBuilder, Kind};

let content = agent_profile_content(
respond_to,
allowlist,
channel_ids,
&resolve_channel_add_policy(),
);
let event = EventBuilder::new(Kind::Custom(KIND_AGENT_PROFILE as u16), content)
.tags([])
.sign_with_keys(keys)
.map_err(|e| relay::RelayError::Http(format!("agent profile sign error: {e}")))?;
publisher.publish_event(event).await?;
Ok(())
}

#[cfg(test)]
mod agent_profile_content_tests {
use super::*;

#[test]
fn content_matches_cross_client_contract() {
let mut allowlist = HashSet::new();
allowlist.insert("B".repeat(64));
allowlist.insert("a".repeat(64));
let mut channels = HashSet::new();
let ch = Uuid::parse_str("af804d33-a3c1-4415-858e-3747c296a613").unwrap();
channels.insert(ch);

let content = agent_profile_content(&RespondTo::Allowlist, &allowlist, &channels, "anyone");
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();

assert_eq!(parsed["respond_to"], "allowlist");
assert_eq!(
parsed["respond_to_allowlist"],
serde_json::json!(["a".repeat(64), "b".repeat(64)]),
);
assert_eq!(parsed["channel_ids"], serde_json::json!([ch.to_string()]));
assert_eq!(parsed["channel_add_policy"], "anyone");
}

#[test]
fn channel_add_policy_is_overridable() {
// Operators with a stricter policy re-advertise it instead of having
// the harness reset them to the default on every republish.
let content = agent_profile_content(
&RespondTo::Anyone,
&HashSet::new(),
&HashSet::new(),
"owner_only",
);
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(parsed["channel_add_policy"], "owner_only");
}

#[test]
fn respond_to_strings_match_client_comparisons() {
for (mode, expected) in [
(RespondTo::Anyone, "anyone"),
(RespondTo::OwnerOnly, "owner-only"),
(RespondTo::Allowlist, "allowlist"),
(RespondTo::Nobody, "nobody"),
] {
let content = agent_profile_content(&mode, &HashSet::new(), &HashSet::new(), "anyone");
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(parsed["respond_to"], expected);
}
}
}

fn emit_runtime_lifecycle(
observer: Option<&observer::ObserverHandle>,
start_nonce: &str,
Expand Down Expand Up @@ -1997,6 +2129,29 @@ async fn tokio_main() -> Result<()> {
}
}

// Advertise the agent in the relay directory (kind:10100) so other
// clients can see its respond_to policy and offer it in mention
// autocomplete. Published after channel subscriptions so channel_ids
// reflects the live set. Best-effort: a miss self-heals on the next
// restart or membership change.
if let Err(e) = publish_agent_profile(
&presence_publisher,
&presence_keys,
&config.respond_to,
&config.respond_to_allowlist,
&subscribed_channel_ids,
)
.await
{
tracing::warn!("failed to publish agent profile (kind:10100): {e}");
} else {
tracing::info!(
channels = subscribed_channel_ids.len(),
respond_to = %config.respond_to,
"agent profile (kind:10100) published"
);
}

if config.lazy_pool {
emit_runtime_lifecycle(
observer.as_ref(),
Expand Down Expand Up @@ -2507,6 +2662,20 @@ async fn tokio_main() -> Result<()> {
tracing::warn!("failed to subscribe to new channel {ch}: {e}");
} else {
subscribed_channel_ids.insert(ch);
// Keep the directory profile's channel_ids
// fresh so the new channel's members can
// mention this agent without an agent restart.
if let Err(e) = publish_agent_profile(
&presence_publisher,
&presence_keys,
&config.respond_to,
&config.respond_to_allowlist,
&subscribed_channel_ids,
)
.await
{
tracing::warn!("failed to refresh agent profile (kind:10100): {e}");
}
}
} else {
tracing::debug!(channel_id = %ch, "membership notification: no matching rules — skipping");
Expand All @@ -2517,6 +2686,19 @@ async fn tokio_main() -> Result<()> {
if let Err(e) = relay.unsubscribe_channel(ch).await {
tracing::warn!("failed to unsubscribe from channel {ch}: {e}");
}
// Drop the channel from the directory profile so
// clients stop offering this agent there.
if let Err(e) = publish_agent_profile(
&presence_publisher,
&presence_keys,
&config.respond_to,
&config.respond_to_allowlist,
&subscribed_channel_ids,
)
.await
{
tracing::warn!("failed to refresh agent profile (kind:10100): {e}");
}
// Drain queued events and invalidate sessions for the
// removed channel. Events already in-flight will
// complete normally (the relay may reject actions if
Expand Down