diff --git a/crates/buzz-cli/src/commands/channel_templates.rs b/crates/buzz-cli/src/commands/channel_templates.rs index 25a5e6e3e9..59e1aa6d9c 100644 --- a/crates/buzz-cli/src/commands/channel_templates.rs +++ b/crates/buzz-cli/src/commands/channel_templates.rs @@ -39,6 +39,12 @@ pub struct TemplateAgentRoster { pub personas: Vec, #[serde(default)] pub teams: Vec, + /// #3953: direct relay pubkeys for agents that are long-running remote + /// identities (provider/VPS) rather than Desktop persona instances. + /// Roster entries that use this variant skip the persona→kind:30177 + /// resolution entirely and add the pubkey directly — deterministic. + #[serde(default)] + pub agent_pubkeys: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -47,6 +53,17 @@ pub struct TemplateAgentEntry { pub persona_id: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplatePubkeyEntry { + /// 64-char hex Nostr pubkey of the agent to add as a member. + pub pubkey: String, + /// Optional human-readable label for the template report. Falls back to + /// a truncated pubkey if absent. + #[serde(default)] + pub label: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TemplateTeamEntry { @@ -191,5 +208,59 @@ mod tests { assert_eq!(t.agents.personas[0].persona_id, "builtin:fizz"); assert_eq!(t.agents.teams.len(), 1); assert_eq!(t.agents.teams[0].team_id, "team-1"); + // #3953: templates written before the agentPubkeys field existed + // must deserialize with the new field as empty rather than erroring. + assert!(t.agents.agent_pubkeys.is_empty()); + } + + #[test] + fn load_templates_parses_direct_agent_pubkeys() { + // #3953: a remote/VPS-managed agent identity with no Desktop persona + // instance should be rosterable directly by relay pubkey, so the + // template no longer needs a sidecar persona definition. + let f = write_store( + r##"[{ + "id":"t2","name":"Remote Ops","channelType":"stream","visibility":"open", + "agents":{ + "personas":[], + "teams":[], + "agentPubkeys":[ + {"pubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","label":"ops-bot"}, + {"pubkey":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + ] + }, + "createdAt":"x","updatedAt":"x" + }]"##, + ); + let t = find_template(f.path(), "Remote Ops").expect("found"); + assert_eq!(t.agents.agent_pubkeys.len(), 2); + assert_eq!( + t.agents.agent_pubkeys[0].pubkey, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + assert_eq!(t.agents.agent_pubkeys[0].label.as_deref(), Some("ops-bot")); + assert!(t.agents.agent_pubkeys[1].label.is_none()); + } + + #[test] + fn load_templates_rejects_invalid_pubkey_entry() { + // A malformed agentPubkeys entry (missing required pubkey) must fail + // parsing, not silently drop the entry — otherwise the roster + // silently under-delivers the same way persona skips did pre-#3953. + let f = write_store( + r##"[{ + "id":"t3","name":"Bad Roster","channelType":"stream","visibility":"open", + "agents":{ + "agentPubkeys":[{"label":"missing-pubkey"}] + }, + "createdAt":"x","updatedAt":"x" + }]"##, + ); + let err = find_template(f.path(), "Bad Roster").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("failed to parse"), + "malformed agentPubkeys entry should fail parse, got: {msg}" + ); } } diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 42844bf1e0..71d187b133 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -9,7 +9,9 @@ use crate::client::{ print_create_response, BuzzClient, }; use crate::commands::agents::fetch_archived_snapshot; -use crate::commands::channel_templates::{self, ChannelTemplateRecord, TemplateAgentRoster}; +use crate::commands::channel_templates::{ + self, ChannelTemplateRecord, TemplateAgentRoster, TemplatePubkeyEntry, +}; use crate::error::CliError; use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid}; @@ -606,6 +608,31 @@ fn finalize_roster_resolution( /// for the pure filter+cardinality core and the fail-open contract). Runs /// entirely before any channel-creation side effect — a cardinality error /// aborts with nothing created. +/// #3953: expand direct relay-pubkey roster entries into `ResolvedAgent`s. +/// +/// Long-running remote/VPS identities have no Desktop persona→kind:30177 +/// indirection to resolve; they're passed through verbatim so the +/// `add_member` call downstream is deterministic. Pubkey validation is +/// deferred to the member-add attempt — a malformed pubkey fails that one +/// member and lands in `member_failures`, never in `skipped`. Label falls +/// back to a truncated 16-char pubkey prefix (not the full 64-hex) so +/// reports stay readable. +fn expand_direct_pubkeys(entries: &[TemplatePubkeyEntry]) -> Vec { + entries + .iter() + .map(|entry| { + let persona_id = entry + .label + .clone() + .unwrap_or_else(|| format!("pubkey:{}", &entry.pubkey[..entry.pubkey.len().min(16)])); + ResolvedAgent { + persona_id, + pubkey: entry.pubkey.clone(), + } + }) + .collect() +} + async fn build_roster_resolution( client: &BuzzClient, owner: &str, @@ -626,9 +653,11 @@ async fn build_roster_resolution( } } + let direct_agents = expand_direct_pubkeys(&roster.agent_pubkeys); + if slugs.is_empty() { return Ok(RosterResolution { - agents: Vec::new(), + agents: direct_agents, skipped: Vec::new(), archived_excluded: Vec::new(), archive_state_warning: None, @@ -639,7 +668,10 @@ async fn build_roster_resolution( let found = scan_managed_agents_by_owner(client, owner, &slug_set).await?; let archived_result = fetch_archived_snapshot(client).await; - finalize_roster_resolution(&slugs, found, archived_result, &mut std::io::stderr()) + let mut resolved = + finalize_roster_resolution(&slugs, found, archived_result, &mut std::io::stderr())?; + resolved.agents.extend(direct_agents); + Ok(resolved) } /// `buzz channels create --template `: load a desktop-local channel @@ -1177,14 +1209,59 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Resu mod tests { use super::{ apply_cardinality_rule, build_template_report, cmd_set_add_policy, - finalize_roster_resolution, name_matches, resolve_roster_with_archive_filter, - validate_ttl_seconds, ArchivedExclusion, ChannelSummary, ResolvedAgent, RosterResolution, - SkippedSlug, + expand_direct_pubkeys, finalize_roster_resolution, name_matches, + resolve_roster_with_archive_filter, validate_ttl_seconds, ArchivedExclusion, + ChannelSummary, ResolvedAgent, RosterResolution, SkippedSlug, }; use crate::client::BuzzClient; + use crate::commands::channel_templates::TemplatePubkeyEntry; use crate::CliError; use serde_json::json; + // ── #3953: direct relay-pubkey roster expansion ───────────────────── + + #[test] + fn expand_direct_pubkeys_uses_label_when_present() { + let entries = vec![TemplatePubkeyEntry { + pubkey: "aabb".repeat(16), + label: Some("ops-bot".into()), + }]; + let got = expand_direct_pubkeys(&entries); + assert_eq!(got.len(), 1); + assert_eq!(got[0].persona_id, "ops-bot"); + assert_eq!(got[0].pubkey, "aabb".repeat(16)); + } + + #[test] + fn expand_direct_pubkeys_falls_back_to_truncated_pubkey_label() { + let pk = "aa".repeat(32); // 64-hex + let entries = vec![ + TemplatePubkeyEntry { + pubkey: pk.clone(), + label: None, + }, + TemplatePubkeyEntry { + pubkey: "short".into(), + label: None, + }, + ]; + let got = expand_direct_pubkeys(&entries); + assert_eq!(got.len(), 2); + // 64-hex pubkey → label truncated to first 16 chars. + assert_eq!(got[0].persona_id, format!("pubkey:{}", &pk[..16])); + // Short/malformed pubkey still passes through — label truncated to + // its actual length (no panics on a shorter-than-16 string). + assert_eq!(got[1].persona_id, "pubkey:short"); + assert_eq!(got[1].pubkey, "short"); + } + + #[test] + fn expand_direct_pubkeys_empty_when_no_entries() { + let got = expand_direct_pubkeys(&[]); + assert!(got.is_empty()); + } + + fn event(tags: serde_json::Value) -> serde_json::Value { json!({ "tags": tags }) }