From 692fdafa61c108bd7fb6c4249f17dcc63cd8ad5a Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Fri, 7 Aug 2026 12:10:56 -0400 Subject: [PATCH] fix(desktop): export/card resolves through the 30179 overlay; snapshot respond_to falls back to instance fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relay-hosted agents made two gaps in the export/card resolvers user-visible: - Gap B: a relay-only agent (30179 head, never started on this device) has no disk record, so materialize_snapshot_bytes / card_mint_key_status / mint_agent_card failed with "agent not found" until first START. - Gap A: a follower device exported the stale disk snapshot instead of the effective 30179 config it actually runs. Fold the private-config overlay onto the disk list at all three resolver sites — the identical resolution the agent list already uses (resolved_records: applies patches to disk records AND synthesizes relay-only entries). Card mint then also inherits the correct live kind:0 avatar for free, since its existing profile fetch runs after resolution. Also give build_snapshot's respond_to/allowlist the same instance fallback parallelism already has: an overlay-materialized record carries its enforced respond_to only on the instance fields, so without the fallback a relay-hosted agent exported respond_to=None while actually enforcing an allowlist — a shared agent that behaves differently than advertised. Mode and list travel together (definition mode -> definition list, instance fallback -> instance list, never mixed). Deferred as fidelity-not-function (tracked for the relay-hosted cutover): kind:0 avatar hydration for ordinary JSON/PNG manifests, name_pool. Verified: cargo test in desktop/src-tauri — 2283 passed, 0 failed; clippy --tests clean; fmt clean. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../src-tauri/src/commands/personas/card.rs | 15 +++ .../src/commands/personas/snapshot.rs | 10 ++ .../src/commands/personas/snapshot/tests.rs | 114 ++++++++++++++++++ .../src/managed_agents/agent_snapshot.rs | 21 +++- .../managed_agents/agent_snapshot_tests.rs | 49 ++++++++ 5 files changed, 207 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 14c7c196b2..51cda34b8b 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -493,7 +493,14 @@ pub fn card_mint_key_status( .lock() .map_err(|e| e.to_string())?; + // Overlay-fold (kind:30179) so the status reflects the agent's effective + // config — including relay-only agents with no local record yet. let instances = load_managed_agents(&app)?; + let instances = state + .private_managed_agent_overlay + .lock() + .map_err(|e| e.to_string())? + .resolved_records(&instances); let definitions = load_agent_definitions(&app)?; let (record, _) = resolve_from_lists(&id, &instances, &definitions)?; @@ -549,7 +556,15 @@ pub async fn mint_agent_card( .lock() .map_err(|e| e.to_string())?; + // Overlay-fold (kind:30179) — same resolution as snapshot export and + // the agent list: mint sees effective config, and a relay-only agent + // resolves before its first START on this device. let instances = load_managed_agents(&app)?; + let instances = state + .private_managed_agent_overlay + .lock() + .map_err(|e| e.to_string())? + .resolved_records(&instances); let definitions = load_agent_definitions(&app)?; let (record, is_definition) = resolve_from_lists(&id, &instances, &definitions).map(|(r, d)| (r.clone(), d))?; diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e7bd1597e6..6d03f45a08 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -245,7 +245,17 @@ pub(crate) async fn materialize_snapshot_bytes( .lock() .map_err(|e| e.to_string())?; + // Fold the kind:30179 overlay onto the disk records (and synthesize + // relay-only entries) so export sees the EFFECTIVE config — the same + // resolution the agent list uses. Raw disk here would export stale + // values on a follower device and fail with "agent not found" for a + // relay-only agent that has never been started on this device. let instances = load_managed_agents(&app)?; + let instances = state + .private_managed_agent_overlay + .lock() + .map_err(|e| e.to_string())? + .resolved_records(&instances); let definitions = load_agent_definitions(&app)?; let (def_record, is_definition) = resolve_from_lists(&id, &instances, &definitions) .map(|(r, is_def)| (r.clone(), is_def))?; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index c453b09a9d..600e960821 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -161,6 +161,120 @@ fn definition_slug_resolves_to_definition_and_linked_instance_is_valid_memory_so // ── Resolver edge cases ─────────────────────────────────────────────────── +// ── Overlay-fold resolution (kind:30179) ────────────────────────────────── +// +// Export resolves against `resolved_records(load_managed_agents(..))`, not +// raw disk. These tests pin the two behaviors that fold buys export: +// a relay-only agent resolves before its first START on this device, and a +// follower exports the effective relay config instead of stale disk values. + +#[cfg(not(target_os = "windows"))] +mod overlay_fold { + use super::*; + use crate::managed_agents::private_config_overlay::PrivateConfigOverlay; + use buzz_core_pkg::private_managed_agent::{ + Payload, PrivateConfig, PrivateIdentity, FORMAT, VERSION, + }; + use serde_json::{json, Map}; + + fn relay_payload(pubkey: &str, name: &str, prompt: &str) -> Payload { + Payload { + format: FORMAT.into(), + version: VERSION, + agent_pubkey: pubkey.into(), + owner_pubkey: "11".repeat(32), + generation: 1, + previous_event_id: None, + updated_at: "2026-08-07T00:00:00Z".into(), + identity: PrivateIdentity { + private_key_nsec: "nsec-test".into(), + auth_tag: None, + }, + config: PrivateConfig { + relay_url: "wss://relay.example".into(), + name: name.into(), + persona_id: None, + runtime: Some("goose".into()), + model: Some("relay-model".into()), + provider: None, + system_prompt: Some(prompt.into()), + parallelism: None, + respond_to: Some("allowlist".into()), + respond_to_allowlist: vec!["ab".repeat(32)], + agent_command_override: None, + agent_args: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + env_vars: BTreeMap::new(), + backend: json!({"type":"local"}), + backend_agent_id: None, + team_id: None, + persona_name_in_team: None, + relay_mesh: None, + extra: Map::new(), + }, + extensions: BTreeMap::new(), + extra: Map::new(), + } + } + + /// Gap B: a relay-only agent (30179 head, no disk record — never started + /// on this device) must resolve for export once the disk list is folded + /// through the overlay. Raw disk alone returns "agent not found". + #[test] + fn relay_only_agent_resolves_for_export_after_overlay_fold() { + let mut overlay = PrivateConfigOverlay::default(); + overlay + .insert(relay_payload("relay-only-pk", "Relay Only", "relay prompt")) + .unwrap(); + let disk: Vec = vec![]; + + // Raw disk: not found (the pre-fix behavior). + assert!(resolve_from_lists("relay-only-pk", &disk, &[]).is_err()); + + // Folded: resolves, with the relay config as the effective record. + let folded = overlay.resolved_records(&disk); + let (record, is_def) = resolve_from_lists("relay-only-pk", &folded, &[]).unwrap(); + assert!(!is_def); + assert_eq!(record.name, "Relay Only"); + assert_eq!(record.system_prompt.as_deref(), Some("relay prompt")); + } + + /// Gap A: a follower device with a stale disk record must export the + /// effective 30179 values, not the disk snapshot — and the exported + /// manifest must advertise the enforced respond_to via the instance + /// fallback (the overlay patch only populates instance fields). + #[test] + fn follower_exports_effective_relay_values_not_stale_disk() { + use crate::managed_agents::agent_snapshot::build_snapshot; + + let mut overlay = PrivateConfigOverlay::default(); + overlay + .insert(relay_payload("follower-pk", "Fresh Name", "fresh prompt")) + .unwrap(); + let mut stale = make_instance("follower-pk", "some-persona"); + stale.name = "Stale Name".into(); + stale.system_prompt = Some("stale prompt".into()); + + let folded = overlay.resolved_records(std::slice::from_ref(&stale)); + let (record, _) = resolve_from_lists("follower-pk", &folded, &[]).unwrap(); + assert_eq!(record.name, "Fresh Name"); + assert_eq!(record.system_prompt.as_deref(), Some("fresh prompt")); + assert_eq!(record.model.as_deref(), Some("relay-model")); + + let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None); + assert_eq!( + snapshot.definition.system_prompt.as_deref(), + Some("fresh prompt") + ); + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + assert_eq!( + snapshot.definition.respond_to_allowlist, + vec!["ab".repeat(32)] + ); + } +} + #[test] fn resolve_by_pubkey_finds_keyed_instance() { let inst = make_instance("pubkey-xyz", "my-agent"); diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 7c08e7095f..e00e262882 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -194,6 +194,23 @@ pub fn build_snapshot( // Use definition-level fields (respond_to, allowlist, parallelism) for // portability — instance-level equivalents are spawn-time snapshots and // would be stale. + // + // respond_to falls back to the instance fields when the definition-level + // mode is unset — same rationale as the parallelism fallback below. A + // record materialized from a kind:30179 relay head carries its effective + // respond_to/allowlist ONLY on the instance fields (the overlay patch + // never populates the definition ones), so without this fallback a + // relay-hosted agent would export `respond_to: None` while actually + // enforcing an allowlist. Mode and list travel together: a definition + // mode uses the definition list, the instance fallback uses the instance + // list — never a mix. + let (respond_to, respond_to_allowlist) = match record.definition_respond_to.clone() { + Some(mode) => (Some(mode), record.definition_respond_to_allowlist.clone()), + None => ( + Some(record.respond_to.as_str().to_string()), + record.respond_to_allowlist.clone(), + ), + }; let definition = AgentSnapshotDefinition { name: record .display_name @@ -205,8 +222,8 @@ pub fn build_snapshot( model: record.model.clone(), provider: record.provider.clone(), parallelism: record.definition_parallelism.or(Some(record.parallelism)), - respond_to: record.definition_respond_to.clone(), - respond_to_allowlist: record.definition_respond_to_allowlist.clone(), + respond_to, + respond_to_allowlist, name_pool: record.name_pool.clone(), idle_timeout_seconds: record.idle_timeout_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e5..8712aacd4e 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -540,6 +540,55 @@ fn definition_fields_present_in_snapshot() { assert!(!snapshot.definition.respond_to_allowlist.is_empty()); } +/// A definition-level mode must carry the DEFINITION list, never the +/// instance list — mode and list travel together (`minimal_record` has +/// distinct values on each level, so a mix-up is detectable). +#[test] +fn definition_respond_to_keeps_definition_list_not_instance_list() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + assert_eq!(snapshot.definition.respond_to_allowlist, vec!["abc123def"]); +} + +/// A record materialized from a kind:30179 relay head carries its effective +/// respond_to/allowlist only on the INSTANCE fields. Export must fall back +/// to them (like parallelism already does) instead of advertising +/// `respond_to: None` while the agent enforces an allowlist. +#[test] +fn respond_to_falls_back_to_instance_fields_when_definition_unset() { + let mut record = minimal_record(); + record.definition_respond_to = None; + record.definition_respond_to_allowlist = vec![]; + record.respond_to = RespondTo::Allowlist; + record.respond_to_allowlist = vec!["ef".repeat(32)]; + + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + assert_eq!( + snapshot.definition.respond_to_allowlist, + vec!["ef".repeat(32)] + ); +} + +/// The instance fallback exports the enforced default explicitly rather +/// than `None` — an owner-only agent advertises owner-only. +#[test] +fn respond_to_fallback_exports_explicit_owner_only_default() { + let mut record = minimal_record(); + record.definition_respond_to = None; + record.definition_respond_to_allowlist = vec![]; + record.respond_to = RespondTo::default(); + record.respond_to_allowlist = vec![]; + + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!( + snapshot.definition.respond_to.as_deref(), + Some("owner-only") + ); + assert!(snapshot.definition.respond_to_allowlist.is_empty()); +} + #[test] fn profile_fields_present_in_snapshot() { let record = minimal_record();