From 2b4ff6fd03077a35b78a50374a81d2fb66d84bbf Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 14 Aug 2026 16:05:23 -0400 Subject: [PATCH] fix(desktop): scope nest AGENTS.md to active, non-archived agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Will Pfleger --- .../src/commands/identity_archive.rs | 46 ++++--- desktop/src-tauri/src/managed_agents/nest.rs | 56 +++++++-- .../src/managed_agents/nest/tests.rs | 119 +++++++++++++++++- 3 files changed, 191 insertions(+), 30 deletions(-) diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index d15ee82ab..902e5569c 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -275,46 +275,58 @@ fn archived_pubkeys_from_snapshot(snapshot: &nostr::Event) -> Vec { .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 { - 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 { + 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 { Ok(ArchivedIdentitiesSnapshot { - archived: archived_pubkeys_from_snapshot(&snapshot), + archived: fetch_archived_pubkeys(&state).await, }) } diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index a57676f0a..bd479579c 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -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) -> bool { + archived.contains(&record.pubkey.to_ascii_lowercase()) +} + pub fn render_dynamic_section( personas: &[AgentDefinition], agents: &[ManagedAgentRecord], + archived: &HashSet, 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::(); 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 = 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)] diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 67cdb5fba..641d42d09 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -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 = [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 = ["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 = [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"),