From 7dc12723a4414b02fae1d0f9df1ca190249d380b Mon Sep 17 00:00:00 2001 From: Bryce Del Rio Date: Fri, 31 Jul 2026 05:57:15 +0000 Subject: [PATCH 1/3] fix(desktop): parse the relay agent directory leniently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_relay_agents deserialized the whole kind:10100 directory in a single serde_json::from_value call, so one profile with a mistyped field — e.g. a numeric respond_to or channel_add_policy — failed the entire query. Kind:10100 content is free-form JSON under each agent author's control, and the relay persists the event even when its own side-effect validation fails (handlers/ingest.rs logs and keeps it), so any authenticated publisher could blank the agent directory for every desktop client. The failure is app-wide and looks like a product bug: useRelayAgentsQuery mounts on ~14 always-live surfaces (mentions, members bar, add-member search, profile popovers, search, pulse). Parse per-entry instead: malformed profiles are logged with their pubkey and skipped, the rest of the directory survives. Same pattern as custom_harnesses.rs (per-file warn-and-skip). Output is unchanged for any directory that parses today; error results now come only from the relay query itself. The write-path half of this root cause — nothing validates kind:10100 content shape before it is persisted — is tracked in the external-agent threads (buzz#2987; buzz#3448 fixes the CLI's clobbering writer). This is the read-side defense. Co-Authored-By: Claude Fable 5 Signed-off-by: Bryce Del Rio --- .../src-tauri/src/commands/agent_discovery.rs | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index cbbf4ce351..02357ed1e0 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1068,13 +1068,93 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result Vec { + let serde_json::Value::Array(items) = agents else { + return Vec::new(); + }; + items + .into_iter() + .filter_map(|item| { + let pubkey_hint = item + .get("pubkey") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_owned(); + match serde_json::from_value::(item) { + Ok(agent) => Some(agent), + Err(e) => { + tracing::warn!( + "list_relay_agents: skipping unparseable agent profile ({pubkey_hint}): {e}" + ); + None + } + } + }) + .collect() } #[cfg(test)] mod tests { use super::*; + // ── parse_relay_agents ──────────────────────────────────────────────────── + + fn directory_entry(name: &str) -> serde_json::Value { + serde_json::json!({ + "pubkey": "ab".repeat(32), + "name": name, + "agent_type": "agent", + "channels": [], + "capabilities": [], + "status": "online", + }) + } + + #[test] + fn parse_relay_agents_parses_valid_entries() { + let parsed = parse_relay_agents(serde_json::json!([ + directory_entry("Scout"), + directory_entry("Rover"), + ])); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].name, "Scout"); + assert_eq!(parsed[1].name, "Rover"); + } + + #[test] + fn parse_relay_agents_skips_malformed_entries_and_keeps_the_rest() { + // One profile with a mistyped field (respond_to must be a string enum) + // must not blank the directory — the other agents still parse. + let mut poisoned = directory_entry("Broken"); + poisoned["respond_to"] = serde_json::json!(123); + + let parsed = parse_relay_agents(serde_json::json!([ + directory_entry("Scout"), + poisoned, + directory_entry("Rover"), + ])); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].name, "Scout"); + assert_eq!(parsed[1].name, "Rover"); + } + + #[test] + fn parse_relay_agents_returns_empty_for_non_array_input() { + assert!(parse_relay_agents(serde_json::json!(null)).is_empty()); + assert!(parse_relay_agents(serde_json::json!({})).is_empty()); + } + // ── is_npm_global_install ───────────────────────────────────────────────── #[test] From 83b85724f43df38c384b6cd18659ed6fffb4d055 Mon Sep 17 00:00:00 2001 From: Bryce Del Rio Date: Fri, 31 Jul 2026 08:25:15 +0000 Subject: [PATCH 2/3] fix(desktop): log an aggregate kept/skipped count on lenient directory parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #3866: per-entry warns identify which profile is malformed, but spotting a poisoned directory shouldn't require counting them — emit one aggregate kept/skipped line per query when anything was dropped. Co-Authored-By: Claude Fable 5 Signed-off-by: Bryce Del Rio --- desktop/src-tauri/src/commands/agent_discovery.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 02357ed1e0..ed6475e25e 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1083,7 +1083,8 @@ fn parse_relay_agents(agents: serde_json::Value) -> Vec { let serde_json::Value::Array(items) = agents else { return Vec::new(); }; - items + let total = items.len(); + let parsed: Vec = items .into_iter() .filter_map(|item| { let pubkey_hint = item @@ -1101,7 +1102,17 @@ fn parse_relay_agents(agents: serde_json::Value) -> Vec { } } }) - .collect() + .collect(); + // One aggregate line per query so a poisoned directory is visible at a + // glance without counting per-entry warns. + let skipped = total - parsed.len(); + if skipped > 0 { + tracing::warn!( + "list_relay_agents: kept {kept} of {total} agent profiles, skipped {skipped} unparseable", + kept = parsed.len() + ); + } + parsed } #[cfg(test)] From f27b2e0f398f68ad0262079bf94ca78938f881d4 Mon Sep 17 00:00:00 2001 From: Bryce Del Rio Date: Fri, 31 Jul 2026 10:51:20 +0000 Subject: [PATCH 3/3] fix(desktop): name the failing field in directory-parse skip warns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #3866: the per-entry warn carried serde's message but not the field path, so spotting schema drift meant guessing. Route deserialization through serde_path_to_error (already in the tree as a transitive dep) — the warn now reads e.g. '(...pubkey...): channel_add_policy: invalid type: integer'. Co-Authored-By: Claude Fable 5 Signed-off-by: Bryce Del Rio --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 3 +++ desktop/src-tauri/src/commands/agent_discovery.rs | 5 ++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 325eb9aa67..ecf2fdbee0 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1076,6 +1076,7 @@ dependencies = [ "security-framework 3.7.0", "serde", "serde_json", + "serde_path_to_error", "serde_yaml", "sha2 0.11.0", "sherpa-onnx", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6f3c03c5a5..684bd80499 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -84,6 +84,9 @@ opus = "0.3" neteq = { version = "0.8", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" +# Names the failing field path in directory-parse skip warns (already in the +# tree as a transitive dep). +serde_path_to_error = "0.1" serde_yaml = "0.9" toml = "0.8" nostr = { version = "0.44", features = ["nip44", "nip49"] } diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index ed6475e25e..d44d3169c8 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1092,7 +1092,10 @@ fn parse_relay_agents(agents: serde_json::Value) -> Vec { .and_then(serde_json::Value::as_str) .unwrap_or("") .to_owned(); - match serde_json::from_value::(item) { + // serde_path_to_error names the offending field (e.g. + // `channel_add_policy: invalid type: integer`) so operators can + // spot schema drift from the warn alone. + match serde_path_to_error::deserialize::<_, RelayAgentInfo>(item) { Ok(agent) => Some(agent), Err(e) => { tracing::warn!(