From 3802b4d3997dbf037507fe632ec22e6284f9d349 Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Tue, 28 Jul 2026 10:44:00 -0500 Subject: [PATCH 01/14] feat(desktop): add secure agent snapshot handoff Accept config-only Co-Agent snapshots through owner-only staged files and route them into the existing import preview. Add cold and warm deep-link handling plus Hermes ACP runtime discovery. Signed-off-by: Steve Darlow --- desktop/src-tauri/src/deep_link.rs | 215 +++++++++++++++++- .../src/deep_link/agent_snapshot_handoff.rs | 161 +++++++++++++ desktop/src-tauri/src/lib.rs | 19 +- .../src-tauri/src/managed_agents/discovery.rs | 4 +- .../discovery/runtime_metadata.rs | 47 ++++ desktop/src/app/AppShell.tsx | 2 + .../agents/agentSnapshotHandoff.test.mjs | 81 +++++++ .../features/agents/agentSnapshotHandoff.ts | 84 +++++++ desktop/src/testing/e2eBridge.ts | 4 + 9 files changed, 612 insertions(+), 5 deletions(-) create mode 100644 desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs create mode 100644 desktop/src/features/agents/agentSnapshotHandoff.test.mjs create mode 100644 desktop/src/features/agents/agentSnapshotHandoff.ts diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc36..d9b195f53c 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -1,10 +1,205 @@ -use std::{collections::VecDeque, sync::Mutex}; +use std::{ + collections::VecDeque, + io::Read, + path::{Path, PathBuf}, + sync::Mutex, +}; + +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; use serde::Serialize; use tauri::{Emitter, Manager, State}; use url::Url; -use crate::nostr_bind; +use crate::{ + decode_snapshot_from_bytes, managed_agents::agent_snapshot::MemoryLevel, nostr_bind, + MAX_SNAPSHOT_JSON_BYTES, +}; + +const AGENT_SNAPSHOT_HANDOFF_EVENT: &str = "agent-snapshot-import-available"; + +#[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>); + +impl PendingAgentSnapshotImports { + fn enqueue(&self, pending: PendingAgentSnapshotImport) { + let mut queue = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if queue.iter().any(|item| item.id == pending.id) { + return; + } + queue.push_back(pending); + } + + fn first(&self) -> Option { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .front() + .cloned() + } + + fn acknowledge(&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.id == id) { + queue.pop_front(); + true + } else { + false + } + } +} + +#[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, + app: tauri::AppHandle, + pending: State<'_, PendingAgentSnapshotImports>, +) -> bool { + let acknowledged = pending.acknowledge(&id); + if acknowledged && pending.first().is_some() { + let _ = app.emit(AGENT_SNAPSHOT_HANDOFF_EVENT, ()); + } + acknowledged +} + +fn parse_agent_snapshot_handoff_id(url: &Url) -> Option { + let params = url.query_pairs().collect::>(); + if params.len() != 1 || params[0].0 != "handoff" { + return None; + } + let candidate = params[0].1.as_ref(); + let parsed = uuid::Uuid::parse_str(candidate).ok()?; + (parsed.to_string() == candidate).then(|| candidate.to_owned()) +} + +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(unix)] +fn validate_agent_snapshot_handoff_metadata( + metadata: &std::fs::Metadata, + expected_uid: u32, +) -> 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.len() > MAX_SNAPSHOT_JSON_BYTES as u64 { + return Err("handoff exceeds the agent JSON snapshot size limit".to_string()); + } + Ok(()) +} + +#[cfg(unix)] +fn read_agent_snapshot_handoff_from_dir(dir: &Path, handoff_id: &str) -> Result, String> { + 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 dir_metadata = std::fs::symlink_metadata(dir) + .map_err(|error| format!("cannot inspect handoff directory: {error}"))?; + if dir_metadata.file_type().is_symlink() || !dir_metadata.is_dir() { + return Err("handoff directory is not a real directory".to_string()); + } + + let path = dir.join(format!("{handoff_id}.agent.json")); + let mut file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&path) + .map_err(|error| format!("cannot securely open handoff: {error}"))?; + let opened_metadata = file + .metadata() + .map_err(|error| format!("cannot inspect open handoff: {error}"))?; + let expected_uid = dirs::home_dir() + .ok_or_else(|| "cannot resolve the current user's home directory".to_string())? + .metadata() + .map_err(|error| format!("cannot inspect the current user's home directory: {error}"))? + .uid(); + validate_agent_snapshot_handoff_metadata(&opened_metadata, expected_uid)?; + + 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 snapshot = decode_snapshot_from_bytes(&bytes) + .map_err(|error| format!("invalid agent snapshot handoff: {error}"))?; + if snapshot.memory.level != MemoryLevel::None || !snapshot.memory.entries.is_empty() { + return Err("agent snapshot handoff must be config-only".to_string()); + } + + let current_metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("cannot re-inspect handoff before deletion: {error}"))?; + if current_metadata.file_type().is_symlink() + || current_metadata.dev() != opened_metadata.dev() + || current_metadata.ino() != opened_metadata.ino() + { + return Err("handoff changed while it was being read".to_string()); + } + std::fs::remove_file(&path) + .map_err(|error| format!("cannot delete accepted handoff: {error}"))?; + Ok(bytes) +} + +#[cfg(not(unix))] +fn read_agent_snapshot_handoff_from_dir(_dir: &Path, _handoff_id: &str) -> Result, String> { + Err("agent snapshot handoffs require POSIX file security".to_string()) +} + +fn queue_agent_snapshot_handoff(app: &tauri::AppHandle, handoff_id: String) -> Result<(), String> { + let bytes = read_agent_snapshot_handoff_from_dir(&agent_snapshot_handoff_dir()?, &handoff_id)?; + app.state::() + .enqueue(PendingAgentSnapshotImport { + file_name: format!("{handoff_id}.agent.json"), + id: handoff_id, + file_bytes: bytes, + snapshot_kind: "agent".to_string(), + }); + Ok(()) +} #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -310,6 +505,18 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } match url.host_str() { + Some("import-agent-snapshot") => { + 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; + }; + if let Err(error) = queue_agent_snapshot_handoff(app, handoff_id) { + eprintln!("buzz-desktop: rejecting agent snapshot handoff: {error}"); + return; + } + activate_main_window(app); + 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 +591,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..5fbbc37ea8 --- /dev/null +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs @@ -0,0 +1,161 @@ +#[cfg(unix)] +use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt}; +#[cfg(unix)] +use std::path::{Path, PathBuf}; + +use url::Url; + +#[cfg(unix)] +use super::validate_agent_snapshot_handoff_metadata; +use super::{ + parse_agent_snapshot_handoff_id, read_agent_snapshot_handoff_from_dir, + PendingAgentSnapshotImport, PendingAgentSnapshotImports, +}; + +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", + "runtime": runtime, + "respondToAllowlist": [] + }, + "profile": { "displayName": "Hermes helper" }, + "memory": { "level": "none", "entries": [] } + })) + .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}" + ); + } +} + +#[cfg(unix)] +fn stage_handoff(dir: &Path, bytes: &[u8], mode: u32) -> PathBuf { + 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(unix)] +#[test] +fn secure_handoff_read_accepts_valid_snapshot_and_deletes_staged_file() { + 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(), "accepted staged file must be deleted"); +} + +#[cfg(unix)] +#[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(unix)] +#[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(unix)] +#[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).is_err()); +} + +#[cfg(unix)] +#[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()); +} + +#[test] +fn pending_agent_snapshot_import_is_peeked_then_acknowledged() { + let queue = PendingAgentSnapshotImports::default(); + queue.enqueue(PendingAgentSnapshotImport { + id: HANDOFF_ID.to_owned(), + file_bytes: vec![1, 2, 3], + file_name: format!("{HANDOFF_ID}.agent.json"), + snapshot_kind: "agent".to_owned(), + }); + + assert_eq!(queue.first().unwrap().file_bytes, vec![1, 2, 3]); + assert!(!queue.acknowledge("other")); + assert!(queue.acknowledge(HANDOFF_ID)); + assert!(queue.first().is_none()); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35f4eae866..c704ddfb3d 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, 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,8 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + take_pending_agent_snapshot_import, + acknowledge_pending_agent_snapshot_import, take_pending_community_deep_link, acknowledge_pending_community_deep_link, start_builderlab_login, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index eecbf4de3e..3e4dfb95ca 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -13,6 +13,7 @@ use crate::managed_agents::{ mod runtime_metadata; pub(crate) use runtime_metadata::KnownAcpRuntime; +use runtime_metadata::HERMES_RUNTIME; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; @@ -169,6 +170,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, + HERMES_RUNTIME, KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -464,7 +466,7 @@ fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" - | "claudecode" | "buzz-agent" => Some(Vec::new()), + | "claudecode" | "hermes-acp" | "buzz-agent" => Some(Vec::new()), _ => None, } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index fdfe9b8be7..adec003cf7 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -64,6 +64,39 @@ pub(crate) struct KnownAcpRuntime { pub auth_probe_args: Option<&'static [&'static str]>, } +pub(super) const HERMES_RUNTIME: KnownAcpRuntime = KnownAcpRuntime { + id: "hermes", + label: "Hermes", + commands: &["hermes-acp"], + aliases: &[], + avatar_url: "", + mcp_command: None, + mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "", + adapter_install_instructions_url: "", + cli_install_hint: "", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: None, + config_file_format: None, + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: None, + auth_probe_args: None, +}; + impl KnownAcpRuntime { /// Return the CLI install commands for the current platform. /// @@ -121,4 +154,18 @@ mod tests { assert!(codex.adapter_install_instructions_url.contains("codex-acp")); assert!(codex.cli_install_hint.contains("Codex CLI")); } + + #[test] + fn hermes_runtime_resolves_to_hermes_acp_with_conservative_metadata() { + let hermes = known_acp_runtime_exact("hermes").unwrap(); + assert_eq!(hermes.label, "Hermes"); + assert_eq!(hermes.commands, &["hermes-acp"]); + assert!(hermes.aliases.is_empty()); + assert!(hermes.underlying_cli.is_none()); + assert!(hermes.cli_install_commands.is_empty()); + assert!(hermes.adapter_install_commands.is_empty()); + assert!(hermes.model_env_var.is_none()); + assert!(hermes.provider_env_var.is_none()); + assert!(hermes.required_normalized_fields.is_empty()); + } } 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..2c2f9d2018 --- /dev/null +++ b/desktop/src/features/agents/agentSnapshotHandoff.test.mjs @@ -0,0 +1,81 @@ +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", + }; + + const accepted = await drainPendingAgentSnapshotImport({ + take: async () => pending, + acknowledge: async (id) => { + calls.push(["ack", id]); + return true; + }, + requestOpen: (payload) => calls.push(["open", payload]), + goAgents: () => calls.push(["navigate"]), + }); + + assert.equal(accepted, true); + assert.deepEqual(calls, [ + [ + "open", + { + fileBytes: [1, 2, 3], + fileName: pending.fileName, + snapshotKind: "agent", + }, + ], + ["navigate"], + ["ack", pending.id], + ]); +}); + +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; + }, + 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; + }, + 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..e499a4be7d --- /dev/null +++ b/desktop/src/features/agents/agentSnapshotHandoff.ts @@ -0,0 +1,84 @@ +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; + 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, + snapshotKind: "agent", + }); + await deps.goAgents(); + return deps.acknowledge(pending.id); +} + +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, + }), + 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/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; From 3af58f0f05686697d627c069614b5d767b96b2ba Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Tue, 28 Jul 2026 10:50:24 -0500 Subject: [PATCH 02/14] [verified] Expire and isolate snapshot handoffs Signed-off-by: Steve Darlow --- desktop/src-tauri/src/deep_link.rs | 50 +++++++++++++--- .../src/deep_link/agent_snapshot_handoff.rs | 58 ++++++++++++++++++- 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index d9b195f53c..4da25559e3 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -3,6 +3,7 @@ use std::{ io::Read, path::{Path, PathBuf}, sync::Mutex, + time::{Duration, SystemTime}, }; #[cfg(unix)] @@ -18,6 +19,8 @@ use crate::{ }; const AGENT_SNAPSHOT_HANDOFF_EVENT: &str = "agent-snapshot-import-available"; +const AGENT_SNAPSHOT_HANDOFF_MAX_AGE: Duration = Duration::from_secs(10 * 60); +const AGENT_SNAPSHOT_HANDOFF_FUTURE_SKEW: Duration = Duration::from_secs(60); #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -110,6 +113,7 @@ fn agent_snapshot_handoff_dir() -> Result { 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()); @@ -123,6 +127,36 @@ fn validate_agent_snapshot_handoff_metadata( 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(unix)] +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(()) } @@ -134,11 +168,14 @@ fn read_agent_snapshot_handoff_from_dir(dir: &Path, handoff_id: &str) -> Result< return Err("handoff id is not a canonical lowercase UUID".to_string()); } + let expected_uid = dirs::home_dir() + .ok_or_else(|| "cannot resolve the current user's home directory".to_string())? + .metadata() + .map_err(|error| format!("cannot inspect the current user's home directory: {error}"))? + .uid(); let dir_metadata = std::fs::symlink_metadata(dir) .map_err(|error| format!("cannot inspect handoff directory: {error}"))?; - if dir_metadata.file_type().is_symlink() || !dir_metadata.is_dir() { - return Err("handoff directory is not a real directory".to_string()); - } + validate_agent_snapshot_handoff_directory_metadata(&dir_metadata, expected_uid)?; let path = dir.join(format!("{handoff_id}.agent.json")); let mut file = std::fs::OpenOptions::new() @@ -149,12 +186,7 @@ fn read_agent_snapshot_handoff_from_dir(dir: &Path, handoff_id: &str) -> Result< let opened_metadata = file .metadata() .map_err(|error| format!("cannot inspect open handoff: {error}"))?; - let expected_uid = dirs::home_dir() - .ok_or_else(|| "cannot resolve the current user's home directory".to_string())? - .metadata() - .map_err(|error| format!("cannot inspect the current user's home directory: {error}"))? - .uid(); - validate_agent_snapshot_handoff_metadata(&opened_metadata, expected_uid)?; + 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() diff --git a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs index 5fbbc37ea8..1f22463c3a 100644 --- a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs @@ -2,15 +2,20 @@ use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt}; #[cfg(unix)] use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::time::{Duration, SystemTime}; use url::Url; -#[cfg(unix)] -use super::validate_agent_snapshot_handoff_metadata; use super::{ parse_agent_snapshot_handoff_id, read_agent_snapshot_handoff_from_dir, PendingAgentSnapshotImport, PendingAgentSnapshotImports, }; +#[cfg(unix)] +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"; @@ -57,6 +62,7 @@ fn agent_snapshot_handoff_requires_one_canonical_lowercase_uuid() { #[cfg(unix)] 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(); @@ -125,7 +131,53 @@ 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).is_err()); + assert!(validate_agent_snapshot_handoff_metadata( + &metadata, + metadata.uid() + 1, + SystemTime::now() + ) + .is_err()); +} + +#[cfg(unix)] +#[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(unix)] +#[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(unix)] From 4fd9e3908cfa8f019e193c157064302969cbcc27 Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Tue, 28 Jul 2026 12:08:32 -0500 Subject: [PATCH 03/14] [verified] Preserve executable sidecars in Buzz bundles Signed-off-by: Steve Darlow --- scripts/bundle-sidecars.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index 07de477405..453948b4ec 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -37,12 +37,15 @@ mkdir -p "$BINARIES_DIR" for bin in "${SIDECARS[@]}"; do destination="$BINARIES_DIR/${bin}-${TARGET}${EXE}" cp "$SRC_DIR/${bin}${EXE}" "$destination" - # cp preserves the mode of an existing destination on macOS. Generated # sidecar placeholders may not be executable, so make the bundled Unix - # binaries executable explicitly. + # binaries executable explicitly and fail if the mode did not stick. if [[ -z "$EXE" ]]; then - chmod 755 "$destination" + chmod 0755 "$destination" + [[ -x "$destination" ]] || { + echo "Error: bundled sidecar is not executable: $destination" >&2 + exit 1 + } fi done echo "Sidecars bundled for $TARGET" From 7d44430572dcd39da9fd90707dd681fccd940157 Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Tue, 28 Jul 2026 12:16:12 -0500 Subject: [PATCH 04/14] [verified] Keep Hermes discovery within release limits Signed-off-by: Steve Darlow --- desktop/src-tauri/src/managed_agents/discovery.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 3e4dfb95ca..e915afef2a 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -13,7 +13,6 @@ use crate::managed_agents::{ mod runtime_metadata; pub(crate) use runtime_metadata::KnownAcpRuntime; -use runtime_metadata::HERMES_RUNTIME; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; @@ -170,7 +169,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, - HERMES_RUNTIME, + runtime_metadata::HERMES_RUNTIME, KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -458,7 +457,6 @@ pub fn try_record_agent_command( } } - // No runtime id set — legacy agent; use the safe default. Ok(default_agent_command()) } From ab6cb998a5f0f078d2289e68af112ec86764ea7f Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Wed, 29 Jul 2026 00:02:03 -0500 Subject: [PATCH 05/14] [verified] Narrow secure Co-Agent handoff Signed-off-by: Steve Darlow --- desktop/src-tauri/src/deep_link.rs | 42 ++++++++++++++ .../src/deep_link/agent_snapshot_handoff.rs | 56 ++++++++++++++++++- .../src-tauri/src/managed_agents/discovery.rs | 4 +- .../discovery/runtime_metadata.rs | 47 ---------------- scripts/bundle-sidecars.sh | 9 +-- 5 files changed, 100 insertions(+), 58 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 4da25559e3..db61854892 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -160,6 +160,44 @@ fn validate_agent_snapshot_handoff_directory_metadata( 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(unix)] fn read_agent_snapshot_handoff_from_dir(dir: &Path, handoff_id: &str) -> Result, String> { let parsed = @@ -197,8 +235,12 @@ fn read_agent_snapshot_handoff_from_dir(dir: &Path, handoff_id: &str) -> Result< return Err("handoff exceeds the agent JSON snapshot size limit".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()); } diff --git a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs index 1f22463c3a..a25bc40717 100644 --- a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs @@ -25,11 +25,11 @@ fn config_only_snapshot_bytes(runtime: &str) -> Vec { "version": 1, "definition": { "name": "Hermes helper", - "runtime": runtime, - "respondToAllowlist": [] + "systemPrompt": "Be helpful.", + "runtime": runtime }, "profile": { "displayName": "Hermes helper" }, - "memory": { "level": "none", "entries": [] } + "memory": { "level": "none" } })) .unwrap() } @@ -125,6 +125,56 @@ fn secure_handoff_read_rejects_non_regular_permissive_oversize_and_invalid_files ); } +#[cfg(unix)] +#[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(unix)] +#[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(unix)] #[test] fn secure_handoff_metadata_rejects_wrong_owner() { diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index e915afef2a..eecbf4de3e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -169,7 +169,6 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, - runtime_metadata::HERMES_RUNTIME, KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -457,6 +456,7 @@ pub fn try_record_agent_command( } } + // No runtime id set — legacy agent; use the safe default. Ok(default_agent_command()) } @@ -464,7 +464,7 @@ fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" - | "claudecode" | "hermes-acp" | "buzz-agent" => Some(Vec::new()), + | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index adec003cf7..fdfe9b8be7 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -64,39 +64,6 @@ pub(crate) struct KnownAcpRuntime { pub auth_probe_args: Option<&'static [&'static str]>, } -pub(super) const HERMES_RUNTIME: KnownAcpRuntime = KnownAcpRuntime { - id: "hermes", - label: "Hermes", - commands: &["hermes-acp"], - aliases: &[], - avatar_url: "", - mcp_command: None, - mcp_hooks: false, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - cli_install_instructions_url: "", - adapter_install_instructions_url: "", - cli_install_hint: "", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: true, - default_env: &[], - config_file_path: None, - config_file_format: None, - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - required_normalized_fields: &[], - login_hint: None, - auth_probe_args: None, -}; - impl KnownAcpRuntime { /// Return the CLI install commands for the current platform. /// @@ -154,18 +121,4 @@ mod tests { assert!(codex.adapter_install_instructions_url.contains("codex-acp")); assert!(codex.cli_install_hint.contains("Codex CLI")); } - - #[test] - fn hermes_runtime_resolves_to_hermes_acp_with_conservative_metadata() { - let hermes = known_acp_runtime_exact("hermes").unwrap(); - assert_eq!(hermes.label, "Hermes"); - assert_eq!(hermes.commands, &["hermes-acp"]); - assert!(hermes.aliases.is_empty()); - assert!(hermes.underlying_cli.is_none()); - assert!(hermes.cli_install_commands.is_empty()); - assert!(hermes.adapter_install_commands.is_empty()); - assert!(hermes.model_env_var.is_none()); - assert!(hermes.provider_env_var.is_none()); - assert!(hermes.required_normalized_fields.is_empty()); - } } diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index 453948b4ec..07de477405 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -37,15 +37,12 @@ mkdir -p "$BINARIES_DIR" for bin in "${SIDECARS[@]}"; do destination="$BINARIES_DIR/${bin}-${TARGET}${EXE}" cp "$SRC_DIR/${bin}${EXE}" "$destination" + # cp preserves the mode of an existing destination on macOS. Generated # sidecar placeholders may not be executable, so make the bundled Unix - # binaries executable explicitly and fail if the mode did not stick. + # binaries executable explicitly. if [[ -z "$EXE" ]]; then - chmod 0755 "$destination" - [[ -x "$destination" ]] || { - echo "Error: bundled sidecar is not executable: $destination" >&2 - exit 1 - } + chmod 755 "$destination" fi done echo "Sidecars bundled for $TARGET" From f85bc2cb76f0fc2fd2a5a9d347952690c296dec1 Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Wed, 29 Jul 2026 00:21:46 -0500 Subject: [PATCH 06/14] [verified] Close handoff path substitution race Signed-off-by: Steve Darlow --- desktop/src-tauri/src/deep_link.rs | 75 +++++++++++++++---- .../src/deep_link/agent_snapshot_handoff.rs | 34 +++++++++ 2 files changed, 94 insertions(+), 15 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index db61854892..ee0b422d22 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -6,6 +6,10 @@ use std::{ time::{Duration, SystemTime}, }; +#[cfg(unix)] +use std::ffi::CString; +#[cfg(unix)] +use std::os::fd::{AsRawFd, FromRawFd}; #[cfg(unix)] use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; @@ -124,6 +128,9 @@ fn validate_agent_snapshot_handoff_metadata( 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()); } @@ -211,16 +218,36 @@ fn read_agent_snapshot_handoff_from_dir(dir: &Path, handoff_id: &str) -> Result< .metadata() .map_err(|error| format!("cannot inspect the current user's home directory: {error}"))? .uid(); - let dir_metadata = std::fs::symlink_metadata(dir) - .map_err(|error| format!("cannot inspect handoff directory: {error}"))?; + 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 path = dir.join(format!("{handoff_id}.agent.json")); - let mut file = std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) - .open(&path) - .map_err(|error| format!("cannot securely open handoff: {error}"))?; + 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_RDONLY | 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}"))?; @@ -245,16 +272,34 @@ fn read_agent_snapshot_handoff_from_dir(dir: &Path, handoff_id: &str) -> Result< return Err("agent snapshot handoff must be config-only".to_string()); } - let current_metadata = std::fs::symlink_metadata(&path) - .map_err(|error| format!("cannot re-inspect handoff before deletion: {error}"))?; - if current_metadata.file_type().is_symlink() - || current_metadata.dev() != opened_metadata.dev() - || current_metadata.ino() != opened_metadata.ino() + let mut current_metadata = std::mem::MaybeUninit::::uninit(); + let stat_result = unsafe { + libc::fstatat( + directory.as_raw_fd(), + file_name_c.as_ptr(), + current_metadata.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if stat_result != 0 { + return Err(format!( + "cannot re-inspect handoff before deletion: {}", + std::io::Error::last_os_error() + )); + } + let current_metadata = unsafe { current_metadata.assume_init() }; + if current_metadata.st_dev != opened_metadata.dev() as libc::dev_t + || current_metadata.st_ino != opened_metadata.ino() as libc::ino_t { return Err("handoff changed while it was being read".to_string()); } - std::fs::remove_file(&path) - .map_err(|error| format!("cannot delete accepted handoff: {error}"))?; + let unlink_result = unsafe { libc::unlinkat(directory.as_raw_fd(), file_name_c.as_ptr(), 0) }; + if unlink_result != 0 { + return Err(format!( + "cannot delete accepted handoff: {}", + std::io::Error::last_os_error() + )); + } Ok(bytes) } diff --git a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs index a25bc40717..3d9aefe8a8 100644 --- a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs @@ -97,6 +97,40 @@ fn secure_handoff_read_rejects_symlink_and_leaves_target_untouched() { assert!(link.symlink_metadata().is_ok()); } +#[cfg(unix)] +#[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(unix)] +#[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(unix)] #[test] fn secure_handoff_read_rejects_non_regular_permissive_oversize_and_invalid_files() { From c1f9f5837d2861381e94205a20622381a9507f7e Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Wed, 29 Jul 2026 00:41:54 -0500 Subject: [PATCH 07/14] [verified] Make Co-Agent handoff retry-safe Signed-off-by: Steve Darlow --- desktop/src-tauri/src/deep_link.rs | 281 +++-------------- .../src/deep_link/agent_snapshot_handoff.rs | 54 ++-- .../agent_snapshot_handoff_security.rs | 288 ++++++++++++++++++ .../src-tauri/src/managed_agents/discovery.rs | 24 +- .../discovery/command_resolution.rs | 60 ++++ .../agents/agentSnapshotHandoff.test.mjs | 17 +- .../features/agents/agentSnapshotHandoff.ts | 5 +- .../agents/openSnapshotImportFromUrlEvent.ts | 2 + desktop/src/features/agents/ui/AgentsView.tsx | 38 +-- 9 files changed, 478 insertions(+), 291 deletions(-) create mode 100644 desktop/src-tauri/src/deep_link/agent_snapshot_handoff_security.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/command_resolution.rs diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ee0b422d22..53f182f882 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -1,30 +1,29 @@ use std::{ collections::VecDeque, - io::Read, - path::{Path, PathBuf}, sync::Mutex, - time::{Duration, SystemTime}, }; -#[cfg(unix)] -use std::ffi::CString; -#[cfg(unix)] -use std::os::fd::{AsRawFd, FromRawFd}; -#[cfg(unix)] -use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; - use serde::Serialize; use tauri::{Emitter, Manager, State}; use url::Url; -use crate::{ - decode_snapshot_from_bytes, managed_agents::agent_snapshot::MemoryLevel, nostr_bind, - MAX_SNAPSHOT_JSON_BYTES, +use crate::nostr_bind; + +mod agent_snapshot_handoff_security; + +use agent_snapshot_handoff_security::{ + agent_snapshot_handoff_dir, consume_agent_snapshot_handoff_from_dir, + parse_agent_snapshot_handoff_id, read_agent_snapshot_handoff_from_dir, +}; +#[cfg(test)] +use agent_snapshot_handoff_security::{ + 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 AGENT_SNAPSHOT_HANDOFF_MAX_AGE: Duration = Duration::from_secs(10 * 60); -const AGENT_SNAPSHOT_HANDOFF_FUTURE_SKEW: Duration = Duration::from_secs(60); +const AGENT_SNAPSHOT_HANDOFF_URL_PREFIX: &str = "buzz://import-agent-snapshot"; +const AGENT_SNAPSHOT_HANDOFF_MAX_URL_BYTES: usize = 256; #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -39,15 +38,19 @@ pub(crate) struct PendingAgentSnapshotImport { pub(crate) struct PendingAgentSnapshotImports(Mutex>); impl PendingAgentSnapshotImports { - fn enqueue(&self, pending: PendingAgentSnapshotImport) { + fn enqueue(&self, pending: PendingAgentSnapshotImport) -> bool { let mut queue = self .0 .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if queue.iter().any(|item| item.id == pending.id) { - return; + return true; + } + if !queue.is_empty() { + return false; } queue.push_back(pending); + true } fn first(&self) -> Option { @@ -85,6 +88,18 @@ pub(crate) fn acknowledge_pending_agent_snapshot_import( app: tauri::AppHandle, pending: State<'_, PendingAgentSnapshotImports>, ) -> bool { + let Some(current) = pending.first() else { + return false; + }; + if current.id != id { + return false; + } + let Ok(dir) = agent_snapshot_handoff_dir() else { + return false; + }; + if consume_agent_snapshot_handoff_from_dir(&dir, &id, ¤t.file_bytes).is_err() { + return false; + } let acknowledged = pending.acknowledge(&id); if acknowledged && pending.first().is_some() { let _ = app.emit(AGENT_SNAPSHOT_HANDOFF_EVENT, ()); @@ -92,231 +107,19 @@ pub(crate) fn acknowledge_pending_agent_snapshot_import( acknowledged } -fn parse_agent_snapshot_handoff_id(url: &Url) -> Option { - let params = url.query_pairs().collect::>(); - if params.len() != 1 || params[0].0 != "handoff" { - return None; - } - let candidate = params[0].1.as_ref(); - let parsed = uuid::Uuid::parse_str(candidate).ok()?; - (parsed.to_string() == candidate).then(|| candidate.to_owned()) -} - -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(unix)] -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(unix)] -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(unix)] -fn read_agent_snapshot_handoff_from_dir(dir: &Path, handoff_id: &str) -> Result, String> { - 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 = dirs::home_dir() - .ok_or_else(|| "cannot resolve the current user's home directory".to_string())? - .metadata() - .map_err(|error| format!("cannot inspect the current user's home directory: {error}"))? - .uid(); - 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_RDONLY | 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()); - } - - 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()); - } - - let mut current_metadata = std::mem::MaybeUninit::::uninit(); - let stat_result = unsafe { - libc::fstatat( - directory.as_raw_fd(), - file_name_c.as_ptr(), - current_metadata.as_mut_ptr(), - libc::AT_SYMLINK_NOFOLLOW, - ) - }; - if stat_result != 0 { - return Err(format!( - "cannot re-inspect handoff before deletion: {}", - std::io::Error::last_os_error() - )); - } - let current_metadata = unsafe { current_metadata.assume_init() }; - if current_metadata.st_dev != opened_metadata.dev() as libc::dev_t - || current_metadata.st_ino != opened_metadata.ino() as libc::ino_t - { - return Err("handoff changed while it was being read".to_string()); - } - let unlink_result = unsafe { libc::unlinkat(directory.as_raw_fd(), file_name_c.as_ptr(), 0) }; - if unlink_result != 0 { - return Err(format!( - "cannot delete accepted handoff: {}", - std::io::Error::last_os_error() - )); - } - Ok(bytes) -} - -#[cfg(not(unix))] -fn read_agent_snapshot_handoff_from_dir(_dir: &Path, _handoff_id: &str) -> Result, String> { - Err("agent snapshot handoffs require POSIX file security".to_string()) -} - fn queue_agent_snapshot_handoff(app: &tauri::AppHandle, handoff_id: String) -> Result<(), String> { let bytes = read_agent_snapshot_handoff_from_dir(&agent_snapshot_handoff_dir()?, &handoff_id)?; - app.state::() + if !app + .state::() .enqueue(PendingAgentSnapshotImport { file_name: format!("{handoff_id}.agent.json"), id: handoff_id, file_bytes: bytes, snapshot_kind: "agent".to_string(), - }); + }) + { + return Err("another agent snapshot handoff is already awaiting preview".to_string()); + } Ok(()) } @@ -610,6 +413,14 @@ 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() > AGENT_SNAPSHOT_HANDOFF_MAX_URL_BYTES + && url_str + .get(..AGENT_SNAPSHOT_HANDOFF_URL_PREFIX.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(AGENT_SNAPSHOT_HANDOFF_URL_PREFIX)) + { + eprintln!("buzz-desktop: snapshot handoff deep link exceeds the URL size limit"); + return; + } let url = match Url::parse(url_str) { Ok(u) => u, Err(e) => { diff --git a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs index 3d9aefe8a8..6ec34e32fa 100644 --- a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs @@ -1,17 +1,18 @@ -#[cfg(unix)] +#[cfg(target_os = "macos")] use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt}; -#[cfg(unix)] +#[cfg(target_os = "macos")] use std::path::{Path, PathBuf}; -#[cfg(unix)] +#[cfg(target_os = "macos")] use std::time::{Duration, SystemTime}; use url::Url; use super::{ - parse_agent_snapshot_handoff_id, read_agent_snapshot_handoff_from_dir, - PendingAgentSnapshotImport, PendingAgentSnapshotImports, + consume_agent_snapshot_handoff_from_dir, parse_agent_snapshot_handoff_id, + read_agent_snapshot_handoff_from_dir, PendingAgentSnapshotImport, + PendingAgentSnapshotImports, }; -#[cfg(unix)] +#[cfg(target_os = "macos")] use super::{ validate_agent_snapshot_handoff_directory_metadata, validate_agent_snapshot_handoff_metadata, AGENT_SNAPSHOT_HANDOFF_MAX_AGE, @@ -60,7 +61,7 @@ fn agent_snapshot_handoff_requires_one_canonical_lowercase_uuid() { } } -#[cfg(unix)] +#[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")); @@ -69,9 +70,9 @@ fn stage_handoff(dir: &Path, bytes: &[u8], mode: u32) -> PathBuf { path } -#[cfg(unix)] +#[cfg(target_os = "macos")] #[test] -fn secure_handoff_read_accepts_valid_snapshot_and_deletes_staged_file() { +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); @@ -79,10 +80,12 @@ fn secure_handoff_read_accepts_valid_snapshot_and_deletes_staged_file() { let bytes = read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).unwrap(); assert_eq!(bytes, expected); - assert!(!path.exists(), "accepted staged file must be deleted"); + assert!(path.exists(), "source must remain until preview settles"); + consume_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID, &bytes).unwrap(); + assert!(!path.exists(), "acknowledged source must be deleted"); } -#[cfg(unix)] +#[cfg(target_os = "macos")] #[test] fn secure_handoff_read_rejects_symlink_and_leaves_target_untouched() { let dir = tempfile::tempdir().unwrap(); @@ -97,7 +100,7 @@ fn secure_handoff_read_rejects_symlink_and_leaves_target_untouched() { assert!(link.symlink_metadata().is_ok()); } -#[cfg(unix)] +#[cfg(target_os = "macos")] #[test] fn secure_handoff_read_rejects_symlinked_staging_directory() { let real_dir = tempfile::tempdir().unwrap(); @@ -114,7 +117,7 @@ fn secure_handoff_read_rejects_symlinked_staging_directory() { assert!(path.exists()); } -#[cfg(unix)] +#[cfg(target_os = "macos")] #[test] fn secure_handoff_read_rejects_hard_linked_payload() { let dir = tempfile::tempdir().unwrap(); @@ -131,7 +134,7 @@ fn secure_handoff_read_rejects_hard_linked_payload() { assert!(second_link.exists()); } -#[cfg(unix)] +#[cfg(target_os = "macos")] #[test] fn secure_handoff_read_rejects_non_regular_permissive_oversize_and_invalid_files() { let dir = tempfile::tempdir().unwrap(); @@ -159,7 +162,7 @@ fn secure_handoff_read_rejects_non_regular_permissive_oversize_and_invalid_files ); } -#[cfg(unix)] +#[cfg(target_os = "macos")] #[test] fn secure_handoff_read_rejects_fields_outside_the_coagent_contract() { let dir = tempfile::tempdir().unwrap(); @@ -193,7 +196,7 @@ fn secure_handoff_read_rejects_fields_outside_the_coagent_contract() { assert!(read_agent_snapshot_handoff_from_dir(dir.path(), HANDOFF_ID).is_err()); } -#[cfg(unix)] +#[cfg(target_os = "macos")] #[test] fn secure_handoff_read_requires_the_hermes_runtime() { let dir = tempfile::tempdir().unwrap(); @@ -209,7 +212,7 @@ fn secure_handoff_read_requires_the_hermes_runtime() { ); } -#[cfg(unix)] +#[cfg(target_os = "macos")] #[test] fn secure_handoff_metadata_rejects_wrong_owner() { let dir = tempfile::tempdir().unwrap(); @@ -223,7 +226,7 @@ fn secure_handoff_metadata_rejects_wrong_owner() { .is_err()); } -#[cfg(unix)] +#[cfg(target_os = "macos")] #[test] fn secure_handoff_metadata_rejects_expired_and_far_future_files() { let dir = tempfile::tempdir().unwrap(); @@ -245,7 +248,7 @@ fn secure_handoff_metadata_rejects_expired_and_far_future_files() { .is_err()); } -#[cfg(unix)] +#[cfg(target_os = "macos")] #[test] fn secure_handoff_directory_requires_owner_only_permissions() { let dir = tempfile::tempdir().unwrap(); @@ -264,7 +267,7 @@ fn secure_handoff_directory_requires_owner_only_permissions() { ); } -#[cfg(unix)] +#[cfg(target_os = "macos")] #[test] fn secure_handoff_read_rejects_snapshot_with_memory() { let dir = tempfile::tempdir().unwrap(); @@ -283,12 +286,19 @@ fn secure_handoff_read_rejects_snapshot_with_memory() { #[test] fn pending_agent_snapshot_import_is_peeked_then_acknowledged() { let queue = PendingAgentSnapshotImports::default(); - queue.enqueue(PendingAgentSnapshotImport { + assert!(queue.enqueue(PendingAgentSnapshotImport { id: HANDOFF_ID.to_owned(), file_bytes: vec![1, 2, 3], file_name: format!("{HANDOFF_ID}.agent.json"), snapshot_kind: "agent".to_owned(), - }); + })); + + assert!(!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(), + })); assert_eq!(queue.first().unwrap().file_bytes, vec![1, 2, 3]); assert!(!queue.acknowledge("other")); 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..2bd048242c --- /dev/null +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff_security.rs @@ -0,0 +1,288 @@ +use std::{io::Read, 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); + +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")] +fn load_agent_snapshot_handoff_from_dir( + dir: &Path, + handoff_id: &str, + consume_expected: Option<&[u8]>, +) -> Result, String> { + 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_RDONLY | 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()); + } + + let Some(expected) = consume_expected else { + return Ok(bytes); + }; + if bytes != expected { + return Err("handoff contents changed before preview acknowledgement".to_string()); + } + + let mut current_metadata = std::mem::MaybeUninit::::uninit(); + let stat_result = unsafe { + libc::fstatat( + directory.as_raw_fd(), + file_name_c.as_ptr(), + current_metadata.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if stat_result != 0 { + return Err(format!( + "cannot re-inspect handoff before deletion: {}", + std::io::Error::last_os_error() + )); + } + let current_metadata = unsafe { current_metadata.assume_init() }; + if current_metadata.st_dev != opened_metadata.dev() as libc::dev_t + || current_metadata.st_ino != opened_metadata.ino() as libc::ino_t + { + return Err("handoff changed while it was being read".to_string()); + } + let unlink_result = unsafe { libc::unlinkat(directory.as_raw_fd(), file_name_c.as_ptr(), 0) }; + if unlink_result != 0 { + return Err(format!( + "cannot delete accepted handoff: {}", + std::io::Error::last_os_error() + )); + } + Ok(bytes) +} + +#[cfg(target_os = "macos")] +pub(super) fn read_agent_snapshot_handoff_from_dir(dir: &Path, handoff_id: &str) -> Result, String> { + load_agent_snapshot_handoff_from_dir(dir, handoff_id, None) +} + +#[cfg(target_os = "macos")] +pub(super) fn consume_agent_snapshot_handoff_from_dir( + dir: &Path, + handoff_id: &str, + expected: &[u8], +) -> Result<(), String> { + load_agent_snapshot_handoff_from_dir(dir, handoff_id, Some(expected)).map(|_| ()) +} + +#[cfg(not(target_os = "macos"))] +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"))] +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/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index eecbf4de3e..a97e5ec2ac 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -10,8 +10,10 @@ use crate::managed_agents::{ HarnessSource, }; +mod command_resolution; mod runtime_metadata; +use command_resolution::{available_harness_command, default_agent_args, resolve_preset_command}; pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -403,6 +405,7 @@ pub(crate) fn dangling_harness_display(id: &str) -> String { format!("harness (deleted): {id}") } + /// Spawn-time variant of `record_agent_command` that returns a typed error when /// a record's `runtime` id or its persona's `runtime` id is set but cannot be /// resolved (i.e. the definition was deleted after the agent was created). @@ -432,7 +435,7 @@ pub fn try_record_agent_command( } if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) { - return Ok(def.command.clone()); + return Ok(available_harness_command(id, &def.command)); } return Err(format!("DANGLING_HARNESS_ID:{id}")); } @@ -449,7 +452,7 @@ pub fn try_record_agent_command( if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) { - return Ok(def.command.clone()); + return Ok(available_harness_command(id, &def.command)); } return Err(format!("DANGLING_HARNESS_ID:{id}")); } @@ -460,14 +463,6 @@ pub fn try_record_agent_command( Ok(default_agent_command()) } -fn default_agent_args(command: &str) -> Option> { - match normalize_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), - "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" - | "claudecode" | "buzz-agent" => Some(Vec::new()), - _ => None, - } -} pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec { let normalized = agent_args @@ -1477,10 +1472,11 @@ fn preset_catalog_entry( def: &PresetHarness, resolve: impl Fn(&str) -> Option, ) -> AcpRuntimeCatalogEntry { - let (availability, command, binary_path) = match resolve(def.command) { - Some(path) => ( + let resolved = resolve_preset_command(def.id, def.command, &resolve); + let (availability, command, binary_path) = match resolved { + Some((resolved_command, path)) => ( AcpAvailabilityStatus::Available, - Some(def.command.to_string()), + Some(resolved_command.to_string()), Some(path.display().to_string()), ), None => { @@ -1501,7 +1497,7 @@ fn preset_catalog_entry( .map(|p| p.display().to_string()); let default_args = normalize_agent_args( - def.command, + command.as_deref().unwrap_or(def.command), def.args.iter().map(|s| s.to_string()).collect(), ); diff --git a/desktop/src-tauri/src/managed_agents/discovery/command_resolution.rs b/desktop/src-tauri/src/managed_agents/discovery/command_resolution.rs new file mode 100644 index 0000000000..29675de7e0 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/command_resolution.rs @@ -0,0 +1,60 @@ +use std::path::PathBuf; + +use super::{normalize_command_identity, resolve_command}; + +pub(super) fn available_harness_command(id: &str, preferred: &str) -> String { + resolve_preset_command(id, preferred, &resolve_command) + .map(|(command, _)| command.to_string()) + .unwrap_or_else(|| preferred.to_string()) +} + +pub(super) fn resolve_preset_command<'a>( + id: &str, + preferred: &'a str, + resolve: &impl Fn(&str) -> Option, +) -> Option<(&'a str, PathBuf)> { + resolve(preferred) + .map(|path| (preferred, path)) + .or_else(|| { + (id == "hermes") + .then(|| resolve("hermes").map(|path| ("hermes", path))) + .flatten() + }) +} + +pub(super) fn default_agent_args(command: &str) -> Option> { + match normalize_command_identity(command).as_str() { + "goose" | "hermes" => Some(vec!["acp".to_string()]), + "hermes-acp" | "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" + | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::{default_agent_args, resolve_preset_command}; + use std::path::PathBuf; + + #[test] + fn hermes_prefers_acp_shim_and_falls_back_to_cli_subcommand() { + let preferred = resolve_preset_command("hermes", "hermes-acp", &|command| { + matches!(command, "hermes-acp" | "hermes").then(|| PathBuf::from(command)) + }); + assert_eq!(preferred.unwrap().0, "hermes-acp"); + + let fallback = resolve_preset_command("hermes", "hermes-acp", &|command| { + (command == "hermes").then(|| PathBuf::from(command)) + }); + assert_eq!(fallback.unwrap().0, "hermes"); + assert_eq!(default_agent_args("hermes").unwrap(), ["acp"]); + } + + #[test] + fn fallback_is_not_applied_to_other_harnesses() { + let resolved = resolve_preset_command("goose", "goose", &|command| { + (command == "hermes").then(|| PathBuf::from(command)) + }); + assert!(resolved.is_none()); + } +} diff --git a/desktop/src/features/agents/agentSnapshotHandoff.test.mjs b/desktop/src/features/agents/agentSnapshotHandoff.test.mjs index 2c2f9d2018..ccc0511434 100644 --- a/desktop/src/features/agents/agentSnapshotHandoff.test.mjs +++ b/desktop/src/features/agents/agentSnapshotHandoff.test.mjs @@ -11,6 +11,7 @@ test("handoff drain routes accepted bytes through the existing preview flow", as fileName: "550e8400-e29b-41d4-a716-446655440000.agent.json", snapshotKind: "agent", }; + let routedPayload; const accepted = await drainPendingAgentSnapshotImport({ take: async () => pending, @@ -18,7 +19,17 @@ test("handoff drain routes accepted bytes through the existing preview flow", as calls.push(["ack", id]); return true; }, - requestOpen: (payload) => calls.push(["open", payload]), + requestOpen: (payload) => { + routedPayload = payload; + calls.push([ + "open", + { + fileBytes: payload.fileBytes, + fileName: payload.fileName, + snapshotKind: payload.snapshotKind, + }, + ]); + }, goAgents: () => calls.push(["navigate"]), }); @@ -33,8 +44,10 @@ test("handoff drain routes accepted bytes through the existing preview flow", as }, ], ["navigate"], - ["ack", pending.id], ]); + assert.equal(typeof routedPayload.onPreviewSettled, "function"); + await routedPayload.onPreviewSettled(); + assert.deepEqual(calls.at(-1), ["ack", pending.id]); }); test("handoff drain does nothing when no import is pending", async () => { diff --git a/desktop/src/features/agents/agentSnapshotHandoff.ts b/desktop/src/features/agents/agentSnapshotHandoff.ts index e499a4be7d..f83b06212c 100644 --- a/desktop/src/features/agents/agentSnapshotHandoff.ts +++ b/desktop/src/features/agents/agentSnapshotHandoff.ts @@ -28,10 +28,13 @@ export async function drainPendingAgentSnapshotImport( deps.requestOpen({ fileBytes: pending.fileBytes, fileName: pending.fileName, + onPreviewSettled: async () => { + await deps.acknowledge(pending.id); + }, snapshotKind: "agent", }); await deps.goAgents(); - return deps.acknowledge(pending.id); + return true; } export async function listenForAgentSnapshotHandoffs( diff --git a/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts index 62e617d77d..847a9d913f 100644 --- a/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts +++ b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts @@ -12,6 +12,7 @@ export type PendingSnapshotImport = { fileBytes: number[]; fileName: string; + onPreviewSettled?: () => Promise; snapshotKind: "agent" | "team"; }; @@ -28,6 +29,7 @@ export function requestOpenSnapshotImport(payload: PendingSnapshotImport) { pendingImport = { fileBytes: payload.fileBytes, fileName: payload.fileName, + onPreviewSettled: payload.onPreviewSettled, 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..ba668296fa 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,32 @@ export function AgentsView() { ); // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable React.useEffect(() => { + const openPendingSnapshot = async (pending: PendingSnapshotImport) => { + try { + if (pending.snapshotKind === "team") { + await teamActions.handleImportTeamSnapshotFile( + pending.fileBytes, + pending.fileName, + ); + } else { + await personas.handleImportSnapshotFile( + pending.fileBytes, + pending.fileName, + ); + } + } finally { + await pending.onPreviewSettled?.(); + } + }; // Consume a snapshot import that was enqueued before navigation (e.g. from // a timeline AgentSnapshotCard click that navigated here). const pending = consumePendingSnapshotImport(); if (pending) { - if (pending.snapshotKind === "team") { - void teamActions.handleImportTeamSnapshotFile( - pending.fileBytes, - pending.fileName, - ); - } else { - void personas.handleImportSnapshotFile( - pending.fileBytes, - pending.fileName, - ); - } + 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); }); }, []); From b346d861dd5c200f615b85f0ed095c4984f55715 Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Wed, 29 Jul 2026 00:43:26 -0500 Subject: [PATCH 08/14] [verified] Format handoff security module Signed-off-by: Steve Darlow --- .../agent_snapshot_handoff_security.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 index 2bd048242c..69e8d6af4e 100644 --- a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff_security.rs +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff_security.rs @@ -1,4 +1,8 @@ -use std::{io::Read, path::{Path, PathBuf}, time::{Duration, SystemTime}}; +use std::{ + io::Read, + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; #[cfg(target_os = "macos")] use std::ffi::CString; @@ -259,7 +263,10 @@ fn load_agent_snapshot_handoff_from_dir( } #[cfg(target_os = "macos")] -pub(super) fn read_agent_snapshot_handoff_from_dir(dir: &Path, handoff_id: &str) -> Result, String> { +pub(super) fn read_agent_snapshot_handoff_from_dir( + dir: &Path, + handoff_id: &str, +) -> Result, String> { load_agent_snapshot_handoff_from_dir(dir, handoff_id, None) } @@ -273,7 +280,10 @@ pub(super) fn consume_agent_snapshot_handoff_from_dir( } #[cfg(not(target_os = "macos"))] -pub(super) fn read_agent_snapshot_handoff_from_dir(_dir: &Path, _handoff_id: &str) -> Result, String> { +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()) } @@ -285,4 +295,3 @@ pub(super) fn consume_agent_snapshot_handoff_from_dir( ) -> Result<(), String> { Err("agent snapshot handoffs currently require macOS file security".to_string()) } - From c5c98d627ac92c8cd899d8d98db380350f67acac Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Wed, 29 Jul 2026 00:44:00 -0500 Subject: [PATCH 09/14] [verified] Format nested Buzz workspace Signed-off-by: Steve Darlow --- desktop/src-tauri/src/deep_link.rs | 9 +++------ .../src/deep_link/agent_snapshot_handoff.rs | 15 +++------------ desktop/src-tauri/src/managed_agents/discovery.rs | 2 -- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 53f182f882..2d9a9e1bd9 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -1,7 +1,4 @@ -use std::{ - collections::VecDeque, - sync::Mutex, -}; +use std::{collections::VecDeque, sync::Mutex}; use serde::Serialize; use tauri::{Emitter, Manager, State}; @@ -17,8 +14,8 @@ use agent_snapshot_handoff_security::{ }; #[cfg(test)] use agent_snapshot_handoff_security::{ - validate_agent_snapshot_handoff_directory_metadata, - validate_agent_snapshot_handoff_metadata, AGENT_SNAPSHOT_HANDOFF_MAX_AGE, + 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"; diff --git a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs index 6ec34e32fa..f55677b169 100644 --- a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs @@ -9,8 +9,7 @@ use url::Url; use super::{ consume_agent_snapshot_handoff_from_dir, parse_agent_snapshot_handoff_id, - read_agent_snapshot_handoff_from_dir, PendingAgentSnapshotImport, - PendingAgentSnapshotImports, + read_agent_snapshot_handoff_from_dir, PendingAgentSnapshotImport, PendingAgentSnapshotImports, }; #[cfg(target_os = "macos")] use super::{ @@ -121,11 +120,7 @@ fn secure_handoff_read_rejects_symlinked_staging_directory() { #[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 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(); @@ -200,11 +195,7 @@ fn secure_handoff_read_rejects_fields_outside_the_coagent_contract() { #[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, - ); + 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(), diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index a97e5ec2ac..235632b463 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -405,7 +405,6 @@ pub(crate) fn dangling_harness_display(id: &str) -> String { format!("harness (deleted): {id}") } - /// Spawn-time variant of `record_agent_command` that returns a typed error when /// a record's `runtime` id or its persona's `runtime` id is set but cannot be /// resolved (i.e. the definition was deleted after the agent was created). @@ -463,7 +462,6 @@ pub fn try_record_agent_command( Ok(default_agent_command()) } - pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec { let normalized = agent_args .into_iter() From c2c83c7bc6916407048442dd701de903465bab94 Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Wed, 29 Jul 2026 00:49:42 -0500 Subject: [PATCH 10/14] [verified] Bound normalized handoff URLs Signed-off-by: Steve Darlow --- desktop/src-tauri/src/deep_link.rs | 19 ++++++++++++------- .../src/deep_link/agent_snapshot_handoff.rs | 16 ++++++++++++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 2d9a9e1bd9..ed32be209a 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -19,7 +19,7 @@ use agent_snapshot_handoff_security::{ }; const AGENT_SNAPSHOT_HANDOFF_EVENT: &str = "agent-snapshot-import-available"; -const AGENT_SNAPSHOT_HANDOFF_URL_PREFIX: &str = "buzz://import-agent-snapshot"; +const MAX_DEEP_LINK_URL_BYTES: usize = 64 * 1024; const AGENT_SNAPSHOT_HANDOFF_MAX_URL_BYTES: usize = 256; #[derive(Debug, Clone, PartialEq, Serialize)] @@ -120,6 +120,11 @@ fn queue_agent_snapshot_handoff(app: &tauri::AppHandle, handoff_id: String) -> R Ok(()) } +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 { @@ -410,12 +415,8 @@ 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() > AGENT_SNAPSHOT_HANDOFF_MAX_URL_BYTES - && url_str - .get(..AGENT_SNAPSHOT_HANDOFF_URL_PREFIX.len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case(AGENT_SNAPSHOT_HANDOFF_URL_PREFIX)) - { - eprintln!("buzz-desktop: snapshot handoff deep link exceeds the URL size limit"); + 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) { @@ -433,6 +434,10 @@ 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; diff --git a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs index f55677b169..a6fb8447f0 100644 --- a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs @@ -8,8 +8,9 @@ use std::time::{Duration, SystemTime}; use url::Url; use super::{ - consume_agent_snapshot_handoff_from_dir, parse_agent_snapshot_handoff_id, - read_agent_snapshot_handoff_from_dir, PendingAgentSnapshotImport, PendingAgentSnapshotImports, + agent_snapshot_handoff_url_exceeds_limit, consume_agent_snapshot_handoff_from_dir, + parse_agent_snapshot_handoff_id, read_agent_snapshot_handoff_from_dir, + PendingAgentSnapshotImport, PendingAgentSnapshotImports, }; #[cfg(target_os = "macos")] use super::{ @@ -60,6 +61,17 @@ fn agent_snapshot_handoff_requires_one_canonical_lowercase_uuid() { } } +#[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(); From 0d16c009d402a770c024b40ff622311f73307b01 Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Wed, 29 Jul 2026 00:58:05 -0500 Subject: [PATCH 11/14] [verified] Retain rejected handoff previews Signed-off-by: Steve Darlow --- .../agents/agentSnapshotHandoff.test.mjs | 4 +-- .../features/agents/agentSnapshotHandoff.ts | 2 +- .../agents/openSnapshotImportFromUrlEvent.ts | 4 +-- desktop/src/features/agents/ui/AgentsView.tsx | 27 +++++++++---------- .../features/agents/ui/usePersonaActions.ts | 2 ++ 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/desktop/src/features/agents/agentSnapshotHandoff.test.mjs b/desktop/src/features/agents/agentSnapshotHandoff.test.mjs index ccc0511434..adc2ea82f3 100644 --- a/desktop/src/features/agents/agentSnapshotHandoff.test.mjs +++ b/desktop/src/features/agents/agentSnapshotHandoff.test.mjs @@ -45,8 +45,8 @@ test("handoff drain routes accepted bytes through the existing preview flow", as ], ["navigate"], ]); - assert.equal(typeof routedPayload.onPreviewSettled, "function"); - await routedPayload.onPreviewSettled(); + assert.equal(typeof routedPayload.onPreviewAccepted, "function"); + await routedPayload.onPreviewAccepted(); assert.deepEqual(calls.at(-1), ["ack", pending.id]); }); diff --git a/desktop/src/features/agents/agentSnapshotHandoff.ts b/desktop/src/features/agents/agentSnapshotHandoff.ts index f83b06212c..716d9c96ae 100644 --- a/desktop/src/features/agents/agentSnapshotHandoff.ts +++ b/desktop/src/features/agents/agentSnapshotHandoff.ts @@ -28,7 +28,7 @@ export async function drainPendingAgentSnapshotImport( deps.requestOpen({ fileBytes: pending.fileBytes, fileName: pending.fileName, - onPreviewSettled: async () => { + onPreviewAccepted: async () => { await deps.acknowledge(pending.id); }, snapshotKind: "agent", diff --git a/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts index 847a9d913f..f3b19b3573 100644 --- a/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts +++ b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts @@ -12,7 +12,7 @@ export type PendingSnapshotImport = { fileBytes: number[]; fileName: string; - onPreviewSettled?: () => Promise; + onPreviewAccepted?: () => Promise; snapshotKind: "agent" | "team"; }; @@ -29,7 +29,7 @@ export function requestOpenSnapshotImport(payload: PendingSnapshotImport) { pendingImport = { fileBytes: payload.fileBytes, fileName: payload.fileName, - onPreviewSettled: payload.onPreviewSettled, + onPreviewAccepted: payload.onPreviewAccepted, 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 ba668296fa..8ce1e11790 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -85,20 +85,19 @@ export function AgentsView() { // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable React.useEffect(() => { const openPendingSnapshot = async (pending: PendingSnapshotImport) => { - try { - if (pending.snapshotKind === "team") { - await teamActions.handleImportTeamSnapshotFile( - pending.fileBytes, - pending.fileName, - ); - } else { - await personas.handleImportSnapshotFile( - pending.fileBytes, - pending.fileName, - ); - } - } finally { - await pending.onPreviewSettled?.(); + if (pending.snapshotKind === "team") { + await teamActions.handleImportTeamSnapshotFile( + pending.fileBytes, + pending.fileName, + ); + await pending.onPreviewAccepted?.(); + } else if ( + await personas.handleImportSnapshotFile( + pending.fileBytes, + pending.fileName, + ) + ) { + await pending.onPreviewAccepted?.(); } }; // Consume a snapshot import that was enqueued before navigation (e.g. from 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; } } From a43dbc65834e8abba8b1a6886e05a378652f52b7 Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Wed, 29 Jul 2026 01:07:00 -0500 Subject: [PATCH 12/14] [verified] Release rejected handoff queue items Signed-off-by: Steve Darlow --- desktop/src-tauri/src/deep_link.rs | 8 ++++ desktop/src-tauri/src/lib.rs | 5 ++- .../agents/agentSnapshotHandoff.test.mjs | 41 +++++++++++++++++++ .../features/agents/agentSnapshotHandoff.ts | 6 +++ .../agents/openSnapshotImportFromUrlEvent.ts | 2 + desktop/src/features/agents/ui/AgentsView.tsx | 2 + 6 files changed, 62 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ed32be209a..f2b9264eec 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -104,6 +104,14 @@ pub(crate) fn acknowledge_pending_agent_snapshot_import( acknowledged } +#[tauri::command] +pub(crate) fn reject_pending_agent_snapshot_import( + id: String, + pending: State<'_, PendingAgentSnapshotImports>, +) -> bool { + pending.acknowledge(&id) +} + fn queue_agent_snapshot_handoff(app: &tauri::AppHandle, handoff_id: String) -> Result<(), String> { let bytes = read_agent_snapshot_handoff_from_dir(&agent_snapshot_handoff_dir()?, &handoff_id)?; if !app diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c704ddfb3d..ac7bda3ccf 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -36,8 +36,8 @@ use builderlab::*; use commands::*; use deep_link::{ acknowledge_pending_agent_snapshot_import, acknowledge_pending_community_deep_link, - handle_deep_link_url, take_pending_agent_snapshot_import, take_pending_community_deep_link, - PendingAgentSnapshotImports, PendingCommunityDeepLinks, + 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, @@ -654,6 +654,7 @@ pub fn run() { .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/features/agents/agentSnapshotHandoff.test.mjs b/desktop/src/features/agents/agentSnapshotHandoff.test.mjs index adc2ea82f3..83a47304b7 100644 --- a/desktop/src/features/agents/agentSnapshotHandoff.test.mjs +++ b/desktop/src/features/agents/agentSnapshotHandoff.test.mjs @@ -19,6 +19,10 @@ test("handoff drain routes accepted bytes through the existing preview flow", as calls.push(["ack", id]); return true; }, + reject: async (id) => { + calls.push(["reject", id]); + return true; + }, requestOpen: (payload) => { routedPayload = payload; calls.push([ @@ -46,10 +50,39 @@ test("handoff drain routes accepted bytes through the existing preview flow", as ["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({ @@ -58,6 +91,10 @@ test("handoff drain does nothing when no import is pending", async () => { touched = true; return true; }, + reject: async () => { + touched = true; + return true; + }, requestOpen: () => { touched = true; }, @@ -84,6 +121,10 @@ test("handoff drain does not acknowledge when preview routing throws", async () acknowledged = true; return true; }, + reject: async () => { + acknowledged = true; + return true; + }, requestOpen: () => { throw new Error("route failed"); }, diff --git a/desktop/src/features/agents/agentSnapshotHandoff.ts b/desktop/src/features/agents/agentSnapshotHandoff.ts index 716d9c96ae..6768117581 100644 --- a/desktop/src/features/agents/agentSnapshotHandoff.ts +++ b/desktop/src/features/agents/agentSnapshotHandoff.ts @@ -15,6 +15,7 @@ type PendingAgentSnapshotImport = PendingSnapshotImport & { type AgentSnapshotHandoffDeps = { take: () => Promise; acknowledge: (id: string) => Promise; + reject: (id: string) => Promise; requestOpen: (payload: PendingSnapshotImport) => void; goAgents: () => unknown; }; @@ -31,6 +32,9 @@ export async function drainPendingAgentSnapshotImport( onPreviewAccepted: async () => { await deps.acknowledge(pending.id); }, + onPreviewRejected: async () => { + await deps.reject(pending.id); + }, snapshotKind: "agent", }); await deps.goAgents(); @@ -59,6 +63,8 @@ export async function listenForAgentSnapshotHandoffs( invoke("acknowledge_pending_agent_snapshot_import", { id, }), + reject: (id) => + invoke("reject_pending_agent_snapshot_import", { id }), requestOpen: requestOpenSnapshotImport, goAgents, }); diff --git a/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts index f3b19b3573..9492ca1a95 100644 --- a/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts +++ b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts @@ -13,6 +13,7 @@ export type PendingSnapshotImport = { fileBytes: number[]; fileName: string; onPreviewAccepted?: () => Promise; + onPreviewRejected?: () => Promise; snapshotKind: "agent" | "team"; }; @@ -30,6 +31,7 @@ export function requestOpenSnapshotImport(payload: PendingSnapshotImport) { 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 8ce1e11790..d82f95a239 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -98,6 +98,8 @@ export function AgentsView() { ) ) { await pending.onPreviewAccepted?.(); + } else { + await pending.onPreviewRejected?.(); } }; // Consume a snapshot import that was enqueued before navigation (e.g. from From b3d0cc0e8341ae8bfad0a2eadbd4edec434e8ea4 Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Wed, 29 Jul 2026 01:11:40 -0500 Subject: [PATCH 13/14] [verified] Gate preview on secure consumption Signed-off-by: Steve Darlow --- desktop/src/features/agents/agentSnapshotHandoff.ts | 8 ++------ .../src/features/agents/openSnapshotImportFromUrlEvent.ts | 4 ++-- desktop/src/features/agents/ui/AgentsView.tsx | 6 +++++- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/agents/agentSnapshotHandoff.ts b/desktop/src/features/agents/agentSnapshotHandoff.ts index 6768117581..e0c19bac70 100644 --- a/desktop/src/features/agents/agentSnapshotHandoff.ts +++ b/desktop/src/features/agents/agentSnapshotHandoff.ts @@ -29,12 +29,8 @@ export async function drainPendingAgentSnapshotImport( deps.requestOpen({ fileBytes: pending.fileBytes, fileName: pending.fileName, - onPreviewAccepted: async () => { - await deps.acknowledge(pending.id); - }, - onPreviewRejected: async () => { - await deps.reject(pending.id); - }, + onPreviewAccepted: () => deps.acknowledge(pending.id), + onPreviewRejected: () => deps.reject(pending.id), snapshotKind: "agent", }); await deps.goAgents(); diff --git a/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts index 9492ca1a95..f5cd58d630 100644 --- a/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts +++ b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts @@ -12,8 +12,8 @@ export type PendingSnapshotImport = { fileBytes: number[]; fileName: string; - onPreviewAccepted?: () => Promise; - onPreviewRejected?: () => Promise; + onPreviewAccepted?: () => Promise; + onPreviewRejected?: () => Promise; snapshotKind: "agent" | "team"; }; diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index d82f95a239..627abda3ac 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -97,7 +97,11 @@ export function AgentsView() { pending.fileName, ) ) { - await pending.onPreviewAccepted?.(); + const acknowledged = await pending.onPreviewAccepted?.(); + if (acknowledged === false) { + personas.closeSnapshotImportDialog(); + await pending.onPreviewRejected?.(); + } } else { await pending.onPreviewRejected?.(); } From 0dd552d366ec79f308c0a4c7a498c3f9c84e7d57 Mon Sep 17 00:00:00 2001 From: Steve Darlow Date: Wed, 29 Jul 2026 01:50:56 -0500 Subject: [PATCH 14/14] [verified] Hold accepted handoff descriptor --- desktop/src-tauri/src/deep_link.rs | 119 +++++++++------- .../src/deep_link/agent_snapshot_handoff.rs | 130 ++++++++++++++++-- .../agent_snapshot_handoff_security.rs | 130 ++++++++++++------ .../src-tauri/src/managed_agents/discovery.rs | 24 ++-- .../discovery/command_resolution.rs | 60 -------- 5 files changed, 287 insertions(+), 176 deletions(-) delete mode 100644 desktop/src-tauri/src/managed_agents/discovery/command_resolution.rs diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index f2b9264eec..b011ba55ae 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -9,11 +9,12 @@ use crate::nostr_bind; mod agent_snapshot_handoff_security; use agent_snapshot_handoff_security::{ - agent_snapshot_handoff_dir, consume_agent_snapshot_handoff_from_dir, - parse_agent_snapshot_handoff_id, read_agent_snapshot_handoff_from_dir, + 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, }; @@ -32,22 +33,31 @@ pub(crate) struct PendingAgentSnapshotImport { } #[derive(Default)] -pub(crate) struct PendingAgentSnapshotImports(Mutex>); +pub(crate) struct PendingAgentSnapshotImports(Mutex>); + +struct QueuedAgentSnapshotImport { + pending: PendingAgentSnapshotImport, + source: OpenAgentSnapshotHandoff, +} impl PendingAgentSnapshotImports { - fn enqueue(&self, pending: PendingAgentSnapshotImport) -> bool { + 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.id == pending.id) { - return true; + if queue.iter().any(|item| item.pending.id == pending.id) { + return Ok(false); } if !queue.is_empty() { - return false; + return Err(()); } - queue.push_back(pending); - true + queue.push_back(QueuedAgentSnapshotImport { pending, source }); + Ok(true) } fn first(&self) -> Option { @@ -55,20 +65,39 @@ impl PendingAgentSnapshotImports { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .front() - .cloned() + .map(|item| item.pending.clone()) } - fn acknowledge(&self, id: &str) -> bool { + fn consume(&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.id == id) { - queue.pop_front(); - true - } else { - false + 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 } } @@ -82,26 +111,9 @@ pub(crate) fn take_pending_agent_snapshot_import( #[tauri::command] pub(crate) fn acknowledge_pending_agent_snapshot_import( id: String, - app: tauri::AppHandle, pending: State<'_, PendingAgentSnapshotImports>, ) -> bool { - let Some(current) = pending.first() else { - return false; - }; - if current.id != id { - return false; - } - let Ok(dir) = agent_snapshot_handoff_dir() else { - return false; - }; - if consume_agent_snapshot_handoff_from_dir(&dir, &id, ¤t.file_bytes).is_err() { - return false; - } - let acknowledged = pending.acknowledge(&id); - if acknowledged && pending.first().is_some() { - let _ = app.emit(AGENT_SNAPSHOT_HANDOFF_EVENT, ()); - } - acknowledged + pending.consume(&id) } #[tauri::command] @@ -109,23 +121,27 @@ pub(crate) fn reject_pending_agent_snapshot_import( id: String, pending: State<'_, PendingAgentSnapshotImports>, ) -> bool { - pending.acknowledge(&id) + pending.discard(&id) } -fn queue_agent_snapshot_handoff(app: &tauri::AppHandle, handoff_id: String) -> Result<(), String> { - let bytes = read_agent_snapshot_handoff_from_dir(&agent_snapshot_handoff_dir()?, &handoff_id)?; - if !app - .state::() - .enqueue(PendingAgentSnapshotImport { +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(), - }) - { - return Err("another agent snapshot handoff is already awaiting preview".to_string()); + }, + source, + ) { + Ok(is_new) => Ok(is_new), + Err(()) => Err("another agent snapshot handoff is already awaiting preview".to_string()), } - Ok(()) } fn agent_snapshot_handoff_url_exceeds_limit(url: &Url, raw: &str) -> bool { @@ -450,12 +466,17 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { eprintln!("buzz-desktop: snapshot handoff deep link has an invalid id: {url_str}"); return; }; - if let Err(error) = queue_agent_snapshot_handoff(app, handoff_id) { - eprintln!("buzz-desktop: rejecting agent snapshot handoff: {error}"); - 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); - let _ = app.emit(AGENT_SNAPSHOT_HANDOFF_EVENT, ()); + if is_new { + let _ = app.emit(AGENT_SNAPSHOT_HANDOFF_EVENT, ()); + } } Some("connect") => { let Some(relay_url) = parse_websocket_relay_param(&url) else { diff --git a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs index a6fb8447f0..f7af88098d 100644 --- a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff.rs @@ -9,8 +9,8 @@ use url::Url; use super::{ agent_snapshot_handoff_url_exceeds_limit, consume_agent_snapshot_handoff_from_dir, - parse_agent_snapshot_handoff_id, read_agent_snapshot_handoff_from_dir, - PendingAgentSnapshotImport, PendingAgentSnapshotImports, + 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::{ @@ -93,7 +93,63 @@ fn secure_handoff_read_retains_source_until_preview_acknowledgement() { 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(), "acknowledged source must be deleted"); + 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")] @@ -286,25 +342,69 @@ fn secure_handoff_read_rejects_snapshot_with_memory() { 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(); - assert!(queue.enqueue(PendingAgentSnapshotImport { + let pending = PendingAgentSnapshotImport { id: HANDOFF_ID.to_owned(), - file_bytes: vec![1, 2, 3], + 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!(!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(), - })); + 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_eq!(queue.first().unwrap().file_bytes, vec![1, 2, 3]); - assert!(!queue.acknowledge("other")); - assert!(queue.acknowledge(HANDOFF_ID)); + 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 index 69e8d6af4e..43683a52e2 100644 --- a/desktop/src-tauri/src/deep_link/agent_snapshot_handoff_security.rs +++ b/desktop/src-tauri/src/deep_link/agent_snapshot_handoff_security.rs @@ -1,5 +1,5 @@ use std::{ - io::Read, + io::{Read, Seek, SeekFrom}, path::{Path, PathBuf}, time::{Duration, SystemTime}, }; @@ -21,6 +21,74 @@ use crate::{ 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() @@ -144,11 +212,10 @@ fn validate_agent_snapshot_handoff_shape(bytes: &[u8]) -> Result<(), String> { } #[cfg(target_os = "macos")] -fn load_agent_snapshot_handoff_from_dir( +pub(super) fn open_agent_snapshot_handoff_from_dir( dir: &Path, handoff_id: &str, - consume_expected: Option<&[u8]>, -) -> Result, String> { +) -> 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 { @@ -176,7 +243,7 @@ fn load_agent_snapshot_handoff_from_dir( libc::openat( directory.as_raw_fd(), file_name_c.as_ptr(), - libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + libc::O_RDWR | libc::O_NOFOLLOW | libc::O_CLOEXEC, ) }; if file_fd < 0 { @@ -224,62 +291,38 @@ fn load_agent_snapshot_handoff_from_dir( return Err("agent snapshot handoff must be config-only".to_string()); } - let Some(expected) = consume_expected else { - return Ok(bytes); - }; - if bytes != expected { - return Err("handoff contents changed before preview acknowledgement".to_string()); - } - - let mut current_metadata = std::mem::MaybeUninit::::uninit(); - let stat_result = unsafe { - libc::fstatat( - directory.as_raw_fd(), - file_name_c.as_ptr(), - current_metadata.as_mut_ptr(), - libc::AT_SYMLINK_NOFOLLOW, - ) - }; - if stat_result != 0 { - return Err(format!( - "cannot re-inspect handoff before deletion: {}", - std::io::Error::last_os_error() - )); - } - let current_metadata = unsafe { current_metadata.assume_init() }; - if current_metadata.st_dev != opened_metadata.dev() as libc::dev_t - || current_metadata.st_ino != opened_metadata.ino() as libc::ino_t - { - return Err("handoff changed while it was being read".to_string()); - } - let unlink_result = unsafe { libc::unlinkat(directory.as_raw_fd(), file_name_c.as_ptr(), 0) }; - if unlink_result != 0 { - return Err(format!( - "cannot delete accepted handoff: {}", - std::io::Error::last_os_error() - )); - } - Ok(bytes) + 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> { - load_agent_snapshot_handoff_from_dir(dir, handoff_id, None) + 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> { - load_agent_snapshot_handoff_from_dir(dir, handoff_id, Some(expected)).map(|_| ()) + 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, @@ -288,6 +331,7 @@ pub(super) fn read_agent_snapshot_handoff_from_dir( } #[cfg(not(target_os = "macos"))] +#[cfg(test)] pub(super) fn consume_agent_snapshot_handoff_from_dir( _dir: &Path, _handoff_id: &str, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 235632b463..eecbf4de3e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -10,10 +10,8 @@ use crate::managed_agents::{ HarnessSource, }; -mod command_resolution; mod runtime_metadata; -use command_resolution::{available_harness_command, default_agent_args, resolve_preset_command}; pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -434,7 +432,7 @@ pub fn try_record_agent_command( } if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) { - return Ok(available_harness_command(id, &def.command)); + return Ok(def.command.clone()); } return Err(format!("DANGLING_HARNESS_ID:{id}")); } @@ -451,7 +449,7 @@ pub fn try_record_agent_command( if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) { - return Ok(available_harness_command(id, &def.command)); + return Ok(def.command.clone()); } return Err(format!("DANGLING_HARNESS_ID:{id}")); } @@ -462,6 +460,15 @@ pub fn try_record_agent_command( Ok(default_agent_command()) } +fn default_agent_args(command: &str) -> Option> { + match normalize_command_identity(command).as_str() { + "goose" => Some(vec!["acp".to_string()]), + "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" + | "claudecode" | "buzz-agent" => Some(Vec::new()), + _ => None, + } +} + pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec { let normalized = agent_args .into_iter() @@ -1470,11 +1477,10 @@ fn preset_catalog_entry( def: &PresetHarness, resolve: impl Fn(&str) -> Option, ) -> AcpRuntimeCatalogEntry { - let resolved = resolve_preset_command(def.id, def.command, &resolve); - let (availability, command, binary_path) = match resolved { - Some((resolved_command, path)) => ( + let (availability, command, binary_path) = match resolve(def.command) { + Some(path) => ( AcpAvailabilityStatus::Available, - Some(resolved_command.to_string()), + Some(def.command.to_string()), Some(path.display().to_string()), ), None => { @@ -1495,7 +1501,7 @@ fn preset_catalog_entry( .map(|p| p.display().to_string()); let default_args = normalize_agent_args( - command.as_deref().unwrap_or(def.command), + def.command, def.args.iter().map(|s| s.to_string()).collect(), ); diff --git a/desktop/src-tauri/src/managed_agents/discovery/command_resolution.rs b/desktop/src-tauri/src/managed_agents/discovery/command_resolution.rs deleted file mode 100644 index 29675de7e0..0000000000 --- a/desktop/src-tauri/src/managed_agents/discovery/command_resolution.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::path::PathBuf; - -use super::{normalize_command_identity, resolve_command}; - -pub(super) fn available_harness_command(id: &str, preferred: &str) -> String { - resolve_preset_command(id, preferred, &resolve_command) - .map(|(command, _)| command.to_string()) - .unwrap_or_else(|| preferred.to_string()) -} - -pub(super) fn resolve_preset_command<'a>( - id: &str, - preferred: &'a str, - resolve: &impl Fn(&str) -> Option, -) -> Option<(&'a str, PathBuf)> { - resolve(preferred) - .map(|path| (preferred, path)) - .or_else(|| { - (id == "hermes") - .then(|| resolve("hermes").map(|path| ("hermes", path))) - .flatten() - }) -} - -pub(super) fn default_agent_args(command: &str) -> Option> { - match normalize_command_identity(command).as_str() { - "goose" | "hermes" => Some(vec!["acp".to_string()]), - "hermes-acp" | "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" - | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::{default_agent_args, resolve_preset_command}; - use std::path::PathBuf; - - #[test] - fn hermes_prefers_acp_shim_and_falls_back_to_cli_subcommand() { - let preferred = resolve_preset_command("hermes", "hermes-acp", &|command| { - matches!(command, "hermes-acp" | "hermes").then(|| PathBuf::from(command)) - }); - assert_eq!(preferred.unwrap().0, "hermes-acp"); - - let fallback = resolve_preset_command("hermes", "hermes-acp", &|command| { - (command == "hermes").then(|| PathBuf::from(command)) - }); - assert_eq!(fallback.unwrap().0, "hermes"); - assert_eq!(default_agent_args("hermes").unwrap(), ["acp"]); - } - - #[test] - fn fallback_is_not_applied_to_other_harnesses() { - let resolved = resolve_preset_command("goose", "goose", &|command| { - (command == "hermes").then(|| PathBuf::from(command)) - }); - assert!(resolved.is_none()); - } -}