From 09ce14f843bdd98583b082747d6d24168b5006db Mon Sep 17 00:00:00 2001 From: Bart Tyrpien Date: Thu, 23 Jul 2026 17:01:07 -0400 Subject: [PATCH] feat(agents): open agent imports from the CLI Signed-off-by: Bart Tyrpien --- Cargo.lock | 1 + README.md | 1 + crates/buzz-cli/Cargo.toml | 3 + crates/buzz-cli/README.md | 10 + crates/buzz-cli/src/commands/agents.rs | 131 ++++++++++ crates/buzz-cli/src/lib.rs | 17 +- desktop/playwright.config.ts | 1 + desktop/src-tauri/src/deep_link.rs | 235 +++++++++++++++++- desktop/src-tauri/src/lib.rs | 8 +- desktop/src/app/AppShell.tsx | 3 + .../agents/ui/AgentSnapshotImportDialog.tsx | 73 +++++- .../agents/useAgentSnapshotDeepLinks.ts | 31 +++ desktop/src/shared/deep-link.ts | 49 ++++ desktop/src/testing/e2eBridge.ts | 40 ++- .../tests/e2e/agent-import-deep-link.spec.ts | 66 +++++ desktop/tests/helpers/bridge.ts | 8 + docs/agent-import-deep-link.md | 63 +++++ 17 files changed, 728 insertions(+), 12 deletions(-) create mode 100644 desktop/src/features/agents/useAgentSnapshotDeepLinks.ts create mode 100644 desktop/tests/e2e/agent-import-deep-link.spec.ts create mode 100644 docs/agent-import-deep-link.md diff --git a/Cargo.lock b/Cargo.lock index 32152e41ab..03ff789ff6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -908,6 +908,7 @@ dependencies = [ "tokio", "url", "uuid", + "webbrowser", ] [[package]] diff --git a/README.md b/README.md index 6aa3eeff0b..51b4fb8877 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ A Rust workspace of focused crates. Single source of truth: the relay. See [ARCH - **[VISION.md](VISION.md)** · **[VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)** · **[VISION_PROJECTS.md](VISION_PROJECTS.md)** · **[VISION_AGENT.md](VISION_AGENT.md)** — the four vision docs - **[ARCHITECTURE.md](ARCHITECTURE.md)** — system design, kind ranges, subsystem boundaries - **[TESTING.md](TESTING.md)** — multi-agent E2E test suite +- **[Agent import deep link](docs/agent-import-deep-link.md)** — local v1 handoff for opening a snapshot preview in Buzz Desktop - **[CONTRIBUTING.md](CONTRIBUTING.md)** · **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** · **[SECURITY.md](SECURITY.md)** · **[GOVERNANCE.md](GOVERNANCE.md)**
diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 1476e60bfd..2a95016169 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -73,6 +73,9 @@ buzz-persona = { path = "../buzz-persona" } # channel-templates.json store for `channels create --template` dirs = "6" +# Open Buzz Desktop through its registered buzz:// deep-link handler. +webbrowser = "1" + # WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay) buzz-ws-client = { path = "../buzz-ws-client" } diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a8c668cf06..c6e4e2fb6c 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -82,6 +82,9 @@ buzz mem set "my-value" buzz mem patch --base-hash < diff.patch # or --no-base-hash buzz mem rm +# Local agent snapshot import +buzz agents import --file ./my-agent.agent.json + # Repository protection buzz repos protect list --id my-repo buzz repos protect set --id my-repo --ref refs/heads/main --push admin --no-force-push --no-delete @@ -95,6 +98,12 @@ buzz channels list | jq '.[].name' constraint omitted from the command is removed. `protect list` reports malformed stored rules in `validation_error` so an owner can remove and repair them. +`agents import` is local-only and does not require relay credentials. It opens +Buzz Desktop's existing import preview; the user must confirm **Import** before +the agent is created. Native apps may also use the documented +[`buzz://agent-import` v1 deep link](../../docs/agent-import-deep-link.md) +directly. + ## Commands | Group | Subcommand | Description | @@ -154,6 +163,7 @@ stored rules in `validation_error` so an owner can remove and repair them. | | `protect set` | Create or replace a protection rule | | | `protect remove` | Remove a protection rule | | `upload` | `file` | Upload a file to the Blossom store | +| `agents` | `import` | Open a local snapshot in Buzz Desktop's import preview | | `pack` | `validate` | Validate a persona pack (local, no relay) | | | `inspect` | Inspect a persona pack (local, no relay) | | `mem` | `ls` | List non-tombstoned memories | diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 58564a45c2..be8f36ca14 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -2,6 +2,8 @@ use buzz_core::kind::KIND_IA_ARCHIVED_LIST; use buzz_sdk::builders::{build_archive_identity_request, build_unarchive_identity_request}; use nostr::PublicKey; use serde_json::json; +use std::path::{Path, PathBuf}; +use url::Url; use crate::agent_management::{build_create, build_update, CreateAgentDraft, UpdateAgentDraft}; use crate::client::BuzzClient; @@ -11,6 +13,9 @@ use crate::{AgentsCmd, RespondToArg}; pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), CliError> { match command { + AgentsCmd::Import { .. } => Err(CliError::Other( + "agent import was not handled as a local command".into(), + )), AgentsCmd::DraftCreate { channel, display_name, @@ -151,6 +156,76 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } } +const MAX_AGENT_JSON_BYTES: u64 = 5 * 1024 * 1024; +const MAX_AGENT_PNG_BYTES: u64 = 10 * 1024 * 1024; +const AGENT_IMPORT_DEEP_LINK_VERSION: &str = "1"; + +fn snapshot_size_cap(path: &Path) -> Result { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| CliError::Usage("snapshot filename is not valid UTF-8".into()))? + .to_ascii_lowercase(); + if name.ends_with(".agent.json") { + Ok(MAX_AGENT_JSON_BYTES) + } else if name.ends_with(".agent.png") { + Ok(MAX_AGENT_PNG_BYTES) + } else { + Err(CliError::Usage( + "snapshot filename must end with .agent.json or .agent.png".into(), + )) + } +} + +pub(crate) fn build_agent_import_deep_link(file: &Path) -> Result<(PathBuf, Url), CliError> { + let canonical = file + .canonicalize() + .map_err(|error| CliError::Usage(format!("cannot read snapshot file: {error}")))?; + let metadata = canonical + .metadata() + .map_err(|error| CliError::Usage(format!("cannot inspect snapshot file: {error}")))?; + if !metadata.is_file() { + return Err(CliError::Usage( + "snapshot path must point to a regular file".into(), + )); + } + let cap = snapshot_size_cap(&canonical)?; + if metadata.len() > cap { + return Err(CliError::Usage(format!( + "snapshot file is too large (maximum {} MiB)", + cap / (1024 * 1024) + ))); + } + + let file_url = Url::from_file_path(&canonical) + .map_err(|()| CliError::Usage("could not convert snapshot path to a file URL".into()))?; + let mut deep_link = Url::parse("buzz://agent-import") + .map_err(|error| CliError::Other(format!("invalid import deep-link base: {error}")))?; + deep_link + .query_pairs_mut() + .append_pair("v", AGENT_IMPORT_DEEP_LINK_VERSION) + .append_pair("file", file_url.as_str()); + Ok((canonical, deep_link)) +} + +pub fn cmd_import(file: &Path) -> Result<(), CliError> { + let (canonical, deep_link) = build_agent_import_deep_link(file)?; + webbrowser::open(deep_link.as_str()) + .map_err(|error| CliError::Other(format!("failed to open Buzz Desktop: {error}")))?; + println!( + "{}", + json!({ + "ok": true, + "action": "agent-import", + "opened": true, + "confirmed": false, + "file": canonical, + "message": "Snapshot sent to Buzz Desktop. Review the preview and confirm Import in the app." + }) + ); + Ok(()) +} + /// Require `BUZZ_AUTH_TAG` and parse the owner pubkey from it. Used only by /// the `draft-create` and `draft-update` paths. fn require_owner(client: &BuzzClient) -> Result { @@ -386,6 +461,7 @@ mod tests { use buzz_core::kind::KIND_IA_ARCHIVED_LIST; use nostr::{EventBuilder, Keys, Kind, Tag}; use serde_json::json; + use std::io::Write; fn hex64(c: char) -> String { std::iter::repeat_n(c, 64).collect() @@ -395,6 +471,61 @@ mod tests { std::iter::repeat_n(c, 128).collect() } + #[test] + fn agent_import_deep_link_contains_canonical_file_url() { + let directory = tempfile::tempdir().expect("temp directory"); + let snapshot_path = directory.path().join("name with spaces.agent.json"); + std::fs::write(&snapshot_path, b"{}").expect("write snapshot"); + + let (canonical, deep_link) = + build_agent_import_deep_link(&snapshot_path).expect("valid deep link"); + assert_eq!( + canonical, + snapshot_path.canonicalize().expect("canonical path") + ); + assert_eq!(deep_link.scheme(), "buzz"); + assert_eq!(deep_link.host_str(), Some("agent-import")); + assert_eq!( + deep_link + .query_pairs() + .find(|(key, _)| key == "v") + .map(|(_, value)| value.into_owned()) + .as_deref(), + Some(AGENT_IMPORT_DEEP_LINK_VERSION) + ); + let file = deep_link + .query_pairs() + .find(|(key, _)| key == "file") + .map(|(_, value)| value.into_owned()) + .expect("file query parameter"); + assert!(file.starts_with("file://")); + assert!(file.contains("name%20with%20spaces.agent.json")); + } + + #[test] + fn agent_import_rejects_unknown_extension() { + let mut snapshot = tempfile::NamedTempFile::new().expect("temp file"); + snapshot.write_all(b"{}").expect("write snapshot"); + + let error = + build_agent_import_deep_link(snapshot.path()).expect_err("extension must be rejected"); + assert!(error.to_string().contains(".agent.json or .agent.png")); + } + + #[test] + fn agent_import_rejects_oversize_json_before_opening_desktop() { + let directory = tempfile::tempdir().expect("temp directory"); + let snapshot_path = directory.path().join("large.agent.json"); + let snapshot = std::fs::File::create(&snapshot_path).expect("create snapshot"); + snapshot + .set_len(MAX_AGENT_JSON_BYTES + 1) + .expect("extend snapshot"); + + let error = + build_agent_import_deep_link(&snapshot_path).expect_err("size must be rejected"); + assert!(error.to_string().contains("maximum 5 MiB")); + } + // --- (b) auth-selection matrix: extract_owner_auth_tag --- #[test] diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0f8caa416a..a6386a1fcd 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -71,7 +71,7 @@ Configuration (flags override env vars): BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required] BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional] -The 'pack' subcommand runs locally and does not require a relay connection. +The 'pack' subcommand and 'agents import' run locally and do not require a relay connection. Exit codes: 0=ok 1=bad input 2=relay/network error 3=auth error 4=other 5=write conflict Errors are JSON on stderr: {\"error\": \"\", \"message\": \"\"}" @@ -258,6 +258,12 @@ impl RespondToArg { #[derive(Subcommand)] pub enum AgentsCmd { + /// Open a local snapshot in Buzz Desktop's import preview + Import { + /// Path to an .agent.json or .agent.png snapshot + #[arg(long)] + file: std::path::PathBuf, + }, /// Open a prefilled create-agent form in the owner's Buzz Desktop DraftCreate { /// Current channel UUID; the new agent is added here after save @@ -1738,6 +1744,12 @@ async fn run(cli: Cli) -> Result<(), CliError> { }; } + // Agent snapshot import only opens Buzz's review surface. It never talks + // to the relay and must remain usable without a Nostr private key. + if let Cmd::Agents(AgentsCmd::Import { ref file }) = cli.command { + return commands::agents::cmd_import(file); + } + // Auth: private key is required for all relay operations. // The keypair IS the identity — no tokens, no other auth. let private_key_str = cli.private_key.ok_or_else(|| { @@ -1872,6 +1884,7 @@ mod tests { "archived", "draft-create", "draft-update", + "import", "unarchive" ] ); @@ -1992,7 +2005,7 @@ mod tests { #[test] fn subcommand_counts_are_stable() { let expected: Vec<(&str, usize)> = vec![ - ("agents", 5), + ("agents", 6), ("canvas", 2), ("channels", 16), ("dms", 4), diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index be9d73ed0a..485331244e 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -101,6 +101,7 @@ export default defineConfig({ "**/channel-sort.spec.ts", "**/identity-lost.spec.ts", "**/deep-link-invite.spec.ts", + "**/agent-import-deep-link.spec.ts", "**/invite-qr-download.spec.ts", "**/global-agent-config-screenshots.spec.ts", "**/doctor-states.spec.ts", diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc36..2614d76ebe 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -1,4 +1,9 @@ -use std::{collections::VecDeque, sync::Mutex}; +use std::{ + collections::VecDeque, + io::Read, + path::{Path, PathBuf}, + sync::Mutex, +}; use serde::Serialize; use tauri::{Emitter, Manager, State}; @@ -20,6 +25,58 @@ pub(crate) struct PendingCommunityDeepLink { #[derive(Default)] pub(crate) struct PendingCommunityDeepLinks(Mutex>); +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingAgentSnapshotImport { + id: String, + file_bytes: Vec, + file_name: String, +} + +#[derive(Default)] +pub(crate) struct PendingAgentSnapshotImports(Mutex>); + +const AGENT_IMPORT_DEEP_LINK_VERSION: &str = "1"; + +impl PendingAgentSnapshotImports { + fn enqueue(&self, pending: PendingAgentSnapshotImport) -> Result<(), String> { + let mut queue = self + .0 + .lock() + .map_err(|error| format!("pending agent-import queue poisoned: {error}"))?; + if queue.iter().any(|item| { + item.file_name == pending.file_name && item.file_bytes == pending.file_bytes + }) { + return Ok(()); + } + if !queue.is_empty() { + return Err("another agent snapshot import is already pending".into()); + } + queue.push_back(pending); + Ok(()) + } + + fn first(&self) -> Result, String> { + self.0 + .lock() + .map_err(|error| format!("pending agent-import queue poisoned: {error}")) + .map(|queue| queue.front().cloned()) + } + + fn acknowledge(&self, id: &str) -> Result { + let mut queue = self + .0 + .lock() + .map_err(|error| format!("pending agent-import queue poisoned: {error}"))?; + if queue.front().is_some_and(|item| item.id == id) { + queue.pop_front(); + Ok(true) + } else { + Ok(false) + } + } +} + impl PendingCommunityDeepLinks { fn enqueue(&self, pending: PendingCommunityDeepLink) { let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); @@ -69,6 +126,21 @@ pub(crate) fn acknowledge_pending_community_deep_link( pending.acknowledge(&id) } +#[tauri::command] +pub(crate) fn take_pending_agent_snapshot_import( + pending: State<'_, PendingAgentSnapshotImports>, +) -> Result, String> { + pending.first() +} + +#[tauri::command] +pub(crate) fn acknowledge_pending_agent_snapshot_import( + id: String, + pending: State<'_, PendingAgentSnapshotImports>, +) -> Result { + pending.acknowledge(&id) +} + fn queue_community_deep_link( app: &tauri::AppHandle, kind: &str, @@ -190,6 +262,79 @@ fn parse_add_community_deep_link(url: &Url) -> Option Result { + let version = non_empty_param(url, "v")?; + if version != AGENT_IMPORT_DEEP_LINK_VERSION { + return Err(format!("unsupported agent-import version: {version}")); + } + let file_url = non_empty_param(url, "file")?; + let parsed = Url::parse(&file_url).map_err(|error| format!("invalid file URL: {error}"))?; + if parsed.scheme() != "file" { + return Err("agent-import file must use the file scheme".into()); + } + let path = parsed + .to_file_path() + .map_err(|()| "agent-import file URL is not a local path".to_string())?; + let lower_name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "agent-import filename is not valid UTF-8".to_string())? + .to_ascii_lowercase(); + if !lower_name.ends_with(".agent.json") && !lower_name.ends_with(".agent.png") { + return Err("agent-import file must end with .agent.json or .agent.png".into()); + } + Ok(path) +} + +fn load_agent_snapshot_import(path: &Path) -> Result { + let canonical = path + .canonicalize() + .map_err(|error| format!("cannot read agent snapshot: {error}"))?; + let metadata = canonical + .metadata() + .map_err(|error| format!("cannot inspect agent snapshot: {error}"))?; + if !metadata.is_file() { + return Err("agent snapshot path must point to a regular file".into()); + } + let file_name = canonical + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "agent snapshot filename is not valid UTF-8".to_string())? + .to_string(); + let is_png = file_name.to_ascii_lowercase().ends_with(".agent.png"); + let cap = if is_png { + crate::commands::MAX_SNAPSHOT_PNG_BYTES + } else { + crate::commands::MAX_SNAPSHOT_JSON_BYTES + }; + if metadata.len() > cap as u64 { + return Err(format!( + "agent snapshot is too large (maximum {} MiB)", + cap / (1024 * 1024) + )); + } + let file = std::fs::File::open(&canonical) + .map_err(|error| format!("cannot read agent snapshot: {error}"))?; + let mut file_bytes = Vec::with_capacity((metadata.len() as usize).min(cap)); + file.take((cap + 1) as u64) + .read_to_end(&mut file_bytes) + .map_err(|error| format!("cannot read agent snapshot: {error}"))?; + if file_bytes.len() > cap { + return Err(format!( + "agent snapshot is too large (maximum {} MiB)", + cap / (1024 * 1024) + )); + } + crate::commands::decode_snapshot_from_bytes(&file_bytes) + .map_err(|error| format!("invalid agent snapshot: {error}"))?; + + Ok(PendingAgentSnapshotImport { + id: uuid::Uuid::new_v4().to_string(), + file_bytes, + file_name, + }) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct NostrBindDeepLinkPayload { @@ -295,6 +440,7 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result` — emits `deep-link-connect` to the frontend +/// - `buzz://agent-import?v=1&file=` — opens the existing agent import preview pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let url = match Url::parse(url_str) { Ok(u) => u, @@ -366,6 +512,24 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { activate_main_window(app); let _ = app.emit("deep-link-message", payload); } + Some("agent-import") => { + let pending = parse_agent_import_deep_link(&url) + .and_then(|path| load_agent_snapshot_import(&path)); + match pending { + Ok(pending) => { + let queue = app.state::(); + if let Err(error) = queue.enqueue(pending) { + eprintln!("buzz-desktop: could not queue agent import: {error}"); + return; + } + activate_main_window(app); + let _ = app.emit("deep-link-agent-import", ()); + } + Err(error) => { + eprintln!("buzz-desktop: rejecting agent-import deep link: {error}: {url_str}"); + } + } + } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { Ok(payload) => { activate_main_window(app); @@ -389,8 +553,10 @@ mod tests { use url::Url; use super::{ - parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link, - parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, + parse_add_community_deep_link, parse_agent_import_deep_link, parse_join_deep_link, + parse_message_deep_link, parse_nostr_bind_deep_link, PendingAgentSnapshotImport, + PendingAgentSnapshotImports, PendingCommunityDeepLink, PendingCommunityDeepLinks, + AGENT_IMPORT_DEEP_LINK_VERSION, }; fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { @@ -404,6 +570,69 @@ mod tests { } } + fn pending_agent_import(id: &str, name: &str) -> PendingAgentSnapshotImport { + PendingAgentSnapshotImport { + id: id.to_owned(), + file_bytes: vec![1, 2, 3], + file_name: name.to_owned(), + } + } + + #[test] + fn pending_agent_import_queue_deduplicates_and_rejects_overlap() { + let queue = PendingAgentSnapshotImports::default(); + queue + .enqueue(pending_agent_import("first", "one.agent.json")) + .unwrap(); + queue + .enqueue(pending_agent_import("duplicate", "one.agent.json")) + .expect("identical snapshot should be idempotent"); + assert!( + queue + .enqueue(pending_agent_import("second", "two.agent.json")) + .is_err(), + "a second preview must not replace the pending import" + ); + assert_eq!(queue.first().unwrap().unwrap().id, "first"); + assert!(!queue.acknowledge("second").unwrap()); + assert!(queue.acknowledge("first").unwrap()); + assert!(queue.first().unwrap().is_none()); + } + + #[test] + fn parse_agent_import_deep_link_accepts_local_agent_snapshot() { + let directory = tempfile::tempdir().expect("temp directory"); + let snapshot_path = directory.path().join("test agent.agent.json"); + let file_url = Url::from_file_path(&snapshot_path).unwrap(); + let mut url = Url::parse("buzz://agent-import").unwrap(); + url.query_pairs_mut() + .append_pair("v", AGENT_IMPORT_DEEP_LINK_VERSION) + .append_pair("file", file_url.as_str()); + let parsed = parse_agent_import_deep_link(&url).unwrap(); + assert_eq!(parsed, snapshot_path); + } + + #[test] + fn parse_agent_import_deep_link_rejects_remote_wrong_extension_or_version() { + for (version, file) in [ + ("1", "https://example.com/test.agent.json"), + ("1", "file:///tmp/test.json"), + ("2", "file:///tmp/test.agent.json"), + ] { + let mut url = Url::parse("buzz://agent-import").unwrap(); + url.query_pairs_mut() + .append_pair("v", version) + .append_pair("file", file); + assert!(parse_agent_import_deep_link(&url).is_err()); + } + + let mut missing_version = Url::parse("buzz://agent-import").unwrap(); + missing_version + .query_pairs_mut() + .append_pair("file", "file:///tmp/test.agent.json"); + assert!(parse_agent_import_deep_link(&missing_version).is_err()); + } + #[test] fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { let mut link = pending("join", "wss://relay.example", Some("invite")); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 48e897b19a..0b58e7a07f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -33,8 +33,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, @@ -353,6 +354,7 @@ pub fn run() { .manage(build_app_state()) .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) + .manage(PendingAgentSnapshotImports::default()) .manage(BuilderlabSession::default()) .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) @@ -645,6 +647,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/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 1d6223e713..4c274dd3e5 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -35,6 +35,7 @@ import { } from "@/features/notifications/hooks"; import { PreventSleepProvider } from "@/features/agents/usePreventSleep"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; +import { useAgentSnapshotDeepLinks } from "@/features/agents/useAgentSnapshotDeepLinks"; import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh"; import { useManagedAgentRuntimeReconciliation } from "@/features/agents/useManagedAgentRuntimeReconciliation"; import { useAutoRestartPolicy } from "@/features/agents/lib/useAutoRestartPolicy"; @@ -575,6 +576,8 @@ export function AppShell() { // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); + // Route CLI/browser snapshot requests through the existing import preview. + useAgentSnapshotDeepLinks(); const handleOpenNewDm = React.useCallback( () => void goNewMessage(), diff --git a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx index ad1219310d..88101f6475 100644 --- a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx +++ b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx @@ -19,6 +19,8 @@ import { Separator } from "@/shared/ui/separator"; type ImportPhase = "preview" | "confirming" | "result"; +const SYSTEM_PROMPT_PREVIEW_CHARS = 180; + type AgentSnapshotImportDialogProps = { open: boolean; /** Preview data loaded by the caller before opening. */ @@ -47,11 +49,13 @@ export function AgentSnapshotImportDialog({ }: AgentSnapshotImportDialogProps) { // Default: clear the source allowlist (safe default per spec). const [keepAllowlist, setKeepAllowlist] = React.useState(false); + const [showFullPrompt, setShowFullPrompt] = React.useState(false); - // Reset choice whenever the dialog opens with new data. + // Reset preview choices whenever the dialog opens. React.useEffect(() => { if (open) { setKeepAllowlist(false); + setShowFullPrompt(false); } }, [open]); @@ -124,6 +128,8 @@ export function AgentSnapshotImportDialog({ memoryLevelLabel={memoryLevelLabel} keepAllowlist={keepAllowlist} onKeepAllowlistChange={setKeepAllowlist} + onShowFullPromptChange={setShowFullPrompt} + showFullPrompt={showFullPrompt} /> ) : phase === "confirming" ? (
@@ -145,12 +151,16 @@ function PreviewBody({ memoryLevelLabel, keepAllowlist, onKeepAllowlistChange, + onShowFullPromptChange, + showFullPrompt, }: { preview: AgentSnapshotImportPreview; hasMemory: boolean; memoryLevelLabel: string; keepAllowlist: boolean; onKeepAllowlistChange: (v: boolean) => void; + onShowFullPromptChange: (show: boolean) => void; + showFullPrompt: boolean; }) { return (
@@ -158,9 +168,11 @@ function PreviewBody({

{preview.displayName}

{preview.systemPrompt ? ( -

- {preview.systemPrompt} -

+ ) : null}
@@ -238,6 +250,59 @@ function PreviewBody({ ); } +function SystemPromptReview({ + prompt, + expanded, + onExpandedChange, +}: { + prompt: string; + expanded: boolean; + onExpandedChange: (expanded: boolean) => void; +}) { + const hasMore = prompt.length > SYSTEM_PROMPT_PREVIEW_CHARS; + const preview = hasMore + ? `${prompt.slice(0, SYSTEM_PROMPT_PREVIEW_CHARS).trimEnd()}…` + : prompt; + + return ( +
+

+ Instructions · {prompt.length.toLocaleString()} characters +

+ {expanded ? ( +
+

+ {prompt} +

+
+ ) : ( +

+ {preview} +

+ )} + {hasMore ? ( + + ) : null} +
+ ); +} + // ── Result body ─────────────────────────────────────────────────────────────── export function ResultBody({ diff --git a/desktop/src/features/agents/useAgentSnapshotDeepLinks.ts b/desktop/src/features/agents/useAgentSnapshotDeepLinks.ts new file mode 100644 index 0000000000..13ccd6d91b --- /dev/null +++ b/desktop/src/features/agents/useAgentSnapshotDeepLinks.ts @@ -0,0 +1,31 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { requestOpenSnapshotImport } from "@/features/agents/openSnapshotImportFromUrlEvent"; +import { listenForAgentSnapshotDeepLinks } from "@/shared/deep-link"; + +/** + * Sends `buzz://agent-import` files through the same preview-and-confirm flow + * used by message attachment cards and manual file selection. + */ +export function useAgentSnapshotDeepLinks() { + const { goAgents } = useAppNavigation(); + + React.useEffect(() => { + let cancelled = false; + const unlistenPromise = listenForAgentSnapshotDeepLinks((payload) => { + if (cancelled) return false; + requestOpenSnapshotImport({ + fileBytes: payload.fileBytes, + fileName: payload.fileName, + snapshotKind: "agent", + }); + void goAgents(); + return true; + }); + return () => { + cancelled = true; + void unlistenPromise.then((unlisten) => unlisten()); + }; + }, [goAgents]); +} diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index c62a8bec3b..bb34f7ce71 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -49,6 +49,12 @@ export type JoinDeepLinkPayload = { policyReceipt: string | null; }; +export type PendingAgentSnapshotImport = { + id: string; + fileBytes: number[]; + fileName: string; +}; + type PendingCommunityDeepLink = { id: string; kind: "connect" | "join" | "add-community"; @@ -172,3 +178,46 @@ export function listenForNostrBindDeepLinks( onOpen(event.payload); }); } + +/** + * Route a local `buzz://agent-import` request into the existing agent snapshot + * preview. The Rust queue covers cold starts; acknowledging happens only after + * the frontend has accepted the bytes into its one-shot import queue. + */ +export async function listenForAgentSnapshotDeepLinks( + onOpen: (payload: PendingAgentSnapshotImport) => boolean, +): Promise { + let drainRunning = false; + let drainRequested = false; + + const drain = () => { + drainRequested = true; + if (drainRunning) return; + drainRunning = true; + void (async () => { + try { + while (drainRequested) { + drainRequested = false; + const pending = await invoke( + "take_pending_agent_snapshot_import", + ); + if (!pending || !onOpen(pending)) return; + await invoke("acknowledge_pending_agent_snapshot_import", { + id: pending.id, + }); + } + } catch (error: unknown) { + console.warn("Failed to open pending agent snapshot import", error); + } finally { + drainRunning = false; + if (drainRequested) drain(); + } + })(); + }; + + const unlisten = await listen("deep-link-agent-import", drain); + drain(); + return () => { + unlisten(); + }; +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index af7fb4bc2a..4632e18596 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -334,6 +334,14 @@ type E2eConfig = { code?: string | null; name?: string | null; }>; + /** Verified agent snapshots waiting in the mocked Rust deep-link queue. */ + pendingAgentSnapshotImports?: Array<{ + id: string; + fileBytes: number[]; + fileName: string; + }>; + /** System prompt returned by the mocked snapshot preview command. */ + agentSnapshotPreviewSystemPrompt?: string | null; // When true, `get_identity` returns `lost: true` until `persist_current_identity` // or `import_identity` is called. Drives the identity-lost recovery UX in tests. identityLost?: boolean; @@ -3821,6 +3829,11 @@ let mockPendingCommunityDeepLinks: Array<{ code: string | null; name: string | null; }> = []; +let mockPendingAgentSnapshotImports: Array<{ + id: string; + fileBytes: number[]; + fileName: string; +}> = []; function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) { mockPendingCommunityDeepLinks = ( @@ -3832,6 +3845,16 @@ function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) { })); } +function resetMockPendingAgentSnapshotImports(config: E2eConfig | null) { + mockPendingAgentSnapshotImports = ( + config?.mock?.pendingAgentSnapshotImports ?? [] + ).map((pending) => ({ + id: pending.id, + fileBytes: [...pending.fileBytes], + fileName: pending.fileName, + })); +} + function recordMockUserStatus(event: RelayEvent) { const dTag = event.tags.find((tag) => tag[0] === "d")?.[1]; if (dTag) { @@ -8890,6 +8913,7 @@ export function maybeInstallE2eTauriMocks() { resetMockUserStatuses(); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); + resetMockPendingAgentSnapshotImports(config); mockWebsocketSendMutexWedged = false; mockWindows("main"); window.__BUZZ_E2E_COMMANDS__ = []; @@ -9819,6 +9843,19 @@ export function maybeInstallE2eTauriMocks() { mockPendingCommunityDeepLinks.splice(index, 1); return true; } + case "take_pending_agent_snapshot_import": + return mockPendingAgentSnapshotImports[0] ?? null; + case "acknowledge_pending_agent_snapshot_import": { + const { id } = payload as { id: string }; + const index = mockPendingAgentSnapshotImports.findIndex( + (pending) => pending.id === id, + ); + if (index === -1) { + return false; + } + mockPendingAgentSnapshotImports.splice(index, 1); + return true; + } case "get_relay_http_url": return getRelayHttpUrl(activeConfig); case "relay_requires_membership": @@ -9983,7 +10020,8 @@ export function maybeInstallE2eTauriMocks() { // Return a minimal preview — no writes performed. return { displayName: "Imported Agent", - systemPrompt: null, + systemPrompt: + activeConfig?.mock?.agentSnapshotPreviewSystemPrompt ?? null, avatarUrl: null, memoryLevel: "none", memoryEntryCount: 0, diff --git a/desktop/tests/e2e/agent-import-deep-link.spec.ts b/desktop/tests/e2e/agent-import-deep-link.spec.ts new file mode 100644 index 0000000000..36b4122648 --- /dev/null +++ b/desktop/tests/e2e/agent-import-deep-link.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const LONG_PROMPT = Array.from( + { length: 60 }, + (_, index) => + `Section ${index + 1}: Inspect the requested work, preserve unrelated changes, and report concrete evidence before acting.`, +).join("\n\n"); + +test("agent import deep link opens a bounded review dialog", async ({ + page, +}) => { + await installMockBridge(page, { + pendingAgentSnapshotImports: [ + { + id: "agent-import-1", + fileBytes: [123, 125], + fileName: "long-prompt.agent.json", + }, + ], + agentSnapshotPreviewSystemPrompt: LONG_PROMPT, + }); + + await page.goto("/"); + + const dialog = page.getByTestId("agent-snapshot-import-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Imported Agent"); + await expect(dialog).toContainText( + `Instructions · ${LONG_PROMPT.length.toLocaleString()} characters`, + ); + + const excerpt = dialog.getByTestId("agent-snapshot-import-prompt-excerpt"); + await expect(excerpt).toBeVisible(); + await expect(excerpt).not.toContainText("Section 60"); + await expect( + dialog.getByTestId("agent-snapshot-import-confirm"), + ).toBeVisible(); + + const toggle = dialog.getByTestId("agent-snapshot-import-prompt-toggle"); + await expect(toggle).toHaveText("Review full instructions"); + await toggle.click(); + + const fullPrompt = dialog.getByTestId("agent-snapshot-import-full-prompt"); + await expect(fullPrompt).toBeVisible(); + await expect(fullPrompt).toContainText("Section 60"); + const dimensions = await fullPrompt.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + expect(dimensions.scrollHeight).toBeGreaterThan(dimensions.clientHeight); + + await expect(toggle).toHaveText("Hide full instructions"); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => + command === "acknowledge_pending_agent_snapshot_import", + ).length ?? 0, + ), + ) + .toBe(1); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 36b80aef28..188abccd54 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -382,6 +382,14 @@ type MockBridgeOptions = { code?: string | null; name?: string | null; }>; + /** Verified agent snapshots waiting in the mocked Rust deep-link queue. */ + pendingAgentSnapshotImports?: Array<{ + id: string; + fileBytes: number[]; + fileName: string; + }>; + /** System prompt returned by the mocked snapshot preview command. */ + agentSnapshotPreviewSystemPrompt?: string | null; /** * Global agent config returned by `get_global_agent_config`. Defaults to * an empty config (no provider, model, or env vars) if not specified. diff --git a/docs/agent-import-deep-link.md b/docs/agent-import-deep-link.md new file mode 100644 index 0000000000..59940c8603 --- /dev/null +++ b/docs/agent-import-deep-link.md @@ -0,0 +1,63 @@ +# Agent import deep link + +Buzz Desktop exposes a versioned local deep link that lets another native app +open an agent snapshot in Buzz's existing review dialog: + +```text +buzz://agent-import?v=1&file= +``` + +Opening the link does **not** create an agent. Buzz validates and decodes the +snapshot, focuses the app, navigates to My Agents, and shows the import +preview. The user must click **Import** to create the agent. + +## Recommended integration + +Command-line tools should use the Buzz CLI wrapper: + +```bash +buzz agents import --file ./my-agent.agent.json +``` + +This command is local-only and does not require `BUZZ_RELAY_URL`, +`BUZZ_PRIVATE_KEY`, or `BUZZ_AUTH_TAG`. A successful command means the +operating system accepted the request to open Buzz; it does not mean the user +confirmed the import. + +Native apps may open the deep link directly. Build it with a URL library so +the nested file URL is encoded correctly. For example, in Node.js: + +```js +import { pathToFileURL } from "node:url"; + +const deepLink = new URL("buzz://agent-import"); +deepLink.searchParams.set("v", "1"); +deepLink.searchParams.set("file", pathToFileURL(snapshotPath).href); +``` + +## Version 1 contract + +- `v` is required and must equal `1`. Buzz rejects missing or unsupported + versions. +- `file` is required and must be an absolute local `file:` URL. +- The resolved path must be a regular file ending in `.agent.json` or + `.agent.png`. +- JSON snapshots are limited to 5 MiB. PNG snapshots are limited to 10 MiB. +- Buzz Desktop independently rechecks the path, size, extension, and snapshot + contents before showing the preview. +- One snapshot review may be pending at a time. Reopening the same pending file + is idempotent; a different overlapping request is rejected. + +The file URL is only a handoff reference. Agent instructions and snapshot bytes +are not embedded in the deep link. + +## Browser integrations + +Version 1 intentionally accepts only local files. A browser cannot safely +provide Buzz Desktop with a usable local file path, and Buzz does not download +an arbitrary HTTPS URL from this deep link. + +A web integration should first use a trusted native helper or CLI to download +and verify the snapshot locally, then open the v1 handoff. A future remote-file +contract should use a new protocol version and bind the URL to an expected +SHA-256 digest.