mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): scope nest AGENTS.md to active, non-archived agents
The managed "Active Agents" table in ~/.buzz/AGENTS.md was rendered from every managed-agent record with no filtering, so archived duplicate instances and records pinned to defunct relays kept appearing under the active relay's header. Two independent, separately-tested predicates now gate each record: - Relay scope: a local check that the record's relay_url equals the active workspace relay (normalized: trim, drop trailing slash, lowercase). Unconditional — there is no fetch to fail open on. - Identity archive: skip pubkeys present in the relay's kind:13535 snapshot. Local records can't tell — they all carry is_active: true (archived *definition*, not identity-archived), so archive truth lives only relay-side. Fails open: an unreachable relay yields an empty set and hides no one. The archive read is async, so try_regenerate_nest spawns the regen as a fire-and-forget task (matching its existing contract) rather than changing its 12 sync call sites. A just-archived agent may linger one regen cycle. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -275,46 +275,58 @@ fn archived_pubkeys_from_snapshot(snapshot: &nostr::Event) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend
|
||||
/// caches this and tests membership client-side to drive the "Archived" flair.
|
||||
/// Read the relay's latest valid `kind:13535` archive snapshot as lowercase
|
||||
/// hex pubkeys. Shared by the `list_archived_identities` command (frontend
|
||||
/// flair) and the backend nest regen (excluding archived agents from
|
||||
/// `AGENTS.md`).
|
||||
///
|
||||
/// Per NIP-IA §Client Behavior and §Snapshot and Delta Consistency, only a
|
||||
/// snapshot signed by the relay identity advertised in NIP-11 `self` can affect
|
||||
/// archive state. If the relay has no stable `self`, fail open with an empty
|
||||
/// snapshot rather than trusting unauthenticated relay-authoritative state.
|
||||
#[tauri::command]
|
||||
pub async fn list_archived_identities(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ArchivedIdentitiesSnapshot, String> {
|
||||
let Some(relay_self) = fetch_relay_self(&state).await? else {
|
||||
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
|
||||
/// archive state. Every failure path — no stable `self`, no snapshot, a bad
|
||||
/// signature or wrong author, or a query error — **fails open** with an empty
|
||||
/// set rather than trusting unauthenticated relay-authoritative state.
|
||||
pub(crate) async fn fetch_archived_pubkeys(state: &AppState) -> Vec<String> {
|
||||
let Ok(Some(relay_self)) = fetch_relay_self(state).await else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let events = query_relay(
|
||||
&state,
|
||||
let query = query_relay(
|
||||
state,
|
||||
&[serde_json::json!({
|
||||
"authors": [relay_self.clone()],
|
||||
"kinds": [13535],
|
||||
"limit": 1,
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
let Ok(events) = query else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let Some(snapshot) = events.into_iter().next() else {
|
||||
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Defense-in-depth: the filter should already restrict author, but the
|
||||
// client must still reject malformed or wrongly signed relay state.
|
||||
if !snapshot.verify_id() || !snapshot.verify_signature() {
|
||||
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
|
||||
return vec![];
|
||||
}
|
||||
if !snapshot.pubkey.to_hex().eq_ignore_ascii_case(&relay_self) {
|
||||
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
|
||||
return vec![];
|
||||
}
|
||||
|
||||
archived_pubkeys_from_snapshot(&snapshot)
|
||||
}
|
||||
|
||||
/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend
|
||||
/// caches this and tests membership client-side to drive the "Archived" flair.
|
||||
#[tauri::command]
|
||||
pub async fn list_archived_identities(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ArchivedIdentitiesSnapshot, String> {
|
||||
Ok(ArchivedIdentitiesSnapshot {
|
||||
archived: archived_pubkeys_from_snapshot(&snapshot),
|
||||
archived: fetch_archived_pubkeys(&state).await,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ use super::{load_managed_agents, load_personas, AgentDefinition, ManagedAgentRec
|
||||
#[cfg(test)]
|
||||
use super::{BackendKind, RespondTo};
|
||||
use crate::app_state::AppState;
|
||||
use crate::commands::fetch_archived_pubkeys;
|
||||
use crate::relay::relay_ws_url_with_override;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -523,19 +525,49 @@ fn escape_md_cell(s: &str) -> String {
|
||||
s.replace('|', "\\|").replace('\n', " ")
|
||||
}
|
||||
|
||||
/// Normalize a relay URL for equality: trim surrounding space, drop a trailing
|
||||
/// slash, and lowercase. Scheme and host are ASCII-case-insensitive and these
|
||||
/// relay URLs carry no meaningful path, so this collapses the incidental
|
||||
/// variation (trailing `/`, casing) without over-parsing.
|
||||
fn normalize_relay_url(url: &str) -> String {
|
||||
url.trim().trim_end_matches('/').to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// True iff the instance's pinned `relay_url` is the active workspace relay.
|
||||
/// A *local* check on data already in the store — unconditional, unlike the
|
||||
/// archive filter (there is no fetch to fail open on). Records pinned to a
|
||||
/// defunct relay must not render under the active relay's header.
|
||||
fn is_on_active_relay(record: &ManagedAgentRecord, active_relay_normalized: &str) -> bool {
|
||||
normalize_relay_url(&record.relay_url) == active_relay_normalized
|
||||
}
|
||||
|
||||
/// True iff the relay has archived this instance's identity. Membership is
|
||||
/// tested against the relay's `kind:13535` snapshot (lowercased hex); an empty
|
||||
/// set (relay unreachable) fails open — see [`regenerate_nest_context`].
|
||||
fn is_archived(record: &ManagedAgentRecord, archived: &HashSet<String>) -> bool {
|
||||
archived.contains(&record.pubkey.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub fn render_dynamic_section(
|
||||
personas: &[AgentDefinition],
|
||||
agents: &[ManagedAgentRecord],
|
||||
archived: &HashSet<String>,
|
||||
relay_url: &str,
|
||||
) -> String {
|
||||
let active_agents = if agents.is_empty() {
|
||||
let active_relay = normalize_relay_url(relay_url);
|
||||
let live: Vec<&ManagedAgentRecord> = agents
|
||||
.iter()
|
||||
.filter(|a| is_on_active_relay(a, &active_relay))
|
||||
.filter(|a| !is_archived(a, archived))
|
||||
.collect();
|
||||
let active_agents = if live.is_empty() {
|
||||
"## Active Agents\n\n*(No agents deployed yet. Add agents in the Buzz desktop app.)*"
|
||||
.to_string()
|
||||
} else {
|
||||
let mut table =
|
||||
"## Active Agents\n\n| Name | Persona | How to address |\n|------|---------|----------------|"
|
||||
.to_string();
|
||||
for agent in agents {
|
||||
for agent in live {
|
||||
let role = agent
|
||||
.persona_id
|
||||
.as_deref()
|
||||
@@ -645,7 +677,7 @@ pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> {
|
||||
pub async fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> {
|
||||
let nest = nest_dir().ok_or("cannot resolve home directory for nest")?;
|
||||
let agents_md = nest.join("AGENTS.md");
|
||||
|
||||
@@ -657,7 +689,11 @@ pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> {
|
||||
let agents = load_managed_agents(app)?;
|
||||
let state = app.state::<AppState>();
|
||||
let relay_url = relay_ws_url_with_override(&state);
|
||||
let content = render_dynamic_section(&personas, &agents, &relay_url);
|
||||
// Identity-archived agents live only in the relay's `kind:13535` snapshot;
|
||||
// local records all read `is_active: true`. Fails open (empty set → render
|
||||
// everyone) so an unreachable relay can't blank the roster.
|
||||
let archived: HashSet<String> = fetch_archived_pubkeys(&state).await.into_iter().collect();
|
||||
let content = render_dynamic_section(&personas, &agents, &archived, &relay_url);
|
||||
upsert_managed_section(&agents_md, &content)
|
||||
.map_err(|e| format!("regenerate nest context: {e}"))?;
|
||||
|
||||
@@ -668,10 +704,16 @@ pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> {
|
||||
///
|
||||
/// All call sites treat regeneration as fire-and-forget — agents run fine with
|
||||
/// a stale AGENTS.md, so we warn and continue rather than propagating the error.
|
||||
/// Regeneration reads relay archive state, so it runs on a spawned async task;
|
||||
/// callers do not await it. A just-archived agent may linger for one regen
|
||||
/// cycle until the next regen (any agent/team edit or the next launch).
|
||||
pub fn try_regenerate_nest(app: &AppHandle) {
|
||||
if let Err(error) = regenerate_nest_context(app) {
|
||||
eprintln!("buzz-desktop: nest context regeneration failed: {error}");
|
||||
}
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(error) = regenerate_nest_context(&app).await {
|
||||
eprintln!("buzz-desktop: nest context regeneration failed: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use super::*;
|
||||
|
||||
/// Relay URL every `make_agent` record is pinned to; render calls pass the same
|
||||
/// value so the relay-scope filter keeps them unless a test overrides it.
|
||||
const TEST_RELAY: &str = "ws://example.com:3000";
|
||||
|
||||
#[test]
|
||||
fn nest_dir_is_under_home() {
|
||||
if let Some(dir) = nest_dir() {
|
||||
@@ -454,7 +458,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
|
||||
persona_id: persona_id.map(|s| s.to_string()),
|
||||
private_key_nsec: String::new(),
|
||||
auth_tag: None,
|
||||
relay_url: String::new(),
|
||||
relay_url: TEST_RELAY.to_string(),
|
||||
avatar_url: None,
|
||||
acp_command: String::new(),
|
||||
agent_command: String::new(),
|
||||
@@ -510,7 +514,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
|
||||
fn test_render_dynamic_section_with_agents() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Kit", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
|
||||
assert!(output.contains("| Kit | Builder | @Kit |"));
|
||||
assert!(output.contains("| Name | Persona | How to address |"));
|
||||
assert!(output.contains("## Workspace"));
|
||||
@@ -518,7 +522,7 @@ fn test_render_dynamic_section_with_agents() {
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_empty() {
|
||||
let output = render_dynamic_section(&[], &[], "ws://example.com:3000");
|
||||
let output = render_dynamic_section(&[], &[], &HashSet::new(), TEST_RELAY);
|
||||
assert!(output.contains("No agents deployed yet"));
|
||||
}
|
||||
|
||||
@@ -526,10 +530,113 @@ fn test_render_dynamic_section_empty() {
|
||||
fn test_render_dynamic_section_agent_no_persona() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Scout", Some("nonexistent"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
|
||||
assert!(output.contains("| Scout | — | @Scout |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_excludes_archived_agents() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let mut live = make_agent("Live", Some("p1"));
|
||||
live.pubkey = "aa".repeat(32);
|
||||
let mut gone = make_agent("Archived", Some("p1"));
|
||||
gone.pubkey = "bb".repeat(32);
|
||||
let archived: HashSet<String> = [gone.pubkey.clone()].into_iter().collect();
|
||||
|
||||
let output = render_dynamic_section(&personas, &[live, gone], &archived, TEST_RELAY);
|
||||
|
||||
assert!(output.contains("| Live | Builder | @Live |"));
|
||||
assert!(
|
||||
!output.contains("Archived"),
|
||||
"archived agent must not render"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_archived_match_is_case_insensitive() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let mut gone = make_agent("Archived", Some("p1"));
|
||||
gone.pubkey = "AB".repeat(32); // uppercase hex in the record
|
||||
// Snapshot pubkeys are lowercased by `archived_pubkeys_from_snapshot`.
|
||||
let archived: HashSet<String> = ["ab".repeat(32)].into_iter().collect();
|
||||
|
||||
let output = render_dynamic_section(&personas, &[gone], &archived, TEST_RELAY);
|
||||
|
||||
assert!(
|
||||
output.contains("No agents deployed yet"),
|
||||
"all-archived roster renders the empty placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_empty_archived_set_renders_all() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let mut a = make_agent("Kit", Some("p1"));
|
||||
a.pubkey = "cc".repeat(32);
|
||||
// Fail-open: an empty snapshot (relay unreachable) must render everyone.
|
||||
let output = render_dynamic_section(&personas, &[a], &HashSet::new(), TEST_RELAY);
|
||||
assert!(output.contains("| Kit | Builder | @Kit |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_excludes_foreign_relay_agents() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let here = make_agent("Local", Some("p1"));
|
||||
let mut elsewhere = make_agent("Foreign", Some("p1"));
|
||||
elsewhere.relay_url = "wss://defunct.communities.buzz.xyz".to_string();
|
||||
|
||||
let output = render_dynamic_section(&personas, &[here, elsewhere], &HashSet::new(), TEST_RELAY);
|
||||
|
||||
assert!(output.contains("| Local | Builder | @Local |"));
|
||||
assert!(
|
||||
!output.contains("Foreign"),
|
||||
"an agent pinned to another relay must not render"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_relay_match_ignores_trailing_slash_and_case() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let mut a = make_agent("Kit", Some("p1"));
|
||||
a.relay_url = "WS://Example.com:3000/".to_string();
|
||||
// Active relay lacks the trailing slash and differs in case.
|
||||
let output = render_dynamic_section(&personas, &[a], &HashSet::new(), TEST_RELAY);
|
||||
assert!(output.contains("| Kit | Builder | @Kit |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_all_foreign_relay_renders_empty_placeholder() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let mut a = make_agent("Kit", Some("p1"));
|
||||
a.relay_url = "wss://defunct.communities.buzz.xyz".to_string();
|
||||
let output = render_dynamic_section(&personas, &[a], &HashSet::new(), TEST_RELAY);
|
||||
assert!(
|
||||
output.contains("No agents deployed yet"),
|
||||
"a store with no active-relay records renders the placeholder, not a bare header"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_filters_are_order_independent() {
|
||||
// A record that is BOTH foreign-relay and archived is dropped once; the two
|
||||
// predicates are independent so neither filter's outcome depends on the
|
||||
// other running first.
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let keep = make_agent("Keep", Some("p1"));
|
||||
let mut both = make_agent("Both", Some("p1"));
|
||||
both.pubkey = "dd".repeat(32);
|
||||
both.relay_url = "wss://defunct.communities.buzz.xyz".to_string();
|
||||
let archived: HashSet<String> = [both.pubkey.clone()].into_iter().collect();
|
||||
|
||||
let output = render_dynamic_section(&personas, &[keep, both], &archived, TEST_RELAY);
|
||||
|
||||
assert!(output.contains("| Keep | Builder | @Keep |"));
|
||||
assert!(
|
||||
!output.contains("Both"),
|
||||
"a foreign-and-archived record must not render"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_with_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -762,7 +869,7 @@ fn test_upsert_marker_in_code_block() {
|
||||
fn test_render_pipe_in_agent_name() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Kit|Pro", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
|
||||
|
||||
assert!(
|
||||
output.contains("Kit\\|Pro"),
|
||||
@@ -791,7 +898,7 @@ fn test_render_pipe_in_agent_name() {
|
||||
fn test_render_newline_in_persona_name() {
|
||||
let personas = vec![make_persona("p1", "Builder\nExpert")];
|
||||
let agents = vec![make_agent("Scout", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
|
||||
|
||||
assert!(
|
||||
output.contains("Builder Expert"),
|
||||
|
||||
Reference in New Issue
Block a user