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:
Hayt
2026-08-17 19:00:16 -04:00
committed by Duncan
co-authored by Will Pfleger
parent 7f61cf431a
commit 2b4ff6fd03
3 changed files with 191 additions and 30 deletions
@@ -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,
})
}
+49 -7
View File
@@ -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"),