diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc36..b011ba55ae 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -6,6 +6,149 @@ use url::Url; use crate::nostr_bind; +mod agent_snapshot_handoff_security; + +use agent_snapshot_handoff_security::{ + agent_snapshot_handoff_dir, open_agent_snapshot_handoff_from_dir, + parse_agent_snapshot_handoff_id, OpenAgentSnapshotHandoff, +}; +#[cfg(test)] +use agent_snapshot_handoff_security::{ + consume_agent_snapshot_handoff_from_dir, read_agent_snapshot_handoff_from_dir, + validate_agent_snapshot_handoff_directory_metadata, validate_agent_snapshot_handoff_metadata, + AGENT_SNAPSHOT_HANDOFF_MAX_AGE, +}; + +const AGENT_SNAPSHOT_HANDOFF_EVENT: &str = "agent-snapshot-import-available"; +const MAX_DEEP_LINK_URL_BYTES: usize = 64 * 1024; +const AGENT_SNAPSHOT_HANDOFF_MAX_URL_BYTES: usize = 256; + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingAgentSnapshotImport { + id: String, + file_bytes: Vec, + file_name: String, + snapshot_kind: String, +} + +#[derive(Default)] +pub(crate) struct PendingAgentSnapshotImports(Mutex>); + +struct QueuedAgentSnapshotImport { + pending: PendingAgentSnapshotImport, + source: OpenAgentSnapshotHandoff, +} + +impl PendingAgentSnapshotImports { + fn enqueue( + &self, + pending: PendingAgentSnapshotImport, + source: OpenAgentSnapshotHandoff, + ) -> Result { + let mut queue = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if queue.iter().any(|item| item.pending.id == pending.id) { + return Ok(false); + } + if !queue.is_empty() { + return Err(()); + } + queue.push_back(QueuedAgentSnapshotImport { pending, source }); + Ok(true) + } + + fn first(&self) -> Option { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .front() + .map(|item| item.pending.clone()) + } + + fn consume(&self, id: &str) -> bool { + let mut queue = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let Some(item) = queue.front_mut() else { + return false; + }; + if item.pending.id != id + || item + .source + .erase_after_acceptance(&item.pending.file_bytes) + .is_err() + { + return false; + } + queue.pop_front(); + true + } + + fn discard(&self, id: &str) -> bool { + let mut queue = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !queue.front().is_some_and(|item| item.pending.id == id) { + return false; + } + queue.pop_front(); + true + } +} + +#[tauri::command] +pub(crate) fn take_pending_agent_snapshot_import( + pending: State<'_, PendingAgentSnapshotImports>, +) -> Option { + pending.first() +} + +#[tauri::command] +pub(crate) fn acknowledge_pending_agent_snapshot_import( + id: String, + pending: State<'_, PendingAgentSnapshotImports>, +) -> bool { + pending.consume(&id) +} + +#[tauri::command] +pub(crate) fn reject_pending_agent_snapshot_import( + id: String, + pending: State<'_, PendingAgentSnapshotImports>, +) -> bool { + pending.discard(&id) +} + +fn queue_agent_snapshot_handoff( + app: &tauri::AppHandle, + handoff_id: String, +) -> Result { + let source = open_agent_snapshot_handoff_from_dir(&agent_snapshot_handoff_dir()?, &handoff_id)?; + let bytes = source.bytes().to_vec(); + match app.state::().enqueue( + PendingAgentSnapshotImport { + file_name: format!("{handoff_id}.agent.json"), + id: handoff_id, + file_bytes: bytes, + snapshot_kind: "agent".to_string(), + }, + source, + ) { + Ok(is_new) => Ok(is_new), + Err(()) => Err("another agent snapshot handoff is already awaiting preview".to_string()), + } +} + +fn agent_snapshot_handoff_url_exceeds_limit(url: &Url, raw: &str) -> bool { + url.host_str() == Some("import-agent-snapshot") + && raw.len() > AGENT_SNAPSHOT_HANDOFF_MAX_URL_BYTES +} + #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PendingCommunityDeepLink { @@ -296,6 +439,10 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result` — emits `deep-link-connect` to the frontend pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { + if url_str.len() > MAX_DEEP_LINK_URL_BYTES { + eprintln!("buzz-desktop: deep link exceeds the URL size limit"); + return; + } let url = match Url::parse(url_str) { Ok(u) => u, Err(e) => { @@ -310,6 +457,27 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } match url.host_str() { + Some("import-agent-snapshot") => { + if agent_snapshot_handoff_url_exceeds_limit(&url, url_str) { + eprintln!("buzz-desktop: snapshot handoff deep link exceeds the URL size limit"); + return; + } + let Some(handoff_id) = parse_agent_snapshot_handoff_id(&url) else { + eprintln!("buzz-desktop: snapshot handoff deep link has an invalid id: {url_str}"); + return; + }; + let is_new = match queue_agent_snapshot_handoff(app, handoff_id) { + Ok(is_new) => is_new, + Err(error) => { + eprintln!("buzz-desktop: rejecting agent snapshot handoff: {error}"); + return; + } + }; + activate_main_window(app); + if is_new { + let _ = app.emit(AGENT_SNAPSHOT_HANDOFF_EVENT, ()); + } + } Some("connect") => { let Some(relay_url) = parse_websocket_relay_param(&url) else { eprintln!("buzz-desktop: connect deep link missing/invalid relay: {url_str}"); @@ -384,6 +552,10 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } } +#[cfg(test)] +#[path = "deep_link/agent_snapshot_handoff.rs"] +mod agent_snapshot_handoff_tests; + #[cfg(test)] mod tests { use url::Url; diff --git a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs new file mode 100644 index 0000000000..f7af88098d --- /dev/null +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs @@ -0,0 +1,410 @@ +#[cfg(target_os = "macos")] +use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt}; +#[cfg(target_os = "macos")] +use std::path::{Path, PathBuf}; +#[cfg(target_os = "macos")] +use std::time::{Duration, SystemTime}; + +use url::Url; + +use super::{ + agent_snapshot_handoff_url_exceeds_limit, consume_agent_snapshot_handoff_from_dir, + open_agent_snapshot_handoff_from_dir, parse_agent_snapshot_handoff_id, + read_agent_snapshot_handoff_from_dir, PendingAgentSnapshotImport, PendingAgentSnapshotImports, +}; +#[cfg(target_os = "macos")] +use super::{ + validate_agent_snapshot_handoff_directory_metadata, validate_agent_snapshot_handoff_metadata, + AGENT_SNAPSHOT_HANDOFF_MAX_AGE, +}; + +const HANDOFF_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + +fn config_only_snapshot_bytes(runtime: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "format": "buzz-agent-snapshot", + "version": 1, + "definition": { + "name": "Hermes helper", + "systemPrompt": "Be helpful.", + "runtime": runtime + }, + "profile": { "displayName": "Hermes helper" }, + "memory": { "level": "none" } + })) + .unwrap() +} + +#[test] +fn agent_snapshot_handoff_requires_one_canonical_lowercase_uuid() { + let valid = Url::parse(&format!( + "buzz://import-agent-snapshot?handoff={HANDOFF_ID}" + )) + .unwrap(); + assert_eq!( + parse_agent_snapshot_handoff_id(&valid).as_deref(), + Some(HANDOFF_ID) + ); + + for invalid in [ + "buzz://import-agent-snapshot", + "buzz://import-agent-snapshot?handoff=550E8400-E29B-41D4-A716-446655440000", + "buzz://import-agent-snapshot?handoff=550e8400e29b41d4a716446655440000", + "buzz://import-agent-snapshot?handoff=../550e8400-e29b-41d4-a716-446655440000", + "buzz://import-agent-snapshot?handoff=550e8400-e29b-41d4-a716-446655440000&extra=1", + "buzz://import-agent-snapshot?handoff=550e8400-e29b-41d4-a716-446655440000&handoff=550e8400-e29b-41d4-a716-446655440000", + ] { + assert!( + parse_agent_snapshot_handoff_id(&Url::parse(invalid).unwrap()).is_none(), + "accepted invalid handoff URL: {invalid}" + ); + } +} + +#[test] +fn normalized_whitespace_does_not_bypass_handoff_url_limit() { + let raw = format!( + "{}buzz://import-agent-snapshot?handoff={HANDOFF_ID}", + " ".repeat(300) + ); + let parsed = Url::parse(&raw).unwrap(); + assert_eq!(parsed.host_str(), Some("import-agent-snapshot")); + assert!(agent_snapshot_handoff_url_exceeds_limit(&parsed, &raw)); +} + +#[cfg(target_os = "macos")] +fn stage_handoff(dir: &Path, bytes: &[u8], mode: u32) -> PathBuf { + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = dir.join(format!("{HANDOFF_ID}.agent.json")); + std::fs::write(&path, bytes).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + path +} + +#[cfg(target_os = "macos")] +#[test] +fn secure_handoff_read_retains_source_until_preview_acknowledgement() { + let dir = tempfile::tempdir().unwrap(); + let expected = config_only_snapshot_bytes("hermes"); + let path = stage_handoff(dir.path(), &expected, 0o600); + + let bytes = read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).unwrap(); + + assert_eq!(bytes, expected); + assert!(path.exists(), "source must remain until preview settles"); + consume_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID, &bytes).unwrap(); + assert!(path.exists(), "acknowledgement must not unlink by pathname"); + assert_eq!( + path.metadata().unwrap().len(), + 0, + "acknowledged payload must be erased through its verified descriptor" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn accepted_descriptor_erasure_does_not_touch_a_replacement_path() { + let dir = tempfile::tempdir().unwrap(); + let expected = config_only_snapshot_bytes("hermes"); + let path = stage_handoff(dir.path(), &expected, 0o600); + let accepted_path = dir.path().join("accepted-original.agent.json"); + let mut source = open_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).unwrap(); + + std::fs::rename(&path, &accepted_path).unwrap(); + let replacement = b"replacement must survive"; + std::fs::write(&path, replacement).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + + source.erase_after_acceptance(&expected).unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), replacement); + assert_eq!(accepted_path.metadata().unwrap().len(), 0); +} + +#[cfg(target_os = "macos")] +#[test] +fn accepted_descriptor_erasure_clears_a_concurrent_hardlink() { + let dir = tempfile::tempdir().unwrap(); + let expected = config_only_snapshot_bytes("hermes"); + let path = stage_handoff(dir.path(), &expected, 0o600); + let alias = dir.path().join("late-hardlink.agent.json"); + let mut source = open_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).unwrap(); + + std::fs::hard_link(&path, &alias).unwrap(); + source.erase_after_acceptance(&expected).unwrap(); + + assert_eq!(path.metadata().unwrap().len(), 0); + assert_eq!(alias.metadata().unwrap().len(), 0); +} + +#[cfg(target_os = "macos")] +#[test] +fn accepted_descriptor_rejects_content_changed_after_preview() { + let dir = tempfile::tempdir().unwrap(); + let expected = config_only_snapshot_bytes("hermes"); + let changed = config_only_snapshot_bytes("other-runtime"); + let path = stage_handoff(dir.path(), &expected, 0o600); + let mut source = open_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).unwrap(); + + std::fs::write(&path, &changed).unwrap(); + + assert!(source.erase_after_acceptance(&expected).is_err()); + assert_eq!(std::fs::read(path).unwrap(), changed); +} + +#[cfg(target_os = "macos")] +#[test] +fn secure_handoff_read_rejects_symlink_and_leaves_target_untouched() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("target.json"); + std::fs::write(&target, config_only_snapshot_bytes("hermes")).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap(); + let link = dir.path().join(format!("{HANDOFF_ID}.agent.json")); + symlink(&target, &link).unwrap(); + + assert!(read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).is_err()); + assert!(target.exists()); + assert!(link.symlink_metadata().is_ok()); +} + +#[cfg(target_os = "macos")] +#[test] +fn secure_handoff_read_rejects_symlinked_staging_directory() { + let real_dir = tempfile::tempdir().unwrap(); + let path = stage_handoff( + real_dir.path(), + &config_only_snapshot_bytes("hermes"), + 0o600, + ); + let parent = tempfile::tempdir().unwrap(); + let linked_dir = parent.path().join("buzz-handoffs"); + symlink(real_dir.path(), &linked_dir).unwrap(); + + assert!(read_agent_snapshot_handoff_from_dir(&linked_dir, HANDOFF_ID).is_err()); + assert!(path.exists()); +} + +#[cfg(target_os = "macos")] +#[test] +fn secure_handoff_read_rejects_hard_linked_payload() { + let dir = tempfile::tempdir().unwrap(); + let path = stage_handoff(dir.path(), &config_only_snapshot_bytes("hermes"), 0o600); + let second_link = dir.path().join("second-link.agent.json"); + std::fs::hard_link(&path, &second_link).unwrap(); + + assert!(read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).is_err()); + assert!(path.exists()); + assert!(second_link.exists()); +} + +#[cfg(target_os = "macos")] +#[test] +fn secure_handoff_read_rejects_non_regular_permissive_oversize_and_invalid_files() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(format!("{HANDOFF_ID}.agent.json")); + std::fs::create_dir(&path).unwrap(); + assert!(read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).is_err()); + std::fs::remove_dir(&path).unwrap(); + + stage_handoff(dir.path(), &config_only_snapshot_bytes("hermes"), 0o644); + assert!(read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).is_err()); + std::fs::remove_file(&path).unwrap(); + + let file = std::fs::File::create(&path).unwrap(); + file.set_len((crate::MAX_SNAPSHOT_JSON_BYTES + 1) as u64) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).is_err()); + std::fs::remove_file(&path).unwrap(); + + stage_handoff(dir.path(), br#"{"not":"a snapshot"}"#, 0o600); + assert!(read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).is_err()); + assert!( + path.exists(), + "rejected staged file must remain for diagnosis" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn secure_handoff_read_rejects_fields_outside_the_coagent_contract() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(format!("{HANDOFF_ID}.agent.json")); + for (section, field, value) in [ + ("definition", "respondTo", serde_json::json!("anyone")), + ("definition", "respondToAllowlist", serde_json::json!([])), + ("definition", "model", serde_json::json!("remote-model")), + ( + "profile", + "avatarUrl", + serde_json::json!("https://example.test/a.png"), + ), + ("memory", "entries", serde_json::json!([])), + ] { + let mut snapshot: serde_json::Value = + serde_json::from_slice(&config_only_snapshot_bytes("hermes")).unwrap(); + snapshot[section][field] = value; + stage_handoff(dir.path(), &serde_json::to_vec(&snapshot).unwrap(), 0o600); + assert!( + read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).is_err(), + "accepted forbidden {section}.{field}" + ); + std::fs::remove_file(&path).unwrap(); + } + + let mut snapshot: serde_json::Value = + serde_json::from_slice(&config_only_snapshot_bytes("hermes")).unwrap(); + snapshot["credentialRef"] = serde_json::json!("opaque-but-forbidden"); + stage_handoff(dir.path(), &serde_json::to_vec(&snapshot).unwrap(), 0o600); + assert!(read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).is_err()); +} + +#[cfg(target_os = "macos")] +#[test] +fn secure_handoff_read_requires_the_hermes_runtime() { + let dir = tempfile::tempdir().unwrap(); + let path = stage_handoff(dir.path(), &config_only_snapshot_bytes("buzz-agent"), 0o600); + assert!(read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).is_err()); + assert!( + path.exists(), + "rejected staged file must remain for diagnosis" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn secure_handoff_metadata_rejects_wrong_owner() { + let dir = tempfile::tempdir().unwrap(); + let path = stage_handoff(dir.path(), &config_only_snapshot_bytes("hermes"), 0o600); + let metadata = path.metadata().unwrap(); + assert!(validate_agent_snapshot_handoff_metadata( + &metadata, + metadata.uid() + 1, + SystemTime::now() + ) + .is_err()); +} + +#[cfg(target_os = "macos")] +#[test] +fn secure_handoff_metadata_rejects_expired_and_far_future_files() { + let dir = tempfile::tempdir().unwrap(); + let path = stage_handoff(dir.path(), &config_only_snapshot_bytes("hermes"), 0o600); + let metadata = path.metadata().unwrap(); + let modified = metadata.modified().unwrap(); + + assert!(validate_agent_snapshot_handoff_metadata( + &metadata, + metadata.uid(), + modified + AGENT_SNAPSHOT_HANDOFF_MAX_AGE + Duration::from_secs(1) + ) + .is_err()); + assert!(validate_agent_snapshot_handoff_metadata( + &metadata, + metadata.uid(), + modified - Duration::from_secs(61) + ) + .is_err()); +} + +#[cfg(target_os = "macos")] +#[test] +fn secure_handoff_directory_requires_owner_only_permissions() { + let dir = tempfile::tempdir().unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let metadata = dir.path().metadata().unwrap(); + assert!(validate_agent_snapshot_handoff_directory_metadata(&metadata, metadata.uid()).is_ok()); + + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + let permissive = dir.path().metadata().unwrap(); + assert!( + validate_agent_snapshot_handoff_directory_metadata(&permissive, permissive.uid()).is_err() + ); + assert!( + validate_agent_snapshot_handoff_directory_metadata(&permissive, permissive.uid() + 1) + .is_err() + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn secure_handoff_read_rejects_snapshot_with_memory() { + let dir = tempfile::tempdir().unwrap(); + let mut value: serde_json::Value = + serde_json::from_slice(&config_only_snapshot_bytes("hermes")).unwrap(); + value["memory"] = serde_json::json!({ + "level": "everything", + "entries": [{ "slug": "secret", "body": "must not cross handoff" }] + }); + let path = stage_handoff(dir.path(), &serde_json::to_vec(&value).unwrap(), 0o600); + + assert!(read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).is_err()); + assert!(path.exists()); +} + +#[cfg(target_os = "macos")] +#[test] +fn pending_agent_snapshot_import_is_peeked_then_acknowledged() { + let dir = tempfile::tempdir().unwrap(); + let path = stage_handoff(dir.path(), &config_only_snapshot_bytes("hermes"), 0o600); + let source = open_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).unwrap(); + let duplicate_source = open_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).unwrap(); + let full_source = open_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).unwrap(); + let queue = PendingAgentSnapshotImports::default(); + let pending = PendingAgentSnapshotImport { + id: HANDOFF_ID.to_owned(), + file_bytes: config_only_snapshot_bytes("hermes"), + file_name: format!("{HANDOFF_ID}.agent.json"), + snapshot_kind: "agent".to_owned(), + }; + assert_eq!(queue.enqueue(pending.clone(), source), Ok(true)); + assert_eq!(queue.enqueue(pending, duplicate_source), Ok(false)); + + assert_eq!( + queue.enqueue( + PendingAgentSnapshotImport { + id: "550e8400-e29b-41d4-a716-446655440001".to_owned(), + file_bytes: vec![4, 5, 6], + file_name: "second.agent.json".to_owned(), + snapshot_kind: "agent".to_owned(), + }, + full_source, + ), + Err(()) + ); + + assert_eq!( + queue.first().unwrap().file_bytes, + config_only_snapshot_bytes("hermes") + ); + assert!(!queue.consume("other")); + assert!(queue.consume(HANDOFF_ID)); + assert!(queue.first().is_none()); + assert_eq!(path.metadata().unwrap().len(), 0); +} + +#[cfg(target_os = "macos")] +#[test] +fn rejected_pending_handoff_keeps_the_staged_source() { + let dir = tempfile::tempdir().unwrap(); + let expected = config_only_snapshot_bytes("hermes"); + let path = stage_handoff(dir.path(), &expected, 0o600); + let source = open_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).unwrap(); + let queue = PendingAgentSnapshotImports::default(); + assert_eq!( + queue.enqueue( + PendingAgentSnapshotImport { + id: HANDOFF_ID.to_owned(), + file_bytes: expected.clone(), + file_name: format!("{HANDOFF_ID}.agent.json"), + snapshot_kind: "agent".to_owned(), + }, + source, + ), + Ok(true) + ); + + assert!(queue.discard(HANDOFF_ID)); + assert!(queue.first().is_none()); + assert_eq!(std::fs::read(path).unwrap(), expected); +} diff --git a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff_security.rs b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff_security.rs new file mode 100644 index 0000000000..43683a52e2 --- /dev/null +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff_security.rs @@ -0,0 +1,341 @@ +use std::{ + io::{Read, Seek, SeekFrom}, + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; + +#[cfg(target_os = "macos")] +use std::ffi::CString; +#[cfg(target_os = "macos")] +use std::os::fd::{AsRawFd, FromRawFd}; +#[cfg(target_os = "macos")] +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + +use url::Url; + +use crate::{ + decode_snapshot_from_bytes, managed_agents::agent_snapshot::MemoryLevel, + MAX_SNAPSHOT_JSON_BYTES, +}; + +pub(super) const AGENT_SNAPSHOT_HANDOFF_MAX_AGE: Duration = Duration::from_secs(10 * 60); +pub(super) const AGENT_SNAPSHOT_HANDOFF_FUTURE_SKEW: Duration = Duration::from_secs(60); + +#[cfg(target_os = "macos")] +#[derive(Debug)] +pub(super) struct OpenAgentSnapshotHandoff { + file: std::fs::File, + bytes: Vec, +} + +#[cfg(target_os = "macos")] +impl OpenAgentSnapshotHandoff { + pub(super) fn bytes(&self) -> &[u8] { + &self.bytes + } + + pub(super) fn erase_after_acceptance(&mut self, expected: &[u8]) -> Result<(), String> { + if self.bytes != expected { + return Err("handoff preview bytes do not match the queued source".to_string()); + } + self.file + .seek(SeekFrom::Start(0)) + .map_err(|error| format!("cannot rewind accepted handoff: {error}"))?; + let mut current = Vec::with_capacity(expected.len()); + self.file + .by_ref() + .take(MAX_SNAPSHOT_JSON_BYTES as u64 + 1) + .read_to_end(&mut current) + .map_err(|error| format!("cannot re-read accepted handoff: {error}"))?; + if current != expected { + return Err("handoff contents changed before preview acknowledgement".to_string()); + } + + // macOS has no unlink-by-file-descriptor operation. Erase the exact + // validated inode through the retained descriptor instead of racing a + // pathname comparison against unlinkat. Any concurrent hard links are + // truncated with the same inode; a replacement pathname is untouched. + self.file + .set_len(0) + .map_err(|error| format!("cannot erase accepted handoff: {error}"))?; + self.file + .sync_all() + .map_err(|error| format!("cannot persist accepted handoff erasure: {error}"))?; + if self + .file + .metadata() + .map_err(|error| format!("cannot verify accepted handoff erasure: {error}"))? + .len() + != 0 + { + return Err("accepted handoff erasure did not reach zero bytes".to_string()); + } + Ok(()) + } +} + +#[cfg(not(target_os = "macos"))] +#[derive(Debug)] +pub(super) struct OpenAgentSnapshotHandoff; + +#[cfg(not(target_os = "macos"))] +impl OpenAgentSnapshotHandoff { + pub(super) fn bytes(&self) -> &[u8] { + &[] + } + + pub(super) fn erase_after_acceptance(&mut self, _expected: &[u8]) -> Result<(), String> { + Err("agent snapshot handoffs currently require macOS file security".to_string()) + } +} + +pub(super) fn parse_agent_snapshot_handoff_id(url: &Url) -> Option { + if !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + || !url.path().is_empty() + || url.fragment().is_some() + { + return None; + } + let mut params = url.query_pairs(); + let (key, value) = params.next()?; + if key != "handoff" || params.next().is_some() { + return None; + } + let candidate = value.as_ref(); + let parsed = uuid::Uuid::parse_str(candidate).ok()?; + (parsed.to_string() == candidate).then(|| candidate.to_owned()) +} + +pub(super) fn agent_snapshot_handoff_dir() -> Result { + dirs::home_dir() + .map(|home| { + home.join("Library") + .join("Application Support") + .join("Co-Agent") + .join("buzz-handoffs") + }) + .ok_or_else(|| "cannot resolve the current user's home directory".to_string()) +} + +#[cfg(target_os = "macos")] +pub(super) fn validate_agent_snapshot_handoff_metadata( + metadata: &std::fs::Metadata, + expected_uid: u32, + now: SystemTime, +) -> Result<(), String> { + if !metadata.file_type().is_file() { + return Err("handoff is not a regular file".to_string()); + } + if metadata.uid() != expected_uid { + return Err("handoff is not owned by the current user".to_string()); + } + if metadata.mode() & 0o077 != 0 { + return Err("handoff has group or world permissions".to_string()); + } + if metadata.nlink() != 1 { + return Err("handoff must have exactly one filesystem link".to_string()); + } + if metadata.len() > MAX_SNAPSHOT_JSON_BYTES as u64 { + return Err("handoff exceeds the agent JSON snapshot size limit".to_string()); + } + let modified = metadata + .modified() + .map_err(|error| format!("cannot inspect handoff age: {error}"))?; + match now.duration_since(modified) { + Ok(age) if age > AGENT_SNAPSHOT_HANDOFF_MAX_AGE => { + return Err("handoff has expired".to_string()); + } + Ok(_) => {} + Err(error) if error.duration() > AGENT_SNAPSHOT_HANDOFF_FUTURE_SKEW => { + return Err("handoff modification time is too far in the future".to_string()); + } + Err(_) => {} + } + Ok(()) +} + +#[cfg(target_os = "macos")] +pub(super) fn validate_agent_snapshot_handoff_directory_metadata( + metadata: &std::fs::Metadata, + expected_uid: u32, +) -> Result<(), String> { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("handoff directory is not a real directory".to_string()); + } + if metadata.uid() != expected_uid { + return Err("handoff directory is not owned by the current user".to_string()); + } + if metadata.mode() & 0o077 != 0 { + return Err("handoff directory has group or world permissions".to_string()); + } + Ok(()) +} + +fn validate_agent_snapshot_handoff_shape(bytes: &[u8]) -> Result<(), String> { + fn require_only_keys( + value: &serde_json::Value, + allowed: &[&str], + section: &str, + ) -> Result<(), String> { + let object = value + .as_object() + .ok_or_else(|| format!("handoff {section} must be a JSON object"))?; + if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(format!("handoff {section} contains forbidden field {key}")); + } + Ok(()) + } + + let value: serde_json::Value = serde_json::from_slice(bytes) + .map_err(|error| format!("invalid agent snapshot handoff JSON: {error}"))?; + require_only_keys( + &value, + &["format", "version", "definition", "profile", "memory"], + "root", + )?; + let root = value + .as_object() + .ok_or_else(|| "handoff root must be a JSON object".to_string())?; + for (section, allowed) in [ + ("definition", &["name", "systemPrompt", "runtime"][..]), + ("profile", &["displayName"][..]), + ("memory", &["level"][..]), + ] { + let child = root + .get(section) + .ok_or_else(|| format!("handoff is missing {section}"))?; + require_only_keys(child, allowed, section)?; + } + Ok(()) +} + +#[cfg(target_os = "macos")] +pub(super) fn open_agent_snapshot_handoff_from_dir( + dir: &Path, + handoff_id: &str, +) -> Result { + let parsed = + uuid::Uuid::parse_str(handoff_id).map_err(|_| "handoff id is not a UUID".to_string())?; + if parsed.to_string() != handoff_id { + return Err("handoff id is not a canonical lowercase UUID".to_string()); + } + + let expected_uid = unsafe { libc::geteuid() }; + let directory = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(dir) + .map_err(|error| format!("cannot securely open handoff directory: {error}"))?; + let dir_metadata = directory + .metadata() + .map_err(|error| format!("cannot inspect open handoff directory: {error}"))?; + validate_agent_snapshot_handoff_directory_metadata(&dir_metadata, expected_uid)?; + + let file_name = format!("{handoff_id}.agent.json"); + let file_name_c = CString::new(file_name.as_bytes()) + .map_err(|_| "handoff filename contains a NUL byte".to_string())?; + // Resolve the child relative to the already validated directory descriptor. + // A pathname rename or symlink substitution cannot redirect this open into a + // replacement directory after the directory metadata check. + let file_fd = unsafe { + libc::openat( + directory.as_raw_fd(), + file_name_c.as_ptr(), + libc::O_RDWR | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if file_fd < 0 { + return Err(format!( + "cannot securely open handoff: {}", + std::io::Error::last_os_error() + )); + } + let mut file = unsafe { std::fs::File::from_raw_fd(file_fd) }; + let opened_metadata = file + .metadata() + .map_err(|error| format!("cannot inspect open handoff: {error}"))?; + validate_agent_snapshot_handoff_metadata(&opened_metadata, expected_uid, SystemTime::now())?; + + let mut bytes = Vec::with_capacity(opened_metadata.len() as usize); + file.by_ref() + .take(MAX_SNAPSHOT_JSON_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("cannot read handoff: {error}"))?; + if bytes.len() > MAX_SNAPSHOT_JSON_BYTES { + return Err("handoff exceeds the agent JSON snapshot size limit".to_string()); + } + let read_metadata = file + .metadata() + .map_err(|error| format!("cannot re-inspect open handoff after read: {error}"))?; + if read_metadata.dev() != opened_metadata.dev() + || read_metadata.ino() != opened_metadata.ino() + || read_metadata.len() != opened_metadata.len() + || read_metadata.uid() != opened_metadata.uid() + || read_metadata.mode() != opened_metadata.mode() + || read_metadata.nlink() != opened_metadata.nlink() + || read_metadata.mtime() != opened_metadata.mtime() + || read_metadata.mtime_nsec() != opened_metadata.mtime_nsec() + { + return Err("handoff changed while it was being read".to_string()); + } + + validate_agent_snapshot_handoff_shape(&bytes)?; + let snapshot = decode_snapshot_from_bytes(&bytes) + .map_err(|error| format!("invalid agent snapshot handoff: {error}"))?; + if snapshot.definition.runtime.as_deref() != Some("hermes") { + return Err("agent snapshot handoff runtime must be hermes".to_string()); + } + if snapshot.memory.level != MemoryLevel::None || !snapshot.memory.entries.is_empty() { + return Err("agent snapshot handoff must be config-only".to_string()); + } + + Ok(OpenAgentSnapshotHandoff { file, bytes }) +} + +#[cfg(target_os = "macos")] +#[cfg(test)] +pub(super) fn read_agent_snapshot_handoff_from_dir( + dir: &Path, + handoff_id: &str, +) -> Result, String> { + Ok(open_agent_snapshot_handoff_from_dir(dir, handoff_id)?.bytes) +} + +#[cfg(target_os = "macos")] +#[cfg(test)] +pub(super) fn consume_agent_snapshot_handoff_from_dir( + dir: &Path, + handoff_id: &str, + expected: &[u8], +) -> Result<(), String> { + open_agent_snapshot_handoff_from_dir(dir, handoff_id)?.erase_after_acceptance(expected) +} + +#[cfg(not(target_os = "macos"))] +pub(super) fn open_agent_snapshot_handoff_from_dir( + _dir: &Path, + _handoff_id: &str, +) -> Result { + Err("agent snapshot handoffs currently require macOS file security".to_string()) +} + +#[cfg(not(target_os = "macos"))] +#[cfg(test)] +pub(super) fn read_agent_snapshot_handoff_from_dir( + _dir: &Path, + _handoff_id: &str, +) -> Result, String> { + Err("agent snapshot handoffs currently require macOS file security".to_string()) +} + +#[cfg(not(target_os = "macos"))] +#[cfg(test)] +pub(super) fn consume_agent_snapshot_handoff_from_dir( + _dir: &Path, + _handoff_id: &str, + _expected: &[u8], +) -> Result<(), String> { + Err("agent snapshot handoffs currently require macOS file security".to_string()) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35f4eae866..ac7bda3ccf 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -35,8 +35,9 @@ use app_state::{build_app_state, resolve_persisted_identity, AppState}; use builderlab::*; use commands::*; use deep_link::{ - acknowledge_pending_community_deep_link, handle_deep_link_url, - take_pending_community_deep_link, PendingCommunityDeepLinks, + acknowledge_pending_agent_snapshot_import, acknowledge_pending_community_deep_link, + handle_deep_link_url, reject_pending_agent_snapshot_import, take_pending_agent_snapshot_import, + take_pending_community_deep_link, PendingAgentSnapshotImports, PendingCommunityDeepLinks, }; use huddle::audio_output::{ get_audio_output_device, list_audio_output_devices, set_audio_output_device, @@ -354,6 +355,7 @@ pub fn run() { }) .manage(build_app_state()) .manage(ClipboardState::new()) + .manage(PendingAgentSnapshotImports::default()) .manage(PendingCommunityDeepLinks::default()) .manage(BuilderlabSession::default()) .manage(BuilderlabLogin::default()) @@ -556,6 +558,17 @@ pub fn run() { handle_deep_link_url(&dl_handle, url.as_str()); } }); + match app.deep_link().get_current() { + Ok(Some(urls)) => { + for url in urls { + handle_deep_link_url(app.handle(), url.as_str()); + } + } + Ok(None) => {} + Err(error) => { + eprintln!("buzz-desktop: failed to read launch-time deep link: {error}"); + } + } } // Defer launch-time agent restoration until `apply_workspace` has @@ -639,6 +652,9 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + take_pending_agent_snapshot_import, + acknowledge_pending_agent_snapshot_import, + reject_pending_agent_snapshot_import, take_pending_community_deep_link, acknowledge_pending_community_deep_link, start_builderlab_login, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 75f57257cc..186f6fcadd 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -36,6 +36,7 @@ import { } from "@/features/notifications/hooks"; import { PreventSleepProvider } from "@/features/agents/usePreventSleep"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; +import { useAgentSnapshotHandoffs } from "@/features/agents/agentSnapshotHandoff"; import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh"; import { useManagedAgentRuntimeReconciliation } from "@/features/agents/useManagedAgentRuntimeReconciliation"; import { useAutoRestartPolicy } from "@/features/agents/lib/useAutoRestartPolicy"; @@ -134,6 +135,7 @@ export function AppShell() { closeSettings, openSearchHit, } = useAppNavigation(); + useAgentSnapshotHandoffs(goAgents); const { canGoBack, canGoForward, goBack, goForward } = useBackForwardControls(); const { selectedChannelId, selectedView } = React.useMemo( diff --git a/desktop/src/features/agents/agentSnapshotHandoff.test.mjs b/desktop/src/features/agents/agentSnapshotHandoff.test.mjs new file mode 100644 index 0000000000..83a47304b7 --- /dev/null +++ b/desktop/src/features/agents/agentSnapshotHandoff.test.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { drainPendingAgentSnapshotImport } from "./agentSnapshotHandoff.ts"; + +test("handoff drain routes accepted bytes through the existing preview flow", async () => { + const calls = []; + const pending = { + id: "550e8400-e29b-41d4-a716-446655440000", + fileBytes: [1, 2, 3], + fileName: "550e8400-e29b-41d4-a716-446655440000.agent.json", + snapshotKind: "agent", + }; + let routedPayload; + + const accepted = await drainPendingAgentSnapshotImport({ + take: async () => pending, + acknowledge: async (id) => { + calls.push(["ack", id]); + return true; + }, + reject: async (id) => { + calls.push(["reject", id]); + return true; + }, + requestOpen: (payload) => { + routedPayload = payload; + calls.push([ + "open", + { + fileBytes: payload.fileBytes, + fileName: payload.fileName, + snapshotKind: payload.snapshotKind, + }, + ]); + }, + goAgents: () => calls.push(["navigate"]), + }); + + assert.equal(accepted, true); + assert.deepEqual(calls, [ + [ + "open", + { + fileBytes: [1, 2, 3], + fileName: pending.fileName, + snapshotKind: "agent", + }, + ], + ["navigate"], + ]); + assert.equal(typeof routedPayload.onPreviewAccepted, "function"); + assert.equal(typeof routedPayload.onPreviewRejected, "function"); + await routedPayload.onPreviewAccepted(); + assert.deepEqual(calls.at(-1), ["ack", pending.id]); +}); + +test("preview rejection releases the queue without accepting the source", async () => { + const calls = []; + let routedPayload; + await drainPendingAgentSnapshotImport({ + take: async () => ({ + id: "550e8400-e29b-41d4-a716-446655440000", + fileBytes: [1], + fileName: "snapshot.agent.json", + snapshotKind: "agent", + }), + acknowledge: async (id) => { + calls.push(["ack", id]); + return true; + }, + reject: async (id) => { + calls.push(["reject", id]); + return true; + }, + requestOpen: (payload) => { + routedPayload = payload; + }, + goAgents: () => {}, + }); + + await routedPayload.onPreviewRejected(); + assert.deepEqual(calls, [["reject", "550e8400-e29b-41d4-a716-446655440000"]]); +}); + +test("handoff drain does nothing when no import is pending", async () => { + let touched = false; + const accepted = await drainPendingAgentSnapshotImport({ + take: async () => null, + acknowledge: async () => { + touched = true; + return true; + }, + reject: async () => { + touched = true; + return true; + }, + requestOpen: () => { + touched = true; + }, + goAgents: () => { + touched = true; + }, + }); + + assert.equal(accepted, false); + assert.equal(touched, false); +}); + +test("handoff drain does not acknowledge when preview routing throws", async () => { + let acknowledged = false; + await assert.rejects(() => + drainPendingAgentSnapshotImport({ + take: async () => ({ + id: "550e8400-e29b-41d4-a716-446655440000", + fileBytes: [1], + fileName: "snapshot.agent.json", + snapshotKind: "agent", + }), + acknowledge: async () => { + acknowledged = true; + return true; + }, + reject: async () => { + acknowledged = true; + return true; + }, + requestOpen: () => { + throw new Error("route failed"); + }, + goAgents: () => {}, + }), + ); + assert.equal(acknowledged, false); +}); diff --git a/desktop/src/features/agents/agentSnapshotHandoff.ts b/desktop/src/features/agents/agentSnapshotHandoff.ts new file mode 100644 index 0000000000..e0c19bac70 --- /dev/null +++ b/desktop/src/features/agents/agentSnapshotHandoff.ts @@ -0,0 +1,89 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { useEffect } from "react"; + +import { + requestOpenSnapshotImport, + type PendingSnapshotImport, +} from "@/features/agents/openSnapshotImportFromUrlEvent"; + +type PendingAgentSnapshotImport = PendingSnapshotImport & { + id: string; + snapshotKind: "agent"; +}; + +type AgentSnapshotHandoffDeps = { + take: () => Promise; + acknowledge: (id: string) => Promise; + reject: (id: string) => Promise; + requestOpen: (payload: PendingSnapshotImport) => void; + goAgents: () => unknown; +}; + +export async function drainPendingAgentSnapshotImport( + deps: AgentSnapshotHandoffDeps, +): Promise { + const pending = await deps.take(); + if (!pending) return false; + + deps.requestOpen({ + fileBytes: pending.fileBytes, + fileName: pending.fileName, + onPreviewAccepted: () => deps.acknowledge(pending.id), + onPreviewRejected: () => deps.reject(pending.id), + snapshotKind: "agent", + }); + await deps.goAgents(); + return true; +} + +export async function listenForAgentSnapshotHandoffs( + goAgents: () => unknown, +): Promise { + let drainRunning = false; + let drainRequested = false; + const drain = () => { + drainRequested = true; + if (drainRunning) return; + drainRunning = true; + void (async () => { + try { + while (drainRequested) { + drainRequested = false; + await drainPendingAgentSnapshotImport({ + take: () => + invoke( + "take_pending_agent_snapshot_import", + ), + acknowledge: (id) => + invoke("acknowledge_pending_agent_snapshot_import", { + id, + }), + reject: (id) => + invoke("reject_pending_agent_snapshot_import", { id }), + requestOpen: requestOpenSnapshotImport, + goAgents, + }); + } + } catch (error: unknown) { + console.warn("Failed to drain pending agent snapshot handoff", error); + } finally { + drainRunning = false; + if (drainRequested) drain(); + } + })(); + }; + + const unlisten = await listen("agent-snapshot-import-available", drain); + drain(); + return unlisten; +} + +export function useAgentSnapshotHandoffs(goAgents: () => unknown) { + useEffect(() => { + const unlisten = listenForAgentSnapshotHandoffs(goAgents); + return () => { + void unlisten.then((stop) => stop()); + }; + }, [goAgents]); +} diff --git a/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts index 62e617d77d..f5cd58d630 100644 --- a/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts +++ b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts @@ -12,6 +12,8 @@ export type PendingSnapshotImport = { fileBytes: number[]; fileName: string; + onPreviewAccepted?: () => Promise; + onPreviewRejected?: () => Promise; snapshotKind: "agent" | "team"; }; @@ -28,6 +30,8 @@ export function requestOpenSnapshotImport(payload: PendingSnapshotImport) { pendingImport = { fileBytes: payload.fileBytes, fileName: payload.fileName, + onPreviewAccepted: payload.onPreviewAccepted, + onPreviewRejected: payload.onPreviewRejected, snapshotKind: payload.snapshotKind, }; if (typeof window !== "undefined") { diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 6e55f92dfe..627abda3ac 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -3,6 +3,7 @@ import { OctagonX, Settings2 } from "lucide-react"; import { consumePendingSnapshotImport, subscribeSnapshotImport, + type PendingSnapshotImport, } from "@/features/agents/openSnapshotImportFromUrlEvent"; import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; @@ -83,29 +84,37 @@ export function AgentsView() { ); // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable React.useEffect(() => { - // Consume a snapshot import that was enqueued before navigation (e.g. from - // a timeline AgentSnapshotCard click that navigated here). - const pending = consumePendingSnapshotImport(); - if (pending) { + const openPendingSnapshot = async (pending: PendingSnapshotImport) => { if (pending.snapshotKind === "team") { - void teamActions.handleImportTeamSnapshotFile( + await teamActions.handleImportTeamSnapshotFile( pending.fileBytes, pending.fileName, ); - } else { - void personas.handleImportSnapshotFile( + await pending.onPreviewAccepted?.(); + } else if ( + await personas.handleImportSnapshotFile( pending.fileBytes, pending.fileName, - ); + ) + ) { + const acknowledged = await pending.onPreviewAccepted?.(); + if (acknowledged === false) { + personas.closeSnapshotImportDialog(); + await pending.onPreviewRejected?.(); + } + } else { + await pending.onPreviewRejected?.(); } + }; + // Consume a snapshot import that was enqueued before navigation (e.g. from + // a timeline AgentSnapshotCard click that navigated here). + const pending = consumePendingSnapshotImport(); + if (pending) { + void openPendingSnapshot(pending); } - return subscribeSnapshotImport(({ fileBytes, fileName, snapshotKind }) => { - if (snapshotKind === "team") { - void teamActions.handleImportTeamSnapshotFile(fileBytes, fileName); - } else { - void personas.handleImportSnapshotFile(fileBytes, fileName); - } + return subscribeSnapshotImport((next) => { + void openPendingSnapshot(next); }); }, []); diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 0c56eeac10..f81ecbb016 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -371,12 +371,14 @@ export function usePersonaActions() { setSnapshotImportState({ fileBytes, fileName, preview }); setSnapshotImportResult(null); setSnapshotImportConfirmError(null); + return true; } catch (err) { setPersonaErrorMessage( err instanceof Error ? err.message : "Failed to read agent snapshot file.", ); + return false; } } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7b13273c60..24a0219746 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10398,6 +10398,10 @@ export function maybeInstallE2eTauriMocks() { activeWorkspaceId: null, onboardingCompletions: [], }; + case "take_pending_agent_snapshot_import": + return null; + case "acknowledge_pending_agent_snapshot_import": + return false; case "take_pending_community_deep_link": // Mirrors the Rust queue: peek the head; acknowledge removes it. return mockPendingCommunityDeepLinks[0] ?? null;