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
21 changes: 21 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ members = [
"crates/buzz-workflow",
"crates/buzz-media",
"crates/buzz-cli",
"crates/buzz-supervisor",
"crates/buzz-pairing-cli",
"crates/buzz-sdk",
"crates/buzz-persona",
Expand Down
122 changes: 122 additions & 0 deletions crates/buzz-cli/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,129 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli
}

AgentsCmd::Archived => cmd_archived(client).await,

AgentsCmd::PublishProfile {
name,
agent_type,
channel_ids,
status,
respond_to,
channel_add_policy,
} => {
cmd_publish_profile(
client,
buzz_sdk::builders::AgentProfilePatch {
name,
agent_type,
channels: None,
channel_ids,
capabilities: None,
status,
respond_to,
channel_add_policy,
},
)
.await
}
}
}

/// Fetches this identity's current `kind:10100` record (if any), applies
/// `patch` on top of it via `buzz_sdk::builders::build_agent_profile_update`,
/// and publishes. If the relay rejects the write as a stale generation —
/// another writer published in between our read and our submit — re-fetches
/// once and retries with a fresh generation before giving up.
async fn cmd_publish_profile(
client: &BuzzClient,
patch: buzz_sdk::builders::AgentProfilePatch,
) -> Result<(), CliError> {
for attempt in 0..2 {
let current = fetch_agent_profile_state(client).await?;
let builder =
buzz_sdk::builders::build_agent_profile_update(current.as_ref(), patch.clone())
.map_err(|e| CliError::Usage(e.to_string()))?;
let event = client.sign_event(builder)?;
match client.submit_event(event).await {
Ok(resp) => {
println!("{resp}");
return Ok(());
}
Err(CliError::Relay { status, body })
if attempt == 0 && body.contains("stale generation") =>
{
let _ = status;
// Another writer published between our read and our submit —
// re-fetch the now-current record and retry once.
continue;
}
Err(e) => return Err(e),
}
}
Err(CliError::Other(
"publish-profile: still hit a stale generation after retrying once".into(),
))
}

/// Queries the relay for this identity's current `kind:10100` record and
/// parses it into `AgentProfileState`. Returns `None` if it's never
/// published one.
async fn fetch_agent_profile_state(
client: &BuzzClient,
) -> Result<Option<buzz_sdk::builders::AgentProfileState>, CliError> {
let my_pubkey = client.keys().public_key().to_hex();
let filter = serde_json::json!({
"kinds": [buzz_sdk::kind::KIND_AGENT_PROFILE],
"authors": [my_pubkey],
"limit": 1,
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp)
.map_err(|e| CliError::Other(format!("invalid relay response: {e}")))?;
let Some(event) = events.into_iter().next() else {
return Ok(None);
};
let content: serde_json::Value = event
.get("content")
.and_then(|c| c.as_str())
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();

let as_str_vec = |key: &str| -> Vec<String> {
content
.get(key)
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
};
let as_str = |key: &str| -> String {
content
.get(key)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
};

Ok(Some(buzz_sdk::builders::AgentProfileState {
name: content
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string),
agent_type: as_str("agent_type"),
channels: as_str_vec("channels"),
channel_ids: as_str_vec("channel_ids"),
capabilities: as_str_vec("capabilities"),
status: as_str("status"),
respond_to: as_str("respond_to"),
channel_add_policy: as_str("channel_add_policy"),
generation: content
.get("generation")
.and_then(|v| v.as_u64())
.unwrap_or(0),
}))
}

/// Require `BUZZ_AUTH_TAG` and parse the owner pubkey from it. Used only by
Expand Down
46 changes: 43 additions & 3 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
pub mod agent_management;
mod client;
pub mod client;
mod commands;
mod error;
pub mod error;
mod links;
mod validate;

Expand Down Expand Up @@ -365,6 +365,45 @@ Examples:\n \
buzz agents archived"
)]
Archived,
/// Publish this identity's relay agent profile (kind:10100) — makes a headless
/// (non-managed) agent discoverable and @mentionable by Buzz Desktop clients.
#[command(
after_help = "kind:10100 is a replaceable event with a generation counter: this fetches \
the current record for the signing identity, applies your changes on top of it (fields you \
don't pass are left as-is), and republishes with generation + 1. If another writer published \
in between, the relay rejects the write as a stale generation and this command re-fetches and \
retries once automatically.\n\n\
There is no --respond-to-allowlist flag. kind:10100 is community-visible, so the exact pubkeys \
allowed to trigger an `allowlist`-mode agent are never published here — that's a private access \
boundary, not public routing state. The harness enforces the real allowlist locally; this \
command only advertises the mode.\n\n\
Examples:\n \
buzz agents publish-profile --name Coder --channel-ids <UUID> --respond-to allowlist\n \
buzz agents publish-profile --status away"
)]
PublishProfile {
/// Display name shown in Buzz Desktop. Leaves the current name unchanged if omitted.
#[arg(long)]
name: Option<String>,
/// Free-form agent type label. Defaults to "agent" on first publish.
#[arg(long)]
agent_type: Option<String>,
/// Channel UUIDs this agent should be considered mentionable in (comma-separated).
/// Replaces the full list; leaves it unchanged if omitted.
#[arg(long, value_delimiter = ',')]
channel_ids: Option<Vec<String>>,
/// Presence status: online, away, or offline. Defaults to "online" on first publish.
#[arg(long)]
status: Option<String>,
/// Who this agent responds to: owner-only, allowlist, or anyone.
/// Defaults to "owner-only" on first publish.
#[arg(long)]
respond_to: Option<String>,
/// Who may add this agent to new channels: anyone, owner_only, or nobody.
/// Defaults to "owner_only" on first publish.
#[arg(long)]
channel_add_policy: Option<String>,
},
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -2175,6 +2214,7 @@ mod tests {
"archived",
"draft-create",
"draft-update",
"publish-profile",
"unarchive"
]
);
Expand Down Expand Up @@ -2313,7 +2353,7 @@ mod tests {
#[test]
fn subcommand_counts_are_stable() {
let expected: Vec<(&str, usize)> = vec![
("agents", 5),
("agents", 6),
("canvas", 2),
("channels", 16),
("dms", 4),
Expand Down
140 changes: 140 additions & 0 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,142 @@ pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::Publ
event.pubkey.to_bytes().to_vec()
}

const VALID_AGENT_STATUSES: &[&str] = &["online", "away", "offline"];
const VALID_AGENT_RESPOND_TO: &[&str] = &["owner-only", "allowlist", "anyone"];
const VALID_AGENT_CHANNEL_ADD_POLICIES: &[&str] = &["anyone", "owner_only", "nobody"];
const MAX_AGENT_NAME_LEN: usize = 256;

/// Validates and CAS-checks a `kind:10100` agent-directory publish before
/// it's accepted.
///
/// `kind:10100` is a plain replaceable event (NIP-01: highest `created_at`
/// wins on read), so without an explicit generation counter, two writers
/// that both read an older record and republish it "whole" can silently
/// clobber each other's fields depending on wall-clock timing — the exact
/// failure mode reported against #5528/#5530/#5546. This makes the loss
/// loud instead of silent: a publish whose `generation` isn't strictly
/// greater than the currently stored value is rejected, forcing the writer
/// to re-fetch and retry against the latest state
/// (`buzz_sdk::builders::build_agent_profile_update` is the shared helper
/// every writer should use to do that correctly).
///
/// Also enforces the schema `build_agent_profile_update` produces,
/// independent of that helper, so a raw/malformed publish can't bypass
/// validation: bounded `status`/`respond_to`/`channel_add_policy` enums,
/// UUID-shaped `channel_ids`, a non-empty bounded `name` when present, and
/// — the other half of the same reports — **no `respond_to_allowlist`
/// field**. That field would publish the exact pubkeys allowed to trigger
/// an `allowlist`-mode agent to every member of the community via a
/// world-readable event; real enforcement already lives entirely in the
/// harness process, so the public directory has no legitimate need for it.
async fn validate_agent_profile_publish(
tenant: &TenantContext,
state: &Arc<AppState>,
event: &Event,
) -> Result<(), IngestError> {
let content: serde_json::Value = serde_json::from_str(&event.content)
.map_err(|_| IngestError::Rejected("invalid: kind:10100 content must be JSON".into()))?;
let obj = content.as_object().ok_or_else(|| {
IngestError::Rejected("invalid: kind:10100 content must be a JSON object".into())
})?;

if obj.contains_key("respond_to_allowlist") {
return Err(IngestError::Rejected(
"invalid: respond_to_allowlist is not publishable — kind:10100 is community-visible; \
this field would disclose an agent's exact access list to every member. Enforcement \
belongs to the harness, not the public directory."
.into(),
));
}

if let Some(name) = obj.get("name") {
let s = name
.as_str()
.ok_or_else(|| IngestError::Rejected("invalid: name must be a string".into()))?;
if s.is_empty() || s.chars().count() > MAX_AGENT_NAME_LEN {
return Err(IngestError::Rejected(format!(
"invalid: name must be 1..={MAX_AGENT_NAME_LEN} chars"
)));
}
}
if let Some(status) = obj.get("status") {
let s = status.as_str().unwrap_or_default();
if !VALID_AGENT_STATUSES.contains(&s) {
return Err(IngestError::Rejected(format!(
"invalid: status must be one of {VALID_AGENT_STATUSES:?}"
)));
}
}
if let Some(respond_to) = obj.get("respond_to") {
let s = respond_to.as_str().unwrap_or_default();
if !VALID_AGENT_RESPOND_TO.contains(&s) {
return Err(IngestError::Rejected(format!(
"invalid: respond_to must be one of {VALID_AGENT_RESPOND_TO:?}"
)));
}
}
if let Some(policy) = obj.get("channel_add_policy") {
let s = policy.as_str().unwrap_or_default();
if !VALID_AGENT_CHANNEL_ADD_POLICIES.contains(&s) {
return Err(IngestError::Rejected(format!(
"invalid: channel_add_policy must be one of {VALID_AGENT_CHANNEL_ADD_POLICIES:?}"
)));
}
}
if let Some(ids) = obj.get("channel_ids") {
let arr = ids
.as_array()
.ok_or_else(|| IngestError::Rejected("invalid: channel_ids must be an array".into()))?;
for id in arr {
let s = id.as_str().unwrap_or_default();
if uuid::Uuid::parse_str(s).is_err() {
return Err(IngestError::Rejected(format!(
"invalid: channel_ids entries must be UUIDs (got: {s})"
)));
}
}
}

let new_generation = obj
.get("generation")
.and_then(|v| v.as_u64())
.ok_or_else(|| {
IngestError::Rejected(
"invalid: kind:10100 requires a positive integer generation field \
(use buzz_sdk::builders::build_agent_profile_update to construct this event)"
.into(),
)
})?;
if new_generation == 0 {
return Err(IngestError::Rejected(
"invalid: generation must be >= 1".into(),
));
}

let pubkey_bytes = event.pubkey.to_bytes().to_vec();
let existing = state
.db
.get_latest_global_replaceable(tenant.community(), KIND_AGENT_PROFILE as i32, &pubkey_bytes)
.await
.map_err(|e| IngestError::Rejected(format!("db error: {e}")))?;

if let Some(existing) = existing {
let existing_generation: u64 =
serde_json::from_str::<serde_json::Value>(&existing.event.content)
.ok()
.and_then(|v| v.get("generation").and_then(|g| g.as_u64()))
.unwrap_or(0);
if new_generation <= existing_generation {
return Err(IngestError::Rejected(format!(
"invalid: stale generation ({new_generation} <= {existing_generation}) — \
re-fetch the current record and retry"
)));
}
}

Ok(())
}

/// Validate kind:40003 edit ownership — event.pubkey must match target's effective author,
/// or the actor must be the owning human of the agent that authored the target message.
async fn validate_edit_ownership(
Expand Down Expand Up @@ -2713,6 +2849,10 @@ async fn ingest_event_inner(
});
}

if kind_u32 == KIND_AGENT_PROFILE {
validate_agent_profile_publish(tenant, state, &event).await?;
}

let tenant_media_base =
crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host());
if kind_u32 == KIND_STREAM_MESSAGE {
Expand Down
Loading