From 4340f8325c7f0aa26e1dbb86059ead6f142a8b12 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 13:17:23 -0400 Subject: [PATCH] fix(desktop): keep agent list and edits working in recovery mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The share-state projection resolves the relay+owner retention scope, which requires signing keys. In recovery mode (identity lost or keyring locked) those keys are withheld, so the hard `?` at all three call sites failed list_personas, create_persona, and update_persona for EVERY agent — not just catalog-shared ones. Share state is a view projection, so an unresolvable scope now degrades to "not shared" rather than propagating. It can under-report visibility but never presents an unshared persona as published, and the durable state lives in the retention head so the true value returns once signing works. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src-tauri/src/commands/personas/mod.rs | 6 +- .../src/commands/personas/pending.rs | 116 +++++++++++++++++- 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 665f7fae5..d59035d03 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -46,7 +46,7 @@ pub async fn list_personas(app: AppHandle) -> Result, Strin .lock() .map_err(|error| error.to_string())?; let mut personas = load_personas(&app)?; - pending::project_active_persona_sharing(&app, &state, &mut personas)?; + pending::project_active_persona_sharing(&app, &state, &mut personas); Ok(personas) }) .await @@ -74,7 +74,7 @@ pub async fn create_persona( .lock() .map_err(|error| error.to_string())?; let mut personas = load_personas(&app)?; - pending::project_active_persona_sharing(&app, &state, &mut personas)?; + pending::project_active_persona_sharing(&app, &state, &mut personas); let name_pool: Vec = input .name_pool .into_iter() @@ -176,7 +176,7 @@ pub async fn update_persona( .lock() .map_err(|error| error.to_string())?; let mut personas = load_personas(&app)?; - pending::project_active_persona_sharing(&app, &state, &mut personas)?; + pending::project_active_persona_sharing(&app, &state, &mut personas); let persona = personas .iter_mut() .find(|record| record.id == input.id) diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 40779c5d6..40a1f9f9b 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -82,10 +82,49 @@ fn retained_persona_is_shared(row: Option<&RetainedEvent>) -> bool { .is_some_and(|event| persona_event_is_shared(&event)) } +/// Project each persona's catalog visibility from the active relay+owner +/// scope's retained head. +/// +/// Infallible by design. The scope needs `signing_keys()`, which fails for the +/// whole process whenever the identity is lost or the keyring is locked, and a +/// propagated error there would break listing, creating, and updating EVERY +/// agent. Share state is a view projection, so an unresolvable scope degrades +/// to "not shared" — the safe direction: it can under-report visibility but can +/// never present an unshared persona as published. The durable share state +/// lives in the retention head, so nothing is lost: the true value reappears +/// once the identity is signable again. pub(super) fn project_active_persona_sharing( app: &AppHandle, state: &AppState, personas: &mut [AgentDefinition], +) { + let scope = crate::managed_agents::retention::active_retention_scope(app, state); + project_scoped_persona_sharing(scope, personas); +} + +fn project_scoped_persona_sharing( + scope: Result, + personas: &mut [AgentDefinition], +) { + let projected = scope.and_then(|scope| { + project_persona_sharing_at( + &scope.db_path, + &scope.owner_keys.public_key().to_hex(), + personas, + ) + }); + if let Err(error) = projected { + eprintln!("buzz-desktop: persona-share-projection unavailable, reporting every agent as unshared: {error}"); + for persona in personas { + persona.shared = false; + } + } +} + +fn project_persona_sharing_at( + db_path: &std::path::Path, + owner_pubkey: &str, + personas: &mut [AgentDefinition], ) -> Result<(), String> { use crate::managed_agents::{ persona_events::persona_d_tag, @@ -93,16 +132,14 @@ pub(super) fn project_active_persona_sharing( }; use buzz_core_pkg::kind::KIND_PERSONA; - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let owner_pubkey = scope.owner_keys.public_key().to_hex(); - let conn = open_retention_db(&scope.db_path)?; + let conn = open_retention_db(db_path)?; for persona in personas { if persona.is_builtin { persona.shared = false; continue; } let retained = - get_retained_event(&conn, KIND_PERSONA, &owner_pubkey, &persona_d_tag(persona))?; + get_retained_event(&conn, KIND_PERSONA, owner_pubkey, &persona_d_tag(persona))?; persona.shared = retained_persona_is_shared(retained.as_ref()); } Ok(()) @@ -279,6 +316,77 @@ mod tests { )); } + /// A `shared = true` persona plus the scope that says so. + fn shared_persona_scope(dir: &std::path::Path) -> (RetentionScope, Vec) { + let keys = nostr::Keys::generate(); + let db_path = scoped_retention_db_path(dir, "wss://a.example", &keys.public_key().to_hex()); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + prepare_persona_publication_at(&db_path, &keys, &persona(), Some(true)).unwrap(); + ( + RetentionScope { + db_path, + relay_url: "wss://a.example".to_string(), + owner_keys: keys, + }, + vec![persona()], + ) + } + + #[test] + fn test_resolvable_scope_projects_the_retained_share_state() { + let dir = tempfile::tempdir().unwrap(); + let (scope, mut personas) = shared_persona_scope(dir.path()); + + project_scoped_persona_sharing(Ok(scope), &mut personas); + + assert!(personas[0].shared); + } + + #[test] + fn test_recovery_mode_identity_projects_unshared_instead_of_failing() { + let dir = tempfile::tempdir().unwrap(); + let (_scope, mut personas) = shared_persona_scope(dir.path()); + personas[0].shared = true; + + // The real recovery-mode failure: `active_retention_scope` cannot + // resolve a scope without signing keys, which is exactly what + // `identity_lost` / `keyring_locked` withhold. + let state = crate::app_state::build_app_state(); + state + .identity_lost + .store(true, std::sync::atomic::Ordering::Release); + let error = state + .signing_keys() + .expect_err("recovery mode must withhold signing keys"); + + project_scoped_persona_sharing(Err(error), &mut personas); + + assert!( + !personas[0].shared, + "an unresolvable scope degrades to unshared so list/create/update keep working" + ); + } + + #[test] + fn test_unopenable_retention_db_projects_unshared_instead_of_failing() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let mut personas = vec![persona()]; + personas[0].shared = true; + + project_scoped_persona_sharing( + Ok(RetentionScope { + // A directory cannot be opened as the retention database. + db_path: dir.path().to_path_buf(), + relay_url: "wss://a.example".to_string(), + owner_keys: keys, + }), + &mut personas, + ); + + assert!(!personas[0].shared); + } + #[test] fn explicit_share_enqueue_failure_is_returned() { let dir = tempfile::tempdir().unwrap();