diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 51cda34b8b..bf06eb1f07 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -29,8 +29,8 @@ use tauri::{AppHandle, State}; use super::super::export_util::save_bytes_with_dialog; use super::snapshot::{ - memory_entries_from_listing, parse_memory_level, resolve_from_lists, - validate_snapshot_encode_size, + load_effective_managed_agents, memory_entries_from_listing, parse_memory_level, + resolve_from_lists, validate_snapshot_encode_size, }; use crate::{ app_state::AppState, @@ -43,8 +43,8 @@ use crate::{ agent_snapshot_envelope::{ decrypt_envelope, encode_locked_snapshot_png, parse_chunk_payload, ChunkPayload, }, - load_agent_definitions, load_global_agent_config, load_managed_agents, load_personas, - save_global_agent_config, validate_global_config, + load_agent_definitions, load_global_agent_config, load_personas, save_global_agent_config, + validate_global_config, }, }; @@ -493,14 +493,7 @@ 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 instances = load_effective_managed_agents(&app, &state)?; let definitions = load_agent_definitions(&app)?; let (record, _) = resolve_from_lists(&id, &instances, &definitions)?; @@ -556,15 +549,7 @@ 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 instances = load_effective_managed_agents(&app, &state)?; 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 13a58caed1..84fb706aaa 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -56,6 +56,29 @@ pub(crate) fn resolve_from_lists<'a>( Err(format!("agent {id:?} not found")) } +/// Load the managed-agent instances with the kind:30179 private-config +/// overlay folded on — patching disk records and synthesizing relay-only +/// entries, the same resolution the agent list uses. +/// +/// Every export/card resolver must read instances through this helper, never +/// raw `load_managed_agents`: raw disk exports stale values on a follower +/// device and fails with "agent not found" for a relay-only agent that has +/// never been started on this device. +/// +/// Lock order: callers already hold `managed_agents_store_lock` (outer); +/// this takes only the overlay lock (inner) and releases it before returning. +pub(crate) fn load_effective_managed_agents( + app: &AppHandle, + state: &State<'_, AppState>, +) -> Result, String> { + let instances = load_managed_agents(app)?; + Ok(state + .private_managed_agent_overlay + .lock() + .map_err(|e| e.to_string())? + .resolved_records(&instances)) +} + /// Validate that `memory_source_pubkey` is an appropriate source for a /// memory-bearing snapshot export. /// @@ -246,16 +269,9 @@ pub(crate) async fn materialize_snapshot_bytes( .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); + // relay-only entries) so export sees the EFFECTIVE config — see + // `load_effective_managed_agents`. + let instances = load_effective_managed_agents(&app, &state)?; 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 600e960821..644970a943 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -162,118 +162,10 @@ 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. - +// Sibling file: keeps this file under the 1000-line gate. #[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)] - ); - } -} +#[path = "tests_overlay_fold.rs"] +mod overlay_fold; #[test] fn resolve_by_pubkey_finds_keyed_instance() { diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_export_resolver_guard.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_export_resolver_guard.rs index 0150e91cfe..65d0308b0c 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_export_resolver_guard.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_export_resolver_guard.rs @@ -1,67 +1,117 @@ //! Source guard: every export resolver folds the private-config overlay. //! //! Kept in a sibling file so `snapshot/tests.rs` stays under the 1000-line -//! gate; `#[path]`-included from there as a child module. +//! gate; `#[path]`-included from `snapshot.rs` as a child module. //! -//! The behavioural tests in `tests.rs` model the fold by calling -//! `resolved_records` / `resolve_from_lists` directly. They CANNOT reach the -//! production call sites: all three live inside `#[tauri::command]` bodies -//! needing a live `AppHandle` + `State`. Measured on the parent -//! commit — deleting any one of the three production folds leaves the full -//! workspace suite green (2283 passed, 0 failed). +//! The behavioural tests in `tests.rs`/`tests_overlay_fold.rs` model the fold +//! by calling `resolved_records` / `resolve_from_lists` directly. They CANNOT +//! reach the production call sites: all three live inside `#[tauri::command]` +//! bodies needing a live `AppHandle` + `State`. Measured before this +//! guard existed — deleting any one of the three production folds left the +//! full workspace suite green (2283 passed, 0 failed). //! //! Same weakness, same remedy, as `write_site_resolve_guard` in //! `private_config_overlay.rs`: assert the call exists at each site. +//! +//! The fold lives in one helper, `load_effective_managed_agents`, so the +//! guarded invariant has three legs: +//! 1. every resolver site calls the helper (exact count per file); +//! 2. no resolver file reads raw `load_managed_agents(` outside the helper; +//! 3. the helper itself still performs the fold. -/// The fold call every export resolver must make. A resolver that reads raw -/// disk instead fails with "agent not found" for a relay-only agent that has -/// never started on this device, and bakes stale disk values into the -/// exported manifest on a follower device. +/// Call-shaped needle for the helper. A resolver that reads raw disk instead +/// fails with "agent not found" for a relay-only agent that has never started +/// on this device, and bakes stale disk values into the exported manifest on +/// a follower device. +const HELPER_CALL: &str = "load_effective_managed_agents(&app, &state)?"; + +/// The fold inside the helper. const FOLD_CALL: &str = ".resolved_records(&instances)"; -/// `(file, source, expected_fold_calls)` — every export resolver that turns -/// an id into a record destined for a snapshot manifest or a card. -fn sites() -> Vec<(&'static str, &'static str, usize)> { +/// Raw-disk read. Exact counts per file: the only permitted occurrence is the +/// one inside the helper in `snapshot.rs`. +const RAW_LOAD_CALL: &str = "load_managed_agents("; + +/// `(file, source, expected_helper_calls, allowed_raw_loads)` — every export +/// resolver that turns an id into a record destined for a snapshot manifest +/// or a card. +fn sites() -> Vec<(&'static str, &'static str, usize, usize)> { vec![ - // `materialize_snapshot_bytes` — JSON + PNG + send-to-channel. + // `materialize_snapshot_bytes` (JSON + PNG + send-to-channel): 1 + // helper call. 1 raw load allowed: the helper's own body. ( "commands/personas/snapshot.rs", include_str!("../snapshot.rs"), 1, + 1, ), // Card precheck (`card_mint_key_status`) + mint resolver - // (`mint_agent_card`). Exact, not `>= 1`: a lower bound would not - // notice one site losing its fold while the other gained a second. - ("commands/personas/card.rs", include_str!("../card.rs"), 2), + // (`mint_agent_card`): 2 helper calls, 0 raw loads. Exact, not + // `>= 1`: a lower bound would not notice one site losing its call + // while the other gained a second. + ( + "commands/personas/card.rs", + include_str!("../card.rs"), + 2, + 0, + ), ] } #[test] fn every_export_resolver_folds_the_private_config_overlay() { - for (file, source, expected) in sites() { - let found = source.matches(FOLD_CALL).count(); + for (file, source, expected, allowed_raw) in sites() { + let found = source.matches(HELPER_CALL).count(); assert_eq!( found, expected, - "{file}: expected {expected} `{FOLD_CALL}` call(s), found {found}. \ + "{file}: expected {expected} `{HELPER_CALL}` call(s), found {found}. \ An export resolver that reads raw disk fails with \"agent not found\" \ for a relay-only agent that has never started on this device, and \ bakes stale disk values into the exported manifest on a follower." ); - } -} - -/// The guard above is a substring count, so prove it can FAIL: a source with -/// the fold removed must not satisfy it. Without this, a typo in `FOLD_CALL` -/// would make every row pass vacuously. -#[test] -fn guard_detects_a_missing_overlay_fold() { - for (file, source, expected) in sites() { - let stripped = source.replace(FOLD_CALL, ".REMOVED(&instances)"); + let raw = source.matches(RAW_LOAD_CALL).count(); assert_eq!( - stripped.matches(FOLD_CALL).count(), - 0, - "{file}: guard string never matched, so its {expected} expected \ - call(s) passed vacuously" + raw, allowed_raw, + "{file}: expected {allowed_raw} raw `{RAW_LOAD_CALL}` call(s), found \ + {raw}. A resolver bypassing `load_effective_managed_agents` reads \ + raw disk and skips the kind:30179 overlay fold." ); } } + +/// The helper must actually fold — the resolver-site counts above would pass +/// vacuously if `load_effective_managed_agents` degraded to a plain load. +#[test] +fn the_helper_itself_performs_the_fold() { + let source = include_str!("../snapshot.rs"); + let found = source.matches(FOLD_CALL).count(); + assert_eq!( + found, 1, + "snapshot.rs: expected exactly 1 `{FOLD_CALL}` call (inside \ + `load_effective_managed_agents`), found {found}" + ); +} + +/// The guards above are substring counts, so prove they can FAIL: a source +/// with the calls removed must not satisfy them. Without this, a typo in a +/// needle would make every row pass vacuously. +#[test] +fn guard_detects_missing_calls() { + for (file, source, expected, _) in sites() { + let stripped = source.replace(HELPER_CALL, "REMOVED(&app, &state)?"); + assert_eq!( + stripped.matches(HELPER_CALL).count(), + 0, + "{file}: helper needle never matched, so its {expected} expected \ + call(s) passed vacuously" + ); + } + let source = include_str!("../snapshot.rs"); + let stripped = source.replace(FOLD_CALL, ".REMOVED(&instances)"); + assert_eq!( + stripped.matches(FOLD_CALL).count(), + 0, + "snapshot.rs: fold needle never matched, so the helper check passed \ + vacuously" + ); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_overlay_fold.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_overlay_fold.rs new file mode 100644 index 0000000000..97a4c65b93 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_overlay_fold.rs @@ -0,0 +1,114 @@ +//! Overlay-fold resolution (kind:30179) tests for export. +//! +//! Kept in a sibling file so `snapshot/tests.rs` stays under the +//! 1000-line gate; `#[path]`-included from there as a child module, +//! so `super::*` still resolves to the shared test helpers. +//! +//! 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. + +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)] + ); +}