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
71 changes: 71 additions & 0 deletions crates/buzz-cli/src/commands/channel_templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ pub struct TemplateAgentRoster {
pub personas: Vec<TemplateAgentEntry>,
#[serde(default)]
pub teams: Vec<TemplateTeamEntry>,
/// #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<TemplatePubkeyEntry>,
}

#[derive(Debug, Clone, Deserialize)]
Expand All @@ -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<String>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TemplateTeamEntry {
Expand Down Expand Up @@ -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}"
);
}
}
89 changes: 83 additions & 6 deletions crates/buzz-cli/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<ResolvedAgent> {
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,
Expand All @@ -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,
Expand All @@ -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 <name>`: load a desktop-local channel
Expand Down Expand Up @@ -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 })
}
Expand Down