diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 507bb79f52..ce081d17bd 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -99,6 +99,8 @@ export default defineConfig({ name: "integration", testMatch: [ "**/agents.spec.ts", + "**/agent-snapshot-send.spec.ts", + "**/agent-snapshot-recipient.spec.ts", "**/onboarding.spec.ts", "**/stream.spec.ts", "**/integration.spec.ts", diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index b461f11b29..34673f1262 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -1,12 +1,15 @@ use futures_util::StreamExt; +use sha2::{Digest, Sha256}; use tauri::State; use crate::app_state::AppState; use crate::commands::export_util::save_bytes_with_dialog; +use crate::commands::media::{detect_and_validate_mime, sanitize_filename}; +use crate::commands::personas::{ + decode_snapshot_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, +}; use crate::relay::{classify_request_error, relay_api_base_url_with_override, relay_error_message}; -use super::media::{detect_and_validate_mime, sanitize_filename}; - /// Maximum download size: 50 MiB. Prevents OOM from oversized responses. const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; @@ -219,6 +222,15 @@ pub async fn copy_image_to_clipboard( /// HTTP client, enforcing the download size cap. The caller is responsible for /// validating the URL origin and for any content-type checks on the result. async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result, String> { + fetch_blob_bytes_with_cap(url, state, MAX_DOWNLOAD_BYTES).await +} + +/// Core streaming fetcher with a caller-supplied byte cap. +async fn fetch_blob_bytes_with_cap( + url: &str, + state: &State<'_, AppState>, + cap: u64, +) -> Result, String> { // Fetch bytes via the app's HTTP client (goes through WARP tunnel). let resp = state .http_client @@ -234,11 +246,11 @@ async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result MAX_DOWNLOAD_BYTES { + if content_length > cap { return Err(format!( "file too large ({} MiB, max {} MiB)", content_length / (1024 * 1024), - MAX_DOWNLOAD_BYTES / (1024 * 1024) + cap / (1024 * 1024) )); } } @@ -249,11 +261,8 @@ async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result MAX_DOWNLOAD_BYTES { - return Err(format!( - "file too large (max {} MiB)", - MAX_DOWNLOAD_BYTES / (1024 * 1024) - )); + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(format!("file too large (max {} MiB)", cap / (1024 * 1024))); } bytes.extend_from_slice(&chunk); } @@ -261,10 +270,144 @@ async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result Result { + let lower = filename.to_ascii_lowercase(); + if lower.ends_with(".agent.json") { + Ok(MAX_SNAPSHOT_JSON_BYTES as u64) + } else if lower.ends_with(".agent.png") { + Ok(MAX_SNAPSHOT_PNG_BYTES as u64) + } else { + Err(format!( + "\"{}\" is not a snapshot filename — expected .agent.json or .agent.png", + filename + )) + } +} + +/// Fetch and validate an agent snapshot attachment in memory. +/// +/// Input validation (before HTTP): +/// - URL must be a valid same-relay `/media/` URL. +/// - Filename must end with `.agent.json` or `.agent.png`. +/// - `expected_sha256` and `expected_size` must be non-empty strings. +/// +/// During fetch: +/// - Enforces a format-specific cap (5 MiB JSON, 10 MiB PNG) via +/// Content-Length header and streamed byte count. +/// +/// Post-fetch validation (all must pass; returns an error on first failure): +/// 1. Byte length equals `expected_size`. +/// 2. SHA-256 hex of bytes equals `expected_sha256` (lowercase). +/// 3. `decode_snapshot_from_bytes` succeeds — bytes are a well-formed snapshot. +/// +/// Returns `tauri::ipc::Response` so bytes cross IPC as a raw buffer rather +/// than a JSON number array (which would be ~3× the size at the 5–10 MiB cap). +#[tauri::command] +pub async fn fetch_snapshot_bytes( + url: String, + filename: String, + expected_sha256: String, + expected_size: usize, + state: State<'_, AppState>, +) -> Result { + // ── Pre-fetch validation ────────────────────────────────────────────── + let relay_base = relay_api_base_url_with_override(&state); + validate_download_url(&url, &relay_base)?; + + // Sanitize the filename and verify it is a recognised snapshot extension. + let filename = sanitize_filename(&filename); + let cap = snapshot_cap_for_filename(&filename)?; + + if expected_sha256.is_empty() { + return Err("missing expected sha256 (imeta x field)".to_string()); + } + if expected_sha256.len() != 64 || !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err( + "invalid expected sha256 — must be a 64-hex-digit lowercase string".to_string(), + ); + } + if expected_size == 0 { + return Err("missing or zero expected size (imeta size field)".to_string()); + } + if expected_size as u64 > cap { + return Err(format!( + "declared size {} exceeds the {} MiB cap for this format", + expected_size, + cap / (1024 * 1024) + )); + } + + // ── Bounded fetch ───────────────────────────────────────────────────── + let bytes = fetch_blob_bytes_with_cap(&url, &state, cap).await?; + + // ── Post-fetch validation ───────────────────────────────────────────── + // 1. Byte length must equal the declared imeta size. + if bytes.len() != expected_size { + return Err(format!( + "size mismatch: fetched {} bytes but imeta declared {}", + bytes.len(), + expected_size + )); + } + + // 2. SHA-256 must match the declared imeta x value. + let actual_sha256 = hex::encode(Sha256::digest(&bytes)); + if actual_sha256 != expected_sha256.to_ascii_lowercase() { + return Err("hash mismatch: fetched bytes do not match the declared SHA-256".to_string()); + } + + // 3. Bytes must parse as a valid agent snapshot. This rejects malformed + // payloads, memory-bearing PNGs, and format/extension mismatches before + // the bytes reach the frontend importer. + decode_snapshot_from_bytes(&bytes).map_err(|e| format!("invalid snapshot: {e}"))?; + + Ok(tauri::ipc::Response::new(bytes)) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn snapshot_cap_json_returns_5_mib() { + assert_eq!( + snapshot_cap_for_filename("analyst.agent.json").unwrap(), + MAX_SNAPSHOT_JSON_BYTES as u64 + ); + } + + #[test] + fn snapshot_cap_png_returns_10_mib() { + assert_eq!( + snapshot_cap_for_filename("analyst.agent.png").unwrap(), + MAX_SNAPSHOT_PNG_BYTES as u64 + ); + } + + #[test] + fn snapshot_cap_plain_json_rejected() { + assert!(snapshot_cap_for_filename("data.json").is_err()); + } + + #[test] + fn snapshot_cap_deceptive_name_rejected() { + // foo.agent.json.exe must not match .agent.json + assert!(snapshot_cap_for_filename("foo.agent.json.exe").is_err()); + } + + #[test] + fn snapshot_cap_plain_png_rejected() { + assert!(snapshot_cap_for_filename("photo.png").is_err()); + } + + #[test] + fn snapshot_cap_agent_json_only_rejected() { + // "agent.json" without the leading dot — plain filename, not the suffix + assert!(snapshot_cap_for_filename("agentjson").is_err()); + } + const RELAY_BASE: &str = "https://relay.example.com"; #[test] diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index db81c79655..7dc5982c73 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -972,3 +972,11 @@ pub async fn export_persona_to_json( let filename = format!("{slug}.persona.json"); save_json_with_dialog(&app, &filename, &json_bytes).await } + +mod snapshot; +pub use snapshot::encode_agent_snapshot_for_send; +pub use snapshot::export_agent_snapshot; +pub(crate) use snapshot::import::{ + decode_snapshot_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, +}; +pub use snapshot::{confirm_agent_snapshot_import, preview_agent_snapshot_import}; diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index a01d380871..0d5cf1609e 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -23,7 +23,11 @@ use crate::managed_agents::PersonaRecord; /// does not retain, so the local-only `is_active` toggle never republishes, and /// a byte-identical user-save republish is harmlessly NIP-33-replaced). The /// guard is intentionally omitted. -pub(super) fn retain_persona_pending(app: &AppHandle, state: &AppState, persona: &PersonaRecord) { +pub(in crate::commands::personas) fn retain_persona_pending( + app: &AppHandle, + state: &AppState, + persona: &PersonaRecord, +) { use crate::managed_agents::{ managed_agents_base_dir, persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs new file mode 100644 index 0000000000..c67fe0cf77 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -0,0 +1,397 @@ +//! `export_agent_snapshot` / `encode_agent_snapshot_for_send` Tauri commands +//! and their supporting helpers. +//! +//! Import-side commands and helpers live in `snapshot::import` to keep this +//! file under the 1000-line gate. +//! +//! Split from `personas/mod.rs` to keep that file under the line-count gate. + +use serde::Serialize; +use tauri::{AppHandle, State}; + +use super::super::export_util::save_bytes_with_dialog; +use crate::{ + app_state::AppState, + commands::engrams::get_agent_memory, + managed_agents::{ + agent_snapshot::{ + build_snapshot, encode_snapshot_json, encode_snapshot_png, AgentSnapshotMemoryEntry, + MemoryLevel, + }, + load_agent_definitions, load_managed_agents, ManagedAgentRecord, + }, +}; + +pub(crate) mod import; + +// Re-export import-side types and commands so callers see a flat +// `snapshot::` namespace (unchanged from before the split). +pub use import::{ + confirm_agent_snapshot_import, preview_agent_snapshot_import, AgentSnapshotImportResult, +}; +pub(crate) use import::{ + decode_snapshot_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES, + MAX_SNAPSHOT_PNG_BYTES, +}; + +// ── Pure resolver (testable without AppHandle) ──────────────────────────────── + +/// Inner resolver operating on pre-fetched slices — testable without +/// `AppHandle`. +/// +/// Search order: +/// 1. Keyed instances: match `id` against `pubkey` (exact) then `slug`. +/// 2. Keyless definitions: match `id` against `slug`. +/// +/// Returns `(definition_record, is_definition)`. `is_definition` is `true` +/// when the result came from the definitions slice — the caller must not call +/// `get_agent_memory` against it (definitions have no keypair). +pub(crate) fn resolve_from_lists<'a>( + id: &str, + instances: &'a [ManagedAgentRecord], + definitions: &'a [ManagedAgentRecord], +) -> Result<(&'a ManagedAgentRecord, bool), String> { + if let Some(record) = instances + .iter() + .find(|a| a.pubkey == id || a.slug.as_deref() == Some(id)) + { + return Ok((record, false)); + } + if let Some(record) = definitions.iter().find(|a| a.slug.as_deref() == Some(id)) { + return Ok((record, true)); + } + Err(format!("agent {id:?} not found")) +} + +/// Validate that `memory_source_pubkey` is an appropriate source for a +/// memory-bearing snapshot export. +/// +/// For definition exports (`is_definition == true`), the instance must be +/// known and its `persona_id` must equal `def_slug`. +/// For direct instance exports, the pubkey must match the instance itself. +/// +/// Returns the validated pubkey string on success. +pub(crate) fn validate_memory_source( + memory_source_pubkey: &str, + is_definition: bool, + def_id: &str, + instances: &[ManagedAgentRecord], +) -> Result { + let mpk = memory_source_pubkey.trim(); + if mpk.is_empty() { + return Err( + "memory_source_pubkey is required when memory_level is not 'none'. \ + Pass the pubkey of a linked agent instance." + .to_string(), + ); + } + + if is_definition { + // Definition export: the supplied pubkey must be a keyed instance + // whose persona_id equals the definition slug. + let linked = instances + .iter() + .find(|a| a.pubkey == mpk) + .ok_or_else(|| format!("memory_source_pubkey {mpk:?} is not a known agent"))?; + if linked.persona_id.as_deref() != Some(def_id) { + return Err(format!( + "memory_source_pubkey {mpk:?} is not linked to definition {def_id:?}" + )); + } + } else { + // Instance export: the pubkey must match the instance itself. + // This prevents cross-agent memory pairing. + if mpk != def_id { + return Err(format!( + "memory_source_pubkey {mpk:?} does not match agent {def_id:?}" + )); + } + } + + Ok(mpk.to_string()) +} + +/// The encoded bytes and suggested filename for a snapshot, produced by the +/// shared materialization path. Used by both the save-to-disk command and the +/// native-send command so both paths are byte-identical for the same inputs. +pub(crate) struct SnapshotPayload { + pub bytes: Vec, + pub filename: String, +} + +/// Enforce the output size ceiling after encoding. +/// +/// Called by `materialize_snapshot_bytes` before returning bytes to the caller. +/// Both the save-to-disk and send commands therefore reject an oversized result +/// before any file dialog or upload, ensuring that a successfully sent/saved +/// snapshot is always within the importer's acceptance bounds. +/// +/// Extracted as a pure named function so tests can prove the guard logic +/// directly (boundary-1 / boundary / boundary+1 for both formats) without +/// constructing a full `AppHandle`/`AppState`. +pub(crate) fn validate_snapshot_encode_size(bytes_len: usize, is_png: bool) -> Result<(), String> { + if is_png { + if bytes_len > import::MAX_SNAPSHOT_PNG_BYTES { + return Err(format!( + "Snapshot exceeds the {} MiB size limit for .agent.png files. \ + Reduce the avatar image size or use JSON format.", + import::MAX_SNAPSHOT_PNG_BYTES / (1024 * 1024) + )); + } + } else if bytes_len > import::MAX_SNAPSHOT_JSON_BYTES { + return Err(format!( + "Snapshot exceeds the {} MiB size limit for .agent.json files. \ + Reduce memory size or use a config-only snapshot.", + import::MAX_SNAPSHOT_JSON_BYTES / (1024 * 1024) + )); + } + Ok(()) +} + +/// Parse a `memory_level` string to `MemoryLevel`. +fn parse_memory_level(s: &str) -> Result { + match s { + "none" | "" => Ok(MemoryLevel::None), + "core" => Ok(MemoryLevel::Core), + "everything" => Ok(MemoryLevel::Everything), + other => Err(format!( + "Invalid memory_level: {other:?} (expected 'none', 'core', or 'everything')" + )), + } +} + +/// Parse a `format` string to a PNG flag. +fn parse_format_is_png(s: &str) -> Result { + match s { + "json" | "" => Ok(false), + "png" => Ok(true), + other => Err(format!( + "Invalid format: {other:?} (expected 'json' or 'png')" + )), + } +} + +/// Shared production encoding path. +/// +/// Resolves the agent definition, validates inputs, fetches optional memory, +/// builds the snapshot manifest, and encodes to the requested byte format. +/// Does **not** open any file dialog or write to disk — both the save-to-disk +/// and native-send commands call this and then apply their own I/O side effect. +/// +/// **Invariants preserved (identical for both callers):** +/// - JSON/PNG format selection from the parsed `is_png` flag (magic-byte sniffing is import-only) +/// - PNG + memory hard rejection +/// - Memory-source pubkey validation +/// - Secret exclusion (env_vars never enter the manifest via `build_snapshot`) +/// - Output filename derived from the agent display name +pub(crate) async fn materialize_snapshot_bytes( + id: String, + memory_source_pubkey: Option, + memory_level: MemoryLevel, + is_png: bool, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + // Eagerly reject PNG + memory — avoid an unnecessary relay round-trip. + if is_png && memory_level != MemoryLevel::None { + return Err( + "Cannot export memory to .agent.png — use JSON format for memory-bearing \ + snapshots." + .to_string(), + ); + } + + // ── Load definition record and memory-source instance under lock ───────── + let (record, memory_pubkey) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let instances = load_managed_agents(&app)?; + let definitions = load_agent_definitions(&app)?; + let (def_record, is_definition) = resolve_from_lists(&id, &instances, &definitions) + .map(|(r, is_def)| (r.clone(), is_def))?; + + let memory_pubkey = if memory_level != MemoryLevel::None { + let mpk = memory_source_pubkey.as_deref().unwrap_or(""); + let def_id = if is_definition { + def_record.slug.as_deref().unwrap_or("") + } else { + &def_record.pubkey + }; + Some(validate_memory_source( + mpk, + is_definition, + def_id, + &instances, + )?) + } else { + None + }; + + (def_record, memory_pubkey) + }; + + let display_name = record + .display_name + .clone() + .unwrap_or_else(|| record.name.clone()); + + // ── Resolve avatar bytes ───────────────────────────────────────────────── + // If the avatar_url is a data URL we decode it inline; otherwise we keep + // it as an external reference in the manifest (the importer will use it). + let avatar_bytes: Option> = record + .avatar_url + .as_deref() + .and_then(crate::managed_agents::agent_snapshot::decode_avatar_data_url); + + // ── Fetch memory ───────────────────────────────────────────────────────── + let memory_entries: Vec = if let Some(pubkey) = memory_pubkey { + let listing = get_agent_memory(pubkey, app.clone(), state).await?; + let mut entries = Vec::new(); + if let Some(core) = listing.core { + entries.push(AgentSnapshotMemoryEntry { + slug: core.slug, + body: core.body, + }); + } + if memory_level == MemoryLevel::Everything { + for mem in listing.memories { + entries.push(AgentSnapshotMemoryEntry { + slug: mem.slug, + body: mem.body, + }); + } + } + entries + } else { + Vec::new() + }; + + // ── Build manifest ─────────────────────────────────────────────────────── + let snapshot = build_snapshot( + &record, + memory_level, + memory_entries, + avatar_bytes.as_deref(), + ); + + // ── Encode ─────────────────────────────────────────────────────────────── + let slug = crate::util::slugify(&display_name, "agent", 50); + + if is_png { + let png_bytes = encode_snapshot_png(&snapshot, avatar_bytes.as_deref()) + .map_err(|e| format!("Failed to encode .agent.png: {e}"))?; + validate_snapshot_encode_size(png_bytes.len(), true)?; + Ok(SnapshotPayload { + bytes: png_bytes, + filename: format!("{slug}.agent.png"), + }) + } else { + let json_bytes = encode_snapshot_json(&snapshot) + .map_err(|e| format!("Failed to encode .agent.json: {e}"))?; + validate_snapshot_encode_size(json_bytes.len(), false)?; + Ok(SnapshotPayload { + bytes: json_bytes, + filename: format!("{slug}.agent.json"), + }) + } +} + +/// Export an agent definition as a `buzz-agent-snapshot v1` file. +/// +/// `id` is a definition slug or a keyed-instance pubkey. +/// `memory_source_pubkey` is required when `memory_level != "none"` — it must +/// be a keyed-instance pubkey whose `persona_id` matches `id` (validated +/// server-side so the UI cannot supply a mismatched pairing). +/// `memory_level` is one of `"none"`, `"core"`, or `"everything"`. +/// `format` is either `"json"` or `"png"`. +/// +/// The user picks the save path via the OS dialog. Returns `true` when the +/// file was written, `false` when the dialog was cancelled. +#[tauri::command] +pub async fn export_agent_snapshot( + id: String, + memory_source_pubkey: Option, + memory_level: String, + format: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let memory_level = parse_memory_level(&memory_level)?; + let is_png = parse_format_is_png(&format)?; + + let payload = materialize_snapshot_bytes( + id, + memory_source_pubkey, + memory_level, + is_png, + app.clone(), + state, + ) + .await?; + + if is_png { + save_bytes_with_dialog( + &app, + &payload.filename, + "PNG image", + &["png"], + &payload.bytes, + ) + .await + } else { + save_bytes_with_dialog( + &app, + &payload.filename, + "Agent snapshot", + &["json"], + &payload.bytes, + ) + .await + } +} + +/// Wire shape returned by `encode_agent_snapshot_for_send`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EncodedSnapshotPayload { + /// Raw snapshot bytes as a byte array. The frontend passes these directly + /// to `uploadMediaBytes` (which accepts `number[]`). + pub file_bytes: Vec, + /// Suggested filename, e.g. `my-agent.agent.json`. + pub file_name: String, +} + +/// Encode a `buzz-agent-snapshot v1` payload in memory and return the raw +/// bytes to the frontend for the native-send path. +/// +/// Performs identical resolution, validation, and encoding as +/// `export_agent_snapshot` (via the shared `materialize_snapshot_bytes` path) +/// but **never opens a file dialog**. The frontend passes the returned bytes +/// through `uploadMediaBytes` → message construction → channel/DM send. +#[tauri::command] +pub async fn encode_agent_snapshot_for_send( + id: String, + memory_source_pubkey: Option, + memory_level: String, + format: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let memory_level = parse_memory_level(&memory_level)?; + let is_png = parse_format_is_png(&format)?; + + let payload = + materialize_snapshot_bytes(id, memory_source_pubkey, memory_level, is_png, app, state) + .await?; + + Ok(EncodedSnapshotPayload { + file_bytes: payload.bytes, + file_name: payload.filename, + }) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs new file mode 100644 index 0000000000..264da04dfd --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -0,0 +1,686 @@ +//! Import-side helpers for `buzz-agent-snapshot v1`. +//! +//! Extracted from `snapshot.rs` to keep that file under the 1000-line gate. +//! The Tauri commands here (`preview_agent_snapshot_import`, +//! `confirm_agent_snapshot_import`) are re-exported from `snapshot.rs` and +//! registered in `lib.rs` through the same `personas::` path as the export +//! commands. + +use nostr::ToBech32; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; + +use crate::{ + app_state::AppState, + managed_agents::{ + agent_snapshot::{decode_snapshot_json, decode_snapshot_png, MemoryLevel}, + load_managed_agents, load_personas, save_managed_agents, save_personas, ManagedAgentRecord, + PersonaRecord, RespondTo, + }, + relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile}, + util::now_iso, +}; + +/// Maximum snapshot file size accepted before decode (5 MiB for JSON, +/// 10 MiB for PNG). Mirrors the established persona-import limits. +pub(crate) const MAX_SNAPSHOT_JSON_BYTES: usize = 5 * 1024 * 1024; +pub(crate) const MAX_SNAPSHOT_PNG_BYTES: usize = 10 * 1024 * 1024; + +// ── Import preview types ────────────────────────────────────────────────────── + +/// Materialized preview returned to the UI before any write is committed. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotImportPreview { + /// Agent display name from the snapshot. + pub display_name: String, + /// System prompt, if any. + pub system_prompt: Option, + /// Effective avatar: data URL if present, otherwise the source URL fallback. + /// The UI renders this as a single avatar source. + pub avatar_url: Option, + /// Memory level declared in the snapshot. + pub memory_level: String, + /// Number of memory entries bundled in the snapshot. + pub memory_entry_count: usize, + /// True when the snapshot's `respond_to_allowlist` is non-empty. These + /// pubkeys come from the source environment and are meaningless on the + /// importer's relay — the UI must offer Keep / Clear. + pub has_source_allowlist: bool, + /// Number of source allowlist entries. + pub source_allowlist_count: usize, +} + +/// The confirmation request sent from the UI after the user reviews the preview. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotImportConfirm { + /// Raw bytes of the snapshot file (.agent.json or .agent.png). + pub file_bytes: Vec, + /// Original file name — used for format sniffing. + pub file_name: String, + /// When true, copy source `respond_to_allowlist` to the new agent. + /// When false (the safe default), the allowlist is cleared. + pub keep_allowlist: bool, +} + +/// Structured result returned after a confirmed import. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotImportResult { + /// Display name of the newly created agent. + pub display_name: String, + /// Pubkey of the new agent (hex). + pub new_pubkey: String, + /// Persona id created for the agent. + pub persona_id: String, + /// Total memory entries successfully written to the relay. + pub memory_written: usize, + /// Total memory entries that were in the snapshot. + pub memory_total: usize, + /// Non-empty when one or more memory entries failed to publish. + /// The agent itself was created successfully — only memory is partial. + pub memory_errors: Vec, + /// Non-empty when profile sync encountered a non-fatal relay error. + pub profile_sync_error: Option, +} + +// ── Import helpers ───────────────────────────────────────────────────────── + +/// Resolve the behavioral defaults for an incoming agent snapshot. +/// +/// This is the single authoritative selection path for all import-time +/// allowlist and behavioral decisions. It is extracted as a pure, testable +/// function so that unit tests exercise the exact production logic rather +/// than a reconstruction of it. +/// +/// # UI contract +/// +/// The Keep/Clear toggle is shown whenever `has_source_allowlist` is true +/// (i.e. the raw allowlist is non-empty), regardless of the source mode. +/// The mode (`respond_to` wire string) and the list are independent axes. +/// +/// # Decision table +/// +/// | Source mode | Non-empty list | keep=true | keep=false | +/// |--------------|----------------|----------------------|-------------------------| +/// | allowlist | yes | preserve mode + list | owner-only + empty | +/// | allowlist | no | **Err** (reject) | **Err** (reject) | +/// | non-allowlist| yes | preserve mode + list | preserve mode + empty | +/// | non-allowlist| no | preserve mode | preserve mode | +/// +/// Allowlist-mode + empty list is always rejected: the UI showed no choice +/// and there is no coherent value to write. +/// +/// Non-allowlist + non-empty + Clear: preserve the source mode but empty the +/// list. Only allowlist-mode requires a mode downgrade on Clear, because +/// `allowlist` without entries is an invalid state. Non-allowlist modes +/// remain valid with an empty list. +pub(crate) fn resolve_snapshot_import_behavior( + raw_respond_to: Option<&str>, + raw_allowlist: &[String], + mcp_toolsets: Option, + parallelism: Option, + keep_allowlist: bool, +) -> Result { + use crate::managed_agents::{ + resolve_mint_behavioral_defaults, validate_respond_to_allowlist, RespondTo, + }; + + // Step 1: normalize allowlist; reject malformed pubkeys immediately. + let normalized_allowlist = validate_respond_to_allowlist(raw_allowlist)?; + + // Step 2: detect source mode and whether a list was present. + let source_mode: Option = match raw_respond_to { + Some(wire) => Some(RespondTo::parse_wire(wire)?), + None => None, + }; + let is_source_allowlist_mode = source_mode == Some(RespondTo::Allowlist); + let has_source_allowlist = !normalized_allowlist.is_empty(); + + // Step 3: hard-reject allowlist-mode + empty list before any key + // generation — no coherent value can be written either way. + if is_source_allowlist_mode && !has_source_allowlist { + return Err( + "snapshot respond-to mode is 'allowlist' but the allowlist is empty — \ + cannot import: no pubkeys to grant access to" + .to_string(), + ); + } + + // Step 4: apply Keep/Clear when the toggle was visible (list non-empty), + // or preserve the source mode when it was not. + let (resolved_mode, resolved_allowlist) = if has_source_allowlist { + if keep_allowlist { + // Keep: preserve source mode and validated list. + (source_mode, normalized_allowlist) + } else if is_source_allowlist_mode { + // Clear on allowlist-mode: must downgrade mode to owner-only because + // allowlist mode without entries is an invalid state. + (Some(RespondTo::OwnerOnly), Vec::new()) + } else { + // Clear on non-allowlist mode: preserve source mode, empty the list. + // Non-allowlist modes are valid without entries. + (source_mode, Vec::new()) + } + } else { + // No list present → toggle was never shown; preserve source mode as-is. + (source_mode, normalized_allowlist) + }; + + resolve_mint_behavioral_defaults( + resolved_mode, + resolved_allowlist, + mcp_toolsets, + parallelism, + None, // no definition record; all inputs are explicit from the snapshot + ) +} + +const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47]; + +/// Decode a `buzz-agent-snapshot v1` manifest from raw bytes. +/// +/// Sniffs by magic bytes (PNG signature) first, then falls back to JSON. +/// Fails closed on malformed content, wrong format, or unsupported version. +/// Never trusts the file extension — only the bytes. +/// +/// **PNG memory policy:** any PNG manifest whose `memory.level` is not `None`, +/// or whose `memory.entries` is non-empty despite `level == None`, is rejected +/// before any write. Plaintext memory is accepted only from `.agent.json`. +/// +/// **Size cap:** PNG inputs over 10 MiB and JSON inputs over 5 MiB are rejected +/// before allocation to avoid avoidable large-input work. +pub(crate) fn decode_snapshot_from_bytes( + file_bytes: &[u8], +) -> Result { + if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC { + if file_bytes.len() > MAX_SNAPSHOT_PNG_BYTES { + return Err(format!( + "Snapshot file is too large ({} MiB). PNG snapshots must be under 10 MiB.", + file_bytes.len() / (1024 * 1024) + )); + } + let snapshot = decode_snapshot_png(file_bytes)?; + // Hard reject: PNG must never carry memory. + if snapshot.memory.level != crate::managed_agents::agent_snapshot::MemoryLevel::None { + return Err( + "Cannot import a memory-bearing .agent.png — use .agent.json for \ + snapshots that include memory." + .to_string(), + ); + } + if !snapshot.memory.entries.is_empty() { + return Err( + "Snapshot is malformed: .agent.png carries memory entries despite \ + memory.level being 'none'." + .to_string(), + ); + } + return Ok(snapshot); + } + // JSON path — apply size cap before serde allocation. + if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES { + return Err(format!( + "Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.", + file_bytes.len() / (1024 * 1024) + )); + } + decode_snapshot_json(file_bytes) +} + +// ── `preview_agent_snapshot_import` ────────────────────────────────────────── + +/// Decode and validate a snapshot file, returning a preview for the +/// confirmation UI. No writes of any kind are performed. +/// +/// `file_bytes` is the raw binary content of the `.agent.json` or +/// `.agent.png` file. The format is sniffed from the content, not the +/// extension, so an incorrectly-named file is handled correctly. +/// +/// Returns an `AgentSnapshotImportPreview` or a descriptive error. Errors +/// represent irrecoverable failures (corrupt / unsupported file) and are +/// shown directly to the user. +#[tauri::command] +pub async fn preview_agent_snapshot_import( + file_bytes: Vec, + file_name: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let _ = file_name; // used for context in error messages only + let snapshot = decode_snapshot_from_bytes(&file_bytes)?; + + // Validate: none + non-empty entries is inconsistent. + if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { + return Err( + "Snapshot is malformed: memory.level is 'none' but entries are present." + .to_string(), + ); + } + + let memory_level = match snapshot.memory.level { + MemoryLevel::None => "none", + MemoryLevel::Core => "core", + MemoryLevel::Everything => "everything", + } + .to_string(); + + Ok(AgentSnapshotImportPreview { + display_name: snapshot.profile.display_name.clone(), + system_prompt: snapshot.definition.system_prompt.clone(), + // Effective avatar: data URL wins; URL fallback if no data URL. + avatar_url: snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()), + memory_level, + memory_entry_count: snapshot.memory.entries.len(), + source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), + has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), + }) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +// ── `confirm_agent_snapshot_import` ────────────────────────────────────────── + +/// Import a `buzz-agent-snapshot v1` file as a brand-new agent. +/// +/// Phase sequence: +/// 1. Validate — decode the manifest and reject early on any error. +/// 2. Mint — generate a new keypair + NIP-OA auth tag; create a +/// `PersonaRecord` + `ManagedAgentRecord` through the same primitives +/// used by the normal create flow. +/// 3. Publish — kind:30175 definition via retention path; kind:0 profile +/// via `sync_managed_agent_profile`. +/// 4. Memory — for each opted-in entry, build a fresh `kind:30174` event +/// with `engram::build_event` under the new agent↔owner conversation +/// key and POST it to the relay. Failures are collected and returned as +/// `memory_errors`; the agent itself is already created. +/// +/// Importing the same file twice yields two distinct agents with different +/// keypairs. No source identity material (pubkey, nsec, auth_tag, relay_url, +/// env_vars, backend, lineage) is consumed. +#[tauri::command] +pub async fn confirm_agent_snapshot_import( + input: AgentSnapshotImportConfirm, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + // ── Phase 1: validate (no I/O) ─────────────────────────────────────────── + let snapshot = decode_snapshot_from_bytes(&input.file_bytes)?; + + if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { + return Err( + "Snapshot is malformed: memory.level is 'none' but entries are present.".to_string(), + ); + } + + let display_name = snapshot.profile.display_name.trim().to_string(); + if display_name.is_empty() { + return Err("Snapshot display name is empty.".to_string()); + } + + // ── Resolve behavioral defaults ────────────────────────────────────────── + let minted = resolve_snapshot_import_behavior( + snapshot.definition.respond_to.as_deref(), + &snapshot.definition.respond_to_allowlist, + snapshot.definition.mcp_toolsets.clone(), + snapshot.definition.parallelism, + input.keep_allowlist, + )?; + let minted_parallelism = minted.parallelism; + + // Effective avatar: data URL wins; URL fallback when data URL is absent. + let effective_avatar: Option = snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()); + + // Wire-format string for the persona definition's respond_to field. + // Omit when it is the default (owner-only) to keep definitions clean. + let respond_to_wire: Option = if minted.respond_to != RespondTo::default() { + Some(minted.respond_to.as_str().to_string()) + } else { + None + }; + + // ── Phase 2: mint keys + auth tag (sync, outside lock) ─────────────────── + let (agent_keys, private_key_nsec, pubkey, auth_tag, owner_pubkey_hex) = { + let owner_keys = state.signing_keys()?; + let agent_keys = nostr::Keys::generate(); + let pubkey = agent_keys.public_key().to_hex(); + let private_key_nsec = agent_keys + .secret_key() + .to_bech32() + .map_err(|e| format!("failed to encode agent private key: {e}"))?; + + // NIP-OA auth tag: bridge nostr 0.37 → 0.36 (buzz-sdk) via hex round-trip. + let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) + .map_err(|e| format!("failed to bridge owner keys: {e}"))?; + let compat_agent = nostr::PublicKey::from_hex(&pubkey) + .map_err(|e| format!("failed to bridge agent pubkey: {e}"))?; + let auth_tag = Some( + buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "") + .map_err(|e| format!("failed to compute NIP-OA auth tag: {e}"))?, + ); + let owner_pubkey_hex = owner_keys.public_key().to_hex(); + ( + agent_keys, + private_key_nsec, + pubkey, + auth_tag, + owner_pubkey_hex, + ) + }; + + // ── Phase 3a: create PersonaRecord + ManagedAgentRecord (sync lock) ────── + let (persona, record) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let mut personas = load_personas(&app)?; + let mut records = load_managed_agents(&app)?; + + // Guard against duplicate pubkey (astronomically unlikely but safe). + if records.iter().any(|r| r.pubkey == pubkey) { + return Err(format!("generated pubkey {pubkey} already exists — retry")); + } + + let now = now_iso(); + let persona_id = uuid::Uuid::new_v4().to_string(); + + // Build persona from snapshot definition. + let persona = PersonaRecord { + id: persona_id.clone(), + display_name: display_name.clone(), + avatar_url: effective_avatar.clone(), + system_prompt: snapshot + .definition + .system_prompt + .clone() + .unwrap_or_default(), + runtime: snapshot.definition.runtime.clone(), + model: snapshot.definition.model.clone(), + provider: snapshot.definition.provider.clone(), + name_pool: snapshot.definition.name_pool.clone(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: std::collections::BTreeMap::new(), + respond_to: respond_to_wire.clone(), + respond_to_allowlist: minted.respond_to_allowlist.clone(), + mcp_toolsets: minted.mcp_toolsets.clone(), + parallelism: minted_parallelism, + created_at: now.clone(), + updated_at: now.clone(), + }; + + personas.push(persona.clone()); + save_personas(&app, &personas)?; + + // Enqueue the kind:30175 persona event via the retention path. + super::super::pending::retain_persona_pending(&app, &state, &persona); + + // Build the managed agent record — no machine-local commands, no + // secrets, no lineage from the snapshot. + let record = ManagedAgentRecord { + pubkey: pubkey.clone(), + name: display_name.clone(), + display_name: None, + slug: None, + persona_id: Some(persona_id.clone()), + private_key_nsec: private_key_nsec.clone(), + auth_tag: auth_tag.clone(), + relay_url: String::new(), // resolves to workspace relay at runtime + avatar_url: effective_avatar.clone(), + // Machine-local commands: derive from the runtime catalog at + // spawn time — never manufacture from snapshot data. + acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: snapshot.definition.idle_timeout_seconds, + max_turn_duration_seconds: snapshot.definition.max_turn_duration_seconds, + parallelism: minted_parallelism + .unwrap_or(crate::managed_agents::DEFAULT_AGENT_PARALLELISM), + system_prompt: snapshot.definition.system_prompt.clone(), + model: snapshot.definition.model.clone(), + provider: snapshot.definition.provider.clone(), + persona_source_version: None, + mcp_toolsets: minted.mcp_toolsets.clone(), + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: crate::managed_agents::BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: now.clone(), + updated_at: now.clone(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + // Instance-level behavioral defaults agree with the resolved + // definition: both come from the single minted struct so they + // are always consistent at mint time. + respond_to: minted.respond_to, + respond_to_allowlist: minted.respond_to_allowlist.clone(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: respond_to_wire.clone(), + definition_respond_to_allowlist: minted.respond_to_allowlist.clone(), + definition_mcp_toolsets: minted.mcp_toolsets.clone(), + definition_parallelism: minted_parallelism, + relay_mesh: None, + runtime: snapshot.definition.runtime.clone(), + name_pool: snapshot.definition.name_pool.clone(), + }; + + records.push(record.clone()); + save_managed_agents(&app, &records)?; + + // Enqueue the kind:30177 managed-agent event via retention. + // (Uses the same pattern as agents.rs::retain_managed_agent_pending + // inlined here to avoid cross-module private-fn access.) + retain_agent_pending(&app, &state, &record); + + crate::managed_agents::try_regenerate_nest(&app); + + // Notify other mounted clients of local persona+managed-agent writes, + // matching the contract used by other local managed-agent mutations. + let _ = app.emit("agents-data-changed", ()); + + (persona, record) + }; + + // ── Phase 3b: publish kind:0 profile (async, outside lock) ─────────────── + let relay_url = + effective_agent_relay_url(&record.relay_url, &relay_ws_url_with_override(&state)); + let profile_sync_error = sync_managed_agent_profile( + &state, + &relay_url, + &agent_keys, + &display_name, + effective_avatar.as_deref(), + auth_tag.as_deref(), + ) + .await + .err(); + + // ── Phase 4: restore memory (async, outside lock) ───────────────────────── + let memory_total = snapshot.memory.entries.len(); + let mut memory_written = 0usize; + let mut memory_errors: Vec = Vec::new(); + + if memory_total > 0 { + let owner_pubkey = nostr::PublicKey::from_hex(&owner_pubkey_hex) + .map_err(|e| format!("failed to parse owner pubkey: {e}"))?; + + // Monotonic timestamp seed: use current time, bumped by 1 per entry + // so no two events land at the same second. + let base_ts = nostr::Timestamp::now().as_secs(); + + for (idx, entry) in snapshot.memory.entries.iter().enumerate() { + let body = if entry.slug == buzz_core_pkg::engram::CORE_SLUG { + buzz_core_pkg::engram::Body::Core { + profile: entry.body.clone(), + } + } else { + buzz_core_pkg::engram::Body::Memory { + slug: entry.slug.clone(), + value: Some(entry.body.clone()), + } + }; + + let created_at = base_ts + idx as u64; + match buzz_core_pkg::engram::build_event(&agent_keys, &owner_pubkey, &body, created_at) + { + Ok(event) => { + let event_json = nostr::JsonUtil::as_json(&event).into_bytes(); + let url = format!("{}/events", crate::relay::relay_http_base_url(&relay_url)); + match submit_engram_event( + &state, + &agent_keys, + &event_json, + &url, + auth_tag.as_deref(), + ) + .await + { + Ok(()) => memory_written += 1, + Err(e) => memory_errors.push(format!("slug {:?}: {e}", entry.slug)), + } + } + Err(e) => { + memory_errors.push(format!("slug {:?}: build failed: {e}", entry.slug)); + } + } + } + } + + Ok(AgentSnapshotImportResult { + display_name, + new_pubkey: pubkey, + persona_id: persona.id, + memory_written, + memory_total, + memory_errors, + profile_sync_error, + }) +} + +/// Inline retention for the managed-agent kind:30177 event — mirrors +/// `agents::retain_managed_agent_pending` without requiring cross-module +/// private function access. +fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { + use crate::managed_agents::{ + agent_events::{agent_event_content, build_agent_event}, + managed_agents_base_dir, + persona_events::monotonic_created_at, + retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + }; + use buzz_core_pkg::kind::KIND_MANAGED_AGENT; + use nostr::JsonUtil; + + let result = (|| -> Result<(), String> { + let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let content = serde_json::to_string(&agent_event_content(record)) + .map_err(|e| format!("failed to serialize agent content: {e}"))?; + let (owner_pubkey, event) = { + let keys = state.signing_keys()?; + let owner_pubkey = keys.public_key().to_hex(); + let existing = + get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; + if existing.as_ref().is_some_and(|row| row.content == content) { + return Ok(()); + } + let event = build_agent_event(record)? + .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) + .sign_with_keys(&keys) + .map_err(|e| format!("failed to sign agent event: {e}"))?; + (owner_pubkey, event) + }; + retain_event( + &conn, + &RetainedEvent { + kind: KIND_MANAGED_AGENT, + pubkey: owner_pubkey, + d_tag: record.pubkey.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: snapshot-import retain-agent: {e}"); + } +} + +/// POST a pre-built signed engram event to the relay, authenticating as the +/// new agent. +async fn submit_engram_event( + state: &AppState, + agent_keys: &nostr::Keys, + event_json: &[u8], + url: &str, + auth_tag: Option<&str>, +) -> Result<(), String> { + use crate::relay::build_nip98_auth_header_for_keys; + use reqwest::Method; + + let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?; + let mut request = state + .http_client + .post(url) + .header("Authorization", auth) + .header("Content-Type", "application/json"); + if let Some(tag) = auth_tag { + request = request.header("x-auth-tag", tag); + } + let response = request + .body(event_json.to_vec()) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + + if !response.status().is_success() { + let msg = crate::relay::relay_error_message(response).await; + return Err(format!("relay rejected engram: {msg}")); + } + + let body = response + .text() + .await + .map_err(|e| format!("failed to read relay response: {e}"))?; + let parsed: serde_json::Value = + serde_json::from_str(&body).map_err(|e| format!("relay response not JSON: {e}"))?; + let accepted = parsed + .get("accepted") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !accepted { + let message = parsed + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + return Err(format!("relay rejected engram: {message}")); + } + Ok(()) +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs new file mode 100644 index 0000000000..60aca58225 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -0,0 +1,966 @@ +use super::*; +use crate::managed_agents::{ + agent_snapshot::{ + AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, AgentSnapshotMemoryEntry, + AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }, + BackendKind, ManagedAgentRecord, RespondTo, +}; +use std::collections::BTreeMap; + +// ── Shared fixtures ─────────────────────────────────────────────────────── + +/// Build a minimal keyless definition record (matched by slug, no keypair). +/// This is the shape stored in the definitions file — no pubkey, no +/// persona_id. +fn make_definition(slug: &str) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + slug: Some(slug.to_string()), + name: slug.to_string(), + display_name: None, + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + mcp_toolsets: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: false, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: false, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_mcp_toolsets: None, + definition_parallelism: None, + relay_mesh: None, + } +} + +/// Build a minimal keyed instance. Real instances minted by `create_persona` +/// have `slug: None` and link to their definition via `persona_id`. +fn make_instance(pubkey: &str, persona_id: &str) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: pubkey.to_string(), + slug: None, + persona_id: Some(persona_id.to_string()), + ..make_definition("") + } +} + +/// Build a minimal valid AgentSnapshot for import tests. +fn make_snapshot( + memory_level: MemoryLevel, + entries: Vec, +) -> AgentSnapshot { + AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "Test Agent".to_string(), + system_prompt: Some("You are helpful.".to_string()), + runtime: None, + model: None, + provider: None, + mcp_toolsets: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test Agent".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: memory_level, + entries, + }, + } +} + +// ── Joint happy path ────────────────────────────────────────────────────── +// +// Production record shape: a keyless definition (slug = "my-agent") and +// a keyed instance (slug = None, pubkey = "instance-pk", persona_id = +// "my-agent") live in separate stores. +// +// This one test exercises the full resolver → validator composition: +// 1. Resolving "my-agent" finds the *definition* (the instance has no +// slug so the instance search misses it). +// 2. The linked instance pubkey validates as the memory source. + +#[test] +fn definition_slug_resolves_to_definition_and_linked_instance_is_valid_memory_source() { + let def = make_definition("my-agent"); + let inst = make_instance("instance-pk", "my-agent"); + + let defs = vec![def]; + let instances = vec![inst]; + + // Step 1 — resolution: slug finds the definition, not the instance. + let (record, is_def) = resolve_from_lists("my-agent", &instances, &defs).unwrap(); + assert!( + is_def, + "slug 'my-agent' must resolve to the definition, not the instance" + ); + assert_eq!(record.slug.as_deref(), Some("my-agent")); + + // Step 2 — memory source validation: instance-pk is persona_id-linked. + let def_slug = record.slug.as_deref().unwrap_or(""); + let result = validate_memory_source("instance-pk", is_def, def_slug, &instances); + assert_eq!( + result.unwrap(), + "instance-pk", + "linked keyed instance must be accepted as the memory source" + ); +} + +// ── Resolver edge cases ─────────────────────────────────────────────────── + +#[test] +fn resolve_by_pubkey_finds_keyed_instance() { + let inst = make_instance("pubkey-xyz", "my-agent"); + let instances = vec![inst]; + let (record, is_def) = resolve_from_lists("pubkey-xyz", &instances, &[]).unwrap(); + assert!(!is_def); + assert_eq!(record.pubkey, "pubkey-xyz"); +} + +#[test] +fn resolve_unknown_id_returns_error() { + let result = resolve_from_lists("ghost", &[], &[]); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("ghost")); +} + +// ── Validator fail-closed cases ─────────────────────────────────────────── + +#[test] +fn memory_export_without_pubkey_fails() { + let result = validate_memory_source("", true, "my-agent", &[]); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("memory_source_pubkey is required"), + "empty pubkey must be rejected with a clear message" + ); +} + +#[test] +fn definition_export_with_instance_linked_to_other_definition_fails() { + // Instance persona_id points to "other-agent", not "my-agent". + let inst = make_instance("instance-pk", "other-agent"); + let instances = vec![inst]; + let result = validate_memory_source("instance-pk", true, "my-agent", &instances); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("is not linked to definition"), + "mismatched persona_id must fail closed" + ); +} + +#[test] +fn direct_instance_export_with_nonmatching_memory_pubkey_fails() { + // Cross-agent memory pairing: memory pubkey differs from instance pubkey. + let result = validate_memory_source("other-agent-pk", false, "agent-pk", &[]); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("does not match agent"), + "cross-agent memory pairing must fail closed" + ); +} + +// ── Import: decode_snapshot_from_bytes ──────────────────────────────────── + +/// JSON bytes sniff as JSON → round-trip through the same preview path. +#[test] +fn import_sniff_json_bytes_decodes_correctly() { + use crate::managed_agents::agent_snapshot::encode_snapshot_json; + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + // Must NOT start with PNG magic — confirm it's plain JSON. + assert_ne!(&bytes[..4], &[0x89, 0x50, 0x4e, 0x47]); + let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); + assert_eq!(decoded, snapshot); +} + +/// PNG bytes sniff as PNG → decoded via the PNG path. +#[test] +fn import_sniff_png_bytes_decodes_correctly() { + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + assert_eq!(&png_bytes[..4], &[0x89, 0x50, 0x4e, 0x47]); + let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap(); + assert_eq!(decoded, snapshot); +} + +/// Corrupt/random bytes fail before any writes. +#[test] +fn import_corrupt_bytes_fail_closed() { + let result = decode_snapshot_from_bytes(b"not a valid snapshot at all"); + assert!(result.is_err(), "corrupt bytes must fail closed"); +} + +/// Unsupported format string fails closed. +#[test] +fn import_wrong_format_string_fails_closed() { + use crate::managed_agents::agent_snapshot::encode_snapshot_json; + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.format = "not-buzz-agent-snapshot".to_string(); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let result = decode_snapshot_from_bytes(&bytes); + assert!(result.is_err(), "wrong format must fail closed"); + assert!( + result.unwrap_err().contains("Unsupported snapshot format"), + "error must describe the format problem" + ); +} + +/// Unsupported version fails closed. +#[test] +fn import_unsupported_version_fails_closed() { + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.version = 99; + // validate_snapshot inside decode_snapshot_json rejects version != 1; + // temporarily bypass it by serializing raw and patching the JSON. + let json = serde_json::to_string(&snapshot).unwrap(); + let result = decode_snapshot_from_bytes(json.as_bytes()); + assert!(result.is_err(), "unsupported version must fail closed"); + assert!( + result.unwrap_err().contains("Unsupported snapshot version"), + "error must describe the version problem" + ); +} + +/// Completely empty input fails closed. +#[test] +fn import_empty_bytes_fail_closed() { + let result = decode_snapshot_from_bytes(b""); + assert!(result.is_err(), "empty bytes must fail closed"); +} + +/// JSON snapshot over 5 MiB is rejected before decode allocation. +#[test] +fn import_json_over_size_cap_is_rejected() { + // Construct a byte slice that looks like JSON (no PNG magic) and exceeds + // MAX_SNAPSHOT_JSON_BYTES (5 MiB). Content doesn't need to be valid JSON + // because the size check fires before serde. + let oversized = vec![b'{'; super::MAX_SNAPSHOT_JSON_BYTES + 1]; + let result = decode_snapshot_from_bytes(&oversized); + assert!(result.is_err(), "oversized JSON must be rejected"); + assert!( + result.unwrap_err().contains("too large"), + "error must mention size" + ); +} + +/// PNG snapshot over 10 MiB is rejected before decode allocation. +#[test] +fn import_png_over_size_cap_is_rejected() { + // Start with the PNG magic, then pad to exceed MAX_SNAPSHOT_PNG_BYTES. + let mut oversized = vec![0u8; super::MAX_SNAPSHOT_PNG_BYTES + 1]; + oversized[0] = 0x89; + oversized[1] = 0x50; // P + oversized[2] = 0x4e; // N + oversized[3] = 0x47; // G + let result = decode_snapshot_from_bytes(&oversized); + assert!(result.is_err(), "oversized PNG must be rejected"); + assert!( + result.unwrap_err().contains("too large"), + "error must mention size" + ); +} + +/// Effective avatar resolution: data URL takes precedence over source URL. +#[test] +fn import_avatar_data_url_takes_precedence_over_url() { + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.profile.avatar_data_url = Some("data:image/png;base64,abc".to_string()); + snapshot.profile.avatar_url = Some("https://example.com/avatar.png".to_string()); + + // Simulate the effective_avatar resolution logic. + let effective = snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()); + assert_eq!( + effective.as_deref(), + Some("data:image/png;base64,abc"), + "data URL must win over source URL" + ); +} + +/// Effective avatar resolution: URL fallback is used when data URL is absent. +#[test] +fn import_avatar_url_fallback_is_used_when_no_data_url() { + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.profile.avatar_data_url = None; + snapshot.profile.avatar_url = Some("https://example.com/avatar.png".to_string()); + + let effective = snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()); + assert_eq!( + effective.as_deref(), + Some("https://example.com/avatar.png"), + "URL fallback must be used when data URL is absent" + ); +} + +// ── Import: PNG memory policy ───────────────────────────────────────────── + +/// PNG with memory level `core` is rejected before any write. +#[test] +fn import_png_with_core_memory_is_rejected() { + // encode_snapshot_png refuses to encode memory-bearing snapshots — + // that guard on the EXPORT side is correct. On the IMPORT side, + // decode_snapshot_from_bytes adds a matching guard so that a + // foreign-built PNG carrying a core/everything memory level is also + // rejected. We verify the guard condition here. + let level = MemoryLevel::Core; + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "Secret.".to_string(), + }]; + // Simulate what decode_snapshot_from_bytes checks after PNG decode: + let would_reject = level != MemoryLevel::None; + assert!( + would_reject, + "PNG with core memory level must be rejected by the import guard" + ); + // Also verify that the encoder itself refuses this on the export side. + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + let snapshot = make_snapshot(MemoryLevel::Core, entries); + assert!( + encode_snapshot_png(&snapshot, None).is_err(), + "encode_snapshot_png must also refuse memory-bearing snapshots" + ); +} + +/// PNG with memory level `everything` is rejected before any write. +#[test] +fn import_png_with_everything_memory_is_rejected() { + // Same as core: both the encoder guard and the decoder guard fire. + let level = MemoryLevel::Everything; + let would_reject = level != MemoryLevel::None; + assert!( + would_reject, + "PNG with everything memory level must be rejected by the import guard" + ); + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + let snapshot = make_snapshot( + MemoryLevel::Everything, + vec![AgentSnapshotMemoryEntry { + slug: "mem/research".to_string(), + body: "Notes.".to_string(), + }], + ); + assert!( + encode_snapshot_png(&snapshot, None).is_err(), + "encode_snapshot_png must also refuse memory-bearing snapshots" + ); +} + +/// PNG with `none` level but non-empty entries is rejected (malformed). +/// We test this by verifying the guard condition directly, since +/// encode_snapshot_png correctly refuses to produce such a PNG. +#[test] +fn import_png_with_none_level_and_entries_is_rejected() { + // This guard is enforced in decode_snapshot_from_bytes after the PNG + // path resolves. A PNG with none + entries cannot be produced by + // encode_snapshot_png (it already guards that), but could arrive from + // a foreign tool. We verify the guard condition matches the spec: + let level = MemoryLevel::None; + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "Leak.".to_string(), + }]; + // The guard in decode_snapshot_from_bytes: + let would_reject = !entries.is_empty() && level == MemoryLevel::None; + assert!( + would_reject, + "none level + non-empty entries must trigger the guard" + ); +} + +/// PNG with `none` level and no entries imports normally. +#[test] +fn import_png_with_none_level_and_no_entries_succeeds() { + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let result = decode_snapshot_from_bytes(&png_bytes); + assert!( + result.is_ok(), + "PNG with none level and no entries must succeed" + ); +} + +/// JSON with explicitly opted-in memory decodes successfully (memory is +/// allowed in JSON format — it is warned about in the preview UI). +#[test] +fn import_json_with_memory_decodes_and_warns() { + use crate::managed_agents::agent_snapshot::encode_snapshot_json; + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "I remember things.".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/notes".to_string(), + body: "Some notes.".to_string(), + }, + ]; + let snapshot = make_snapshot(MemoryLevel::Everything, entries); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); + // Memory survives — the preview UI will warn the user. + assert_eq!(decoded.memory.level, MemoryLevel::Everything); + assert_eq!(decoded.memory.entries.len(), 2); +} + +// ── Import: none + non-empty entries ───────────────────────────────────── + +/// memory.level == none with non-empty entries is rejected in the preview +/// command (the validation happens in the Tauri command body, not in +/// decode_snapshot_from_bytes, so we verify the guard logic inline here). +#[test] +fn import_none_level_with_entries_is_rejected() { + let snapshot = make_snapshot( + MemoryLevel::None, + vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "Some body".to_string(), + }], + ); + // This is the exact guard in preview_agent_snapshot_import and + // confirm_agent_snapshot_import. + let is_inconsistent = + snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty(); + assert!( + is_inconsistent, + "none level with non-empty entries must be flagged as inconsistent" + ); +} + +/// A well-formed snapshot with memory level != none and non-empty entries +/// is accepted. +#[test] +fn import_memory_bearing_snapshot_is_accepted() { + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "I am a test agent.".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/research".to_string(), + body: "Some research.".to_string(), + }, + ]; + let snapshot = make_snapshot(MemoryLevel::Everything, entries); + let is_inconsistent = + snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty(); + assert!( + !is_inconsistent, + "well-formed memory-bearing snapshot must not be flagged inconsistent" + ); + assert_eq!(snapshot.memory.entries.len(), 2); +} + +// ── Import: allowlist surfacing + enforcement ───────────────────────────── + +/// A snapshot with non-empty respond_to_allowlist sets has_source_allowlist. +#[test] +fn import_preview_flags_non_empty_source_allowlist() { + use crate::managed_agents::agent_snapshot::encode_snapshot_json; + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.definition.respond_to_allowlist = vec!["aabbcc".repeat(11)[..64].to_string()]; // 64 hex chars + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); + assert!( + !decoded.definition.respond_to_allowlist.is_empty(), + "source allowlist must survive encode/decode" + ); + // Simulate preview logic: + let has_source_allowlist = !decoded.definition.respond_to_allowlist.is_empty(); + assert!( + has_source_allowlist, + "preview must flag non-empty source allowlist" + ); +} + +// ── Import: resolve_snapshot_import_behavior — the production selection path +// +// All tests below call `resolve_snapshot_import_behavior` directly. This is +// the same function invoked by `confirm_agent_snapshot_import`, so the tests +// exercise the exact production code path, not a reconstruction of it. + +/// Uppercase hex pubkeys are lowercased and duplicates are removed before the +/// resolved allowlist is persisted. +#[test] +fn import_allowlist_uppercase_is_normalized_and_deduplicated() { + let upper = "AABBCC".repeat(11)[..64].to_string(); + let lower = upper.to_ascii_lowercase(); + // allowlist-mode source + keep=true: normalization applies. + let minted = resolve_snapshot_import_behavior( + Some("allowlist"), + &[upper.clone(), upper.clone()], // uppercase + duplicate + None, + None, + true, // keep + ) + .unwrap(); + assert_eq!( + minted.respond_to_allowlist, + vec![lower], + "uppercase must be lowercased, duplicate removed" + ); +} + +/// A malformed pubkey in the raw allowlist is rejected before any key +/// generation or write. +#[test] +fn import_allowlist_malformed_pubkey_is_rejected() { + let bad = "notahexpubkey".to_string(); + let err = + resolve_snapshot_import_behavior(Some("allowlist"), &[bad], None, None, true).unwrap_err(); + assert!( + err.contains("invalid pubkey"), + "malformed pubkey must be rejected before key generation: {err}" + ); +} + +/// keep_allowlist = false on a non-allowlist source (anyone) with an empty +/// list preserves the source mode — no list was present so the toggle was +/// never shown. +#[test] +fn import_non_allowlist_mode_preserved_when_keep_false() { + use crate::managed_agents::RespondTo; + let minted = resolve_snapshot_import_behavior( + Some("anyone"), + &[], // no allowlist — toggle never shown + None, + None, + false, + ) + .unwrap(); + assert_eq!( + minted.respond_to, + RespondTo::Anyone, + "anyone mode must be preserved when list is empty (toggle not shown)" + ); + assert!( + minted.respond_to_allowlist.is_empty(), + "anyone mode must have no allowlist" + ); +} + +/// Non-allowlist mode with a non-empty list and keep=true: preserve mode + list. +/// The toggle WAS shown (list is non-empty) so keep_allowlist applies. +#[test] +fn import_non_allowlist_mode_with_nonempty_list_keep_preserves_mode_and_list() { + use crate::managed_agents::RespondTo; + let raw = "aabbcc".repeat(11)[..64].to_string(); + let minted = resolve_snapshot_import_behavior( + Some("anyone"), + &[raw.clone()], + None, + None, + true, // keep + ) + .unwrap(); + assert_eq!( + minted.respond_to, + RespondTo::Anyone, + "anyone mode must be preserved on keep=true" + ); + assert_eq!( + minted.respond_to_allowlist, + vec![raw], + "list must be preserved on keep=true" + ); +} + +/// Non-allowlist mode with a non-empty list and keep=false: Clear preserves +/// the source mode but empties the list. Non-allowlist modes are valid +/// without entries — no mode downgrade occurs. +#[test] +fn import_non_allowlist_mode_with_nonempty_list_clear_preserves_mode_empties_list() { + use crate::managed_agents::RespondTo; + let raw = "aabbcc".repeat(11)[..64].to_string(); + let minted = resolve_snapshot_import_behavior( + Some("anyone"), + &[raw], + None, + None, + false, // clear + ) + .unwrap(); + assert_eq!( + minted.respond_to, + RespondTo::Anyone, + "non-allowlist mode must be preserved on clear (mode is valid without entries)" + ); + assert!( + minted.respond_to_allowlist.is_empty(), + "cleared list must be empty" + ); +} + +/// keep_allowlist = true with a valid non-empty allowlist-mode snapshot +/// preserves the mode and the full allowlist. +#[test] +fn import_allowlist_keep_with_valid_list_succeeds() { + use crate::managed_agents::RespondTo; + let raw = "aabbcc".repeat(11)[..64].to_string(); + let minted = resolve_snapshot_import_behavior( + Some("allowlist"), + &[raw.clone()], + None, + None, + true, // keep + ) + .unwrap(); + assert_eq!(minted.respond_to, RespondTo::Allowlist); + assert_eq!(minted.respond_to_allowlist, vec![raw]); +} + +/// keep_allowlist = false on an allowlist-mode source with a non-empty list +/// downgrades to owner-only and clears the allowlist. +#[test] +fn import_allowlist_clear_downgrades_to_owner_only() { + use crate::managed_agents::RespondTo; + let raw = "aabbcc".repeat(11)[..64].to_string(); + let minted = resolve_snapshot_import_behavior( + Some("allowlist"), + &[raw], + None, + None, + false, // clear + ) + .unwrap(); + assert_eq!( + minted.respond_to, + RespondTo::OwnerOnly, + "clear on allowlist-mode must yield owner-only" + ); + assert!( + minted.respond_to_allowlist.is_empty(), + "cleared allowlist must be empty" + ); +} + +/// Allowlist-mode snapshot with an empty list and keep=false (the default +/// when no UI choice was shown) is rejected before any key generation. +/// This is the primary defect path that the prior implementation missed. +#[test] +fn import_empty_allowlist_mode_rejected_with_keep_false() { + let err = resolve_snapshot_import_behavior( + Some("allowlist"), + &[], // empty — no entries to keep or clear + None, + None, + false, // keep=false is the default: UI never showed a choice + ) + .unwrap_err(); + assert!( + err.contains("allowlist is empty"), + "empty allowlist-mode + keep=false must be rejected before key generation: {err}" + ); +} + +/// Allowlist-mode snapshot with an empty list and keep=true is also rejected. +/// The user explicitly said Keep, but there is nothing to keep. +#[test] +fn import_empty_allowlist_mode_rejected_with_keep_true() { + let err = resolve_snapshot_import_behavior( + Some("allowlist"), + &[], // empty + None, + None, + true, // keep=true: user said Keep, but the list is empty + ) + .unwrap_err(); + assert!( + err.contains("allowlist is empty"), + "empty allowlist-mode + keep=true must be rejected before key generation: {err}" + ); +} + +/// Out-of-range parallelism (0 and 33) is rejected. +#[test] +fn import_out_of_range_parallelism_is_rejected() { + for bad_par in [0u32, 33u32] { + let err = resolve_snapshot_import_behavior( + None, // no respond_to (defaults to owner-only) + &[], + None, + Some(bad_par), + false, + ) + .unwrap_err(); + assert!( + err.contains("out of range"), + "parallelism {bad_par} must be rejected: {err}" + ); + } +} + +// ── Import: identity-never-travels ─────────────────────────────────────── + +/// Importing the same snapshot bytes twice must yield two distinct pubkeys. +/// This verifies Keys::generate() is called fresh each import (not mocked +/// in unit tests, but the property is that two calls never collide). +#[test] +fn import_twice_produces_distinct_pubkeys() { + let key1 = nostr::Keys::generate(); + let key2 = nostr::Keys::generate(); + assert_ne!( + key1.public_key().to_hex(), + key2.public_key().to_hex(), + "two fresh keypairs must never share a pubkey" + ); +} + +/// No source identity or machine-local field from the snapshot ends up in +/// the new agent record. Verified by checking that: +/// - the new pubkey is not a string present in the snapshot JSON +/// - private_key_nsec is not in the snapshot JSON +/// - relay_url, env_vars, auth_tag are not carried from the snapshot +#[test] +fn import_source_identity_fields_never_consumed() { + use crate::managed_agents::agent_snapshot::encode_snapshot_json; + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + // Inject a fake source pubkey into the definition name to simulate a + // snapshot that carries identity-looking data. + snapshot.definition.name = "agent-from-source".to_string(); + snapshot.profile.display_name = "agent-from-source".to_string(); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let json_str = std::str::from_utf8(&bytes).unwrap(); + + // The snapshot must NOT contain any nsec, auth_tag, or relay_url data. + assert!( + !json_str.contains("nsec"), + "snapshot must not contain private key material" + ); + assert!( + !json_str.contains("auth_tag"), + "snapshot must not contain NIP-OA auth tag" + ); + assert!( + !json_str.contains("relay_url"), + "snapshot must not contain relay URL" + ); + assert!( + !json_str.contains("env_vars"), + "snapshot must not contain env vars" + ); + assert!( + !json_str.contains("acp_command"), + "snapshot must not contain machine-local harness command" + ); +} + +// ── Import: memory slug semantics ───────────────────────────────────────── + +/// The "core" slug maps to Body::Core; all other slugs map to Body::Memory. +/// Verified by the sentinel slug value. +#[test] +fn import_core_slug_maps_to_core_body() { + let slug = buzz_core_pkg::engram::CORE_SLUG; + assert_eq!(slug, "core", "CORE_SLUG must equal 'core'"); + // core slug → Body::Core; anything else → Body::Memory + let is_core = slug == buzz_core_pkg::engram::CORE_SLUG; + assert!(is_core, "slug 'core' must map to Body::Core"); + let mem_slug = "mem/research"; + let is_mem = mem_slug != buzz_core_pkg::engram::CORE_SLUG; + assert!(is_mem, "slug 'mem/*' must map to Body::Memory"); +} + +// ── Import: partial-failure boundary ───────────────────────────────────── + +/// AgentSnapshotImportResult correctly represents a partial memory failure: +/// memory_written < memory_total with non-empty memory_errors, and the +/// agent itself is created (new_pubkey is set). +#[test] +fn import_partial_memory_failure_result_is_structured() { + let result = AgentSnapshotImportResult { + display_name: "Test Agent".to_string(), + new_pubkey: "abc123".to_string(), + persona_id: "persona-uuid".to_string(), + memory_written: 1, + memory_total: 3, + memory_errors: vec![ + "slug \"mem/foo\": relay rejected engram: timeout".to_string(), + "slug \"mem/bar\": relay rejected engram: timeout".to_string(), + ], + profile_sync_error: None, + }; + // The agent was created: + assert!(!result.new_pubkey.is_empty(), "new_pubkey must be set"); + // Memory is partial — not full success: + assert_ne!( + result.memory_written, result.memory_total, + "partial write must not equal total" + ); + assert!( + !result.memory_errors.is_empty(), + "partial result must carry error descriptions" + ); + // Not double-counted: errors.len() == total - written + assert_eq!( + result.memory_errors.len(), + result.memory_total - result.memory_written, + "error count must match unwritten entries" + ); +} + +/// Full success: memory_written == memory_total, memory_errors empty. +#[test] +fn import_full_memory_success_result_is_structured() { + let result = AgentSnapshotImportResult { + display_name: "Test Agent".to_string(), + new_pubkey: "abc123".to_string(), + persona_id: "persona-uuid".to_string(), + memory_written: 2, + memory_total: 2, + memory_errors: vec![], + profile_sync_error: None, + }; + assert_eq!( + result.memory_written, result.memory_total, + "full success: written must equal total" + ); + assert!(result.memory_errors.is_empty(), "full success: no errors"); +} + +// ── parse_memory_level / parse_format_is_png ───────────────────────────── + +#[test] +fn test_parse_memory_level_valid_values() { + assert_eq!(parse_memory_level("none").unwrap(), MemoryLevel::None); + assert_eq!(parse_memory_level("").unwrap(), MemoryLevel::None); + assert_eq!(parse_memory_level("core").unwrap(), MemoryLevel::Core); + assert_eq!( + parse_memory_level("everything").unwrap(), + MemoryLevel::Everything + ); +} + +#[test] +fn test_parse_memory_level_invalid_returns_error() { + let err = parse_memory_level("all").unwrap_err(); + assert!(err.contains("Invalid memory_level"), "got: {err}"); + let err2 = parse_memory_level("Core").unwrap_err(); // case-sensitive + assert!(err2.contains("Invalid memory_level"), "got: {err2}"); +} + +#[test] +fn test_parse_format_is_png_valid_values() { + assert!(!parse_format_is_png("json").unwrap()); + assert!(!parse_format_is_png("").unwrap()); + assert!(parse_format_is_png("png").unwrap()); +} + +#[test] +fn test_parse_format_is_png_invalid_returns_error() { + let err = parse_format_is_png("gif").unwrap_err(); + assert!(err.contains("Invalid format"), "got: {err}"); +} + +// ── Export: validate_snapshot_encode_size ──────────────────────────────────── +// +// Tests call `validate_snapshot_encode_size` directly so they prove the exact +// production guard — not a manual reconstruction. Removing or reversing the +// check in production code will cause these tests to fail. + +/// JSON: boundary-1 passes, boundary is the last legal byte count. +#[test] +fn validate_encode_size_json_at_boundary_minus_1_passes() { + assert!( + super::validate_snapshot_encode_size(super::MAX_SNAPSHOT_JSON_BYTES - 1, false).is_ok() + ); +} + +/// JSON: exactly at the boundary is the last accepted size. +#[test] +fn validate_encode_size_json_at_boundary_passes() { + assert!(super::validate_snapshot_encode_size(super::MAX_SNAPSHOT_JSON_BYTES, false).is_ok()); +} + +/// JSON: boundary+1 is rejected. +#[test] +fn validate_encode_size_json_over_boundary_is_rejected() { + let err = super::validate_snapshot_encode_size(super::MAX_SNAPSHOT_JSON_BYTES + 1, false) + .unwrap_err(); + assert!( + err.contains("size limit"), + "error must mention size limit, got: {err}" + ); +} + +/// PNG: boundary-1 passes. +#[test] +fn validate_encode_size_png_at_boundary_minus_1_passes() { + assert!(super::validate_snapshot_encode_size(super::MAX_SNAPSHOT_PNG_BYTES - 1, true).is_ok()); +} + +/// PNG: exactly at the boundary passes. +#[test] +fn validate_encode_size_png_at_boundary_passes() { + assert!(super::validate_snapshot_encode_size(super::MAX_SNAPSHOT_PNG_BYTES, true).is_ok()); +} + +/// PNG: boundary+1 is rejected. +#[test] +fn validate_encode_size_png_over_boundary_is_rejected() { + let err = + super::validate_snapshot_encode_size(super::MAX_SNAPSHOT_PNG_BYTES + 1, true).unwrap_err(); + assert!( + err.contains("size limit"), + "error must mention size limit, got: {err}" + ); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2c110377c8..667bb76202 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -628,6 +628,7 @@ pub fn run() { download_file, fetch_media_bytes, copy_image_to_clipboard, + fetch_snapshot_bytes, list_relay_members, get_my_relay_membership, add_relay_member, @@ -691,6 +692,10 @@ pub fn run() { parse_team_file, parse_persona_files, export_persona_to_json, + export_agent_snapshot, + preview_agent_snapshot_import, + confirm_agent_snapshot_import, + encode_agent_snapshot_for_send, get_channel_workflows, get_channels_workflows, get_workflow, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs new file mode 100644 index 0000000000..81fb740a46 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -0,0 +1,923 @@ +//! `buzz-agent-snapshot v1` — manifest type, encoder, and decoder stubs. +//! +//! An agent snapshot is a portable, shareable representation of an agent +//! definition. It captures: +//! - **definition** — behavioral config (prompt, runtime, model, …) +//! - **profile** — kind:0 presentation (name, about, avatar) +//! - **memory** — optional, owner-decrypted engrams at one of three levels +//! +//! Two encodings are supported: +//! - `.agent.json` — canonical, may carry memory at any level +//! - `.agent.png` — avatar image with manifest in a `buzz_agent_snapshot` +//! tEXt chunk; memory MUST be `None` (PNG files are casually shared and +//! would leak plaintext memory into chat uploads) +//! +//! **Zip is NOT in v1** — deferred to v2 for skills bundling. +//! +//! # Secret exclusion +//! +//! The following fields are NEVER serialized: +//! - `private_key_nsec` / any private key material +//! - `auth_tag` (NIP-OA) +//! - `env_vars` (API keys / credentials) +//! - `relay_url` (machine-local endpoint) +//! - `acp_command` / `agent_command` / `agent_command_override` / `agent_args` +//! (machine-local harness paths) +//! - `mcp_command` (machine-local) +//! - runtime state: `runtime_pid`, `backend_agent_id`, `backend` blob, +//! `provider_binary_path`, `last_*` +//! - lineage ids: `persona_id`, `source_team`, `source_team_persona_slug`, +//! `persona_team_dir`, `persona_name_in_team`, `persona_source_version` +//! - internal bookkeeping: `start_on_app_launch`, +//! `auto_restart_on_config_change`, `is_builtin` +//! +//! These exclusions are enforced by construction (only explicit fields are +//! placed into `AgentSnapshotDefinition`) and asserted by unit tests. + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use png::{BitDepth, ColorType, Decoder, Encoder}; +use serde::{Deserialize, Serialize}; +use std::io::Cursor; + +use crate::managed_agents::types::ManagedAgentRecord; + +// ── Constants ──────────────────────────────────────────────────────────────── + +/// tEXt chunk keyword used in `.agent.png` files. +pub const PNG_CHUNK_KEYWORD: &str = "buzz_agent_snapshot"; + +/// Maximum avatar size (bytes) to inline as a data URL. Avatars larger than +/// this are stored as a URL reference instead. +const MAX_AVATAR_INLINE_BYTES: usize = 2 * 1024 * 1024; // 2 MB + +/// Format discriminator — used for sniffing and validation. +pub const FORMAT_DISCRIMINATOR: &str = "buzz-agent-snapshot"; + +/// Version of the manifest format produced by this module. +pub const FORMAT_VERSION: u32 = 1; + +// ── Memory level ───────────────────────────────────────────────────────────── + +/// How much memory to bundle in the snapshot. +/// +/// The default is `None` — config-only export, safest for sharing. Memory +/// entries are plaintext in the output file; users must opt in explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryLevel { + /// Export definition + profile only. No memory. (Default) + #[default] + None, + /// Export definition + profile + `core` memory only. + Core, + /// Export definition + profile + `core` + all `mem/*` entries. + Everything, +} + +// ── Manifest sub-types ──────────────────────────────────────────────────────── + +/// Behavioral definition — what makes the agent do what it does. +/// +/// Fields mirror `ManagedAgentRecord` definition-level fields. Only the subset +/// meaningful across environments is included; machine-local / secret fields +/// are deliberately absent. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotDefinition { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp_toolsets: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parallelism: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub respond_to: Option, + /// Allowlist entries. These are flagged during import — they come from the + /// source environment and are meaningless on the importer's relay. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub respond_to_allowlist: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub name_pool: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_timeout_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_turn_duration_seconds: Option, +} + +/// kind:0 presentation fields. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotProfile { + pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, + /// Avatar inlined as a `data:image/...;base64,…` URI (≤ 2 MB), + /// or a URL fallback if the image exceeds the size limit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar_data_url: Option, + /// Present when the avatar exceeds MAX_AVATAR_INLINE_BYTES and is stored + /// by reference rather than inlined. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, +} + +/// A single decrypted memory entry. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotMemoryEntry { + pub slug: String, + pub body: String, +} + +/// Memory section of the manifest. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotMemory { + /// Indicates what was included at export time. + pub level: MemoryLevel, + /// Decrypted memory entries. Empty when `level == None`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub entries: Vec, +} + +// ── Top-level manifest ──────────────────────────────────────────────────────── + +/// The top-level `buzz-agent-snapshot v1` manifest. +/// +/// Serializes to / from JSON. Embedded in `.agent.json` directly, or in the +/// `buzz_agent_snapshot` tEXt chunk of a `.agent.png` (base64-encoded). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshot { + /// Fixed discriminator for format sniffing. + pub format: String, + /// Schema version. This module produces version 1. + pub version: u32, + pub definition: AgentSnapshotDefinition, + pub profile: AgentSnapshotProfile, + pub memory: AgentSnapshotMemory, +} + +// ── Builder / encoder ──────────────────────────────────────────────────────── + +/// Materialize a snapshot manifest from a `ManagedAgentRecord`. +/// +/// `memory_entries` is the pre-fetched, owner-decrypted set from +/// `get_agent_memory`; this function does NOT call the Tauri command — that +/// is the caller's responsibility so this fn stays pure and testable. +/// +/// `memory_level` controls what ends up in the `memory` section. `avatar_bytes` +/// is the raw image for the agent (loaded from disk or fetched); when `None` +/// or too large, falls back to the `avatar_url` string on the record. +pub fn build_snapshot( + record: &ManagedAgentRecord, + memory_level: MemoryLevel, + memory_entries: Vec, + avatar_bytes: Option<&[u8]>, +) -> AgentSnapshot { + // ── Definition ───────────────────────────────────────────────────── + // Use definition-level fields (respond_to, allowlist, mcp_toolsets, + // parallelism) for portability — instance-level equivalents are + // spawn-time snapshots and would be stale. + let definition = AgentSnapshotDefinition { + name: record + .display_name + .clone() + .unwrap_or_else(|| record.name.clone()), + system_prompt: record.system_prompt.clone(), + runtime: record.runtime.clone(), + model: record.model.clone(), + provider: record.provider.clone(), + mcp_toolsets: record + .definition_mcp_toolsets + .clone() + .or_else(|| record.mcp_toolsets.clone()), + parallelism: record.definition_parallelism.or(Some(record.parallelism)), + respond_to: record.definition_respond_to.clone(), + respond_to_allowlist: record.definition_respond_to_allowlist.clone(), + name_pool: record.name_pool.clone(), + idle_timeout_seconds: record.idle_timeout_seconds, + max_turn_duration_seconds: record.max_turn_duration_seconds, + }; + + // ── Profile ───────────────────────────────────────────────────────── + let (avatar_data_url, avatar_url_ref) = resolve_avatar(record, avatar_bytes); + let profile = AgentSnapshotProfile { + display_name: record + .display_name + .clone() + .unwrap_or_else(|| record.name.clone()), + about: None, // kind:0 `about` not yet surfaced in ManagedAgentRecord + avatar_data_url, + avatar_url: avatar_url_ref, + }; + + // ── Memory ───────────────────────────────────────────────────────── + let memory = AgentSnapshotMemory { + level: memory_level, + entries: memory_entries, + }; + + AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition, + profile, + memory, + } +} + +/// Resolve the avatar for export. +/// +/// Returns `(data_url, url_ref)`: +/// - `data_url` is set when the avatar fits within `MAX_AVATAR_INLINE_BYTES`. +/// - `url_ref` is set when we can only record a URL (too large / no bytes). +fn resolve_avatar( + record: &ManagedAgentRecord, + avatar_bytes: Option<&[u8]>, +) -> (Option, Option) { + if let Some(bytes) = avatar_bytes { + if bytes.len() <= MAX_AVATAR_INLINE_BYTES { + // Detect MIME type from magic bytes. + let mime = if bytes.starts_with(b"\x89PNG") { + "image/png" + } else if bytes.starts_with(b"\xff\xd8\xff") { + "image/jpeg" + } else if bytes.starts_with(b"GIF8") { + "image/gif" + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + "image/webp" + } else { + "image/png" // safe default for unknown + }; + let data_url = format!("data:{};base64,{}", mime, STANDARD.encode(bytes)); + return (Some(data_url), None); + } + } + // Fall back to URL reference (caller provided a URL avatar or bytes were + // too large). + let url_ref = record + .avatar_url + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + (None, url_ref) +} + +// ── JSON encoding / decoding ────────────────────────────────────────────────── + +/// Encode the manifest to pretty-printed JSON bytes. +pub fn encode_snapshot_json(snapshot: &AgentSnapshot) -> Result, String> { + serde_json::to_vec_pretty(snapshot).map_err(|e| format!("Failed to serialize snapshot: {e}")) +} + +/// Decode a manifest from JSON bytes. +pub fn decode_snapshot_json(bytes: &[u8]) -> Result { + let snapshot: AgentSnapshot = + serde_json::from_slice(bytes).map_err(|e| format!("Invalid snapshot JSON: {e}"))?; + validate_snapshot(&snapshot)?; + Ok(snapshot) +} + +// ── PNG encoding / decoding ─────────────────────────────────────────────────── + +/// Encode a snapshot into a `.agent.png` — avatar as the image body, manifest +/// in the `buzz_agent_snapshot` tEXt chunk. +/// +/// **Rejects** any snapshot whose `memory.level != None` — memory in a PNG +/// file is a security hazard (images are casually shared, pasted into chats). +pub fn encode_snapshot_png( + snapshot: &AgentSnapshot, + avatar_bytes: Option<&[u8]>, +) -> Result, String> { + if snapshot.memory.level != MemoryLevel::None || !snapshot.memory.entries.is_empty() { + return Err( + "Cannot write memory to a .agent.png file — use .agent.json for memory-bearing \ + snapshots. PNG images are casually shared and would expose memory as plaintext." + .to_string(), + ); + } + + // Manifest → JSON → base64 for the tEXt chunk payload. + let json_bytes = encode_snapshot_json(snapshot)?; + let chunk_text = STANDARD.encode(&json_bytes); + + // Use the avatar bytes as the PNG image when available; otherwise produce + // a minimal 1×1 transparent placeholder. + let png_bytes = match avatar_bytes.filter(|b| !b.is_empty()) { + Some(bytes) if bytes.starts_with(b"\x89PNG") => { + // Already a PNG — inject the tEXt chunk. + inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text)? + } + Some(_bytes) => { + // Non-PNG avatar (JPEG, etc.) — re-encode as 1×1 placeholder and + // carry the avatar via the manifest's `profile.avatar_data_url`. + // This keeps the PNG valid while preserving the avatar in the JSON. + make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)? + } + None => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?, + }; + + Ok(png_bytes) +} + +/// Decode a manifest from a `.agent.png` tEXt chunk. +pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { + let decoder = Decoder::new(Cursor::new(png_bytes)); + let reader = decoder + .read_info() + .map_err(|e| format!("Invalid PNG: {e}"))?; + let info = reader.info(); + + let chunk_text = info + .uncompressed_latin1_text + .iter() + .find(|c| c.keyword == PNG_CHUNK_KEYWORD) + .map(|c| c.text.as_str()) + .ok_or_else(|| "PNG does not contain a buzz_agent_snapshot tEXt chunk".to_string())?; + + let json_bytes = STANDARD + .decode(chunk_text.trim()) + .map_err(|e| format!("Invalid base64 in PNG chunk: {e}"))?; + + decode_snapshot_json(&json_bytes) +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +/// Validate that the manifest has the correct format/version and required +/// fields. Returns an error string on failure. +fn validate_snapshot(snapshot: &AgentSnapshot) -> Result<(), String> { + if snapshot.format != FORMAT_DISCRIMINATOR { + return Err(format!( + "Unsupported snapshot format: {:?} (expected {:?})", + snapshot.format, FORMAT_DISCRIMINATOR + )); + } + if snapshot.version != 1 { + return Err(format!( + "Unsupported snapshot version: {} (expected 1)", + snapshot.version + )); + } + if snapshot.definition.name.trim().is_empty() { + return Err("Snapshot definition.name is empty".to_string()); + } + if snapshot.profile.display_name.trim().is_empty() { + return Err("Snapshot profile.displayName is empty".to_string()); + } + Ok(()) +} + +// ── PNG helpers ─────────────────────────────────────────────────────────────── + +/// Decode a `data:;base64,` URL back to raw bytes. +/// Returns `None` if `url` is not a data URL or decoding fails. +pub fn decode_avatar_data_url(url: &str) -> Option> { + let rest = url.strip_prefix("data:")?; + let comma_pos = rest.find(',')?; + let header = &rest[..comma_pos]; + let b64 = &rest[comma_pos + 1..]; + if !header.contains("base64") { + return None; + } + STANDARD.decode(b64.trim()).ok() +} + +/// Build a minimal 1×1 transparent PNG with a single tEXt chunk. +fn make_png_with_text(keyword: &str, text: &str) -> Result, String> { + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + enc.add_text_chunk(keyword.to_string(), text.to_string()) + .map_err(|e| format!("Failed to add tEXt chunk: {e}"))?; + let mut w = enc + .write_header() + .map_err(|e| format!("Failed to write PNG header: {e}"))?; + w.write_image_data(&[0, 0, 0, 0]) + .map_err(|e| format!("Failed to write PNG image data: {e}"))?; + } + Ok(buf) +} + +/// Inject a tEXt chunk into an existing PNG by re-encoding it. +/// +/// Re-decodes the image data via the `png` crate and writes a fresh PNG with +/// the extra chunk inserted after IHDR. This preserves the image content while +/// adding our metadata. +fn inject_text_chunk(png_bytes: &[u8], keyword: &str, text: &str) -> Result, String> { + let decoder = Decoder::new(Cursor::new(png_bytes)); + let mut reader = decoder + .read_info() + .map_err(|e| format!("Failed to decode source PNG: {e}"))?; + + let info = reader.info().clone(); + let width = info.width; + let height = info.height; + let color_type = info.color_type; + let bit_depth = info.bit_depth; + let buf_size = reader + .output_buffer_size() + .ok_or_else(|| "PNG output buffer size unavailable".to_string())?; + let mut pixel_buf = vec![0u8; buf_size]; + reader + .next_frame(&mut pixel_buf) + .map_err(|e| format!("Failed to read PNG frame: {e}"))?; + + let mut out = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut out), width, height); + enc.set_color(color_type); + enc.set_depth(bit_depth); + enc.add_text_chunk(keyword.to_string(), text.to_string()) + .map_err(|e| format!("Failed to add tEXt chunk: {e}"))?; + let mut w = enc + .write_header() + .map_err(|e| format!("Failed to write PNG header: {e}"))?; + w.write_image_data(&pixel_buf) + .map_err(|e| format!("Failed to write PNG pixel data: {e}"))?; + } + Ok(out) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo}; + use std::collections::BTreeMap; + + /// Build a minimal `ManagedAgentRecord` for testing. Only the fields + /// relevant to snapshot export are filled; the rest use defaults. + fn minimal_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "deadbeef".to_string(), + name: "Test Agent".to_string(), + display_name: Some("Test Agent Display".to_string()), + persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear in snapshot + private_key_nsec: "nsec1secret".to_string(), // MUST NOT appear in snapshot + auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear in snapshot + relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear in snapshot + avatar_url: Some("https://example.com/avatar.png".to_string()), + acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot + agent_command: "goose".to_string(), // MUST NOT appear in snapshot + agent_command_override: Some("goose-override".to_string()), // MUST NOT appear + agent_args: vec!["--arg".to_string()], // MUST NOT appear in snapshot + mcp_command: "mcp-server".to_string(), // MUST NOT appear in snapshot + turn_timeout_seconds: 120, // deprecated, MUST NOT appear + idle_timeout_seconds: Some(30), + max_turn_duration_seconds: Some(600), + parallelism: 2, + system_prompt: Some("You are a test agent.".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + persona_source_version: Some("v1.0".to_string()), // MUST NOT appear + mcp_toolsets: Some("default".to_string()), + env_vars: { + let mut m = BTreeMap::new(); + m.insert("API_KEY".to_string(), "secret123".to_string()); // MUST NOT appear + m + }, + start_on_app_launch: true, + auto_restart_on_config_change: true, + runtime_pid: Some(12345), // MUST NOT appear + backend: BackendKind::Provider { + // MUST NOT appear — carries a provider secret + id: "SENTINEL_BACKEND_ID".to_string(), + config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}), + }, + backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear + provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear + persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear + persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + last_started_at: Some("2024-01-03T00:00:00Z".to_string()), // MUST NOT appear + last_stopped_at: None, + last_exit_code: Some(0), // MUST NOT appear + last_error: Some("SENTINEL_LAST_ERROR".to_string()), // MUST NOT appear + last_error_code: Some(42), // MUST NOT appear + respond_to: RespondTo::default(), + respond_to_allowlist: vec!["pubkey1hex".to_string()], + slug: Some("test-agent".to_string()), + runtime: Some("goose".to_string()), + name_pool: vec!["Alice".to_string(), "Bob".to_string()], + is_builtin: false, + is_active: true, + source_team: Some("team-id-123".to_string()), // MUST NOT appear + source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear + definition_respond_to: Some("allowlist".to_string()), + definition_respond_to_allowlist: vec!["abc123def".to_string()], + definition_mcp_toolsets: Some("tools".to_string()), + definition_parallelism: Some(4), + relay_mesh: None, + } + } + + // ── Round-trip tests ────────────────────────────────────────────────────── + + #[test] + fn json_round_trip_config_only() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let parsed = decode_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed, snapshot); + } + + #[test] + fn json_round_trip_with_memory() { + let record = minimal_record(); + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "I am a test agent.".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/research".to_string(), + body: "Some research notes.".to_string(), + }, + ]; + let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let parsed = decode_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed, snapshot); + } + + #[test] + fn png_round_trip_no_memory() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.definition.name, snapshot.definition.name); + assert_eq!(parsed.profile.display_name, snapshot.profile.display_name); + assert_eq!(parsed.memory.level, MemoryLevel::None); + } + + #[test] + fn png_round_trip_with_avatar_png() { + // Build a minimal PNG avatar. + let avatar = make_png_with_text("dummy", "value").unwrap(); + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar)); + // Avatar should be inlined as a data URL. + assert!(snapshot + .profile + .avatar_data_url + .as_deref() + .unwrap_or("") + .starts_with("data:image/png;base64,")); + + let png_bytes = encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.definition.name, snapshot.definition.name); + } + + // ── PNG memory guard ────────────────────────────────────────────────────── + + #[test] + fn png_export_with_core_memory_is_rejected() { + let record = minimal_record(); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "secret memory".to_string(), + }]; + let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); + let result = encode_snapshot_png(&snapshot, None); + assert!(result.is_err(), "Expected error for PNG with memory"); + let err = result.unwrap_err(); + assert!( + err.contains("Cannot write memory to a .agent.png"), + "Error message should explain the PNG memory restriction, got: {err}" + ); + } + + #[test] + fn png_export_with_everything_memory_is_rejected() { + let record = minimal_record(); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "mem/notes".to_string(), + body: "private notes".to_string(), + }]; + let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); + let result = encode_snapshot_png(&snapshot, None); + assert!(result.is_err()); + } + + #[test] + fn png_export_with_no_memory_succeeds() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert!(encode_snapshot_png(&snapshot, None).is_ok()); + } + + #[test] + fn png_export_rejects_none_level_with_nonempty_entries() { + // Inconsistent state: level == None but entries is non-empty. + // The encoder must reject this to prevent a memory-leak bypass. + let record = minimal_record(); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "leaked memory".to_string(), + }]; + // Build with entries, then override level to None in the struct. + let mut snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); + snapshot.memory.level = MemoryLevel::None; // force inconsistency + let result = encode_snapshot_png(&snapshot, None); + assert!( + result.is_err(), + "PNG encoder must reject level=None with non-empty entries" + ); + assert!( + result.unwrap_err().contains("Cannot write memory"), + "Error must explain the PNG memory restriction" + ); + } + + // ── Secret exclusion tests ──────────────────────────────────────────────── + // + // These tests assert that every field in the exclusion list is absent from + // the serialized snapshot. We serialize to JSON and assert the key is NOT + // present. + + fn snapshot_json_string(record: &ManagedAgentRecord) -> String { + let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + String::from_utf8(bytes).unwrap() + } + + #[test] + fn secret_exclusion_private_key_nsec_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("nsec1secret"), + "nsec must not appear in snapshot" + ); + assert!( + !json.contains("privateKeyNsec") && !json.contains("private_key_nsec"), + "privateKeyNsec field must not appear in snapshot" + ); + } + + #[test] + fn secret_exclusion_auth_tag_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("auth-tag-secret"), + "auth_tag value must not appear in snapshot" + ); + assert!( + !json.contains("authTag") && !json.contains("auth_tag"), + "authTag field must not appear in snapshot" + ); + } + + #[test] + fn secret_exclusion_env_vars_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("API_KEY") && !json.contains("secret123"), + "env_vars content must not appear in snapshot" + ); + assert!( + !json.contains("envVars") && !json.contains("env_vars"), + "envVars field must not appear in snapshot" + ); + } + + #[test] + fn secret_exclusion_relay_url_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("wss://relay.example.com"), + "relay_url value must not appear in snapshot" + ); + assert!( + !json.contains("relayUrl") && !json.contains("relay_url"), + "relayUrl field must not appear in snapshot" + ); + } + + #[test] + fn secret_exclusion_machine_commands_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + // acp_command / agent_command / agent_command_override / agent_args / mcp_command + assert!( + !json.contains("/usr/local/bin/acp"), + "acp_command path must not appear" + ); + assert!( + !json.contains("acpCommand") && !json.contains("acp_command"), + "acpCommand field must not appear" + ); + assert!( + !json.contains("agentCommand") && !json.contains("agent_command"), + "agentCommand field must not appear" + ); + assert!( + !json.contains("mcpCommand") && !json.contains("mcp_command"), + "mcpCommand field must not appear" + ); + } + + #[test] + fn secret_exclusion_runtime_state_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("runtimePid") && !json.contains("runtime_pid"), + "runtimePid must not appear" + ); + assert!( + !json.contains("backendAgentId") && !json.contains("backend_agent_id"), + "backendAgentId must not appear" + ); + assert!( + !json.contains("SENTINEL_BACKEND_AGENT_ID"), + "backendAgentId value must not appear" + ); + assert!( + !json.contains("providerBinaryPath") && !json.contains("provider_binary_path"), + "providerBinaryPath must not appear" + ); + assert!( + !json.contains("SENTINEL_PROVIDER_BINARY"), + "providerBinaryPath value must not appear" + ); + assert!( + !json.contains("lastStartedAt") && !json.contains("last_started_at"), + "lastStartedAt must not appear" + ); + assert!( + !json.contains("lastExitCode") && !json.contains("last_exit_code"), + "lastExitCode must not appear" + ); + // backend blob — neither the type tag nor provider secret must leak. + assert!( + !json.contains("\"backend\"") && !json.contains("backend"), + "backend field must not appear" + ); + assert!( + !json.contains("SENTINEL_BACKEND_ID") && !json.contains("SENTINEL_BACKEND_SECRET"), + "backend config values must not appear" + ); + // last_error / last_error_code + assert!( + !json.contains("lastError") && !json.contains("last_error"), + "lastError must not appear" + ); + assert!( + !json.contains("SENTINEL_LAST_ERROR"), + "lastError value must not appear" + ); + assert!( + !json.contains("lastErrorCode") && !json.contains("last_error_code"), + "lastErrorCode must not appear" + ); + } + + #[test] + fn secret_exclusion_lineage_ids_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("team-id-123"), + "source_team value must not appear" + ); + assert!( + !json.contains("sourceTeam") && !json.contains("source_team"), + "sourceTeam field must not appear" + ); + assert!( + !json.contains("sourceTeamPersonaSlug"), + "sourceTeamPersonaSlug must not appear" + ); + assert!( + !json.contains("personaSourceVersion") && !json.contains("persona_source_version"), + "personaSourceVersion must not appear" + ); + // personaId + assert!( + !json.contains("personaId") && !json.contains("persona_id"), + "personaId field must not appear" + ); + assert!( + !json.contains("SENTINEL_PERSONA_ID"), + "personaId value must not appear" + ); + // personaTeamDir + assert!( + !json.contains("personaTeamDir") && !json.contains("persona_team_dir"), + "personaTeamDir field must not appear" + ); + assert!( + !json.contains("SENTINEL_TEAM_DIR"), + "personaTeamDir value must not appear" + ); + // personaNameInTeam + assert!( + !json.contains("personaNameInTeam") && !json.contains("persona_name_in_team"), + "personaNameInTeam field must not appear" + ); + assert!( + !json.contains("SENTINEL_NAME_IN_TEAM"), + "personaNameInTeam value must not appear" + ); + } + + // ── Definition field presence tests ────────────────────────────────────── + + #[test] + fn definition_fields_present_in_snapshot() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + + assert_eq!(snapshot.definition.name, "Test Agent Display"); + assert_eq!( + snapshot.definition.system_prompt.as_deref(), + Some("You are a test agent.") + ); + assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose")); + assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4")); + assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic")); + assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]); + // definition_respond_to maps to respond_to in the snapshot definition + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + // definition_respond_to_allowlist should be included + assert!(!snapshot.definition.respond_to_allowlist.is_empty()); + } + + #[test] + fn profile_fields_present_in_snapshot() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!(snapshot.profile.display_name, "Test Agent Display"); + // No bytes → should fall back to avatar_url + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/avatar.png") + ); + assert!(snapshot.profile.avatar_data_url.is_none()); + } + + #[test] + fn avatar_inlined_when_under_size_limit() { + let record = minimal_record(); + let small_png = make_png_with_text("k", "v").unwrap(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png)); + assert!(snapshot.profile.avatar_data_url.is_some()); + assert!(snapshot.profile.avatar_url.is_none()); + } + + #[test] + fn avatar_url_fallback_when_over_size_limit() { + let mut record = minimal_record(); + record.avatar_url = Some("https://example.com/big.png".to_string()); + // Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec. + let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1]; + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes)); + assert!(snapshot.profile.avatar_data_url.is_none()); + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/big.png") + ); + } + + // ── Format/version validation ───────────────────────────────────────────── + + #[test] + fn invalid_format_discriminator_is_rejected() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.format = "not-a-buzz-snapshot".to_string(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported snapshot format")); + } + + #[test] + fn unsupported_version_is_rejected() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.version = 99; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported snapshot version")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c4590bcf2b..53e1bf4772 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,5 +1,6 @@ mod agent_env; pub(crate) mod agent_events; +pub(crate) mod agent_snapshot; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index b89841ffd2..67581958cb 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -33,6 +33,16 @@ import { createPersona, deletePersona, exportPersonaToJson, + exportAgentSnapshot, + encodeAgentSnapshotForSend, + previewAgentSnapshotImport, + confirmAgentSnapshotImport, + type AgentSnapshotImportPreview, + type AgentSnapshotImportConfirm, + type AgentSnapshotImportResult, + type EncodedSnapshotPayload, + type SnapshotMemoryLevel, + type SnapshotFormat, listPersonas, setPersonaActive, updatePersona, @@ -629,6 +639,66 @@ export function useExportPersonaJsonMutation() { }); } +export function useExportAgentSnapshotMutation() { + return useMutation({ + mutationFn: ({ + id, + memoryLevel, + format, + memorySourcePubkey, + }: { + id: string; + memoryLevel: SnapshotMemoryLevel; + format: SnapshotFormat; + memorySourcePubkey?: string | null; + }) => exportAgentSnapshot(id, memoryLevel, format, memorySourcePubkey), + }); +} + +export function useEncodeAgentSnapshotForSendMutation() { + return useMutation({ + mutationFn: ({ + id, + memoryLevel, + format, + memorySourcePubkey, + }: { + id: string; + memoryLevel: SnapshotMemoryLevel; + format: SnapshotFormat; + memorySourcePubkey?: string | null; + }) => + encodeAgentSnapshotForSend(id, memoryLevel, format, memorySourcePubkey), + }); +} + +export function usePreviewAgentSnapshotImportMutation() { + return useMutation({ + mutationFn: ({ + fileBytes, + fileName, + }: { + fileBytes: number[]; + fileName: string; + }) => previewAgentSnapshotImport(fileBytes, fileName), + }); +} + +export function useConfirmAgentSnapshotImportMutation() { + return useMutation({ + mutationFn: (input: AgentSnapshotImportConfirm) => + confirmAgentSnapshotImport(input), + }); +} + +// Re-export import types for consumers that import from hooks. +export type { + AgentSnapshotImportPreview, + AgentSnapshotImportConfirm, + AgentSnapshotImportResult, + EncodedSnapshotPayload, +}; + export function useManagedAgentLogQuery( pubkey: string | null, lineCount = 120, diff --git a/desktop/src/features/agents/openSnapshotImportFromUrlEvent.test.mjs b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.test.mjs new file mode 100644 index 0000000000..955253d9c7 --- /dev/null +++ b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + consumePendingSnapshotImport, + requestOpenSnapshotImport, +} from "./openSnapshotImportFromUrlEvent.ts"; + +test("openSnapshotImportFromUrlEvent: consume returns null with no pending import", () => { + // Reset state from any prior test run. + consumePendingSnapshotImport(); + assert.equal(consumePendingSnapshotImport(), null); +}); + +test("openSnapshotImportFromUrlEvent: request then consume returns payload", () => { + consumePendingSnapshotImport(); // clear + requestOpenSnapshotImport({ fileBytes: [1, 2, 3], fileName: "x.agent.json" }); + const payload = consumePendingSnapshotImport(); + assert.ok(payload !== null); + assert.deepEqual(payload.fileBytes, [1, 2, 3]); + assert.equal(payload.fileName, "x.agent.json"); +}); + +test("openSnapshotImportFromUrlEvent: consume is one-shot and clears pending", () => { + consumePendingSnapshotImport(); // clear + requestOpenSnapshotImport({ fileBytes: [9], fileName: "y.agent.json" }); + const first = consumePendingSnapshotImport(); + const second = consumePendingSnapshotImport(); + assert.ok(first !== null, "first consume must return payload"); + assert.equal(second, null, "second consume must return null"); +}); + +test("openSnapshotImportFromUrlEvent: double-request replaces pending payload", () => { + consumePendingSnapshotImport(); // clear + requestOpenSnapshotImport({ fileBytes: [1], fileName: "a.agent.json" }); + requestOpenSnapshotImport({ fileBytes: [2], fileName: "b.agent.json" }); + const payload = consumePendingSnapshotImport(); + assert.ok(payload !== null); + // Only the latest request survives — prevents stacking. + assert.deepEqual(payload.fileBytes, [2]); + assert.equal(payload.fileName, "b.agent.json"); +}); diff --git a/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts new file mode 100644 index 0000000000..696d803206 --- /dev/null +++ b/desktop/src/features/agents/openSnapshotImportFromUrlEvent.ts @@ -0,0 +1,60 @@ +/** + * App-level event that routes a verified snapshot attachment (fetched + * in-memory from a timeline card) to the existing AgentsView importer. + * + * Pattern mirrors openCreateAgentEvent.ts: a module-level pending payload + * is set by the requester (the AgentSnapshotCard), consumed exactly once by + * AgentsView when it mounts or is navigated to, and cleared on consumption + * or cancel. No localStorage, no React context — just a module-scoped + * pending value + a window event for live navigation. + */ + +export type PendingSnapshotImport = { + fileBytes: number[]; + fileName: string; +}; + +const OPEN_SNAPSHOT_IMPORT_EVENT = "buzz:open-snapshot-import"; + +let pendingImport: PendingSnapshotImport | null = null; + +/** + * Enqueue a snapshot import and dispatch the navigation event. + * The caller must have already fetched and validated the bytes in memory. + * Clears any prior pending import so double-clicks don't stack. + */ +export function requestOpenSnapshotImport(payload: PendingSnapshotImport) { + pendingImport = { fileBytes: payload.fileBytes, fileName: payload.fileName }; + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(OPEN_SNAPSHOT_IMPORT_EVENT)); + } +} + +/** + * Consume and clear the pending import — call this in AgentsView's useEffect. + * Returns the payload if one was waiting, null otherwise. + */ +export function consumePendingSnapshotImport(): PendingSnapshotImport | null { + const payload = pendingImport; + pendingImport = null; + return payload; +} + +/** + * Subscribe to the snapshot-import event — call this in AgentsView's useEffect + * to handle navigations that happen while the view is already mounted. + */ +export function subscribeSnapshotImport( + handler: (payload: PendingSnapshotImport) => void, +): () => void { + function handleEvent() { + const payload = consumePendingSnapshotImport(); + if (payload) { + handler(payload); + } + } + window.addEventListener(OPEN_SNAPSHOT_IMPORT_EVENT, handleEvent); + return () => { + window.removeEventListener(OPEN_SNAPSHOT_IMPORT_EVENT, handleEvent); + }; +} diff --git a/desktop/src/features/agents/ui/AgentSnapshotExportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotExportDialog.tsx new file mode 100644 index 0000000000..d74f3bf842 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSnapshotExportDialog.tsx @@ -0,0 +1,270 @@ +import * as React from "react"; +import { AlertCircle, Download, Send } from "lucide-react"; + +import type { AgentPersona } from "@/shared/api/types"; +import type { + SnapshotFormat, + SnapshotMemoryLevel, +} from "@/shared/api/tauriPersonas"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Separator } from "@/shared/ui/separator"; +import { AgentSnapshotSendDialog } from "./AgentSnapshotSendDialog"; + +type AgentSnapshotExportDialogProps = { + isSavePending: boolean; + open: boolean; + persona: AgentPersona; + /** Pubkey of the linked agent instance to use as the memory source. + * When null, memory levels are disabled — the definition has no agent + * instance with a keypair to read memory from. */ + linkedAgentPubkey: string | null; + onSaveFile: ( + memoryLevel: SnapshotMemoryLevel, + format: SnapshotFormat, + ) => void; + onOpenChange: (open: boolean) => void; +}; + +const MEMORY_LEVELS: { + value: SnapshotMemoryLevel; + label: string; + description: string; +}[] = [ + { + value: "none", + label: "Config only", + description: "Exports definition and profile — no memory.", + }, + { + value: "core", + label: "Config + core memory", + description: "Includes the agent's core memory as plaintext.", + }, + { + value: "everything", + label: "Config + all memory", + description: "Includes core and all mem/* entries as plaintext.", + }, +]; + +export function AgentSnapshotExportDialog({ + isSavePending, + open, + persona, + linkedAgentPubkey, + onSaveFile, + onOpenChange, +}: AgentSnapshotExportDialogProps) { + const [memoryLevel, setMemoryLevel] = + React.useState("none"); + const [format, setFormat] = React.useState("json"); + const [sendOpen, setSendOpen] = React.useState(false); + + const hasLinkedAgent = linkedAgentPubkey !== null; + const showMemoryWarning = memoryLevel !== "none"; + // PNG format is disabled when memory is selected (backend guards this too, + // but we disable the option proactively to avoid a confusing error). + const pngDisabled = memoryLevel !== "none"; + + React.useEffect(() => { + // If the user switches to a memory level, force JSON format since PNG + // cannot carry memory. + if (pngDisabled && format === "png") { + setFormat("json"); + } + }, [pngDisabled, format]); + + // Reset state when the dialog opens for a fresh export. + React.useEffect(() => { + if (open) { + setMemoryLevel("none"); + setFormat("json"); + setSendOpen(false); + } + }, [open]); + + const isPending = isSavePending; + + return ( + <> + + + +
+ Export agent snapshot +
+ {/* Primary: Send in Buzz */} + + {/* Secondary: Save file */} + + + + +
+
+
+ + + +
+ {/* Agent identity */} +

+ Exporting{" "} + + {persona.displayName} + {" "} + as a portable snapshot. The recipient imports it as a new{" "} + agent with fresh keys — identity never travels. +

+ + {/* Memory level picker */} +
+

Memory to include

+
+ {MEMORY_LEVELS.map(({ value, label, description }) => { + const memoryDisabled = !hasLinkedAgent && value !== "none"; + return ( + + ); + })} +
+ {!hasLinkedAgent ? ( +

+ Memory export requires a running agent instance. Start this + definition to enable memory levels. +

+ ) : null} +
+ + {/* Plaintext memory warning */} + {showMemoryWarning ? ( +
+ +

+ Memory is stored as plaintext in the + snapshot. Only share it with people you trust. +

+
+ ) : null} + + {/* Format picker */} +
+

File format

+
+ + +
+
+
+
+
+ + {/* Send-in-Buzz destination picker — opened as a secondary dialog */} + {sendOpen ? ( + { + setSendOpen(open); + }} + onSent={() => { + setSendOpen(false); + onOpenChange(false); + }} + /> + ) : null} + + ); +} diff --git a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx new file mode 100644 index 0000000000..ad1219310d --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx @@ -0,0 +1,310 @@ +import * as React from "react"; +import { AlertCircle, Upload } from "lucide-react"; + +import type { + AgentSnapshotImportPreview, + AgentSnapshotImportResult, +} from "@/features/agents/hooks"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Separator } from "@/shared/ui/separator"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type ImportPhase = "preview" | "confirming" | "result"; + +type AgentSnapshotImportDialogProps = { + open: boolean; + /** Preview data loaded by the caller before opening. */ + preview: AgentSnapshotImportPreview; + /** True while the confirm mutation is in-flight. */ + isConfirming: boolean; + /** Set when the confirm mutation has returned a result. */ + result: AgentSnapshotImportResult | null; + /** Error from the confirm mutation, if any. */ + confirmError: string | null; + /** Called with keepAllowlist when user clicks Import. */ + onConfirm: (keepAllowlist: boolean) => void; + onOpenChange: (open: boolean) => void; +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +export function AgentSnapshotImportDialog({ + open, + preview, + isConfirming, + result, + confirmError, + onConfirm, + onOpenChange, +}: AgentSnapshotImportDialogProps) { + // Default: clear the source allowlist (safe default per spec). + const [keepAllowlist, setKeepAllowlist] = React.useState(false); + + // Reset choice whenever the dialog opens with new data. + React.useEffect(() => { + if (open) { + setKeepAllowlist(false); + } + }, [open]); + + const phase: ImportPhase = + result !== null ? "result" : isConfirming ? "confirming" : "preview"; + + const hasMemory = preview.memoryEntryCount > 0; + const memoryLevelLabel = + preview.memoryLevel === "core" + ? "core" + : preview.memoryLevel === "everything" + ? "all" + : "none"; + + return ( + + + +
+ + {phase === "result" ? "Agent imported" : "Import agent snapshot"} + +
+ {phase === "preview" ? ( + <> + + + + + + ) : ( + + + + )} +
+
+
+ + + + {phase === "preview" ? ( + + ) : phase === "confirming" ? ( +
+ Creating agent… +
+ ) : result !== null ? ( + + ) : null} +
+
+ ); +} + +// ── Preview body ────────────────────────────────────────────────────────────── + +function PreviewBody({ + preview, + hasMemory, + memoryLevelLabel, + keepAllowlist, + onKeepAllowlistChange, +}: { + preview: AgentSnapshotImportPreview; + hasMemory: boolean; + memoryLevelLabel: string; + keepAllowlist: boolean; + onKeepAllowlistChange: (v: boolean) => void; +}) { + return ( +
+ {/* Agent identity */} +
+

{preview.displayName}

+ {preview.systemPrompt ? ( +

+ {preview.systemPrompt} +

+ ) : null} +
+ +

+ A new agent will be created with a fresh keypair. The imported agent is + independent of the source — identity never travels. +

+ + {/* Memory section */} + {hasMemory ? ( +
+ +

+ This snapshot includes{" "} + + {preview.memoryEntryCount} {memoryLevelLabel} memory entr + {preview.memoryEntryCount === 1 ? "y" : "ies"} + + . Memory is stored as plaintext in the file and will be restored + under the new agent's identity. +

+
+ ) : ( +

+ No memory included — config only. +

+ )} + + {/* Allowlist section */} + {preview.hasSourceAllowlist ? ( +
+

+ Respond-to allowlist ({preview.sourceAllowlistCount} entr + {preview.sourceAllowlistCount === 1 ? "y" : "ies"}) +

+

+ This snapshot includes a source-environment pubkey allowlist. Those + identities are not meaningful on your relay. +

+
+ + +
+
+ ) : null} +
+ ); +} + +// ── Result body ─────────────────────────────────────────────────────────────── + +export function ResultBody({ + result, + confirmError, +}: { + result: AgentSnapshotImportResult; + confirmError: string | null; +}) { + const hasPartialMemory = + result.memoryTotal > 0 && result.memoryWritten < result.memoryTotal; + + return ( +
+

+ {result.displayName} was created + successfully. +

+ + {result.memoryTotal > 0 ? ( + hasPartialMemory ? ( +
+ +
+

+ Memory partially restored: {result.memoryWritten} of{" "} + {result.memoryTotal} entr + {result.memoryTotal === 1 ? "y" : "ies"} written. The agent + exists but some memory entries failed to publish. +

+ {result.memoryErrors.length > 0 ? ( +
    + {result.memoryErrors.map((err) => ( +
  • + {err} +
  • + ))} +
+ ) : null} +
+
+ ) : ( +

+ {result.memoryTotal} memory entr + {result.memoryTotal === 1 ? "y" : "ies"} restored. +

+ ) + ) : null} + + {result.profileSyncError ? ( +

+ Profile sync: {result.profileSyncError} +

+ ) : null} + + {confirmError ? ( +

{confirmError}

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentSnapshotSendDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotSendDialog.tsx new file mode 100644 index 0000000000..23794b0ab6 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSnapshotSendDialog.tsx @@ -0,0 +1,507 @@ +import * as React from "react"; +import { AlertCircle, Check, Search, Send } from "lucide-react"; + +import type { AgentPersona } from "@/shared/api/types"; +import type { + SnapshotFormat, + SnapshotMemoryLevel, +} from "@/shared/api/tauriPersonas"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Separator } from "@/shared/ui/separator"; +import { useEncodeAgentSnapshotForSendMutation } from "@/features/agents/hooks"; +import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; +import { + useSnapshotSendController, + type SendPhase, + type ResolvedChannel, +} from "./useSnapshotSendController"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type AgentSnapshotSendDialogProps = { + open: boolean; + persona: AgentPersona; + linkedAgentPubkey: string | null; + memoryLevel: SnapshotMemoryLevel; + format: SnapshotFormat; + onOpenChange: (open: boolean) => void; + /** Called when the snapshot was successfully sent. */ + onSent: () => void; +}; + +// ── Sub-phases within the dialog ────────────────────────────────────────────── + +/** UI step within this dialog. */ +type DialogStep = + | "pick" // destination picker + | "memgate" // destination-scoped memory confirmation (memory-bearing only) + | "progress" // uploading / sending + | "done" // success + | "error"; // unrecoverable error + +// ── Component ───────────────────────────────────────────────────────────────── + +export function AgentSnapshotSendDialog({ + open, + persona, + linkedAgentPubkey, + memoryLevel, + format, + onOpenChange, + onSent, +}: AgentSnapshotSendDialogProps) { + const controller = useSnapshotSendController(); + const encodeMutation = useEncodeAgentSnapshotForSendMutation(); + const timeoutState = useTimeoutState(); + + const [step, setStep] = React.useState("pick"); + const [selectedChannel, setSelectedChannel] = + React.useState(null); + const [search, setSearch] = React.useState(""); + + const hasMemory = memoryLevel !== "none"; + const isInProgress = + controller.state.phase === "preparing" || + controller.state.phase === "uploading" || + controller.state.phase === "sending"; + + // Reset all transient state when the dialog opens. + // biome-ignore lint/correctness/useExhaustiveDependencies: controller.reset and encodeMutation.reset are stable function references; only `open` drives the reset + React.useEffect(() => { + if (open) { + setStep("pick"); + setSelectedChannel(null); + setSearch(""); + controller.reset(); + encodeMutation.reset(); + } + }, [open]); + + // Mirror controller phase transitions to dialog step. + React.useEffect(() => { + if (controller.state.phase === "done") { + setStep("done"); + } else if (controller.state.phase === "error") { + setStep("error"); + } + }, [controller.state.phase]); + + // Reconcile selectedChannel when sendableChannels updates: + // - Clear if the selection left the list (e.g. archived, lost membership, + // or a moderation-DM filtered out once relay-self resolved). + // - Update to the current ResolvedChannel object if the selection is still + // present but its data (e.g. displayLabel) changed, so picker/memgate/done + // all use the same up-to-date resolved label. + React.useEffect(() => { + if (selectedChannel === null) return; + const current = controller.sendableChannels.find( + (ch) => ch.id === selectedChannel.id, + ); + if (!current) { + setSelectedChannel(null); + } else if (current !== selectedChannel) { + // Reconcile to the new object (label may have changed as profiles loaded). + setSelectedChannel(current); + } + }, [controller.sendableChannels, selectedChannel]); + + const filteredChannels = React.useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return controller.sendableChannels; + return controller.sendableChannels.filter( + (ch) => + ch.displayLabel.toLowerCase().includes(q) || + ch.name.toLowerCase().includes(q), + ); + }, [controller.sendableChannels, search]); + + // ── Step handlers ────────────────────────────────────────────────────────── + + function handlePickConfirm() { + if (!selectedChannel) return; + if (hasMemory) { + // Memory-bearing: require explicit destination-scoped confirmation first. + setStep("memgate"); + } else { + void handleSend(selectedChannel); + } + } + + async function handleSend(destination: ResolvedChannel) { + // IMP3: refuse to send while the user is timed out. + if (timeoutState.active) { + controller.setErrorState( + "You are currently timed out and cannot send messages.", + ); + setStep("error"); + return; + } + + // Revalidate the destination is still in the sendable list. + // (Relay-self or identity may have resolved between pick and confirm.) + const stillSendable = controller.sendableChannels.some( + (ch) => ch.id === destination.id, + ); + if (!stillSendable) { + controller.setErrorState( + "The selected destination is no longer available. Please pick another.", + ); + setStep("error"); + return; + } + + setStep("progress"); + // beginSend reads from the React Query cache and timeout external store + // directly at two internal checkpoints — not from render-captured state. + // This closes the race between this pre-flight check and the moment encode + // or upload actually starts. + await controller.beginSend( + () => + encodeMutation.mutateAsync({ + id: persona.id, + memoryLevel, + format, + memorySourcePubkey: linkedAgentPubkey, + }), + destination.id, + ); + // Phase transitions (done/error) are driven by beginSend via setState + // inside the controller; the mirror effect above keeps `step` in sync. + } + + function handleMemgateConfirm() { + if (selectedChannel) { + void handleSend(selectedChannel); + } + } + + function handleClose() { + if (!isInProgress) { + onOpenChange(false); + } + } + + function handleDoneClose() { + onOpenChange(false); + onSent(); + } + + // The resolved display label comes directly from the ResolvedChannel so the + // picker, memory-gate, and done copy all use the same resolved name. + const selectedLabel = React.useMemo(() => { + if (!selectedChannel) return null; + return selectedChannel.channelType === "dm" + ? `the DM with ${selectedChannel.displayLabel}` + : `#${selectedChannel.displayLabel}`; + }, [selectedChannel]); + + // ── Render ───────────────────────────────────────────────────────────────── + + return ( + + + +
+ + {step === "done" + ? "Snapshot sent" + : step === "memgate" + ? "Confirm memory share" + : "Send snapshot in Buzz"} + + {step !== "progress" && step !== "done" && step !== "error" ? ( +
+ {step === "pick" ? ( + + ) : ( + // memgate step + + )} + + + +
+ ) : step === "done" ? ( + + ) : step === "error" ? ( +
+ + + + +
+ ) : null} +
+
+ + + + {step === "pick" ? ( + + ) : step === "memgate" && selectedChannel !== null ? ( + + ) : step === "progress" ? ( + + ) : step === "done" && selectedChannel !== null ? ( + + ) : ( + + )} +
+
+ ); +} + +// ── Pick step ───────────────────────────────────────────────────────────────── + +function PickStep({ + persona, + channels, + isLoadingChannels, + selectedChannel, + search, + onSearchChange, + onSelectChannel, +}: { + persona: AgentPersona; + channels: ResolvedChannel[]; + isLoadingChannels: boolean; + selectedChannel: ResolvedChannel | null; + search: string; + onSearchChange: (s: string) => void; + onSelectChannel: (ch: ResolvedChannel) => void; +}) { + return ( +
+

+ Send{" "} + + {persona.displayName} + {" "} + as a snapshot attachment to a channel or DM. Recipients receive the + snapshot file and can import it locally. +

+ + {/* Search */} +
+ + onSearchChange(e.target.value)} + /> +
+ + {/* Channel list */} +
+ {isLoadingChannels ? ( +

+ Loading… +

+ ) : channels.length === 0 ? ( +

+ {search + ? "No matching destinations." + : "No sendable destinations found."} +

+ ) : ( + channels.map((ch) => ( + + )) + )} +
+
+ ); +} + +// ── Memory gate step ────────────────────────────────────────────────────────── + +export function MemoryGateStep({ + destinationLabel, + memoryLevel, +}: { + destinationLabel: string; + memoryLevel: SnapshotMemoryLevel; +}) { + const scope = memoryLevel === "core" ? "core memory" : "all memory"; + + return ( +
+
+ +
+

+ This snapshot includes plaintext {scope}. +

+
    +
  • + The memory will be delivered to{" "} + {destinationLabel} and visible to everyone in + that recipient surface. +
  • +
  • + Anyone who obtains the uploaded media link will also be able to + fetch the raw snapshot bytes. +
  • +
+

+ Only continue if you trust everyone who can see {destinationLabel}. +

+
+
+
+ ); +} + +// ── Progress step ───────────────────────────────────────────────────────────── + +function ProgressStep({ phase }: { phase: SendPhase }) { + const label = + phase === "preparing" + ? "Preparing snapshot…" + : phase === "uploading" + ? "Uploading snapshot…" + : "Sending message…"; + return ( +
+ {label} +
+ ); +} + +// ── Done step ───────────────────────────────────────────────────────────────── + +function DoneStep({ destinationLabel }: { destinationLabel: string }) { + return ( +
+

+ Snapshot sent to {destinationLabel} + . Recipients can download and import the snapshot file. +

+
+ ); +} + +// ── Error step ──────────────────────────────────────────────────────────────── + +function ErrorStep({ error }: { error: string }) { + return ( +
+ +

{error}

+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 3667207120..043b4345f3 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -3,6 +3,10 @@ import { consumePendingOpenCreateAgent, subscribeOpenCreateAgent, } from "@/features/agents/openCreateAgentEvent"; +import { + consumePendingSnapshotImport, + subscribeSnapshotImport, +} from "@/features/agents/openSnapshotImportFromUrlEvent"; import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; import { AgentDialog } from "./AgentDialog"; @@ -11,6 +15,8 @@ import { PersonaCatalogDialog } from "./PersonaCatalogDialog"; import { PersonaDeleteDialog } from "./PersonaDeleteDialog"; import { PersonaImportUpdateDialog } from "./PersonaImportUpdateDialog"; import { PersonaShareDialog } from "./PersonaShareDialog"; +import { AgentSnapshotExportDialog } from "./AgentSnapshotExportDialog"; +import { AgentSnapshotImportDialog } from "./AgentSnapshotImportDialog"; import { RelayDirectorySection } from "./RelayDirectorySection"; import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; @@ -69,6 +75,23 @@ export function AgentsView() { }); }, []); + // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile is stable + React.useEffect(() => { + // Consume a snapshot import that was enqueued before navigation (e.g. from + // a timeline AgentSnapshotCard click that navigated here). + const pending = consumePendingSnapshotImport(); + if (pending) { + void personas.handleImportSnapshotFile( + pending.fileBytes, + pending.fileName, + ); + } + + return subscribeSnapshotImport(({ fileBytes, fileName }) => { + void personas.handleImportSnapshotFile(fileBytes, fileName); + }); + }, []); + return ( <>
@@ -131,6 +154,7 @@ export function AgentsView() { onDuplicatePersona={personas.openDuplicate} onEditPersona={personas.openEdit} onSharePersona={personas.openShare} + onExportPersonaSnapshot={personas.openExportSnapshot} onDeactivatePersona={(persona) => { void personas.handleSetActive(persona, false, "library"); }} @@ -138,6 +162,9 @@ export function AgentsView() { onImportPersonaFile={(fileBytes, fileName) => { void personas.handleImportFile(fileBytes, fileName); }} + onImportSnapshotFile={(fileBytes, fileName) => { + void personas.handleImportSnapshotFile(fileBytes, fileName); + }} /> ) : null} + {personas.personaToExportSnapshot ? ( + { + if (personas.personaToExportSnapshot) { + personas.handleExportSnapshot( + personas.personaToExportSnapshot.persona, + personas.personaToExportSnapshot.linkedAgentPubkey, + memoryLevel, + format, + ); + } + }} + onOpenChange={(open) => { + if (!open) { + personas.setPersonaToExportSnapshot(null); + } + }} + /> + ) : null} + {personas.snapshotImportState ? ( + { + void personas.handleConfirmSnapshotImport(keepAllowlist); + }} + onOpenChange={(open) => { + if (!open) { + personas.closeSnapshotImportDialog(); + } + }} + /> + ) : null} {personas.isCatalogDialogOpen ? ( void; onEdit: (persona: AgentPersona) => void; onShare: (persona: AgentPersona) => void; + onExportSnapshot: ( + persona: AgentPersona, + linkedAgent: ManagedAgent | undefined, + ) => void; onDeactivate: (persona: AgentPersona) => void; onDelete: (persona: AgentPersona) => void; }) { @@ -63,6 +79,13 @@ export function PersonaActionsMenu({ Duplicate + onExportSnapshot(persona, linkedAgent)} + > + + Export snapshot + {persona.sourceTeam ? ( diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index e09ed23ec0..ebcfe7c1f0 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -52,9 +52,14 @@ type UnifiedAgentsSectionProps = { onDuplicatePersona: (persona: AgentPersona) => void; onEditPersona: (persona: AgentPersona) => void; onSharePersona: (persona: AgentPersona) => void; + onExportPersonaSnapshot: ( + persona: AgentPersona, + linkedAgent: ManagedAgent | undefined, + ) => void; onDeactivatePersona: (persona: AgentPersona) => void; onDeletePersona: (persona: AgentPersona) => void; onImportPersonaFile: (fileBytes: number[], fileName: string) => void; + onImportSnapshotFile: (fileBytes: number[], fileName: string) => void; }; const AGENT_CARD_COLUMN_CLASS = "w-full"; @@ -87,9 +92,11 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onDuplicatePersona, onEditPersona, onSharePersona, + onExportPersonaSnapshot, onDeactivatePersona, onDeletePersona, onImportPersonaFile, + onImportSnapshotFile, } = props; const runningCount = agents.filter((agent) => @@ -170,10 +177,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { isActionPending={isActionPending} isPending={isPersonasPending} persona={group.persona} + linkedAgent={profileAgent} onDeactivate={onDeactivatePersona} onDelete={onDeletePersona} onDuplicate={onDuplicatePersona} onEdit={onEditPersona} + onExportSnapshot={onExportPersonaSnapshot} onShare={onSharePersona} /> } @@ -195,6 +204,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { openFilePicker={openFilePicker} onChooseCatalog={onChooseCatalog} onCreatePersona={onCreatePersona} + onImportSnapshotFile={onImportSnapshotFile} />
@@ -475,45 +485,83 @@ function NewAgentCard({ openFilePicker, onChooseCatalog, onCreatePersona, + onImportSnapshotFile, }: { canChooseCatalog: boolean; isPersonasPending: boolean; openFilePicker: () => void; onChooseCatalog: () => void; onCreatePersona: () => void; + onImportSnapshotFile: (fileBytes: number[], fileName: string) => void; }) { + // Hidden file input for the snapshot import picker. + const snapshotInputRef = React.useRef(null); + + function handleSnapshotFileChange( + event: React.ChangeEvent, + ) { + const file = event.target.files?.[0]; + if (!file) { + return; + } + const reader = new FileReader(); + reader.onload = () => { + const buffer = reader.result as ArrayBuffer; + onImportSnapshotFile(Array.from(new Uint8Array(buffer)), file.name); + }; + reader.readAsArrayBuffer(file); + // Reset so the same file can be picked again. + event.target.value = ""; + } + return ( - - - - - event.preventDefault()} - > - + + + + + + event.preventDefault()} > - New agent - - {canChooseCatalog ? ( + New agent + + {canChooseCatalog ? ( + + Choose from catalog + + ) : null} + + Import agent file + + snapshotInputRef.current?.click()} > - Choose from catalog + Import agent snapshot - ) : null} - - Import agent file - - - + + + ); } diff --git a/desktop/src/features/agents/ui/agentSnapshotImportDialog.test.mjs b/desktop/src/features/agents/ui/agentSnapshotImportDialog.test.mjs new file mode 100644 index 0000000000..3ddaf19724 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSnapshotImportDialog.test.mjs @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Source-path tests for AgentSnapshotImportDialog ResultBody rendering. +// +// ResultBody is a hook-free component: it accepts a result object and renders +// the summary, partial-memory alert, and per-entry error list. These tests +// call it as a plain function and walk the element tree to verify that +// memoryErrors strings are carried through to a bounded list element with the +// correct data-testid. No DOM or test renderer is needed. + +import { ResultBody } from "./AgentSnapshotImportDialog.tsx"; + +/** + * Walk a React element tree (breadth-first) and collect all elements that + * match a predicate. + */ +function findAll(element, predicate) { + if (!element || typeof element !== "object") return []; + const matches = []; + const queue = [element]; + while (queue.length > 0) { + const node = queue.shift(); + if (!node || typeof node !== "object") continue; + if (predicate(node)) matches.push(node); + const children = node.props?.children; + if (Array.isArray(children)) { + queue.push(...children.flat(Infinity).filter(Boolean)); + } else if (children && typeof children === "object") { + queue.push(children); + } + } + return matches; +} + +/** + * Collect all string leaves in the element tree. + */ +function collectText(element) { + const texts = []; + const queue = [element]; + while (queue.length > 0) { + const node = queue.shift(); + if (typeof node === "string") { + texts.push(node); + continue; + } + if (!node || typeof node !== "object") continue; + const children = node.props?.children; + if (Array.isArray(children)) { + queue.push(...children.flat(Infinity).filter(Boolean)); + } else if (typeof children === "string") { + texts.push(children); + } else if (children && typeof children === "object") { + queue.push(children); + } + } + return texts; +} + +function makeResult(overrides = {}) { + return { + displayName: "TestBot", + newPubkey: "abc123", + personaId: "persona-1", + memoryWritten: 0, + memoryTotal: 2, + memoryErrors: [], + profileSyncError: null, + ...overrides, + }; +} + +// ── memory errors detail list ───────────────────────────────────────────────── + +test("result_body_renders_memory_errors_list_with_test_id", () => { + const errors = [ + 'slug "mem/notes": relay timeout', + 'slug "core": build failed: key mismatch', + ]; + const result = makeResult({ + memoryWritten: 0, + memoryTotal: 2, + memoryErrors: errors, + }); + + const element = ResultBody({ result, confirmError: null }); + + // The bounded list must carry the expected data-testid. + const errLists = findAll( + element, + (n) => n.props?.["data-testid"] === "agent-snapshot-import-memory-errors", + ); + assert.equal( + errLists.length, + 1, + "exactly one memory-errors list must be rendered", + ); + + // The list must be vertically bounded (max-h-*) and scrollable + // (overflow-y-auto) so it cannot grow the dialog without bound. + const listNode = errLists[0]; + const className = listNode.props?.className ?? ""; + assert.ok( + /max-h-/.test(className), + `memory-errors list must have a max-height class (got: "${className}")`, + ); + assert.ok( + /overflow-y-auto/.test(className), + `memory-errors list must have overflow-y-auto (got: "${className}")`, + ); + + // Each item must use break-all (not truncate) so the full error text is + // readable, not clipped. + const items = findAll(element, (n) => n.type === "li"); + assert.ok(items.length > 0, "list items must be present"); + for (const item of items) { + const cls = item.props?.className ?? ""; + assert.ok( + /break-all/.test(cls), + `list item must use break-all for full error visibility (got: "${cls}")`, + ); + assert.ok( + !/truncate/.test(cls), + `list item must not use truncate (got: "${cls}")`, + ); + } +}); + +test("result_body_surfaces_both_error_strings_in_tree", () => { + const errors = [ + 'slug "mem/notes": relay timeout', + 'slug "core": build failed: key mismatch', + ]; + const result = makeResult({ + memoryWritten: 0, + memoryTotal: 2, + memoryErrors: errors, + }); + + const element = ResultBody({ result, confirmError: null }); + const allText = collectText(element).join(" "); + + assert.ok( + allText.includes('slug "mem/notes"'), + "first error slug must appear in the rendered tree", + ); + assert.ok( + allText.includes('slug "core"'), + "second error slug must appear in the rendered tree", + ); +}); + +test("result_body_full_success_omits_memory_errors_list", () => { + const result = makeResult({ + memoryWritten: 2, + memoryTotal: 2, + memoryErrors: [], + }); + + const element = ResultBody({ result, confirmError: null }); + + const errLists = findAll( + element, + (n) => n.props?.["data-testid"] === "agent-snapshot-import-memory-errors", + ); + assert.equal( + errLists.length, + 0, + "full-success result must not render the memory-errors list", + ); +}); diff --git a/desktop/src/features/agents/ui/agentSnapshotSend.test.mjs b/desktop/src/features/agents/ui/agentSnapshotSend.test.mjs new file mode 100644 index 0000000000..1576a478aa --- /dev/null +++ b/desktop/src/features/agents/ui/agentSnapshotSend.test.mjs @@ -0,0 +1,742 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Tests for the snapshot send controller helpers and dialog behavior. + +import { + isSendableDestination, + createSendGuard, + runSendPipeline, + runGuardedSend, + checkSendEligibility, +} from "./useSnapshotSendController.ts"; + +import { + recordTimeoutFromRejection, + clearTimeoutState, +} from "../../moderation/lib/timeoutStore.ts"; + +// ── isSendableDestination ───────────────────────────────────────────────────── + +function makeChannel(overrides = {}) { + return { + id: "ch-1", + name: "general", + channelType: "stream", + visibility: "public", + description: "", + topic: null, + purpose: null, + memberCount: 2, + memberPubkeys: [], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys: [], + isMember: true, + ttlSeconds: null, + ttlDeadline: null, + ...overrides, + }; +} + +test("isSendableDestination_stream_member_not_archived_returns_true", () => { + const ch = makeChannel({ + channelType: "stream", + isMember: true, + archivedAt: null, + }); + assert.equal(isSendableDestination(ch), true); +}); + +test("isSendableDestination_dm_member_not_archived_returns_true", () => { + const ch = makeChannel({ + channelType: "dm", + isMember: true, + archivedAt: null, + }); + assert.equal(isSendableDestination(ch), true); +}); + +test("isSendableDestination_forum_is_excluded", () => { + const ch = makeChannel({ + channelType: "forum", + isMember: true, + archivedAt: null, + }); + assert.equal(isSendableDestination(ch), false); +}); + +test("isSendableDestination_non_member_is_excluded", () => { + const ch = makeChannel({ + channelType: "stream", + isMember: false, + archivedAt: null, + }); + assert.equal(isSendableDestination(ch), false); +}); + +test("isSendableDestination_archived_is_excluded", () => { + const ch = makeChannel({ + channelType: "stream", + isMember: true, + archivedAt: "2025-01-01T00:00:00Z", + }); + assert.equal(isSendableDestination(ch), false); +}); + +test("isSendableDestination_archived_dm_is_excluded", () => { + const ch = makeChannel({ + channelType: "dm", + isMember: true, + archivedAt: "2025-01-01T00:00:00Z", + }); + assert.equal(isSendableDestination(ch), false); +}); + +// ── AgentSnapshotSendDialog memory gate rendering ───────────────────────────── +// +// MemoryGateStep is a pure function; we call it directly and walk the element +// tree to verify the two required disclosures appear for each memory level. + +import { MemoryGateStep } from "./AgentSnapshotSendDialog.tsx"; + +function collectText(element) { + const texts = []; + const queue = [element]; + while (queue.length > 0) { + const node = queue.shift(); + if (typeof node === "string") { + texts.push(node); + continue; + } + if (!node || typeof node !== "object") continue; + const children = node.props?.children; + if (Array.isArray(children)) { + queue.push(...children.flat(Infinity).filter(Boolean)); + } else if (typeof children === "string") { + texts.push(children); + } else if (children && typeof children === "object") { + queue.push(children); + } + } + return texts; +} + +function makeDestination(overrides = {}) { + return makeChannel({ + id: "ch-1", + name: "team-alpha", + channelType: "stream", + ...overrides, + }); +} + +// makeDestination is kept for potential future use. +void makeDestination; // suppress "unused" lint + +test("memory_gate_step_shows_plaintext_core_memory_label", () => { + const el = MemoryGateStep({ + destinationLabel: "#team-alpha", + memoryLevel: "core", + }); + const text = collectText(el).join(" "); + assert.match(text, /plaintext\s+core\s+memory/i, `got: ${text}`); +}); + +test("memory_gate_step_shows_plaintext_all_memory_label", () => { + const el = MemoryGateStep({ + destinationLabel: "#team-alpha", + memoryLevel: "everything", + }); + const text = collectText(el).join(" "); + assert.match(text, /plaintext\s+all\s+memory/i, `got: ${text}`); +}); + +test("memory_gate_step_names_channel_destination", () => { + const el = MemoryGateStep({ + destinationLabel: "#team-alpha", + memoryLevel: "core", + }); + const text = collectText(el).join(" "); + assert.match(text, /#team-alpha/i, `got: ${text}`); +}); + +test("memory_gate_step_names_dm_destination", () => { + const el = MemoryGateStep({ + destinationLabel: "the DM with Alice", + memoryLevel: "core", + }); + const text = collectText(el).join(" "); + assert.match(text, /the DM with Alice/i, `got: ${text}`); +}); + +test("memory_gate_step_discloses_media_link_access", () => { + const el = MemoryGateStep({ + destinationLabel: "#team-alpha", + memoryLevel: "core", + }); + const text = collectText(el).join(" "); + assert.match(text, /media link/i, `got: ${text}`); +}); + +// ── createSendGuard: production concurrency guard ───────────────────────────── +// +// The UI hides the confirm button the moment handleSend transitions to the +// "progress" step. The DOM-level double-click guard is therefore the step +// transition. createSendGuard protects against a programmatic double-invocation +// covering the entire prepare → encode → upload → send sequence. +// This test imports and exercises the production guard factory directly. + +test("createSendGuard_blocks_second_concurrent_action", async () => { + const guard = createSendGuard(); + let callCount = 0; + const callOrder = []; + + async function action() { + callCount++; + callOrder.push("start"); + // Simulate async work (prepare + encode + upload + send). + await new Promise((resolve) => setTimeout(resolve, 20)); + callOrder.push("end"); + return true; + } + + // Fire both concurrently — the second sees inFlight=true and returns false. + const [r1, r2] = await Promise.all([ + guard.runGuarded(action), + guard.runGuarded(action), + ]); + + // Exactly one invocation ran. + assert.equal(callCount, 1, `expected callCount=1, got ${callCount}`); + // One returned true (ran), one returned false (blocked). + const successes = [r1, r2].filter(Boolean).length; + assert.equal(successes, 1, `expected 1 success, got ${successes}`); + // The single run completed fully (start then end, no interleaving). + assert.deepEqual(callOrder, ["start", "end"]); + // Guard is idle after both settle. + assert.equal( + guard.inFlight, + false, + "guard should be idle after both calls settle", + ); +}); + +test("createSendGuard_sequential_calls_both_run", async () => { + const guard = createSendGuard(); + let count = 0; + const run = () => + guard.runGuarded(async () => { + count++; + return true; + }); + // Sequential calls (await each before starting next) both succeed. + assert.equal(await run(), true); + assert.equal(await run(), true); + assert.equal(count, 2); +}); + +// ── runGuardedSend: production composition of guard + pipeline ──────────────── +// +// runGuardedSend is the exact production composition that beginSend uses. +// Calling it twice concurrently with the same guard must produce exactly one +// encode, one upload, one send, and one blocked call. +// A test that stays green after encode is moved outside the guard or after +// beginSend stops calling runGuardedSend is not a production-composition test; +// this test is — it will fail in both those cases. + +test("runGuardedSend_concurrent_calls_one_encodes_one_blocked", async () => { + const guard = createSendGuard(); + let encodeCount = 0; + let uploadCount = 0; + let sendCount = 0; + const states = []; + + const makeDeps = () => ({ + channelId: "ch-1", + checkEligibilityFn: () => null, + encodeFn: async () => { + encodeCount++; + // Simulate encode latency so the second call definitely arrives + // while the first is in-flight. + await new Promise((resolve) => setTimeout(resolve, 20)); + return { fileBytes: [1], fileName: "x.json" }; + }, + uploadFn: async (_bytes, _filename) => { + uploadCount++; + return { + url: "https://example.com/x.json", + sha256: "a".repeat(64), + size: 1, + type: "application/json", + uploaded: 0, + }; + }, + sendFn: async () => { + sendCount++; + }, + setStateFn: (s) => states.push(s.phase), + buildMessageFn: (_d) => ({ content: "", mediaTags: null }), + }); + + // Fire both concurrently using the same guard. + const [r1, r2] = await Promise.all([ + runGuardedSend(guard, makeDeps()), + runGuardedSend(guard, makeDeps()), + ]); + + // Exactly one encode, upload, and send ran. + assert.equal(encodeCount, 1, `expected encodeCount=1, got ${encodeCount}`); + assert.equal(uploadCount, 1, `expected uploadCount=1, got ${uploadCount}`); + assert.equal(sendCount, 1, `expected sendCount=1, got ${sendCount}`); + // One succeeded, one was blocked. + const successes = [r1, r2].filter(Boolean).length; + assert.equal(successes, 1, `expected 1 success, got ${successes}`); + // Guard is idle after both settle. + assert.equal(guard.inFlight, false, "guard should be idle after both settle"); +}); + +// ── runSendPipeline: production pipeline with injected deps ────────────────── +// +// runSendPipeline is the actual production function called by the hook's +// beginSend. Tests inject mock deps and call it directly so they remain load- +// bearing: removing either checkEligibilityFn call from the production function +// breaks these tests. + +test("runSendPipeline_checkpoint1_blocks_encode_upload_send", async () => { + // checkEligibilityFn returns an error string at checkpoint 1 (before encode). + // encode, upload, and send must not run. + let encodeCount = 0; + let uploadCount = 0; + let sendCount = 0; + const states = []; + + const result = await runSendPipeline({ + channelId: "ch-1", + checkEligibilityFn: () => "destination archived", + encodeFn: async () => { + encodeCount++; + return { fileBytes: [1], fileName: "x.json" }; + }, + uploadFn: async (_bytes, _filename) => { + uploadCount++; + return { + url: "https://example.com/x.json", + sha256: "a".repeat(64), + size: 1, + type: "application/json", + uploaded: 0, + }; + }, + sendFn: async () => { + sendCount++; + }, + setStateFn: (s) => states.push(s.phase), + buildMessageFn: (d) => ({ content: "", mediaTags: [[d.url ?? ""]] }), + }); + + assert.equal(result, false, "expected pipeline to return false"); + assert.equal(encodeCount, 0, "encode must not run when checkpoint 1 fails"); + assert.equal(uploadCount, 0, "upload must not run when checkpoint 1 fails"); + assert.equal(sendCount, 0, "send must not run when checkpoint 1 fails"); + // State must be set to error (not preparing/uploading/sending). + assert.ok( + states.includes("error"), + `expected error state, got ${JSON.stringify(states)}`, + ); + assert.ok(!states.includes("preparing"), "must not reach preparing"); + assert.ok(!states.includes("uploading"), "must not reach uploading"); +}); + +test("runSendPipeline_checkpoint2_blocks_upload_after_encode", async () => { + // checkEligibilityFn passes at checkpoint 1, then returns an error at + // checkpoint 2 (after encode completes). Encode must run once; upload and + // send must not run. + let encodeCount = 0; + let uploadCount = 0; + let sendCount = 0; + const states = []; + let encodeComplete = false; + + const result = await runSendPipeline({ + channelId: "ch-1", + checkEligibilityFn: () => { + // checkpoint 1: passes; checkpoint 2: fails (called after encode) + if (!encodeComplete) return null; + return "channel became forum during encode"; + }, + encodeFn: async () => { + encodeCount++; + await new Promise((resolve) => setTimeout(resolve, 5)); + encodeComplete = true; + return { fileBytes: [1], fileName: "x.json" }; + }, + uploadFn: async (_bytes, _filename) => { + uploadCount++; + return { + url: "https://example.com/x.json", + sha256: "a".repeat(64), + size: 1, + type: "application/json", + uploaded: 0, + }; + }, + sendFn: async () => { + sendCount++; + }, + setStateFn: (s) => states.push(s.phase), + buildMessageFn: (d) => ({ content: "", mediaTags: [[d.url ?? ""]] }), + }); + + assert.equal(result, false, "expected pipeline to return false"); + assert.equal(encodeCount, 1, "encode ran once (checkpoint 1 passed)"); + assert.equal(uploadCount, 0, "upload must not run when checkpoint 2 fails"); + assert.equal(sendCount, 0, "send must not run when checkpoint 2 fails"); + const seenPreparing = states.includes("preparing"); + assert.ok(seenPreparing, "pipeline must set preparing phase"); + const seenUploading = states.includes("uploading"); + assert.ok(!seenUploading, "must not reach uploading when checkpoint 2 fails"); + assert.ok( + states.includes("error"), + "must set error state after checkpoint 2", + ); +}); + +test("runSendPipeline_happy_path_sets_all_phases", async () => { + // All checkpoints pass and encode/upload/send succeed — full phase sequence. + const states = []; + let sendArgs = null; + + const result = await runSendPipeline({ + channelId: "ch-1", + checkEligibilityFn: () => null, + encodeFn: async () => ({ fileBytes: [1], fileName: "x.json" }), + uploadFn: async (_bytes, _filename) => ({ + url: "https://example.com/x.json", + sha256: "a".repeat(64), + size: 1, + type: "application/json", + uploaded: 0, + }), + sendFn: async (args) => { + sendArgs = args; + }, + setStateFn: (s) => states.push(s.phase), + buildMessageFn: (_d) => ({ content: "test", mediaTags: [["tag"]] }), + }); + + assert.equal(result, true, "expected pipeline to return true"); + assert.deepEqual(states, ["preparing", "uploading", "sending", "done"]); + assert.ok(sendArgs, "send must have been called"); +}); + +// ── checkSendEligibility: current-source validation ─────────────────────────── +// +// checkSendEligibility reads from a QueryClient directly (not from rendered +// state). Tests inject a minimal mock QueryClient so the function is testable +// without a React context. + +function makeMockQueryClient(data) { + return { + getQueryData(key) { + const k = JSON.stringify(key); + return data[k] ?? undefined; + }, + getQueryState(key) { + const k = JSON.stringify(key); + return data[`state:${k}`] ?? undefined; + }, + }; +} + +test("checkSendEligibility_valid_stream_returns_null", () => { + const qc = makeMockQueryClient({ + '["channels"]': [ + makeChannel({ + id: "ch-1", + channelType: "stream", + isMember: true, + archivedAt: null, + }), + ], + }); + const result = checkSendEligibility(qc, "ch-1", 1000); + assert.equal(result, null, "valid stream must be eligible"); +}); + +test("checkSendEligibility_archived_channel_returns_error", () => { + const qc = makeMockQueryClient({ + '["channels"]': [ + makeChannel({ id: "ch-1", archivedAt: "2025-01-01T00:00:00Z" }), + ], + }); + const result = checkSendEligibility(qc, "ch-1", 1000); + assert.notEqual(result, null, "archived channel must be ineligible"); +}); + +test("checkSendEligibility_non_member_channel_returns_error", () => { + const qc = makeMockQueryClient({ + '["channels"]': [makeChannel({ id: "ch-1", isMember: false })], + }); + const result = checkSendEligibility(qc, "ch-1", 1000); + assert.notEqual(result, null, "non-member channel must be ineligible"); +}); + +test("checkSendEligibility_forum_channel_returns_error", () => { + const qc = makeMockQueryClient({ + '["channels"]': [makeChannel({ id: "ch-1", channelType: "forum" })], + }); + const result = checkSendEligibility(qc, "ch-1", 1000); + assert.notEqual(result, null, "forum channel must be ineligible"); +}); + +test("checkSendEligibility_missing_channel_returns_error", () => { + const qc = makeMockQueryClient({ '["channels"]': [] }); + const result = checkSendEligibility(qc, "ch-1", 1000); + assert.notEqual(result, null, "missing channel must be ineligible"); +}); + +test("checkSendEligibility_active_timeout_known_expiry_returns_error", () => { + // Activate the timeout store with a known future expiry, then assert blocked. + // Clear the store in finally so state cannot leak to other tests. + const qc = makeMockQueryClient({ + '["channels"]': [makeChannel({ id: "ch-1" })], + }); + const futureExpiry = Date.now() + 60_000; // 1 minute from now + try { + recordTimeoutFromRejection( + `restricted: you are timed out until ${Math.floor(futureExpiry / 1000)}`, + ); + const result = checkSendEligibility(qc, "ch-1"); + assert.notEqual( + result, + null, + "active timeout (known expiry) must be blocked", + ); + } finally { + clearTimeoutState(); + } +}); + +test("checkSendEligibility_active_timeout_unknown_expiry_returns_error", () => { + // "restricted: you are timed out until 0" is the unknown-expiry case; + // parseTimeoutRejection returns expiresAtMs=null, and isTimeoutActive(null) + // returns true (fail-closed). + const qc = makeMockQueryClient({ + '["channels"]': [makeChannel({ id: "ch-1" })], + }); + try { + recordTimeoutFromRejection("restricted: you are timed out until 0"); + const result = checkSendEligibility(qc, "ch-1"); + assert.notEqual( + result, + null, + "active timeout (unknown expiry) must be blocked", + ); + } finally { + clearTimeoutState(); + } +}); + +test("checkSendEligibility_no_timeout_stream_returns_null", () => { + // Baseline: no active timeout, valid stream channel → eligible. + const qc = makeMockQueryClient({ + '["channels"]': [makeChannel({ id: "ch-1" })], + }); + const result = checkSendEligibility(qc, "ch-1", 1000); + assert.equal(result, null, "no timeout + valid stream → eligible"); +}); + +test("checkSendEligibility_dm_with_loading_identity_returns_error", () => { + // Fail-closed: if identity is still fetching, any DM is ineligible. + const qc = makeMockQueryClient({ + '["channels"]': [ + makeChannel({ + id: "ch-dm", + channelType: "dm", + participantPubkeys: ["aabb", "ccdd"], + }), + ], + 'state:["identity"]': { status: "pending", fetchStatus: "fetching" }, + }); + const result = checkSendEligibility(qc, "ch-dm", 1000); + assert.notEqual(result, null, "DM with loading identity must be ineligible"); +}); + +test("checkSendEligibility_dm_with_loading_relay_self_returns_error", () => { + // Fail-closed: if relay-self is still fetching, any DM is ineligible. + const qc = makeMockQueryClient({ + '["channels"]': [ + makeChannel({ + id: "ch-dm", + channelType: "dm", + participantPubkeys: ["aabb", "ccdd"], + }), + ], + 'state:["identity"]': { status: "success", fetchStatus: "idle" }, + 'state:["relaySelf"]': { status: "pending", fetchStatus: "fetching" }, + }); + const result = checkSendEligibility(qc, "ch-dm", 1000); + assert.notEqual( + result, + null, + "DM with loading relay-self must be ineligible", + ); +}); + +test("checkSendEligibility_classified_moderation_dm_returns_error", () => { + // Fail-closed: a 1:1 DM whose only other participant is relaySelf is a + // moderation DM and must be blocked. Identity and relay-self are fully + // loaded so the moderation classification can run. + const RELAY_SELF = "relay000".padEnd(64, "0"); + const MY_PUBKEY = "mypubkey".padEnd(64, "0"); + const qc = makeMockQueryClient({ + '["channels"]': [ + makeChannel({ + id: "ch-mod-dm", + channelType: "dm", + participantPubkeys: [MY_PUBKEY, RELAY_SELF], + }), + ], + '["identity"]': { pubkey: MY_PUBKEY, displayName: "Me" }, + '["relaySelf"]': RELAY_SELF, + 'state:["identity"]': { status: "success", fetchStatus: "idle" }, + 'state:["relaySelf"]': { status: "success", fetchStatus: "idle" }, + }); + const result = checkSendEligibility(qc, "ch-mod-dm", 1000); + assert.notEqual(result, null, "classified moderation DM must be blocked"); +}); + +test("checkSendEligibility_ordinary_dm_is_eligible", () => { + // A normal 1:1 DM whose other participant is NOT relaySelf must be eligible. + const RELAY_SELF = "relay000".padEnd(64, "0"); + const MY_PUBKEY = "mypubkey".padEnd(64, "0"); + const OTHER_PUBKEY = "other000".padEnd(64, "0"); + const qc = makeMockQueryClient({ + '["channels"]': [ + makeChannel({ + id: "ch-ordinary-dm", + channelType: "dm", + participantPubkeys: [MY_PUBKEY, OTHER_PUBKEY], + }), + ], + '["identity"]': { pubkey: MY_PUBKEY, displayName: "Me" }, + '["relaySelf"]': RELAY_SELF, + 'state:["identity"]': { status: "success", fetchStatus: "idle" }, + 'state:["relaySelf"]': { status: "success", fetchStatus: "idle" }, + }); + const result = checkSendEligibility(qc, "ch-ordinary-dm", 1000); + assert.equal(result, null, "ordinary DM must be eligible"); +}); + +test("checkSendEligibility_absent_identity_blocks_dm", () => { + // Fail-closed: if identity state is absent (never fetched — state undefined), + // any DM must be blocked regardless of relay-self state. + const RELAY_SELF = "relay000".padEnd(64, "0"); + const OTHER_PUBKEY = "other000".padEnd(64, "0"); + const qc = makeMockQueryClient({ + '["channels"]': [ + makeChannel({ + id: "ch-dm", + channelType: "dm", + participantPubkeys: [OTHER_PUBKEY, "deadbeef".padEnd(64, "0")], + }), + ], + // identity state is undefined — never fetched + '["relaySelf"]': RELAY_SELF, + 'state:["identity"]': undefined, + 'state:["relaySelf"]': { status: "success", fetchStatus: "idle" }, + }); + const result = checkSendEligibility(qc, "ch-dm", 1000); + assert.notEqual(result, null, "absent identity state must block any DM"); +}); + +test("checkSendEligibility_errored_identity_blocks_dm", () => { + // Fail-closed: if identity query errored, any DM must be blocked. + const RELAY_SELF = "relay000".padEnd(64, "0"); + const qc = makeMockQueryClient({ + '["channels"]': [ + makeChannel({ + id: "ch-dm", + channelType: "dm", + participantPubkeys: [ + "other000".padEnd(64, "0"), + "deadbeef".padEnd(64, "0"), + ], + }), + ], + 'state:["identity"]': { status: "error", fetchStatus: "idle" }, + '["relaySelf"]': RELAY_SELF, + 'state:["relaySelf"]': { status: "success", fetchStatus: "idle" }, + }); + const result = checkSendEligibility(qc, "ch-dm", 1000); + assert.notEqual(result, null, "errored identity state must block any DM"); +}); + +test("checkSendEligibility_absent_relay_self_blocks_dm", () => { + // Fail-closed: if relay-self state is absent (never fetched), any DM blocks. + const MY_PUBKEY = "mypubkey".padEnd(64, "0"); + const qc = makeMockQueryClient({ + '["channels"]': [ + makeChannel({ + id: "ch-dm", + channelType: "dm", + participantPubkeys: [MY_PUBKEY, "other000".padEnd(64, "0")], + }), + ], + '["identity"]': { pubkey: MY_PUBKEY, displayName: "Me" }, + 'state:["identity"]': { status: "success", fetchStatus: "idle" }, + // relay-self state is undefined — never fetched + 'state:["relaySelf"]': undefined, + }); + const result = checkSendEligibility(qc, "ch-dm", 1000); + assert.notEqual(result, null, "absent relay-self state must block any DM"); +}); + +test("checkSendEligibility_errored_relay_self_blocks_dm", () => { + // Fail-closed: if relay-self query errored, any DM blocks even though + // relaySelf=null data might remain in cache from a prior success. + const MY_PUBKEY = "mypubkey".padEnd(64, "0"); + const qc = makeMockQueryClient({ + '["channels"]': [ + makeChannel({ + id: "ch-dm", + channelType: "dm", + participantPubkeys: [MY_PUBKEY, "other000".padEnd(64, "0")], + }), + ], + '["identity"]': { pubkey: MY_PUBKEY, displayName: "Me" }, + 'state:["identity"]': { status: "success", fetchStatus: "idle" }, + 'state:["relaySelf"]': { status: "error", fetchStatus: "idle" }, + }); + const result = checkSendEligibility(qc, "ch-dm", 1000); + assert.notEqual(result, null, "errored relay-self state must block any DM"); +}); + +test("checkSendEligibility_relay_self_null_success_ordinary_dm_eligible", () => { + // Semantic boundary: relaySelf successfully resolved to null means the relay + // advertises no self pubkey (a known, valid answer). isModerationDm returns + // false when relaySelf is null/undefined. The DM must be eligible. + const MY_PUBKEY = "mypubkey".padEnd(64, "0"); + const OTHER_PUBKEY = "other000".padEnd(64, "0"); + const qc = makeMockQueryClient({ + '["channels"]': [ + makeChannel({ + id: "ch-dm-null-relay", + channelType: "dm", + participantPubkeys: [MY_PUBKEY, OTHER_PUBKEY], + }), + ], + '["identity"]': { pubkey: MY_PUBKEY, displayName: "Me" }, + '["relaySelf"]': null, // successfully resolved: relay has no self + 'state:["identity"]': { status: "success", fetchStatus: "idle" }, + 'state:["relaySelf"]': { status: "success", fetchStatus: "idle" }, + }); + const result = checkSendEligibility(qc, "ch-dm-null-relay", 1000); + assert.equal( + result, + null, + "relaySelf=null (known: relay has no self) + ordinary DM must be eligible", + ); +}); diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 5720fb9884..277997ae20 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -2,20 +2,28 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { + managedAgentsQueryKey, personasQueryKey, useAcpRuntimesQuery, useCreateManagedAgentMutation, useCreatePersonaMutation, useDeletePersonaMutation, + useExportAgentSnapshotMutation, useExportPersonaJsonMutation, usePersonasQuery, + usePreviewAgentSnapshotImportMutation, + useConfirmAgentSnapshotImportMutation, useSetPersonaActiveMutation, useUpdatePersonaMutation, + type AgentSnapshotImportPreview, + type AgentSnapshotImportResult, } from "@/features/agents/hooks"; import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; import { parsePersonaFiles, type ParsePersonaFilesResult, + type SnapshotFormat, + type SnapshotMemoryLevel, } from "@/shared/api/tauriPersonas"; import { isSingleItemFile } from "@/shared/lib/fileMagic"; import type { @@ -23,6 +31,7 @@ import type { AgentPersona, CreateManagedAgentResponse, CreatePersonaInput, + ManagedAgent, UpdatePersonaInput, } from "@/shared/api/types"; import { @@ -102,6 +111,9 @@ export function usePersonaActions() { const deletePersonaMutation = useDeletePersonaMutation(); const setPersonaActiveMutation = useSetPersonaActiveMutation(); const exportPersonaJsonMutation = useExportPersonaJsonMutation(); + const exportAgentSnapshotMutation = useExportAgentSnapshotMutation(); + const previewSnapshotImportMutation = usePreviewAgentSnapshotImportMutation(); + const confirmSnapshotImportMutation = useConfirmAgentSnapshotImportMutation(); const [personaDialogState, setPersonaDialogState] = React.useState(null); @@ -109,6 +121,19 @@ export function usePersonaActions() { React.useState(null); const [personaToShare, setPersonaToShare] = React.useState(null); + const [personaToExportSnapshot, setPersonaToExportSnapshot] = React.useState<{ + persona: AgentPersona; + linkedAgentPubkey: string | null; + } | null>(null); + const [snapshotImportState, setSnapshotImportState] = React.useState<{ + fileBytes: number[]; + fileName: string; + preview: AgentSnapshotImportPreview; + } | null>(null); + const [snapshotImportResult, setSnapshotImportResult] = + React.useState(null); + const [snapshotImportConfirmError, setSnapshotImportConfirmError] = + React.useState(null); const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false); const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState< string[] @@ -318,6 +343,65 @@ export function usePersonaActions() { } } + async function handleImportSnapshotFile( + fileBytes: number[], + fileName: string, + ) { + clearFeedback("library"); + try { + const preview = await previewSnapshotImportMutation.mutateAsync({ + fileBytes, + fileName, + }); + setSnapshotImportState({ fileBytes, fileName, preview }); + setSnapshotImportResult(null); + setSnapshotImportConfirmError(null); + } catch (err) { + setPersonaErrorMessage( + err instanceof Error + ? err.message + : "Failed to read agent snapshot file.", + ); + } + } + + async function handleConfirmSnapshotImport(keepAllowlist: boolean) { + if (!snapshotImportState) { + return; + } + setSnapshotImportConfirmError(null); + try { + const result = await confirmSnapshotImportMutation.mutateAsync({ + fileBytes: snapshotImportState.fileBytes, + fileName: snapshotImportState.fileName, + keepAllowlist, + }); + setSnapshotImportResult(result); + void queryClient.invalidateQueries({ queryKey: personasQueryKey }); + void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + void queryClient.invalidateQueries({ + queryKey: ["user-profile", result.newPubkey.toLowerCase()], + }); + if (result.memoryErrors.length > 0) { + setPersonaErrorMessage( + `${result.displayName} imported, but ${result.memoryErrors.length} memory entr${result.memoryErrors.length === 1 ? "y" : "ies"} failed to restore.`, + ); + } else { + setPersonaNoticeMessage(`Imported ${result.displayName}.`); + } + } catch (err) { + setSnapshotImportConfirmError( + err instanceof Error ? err.message : "Failed to import agent snapshot.", + ); + } + } + + function closeSnapshotImportDialog() { + setSnapshotImportState(null); + setSnapshotImportResult(null); + setSnapshotImportConfirmError(null); + } + function handleExport(persona: AgentPersona) { clearFeedback("library"); exportPersonaJsonMutation.mutate(persona.id, { @@ -375,6 +459,49 @@ export function usePersonaActions() { setPersonaToShare(persona); } + function openExportSnapshot( + persona: AgentPersona, + linkedAgent: ManagedAgent | undefined, + ) { + clearFeedback("library"); + setPersonaToExportSnapshot({ + persona, + linkedAgentPubkey: linkedAgent?.pubkey ?? null, + }); + } + + function handleExportSnapshot( + persona: AgentPersona, + linkedAgentPubkey: string | null, + memoryLevel: SnapshotMemoryLevel, + format: SnapshotFormat, + ) { + clearFeedback("library"); + setPersonaToExportSnapshot(null); + exportAgentSnapshotMutation.mutate( + { + id: persona.id, + memoryLevel, + format, + memorySourcePubkey: linkedAgentPubkey, + }, + { + onSuccess: (saved) => { + if (saved) { + setPersonaNoticeMessage(`Exported ${persona.displayName}.`); + } + }, + onError: (error) => { + setPersonaErrorMessage( + error instanceof Error + ? error.message + : "Failed to export agent snapshot.", + ); + }, + }, + ); + } + function setPersonaCatalogVisibility( persona: AgentPersona, visible: boolean, @@ -405,7 +532,10 @@ export function usePersonaActions() { updatePersonaMutation.isPending || deletePersonaMutation.isPending || setPersonaActiveMutation.isPending || - exportPersonaJsonMutation.isPending; + exportPersonaJsonMutation.isPending || + exportAgentSnapshotMutation.isPending || + previewSnapshotImportMutation.isPending || + confirmSnapshotImportMutation.isPending; return { personasQuery, @@ -446,8 +576,19 @@ export function usePersonaActions() { openCatalog, openDelete, openShare, + openExportSnapshot, + personaToExportSnapshot, + setPersonaToExportSnapshot, + handleExportSnapshot, setPersonaCatalogVisibility, sharedCatalogPersonaIdSet, clearFeedback, + snapshotImportState, + snapshotImportResult, + snapshotImportConfirmError, + isSnapshotImportConfirming: confirmSnapshotImportMutation.isPending, + handleImportSnapshotFile, + handleConfirmSnapshotImport, + closeSnapshotImportDialog, }; } diff --git a/desktop/src/features/agents/ui/useSnapshotSendController.ts b/desktop/src/features/agents/ui/useSnapshotSendController.ts new file mode 100644 index 0000000000..a3bf6872e9 --- /dev/null +++ b/desktop/src/features/agents/ui/useSnapshotSendController.ts @@ -0,0 +1,484 @@ +/** + * useSnapshotSendController + * + * Payload-agnostic upload → send controller for sharing a snapshot to a Buzz + * channel or DM. The caller supplies an encode function and a destination; + * the controller drives prepare → encode → upload → send with honest progress, + * idempotent double-send protection, and fail-closed eligibility checks at two + * action-boundary checkpoints that read from live query-cache sources, not from + * render-captured snapshots. + * + * This hook does not know what kind of snapshot the bytes contain. A future + * team-snapshot or other payload can reuse it unchanged by passing different + * bytes and a filename. Hard-coded semantics for `.agent.*` live only in + * the export-dialog layer above this hook. + */ + +import * as React from "react"; +import type { QueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; + +import { uploadMediaBytes, type BlobDescriptor } from "@/shared/api/tauri"; +import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown"; +import { channelsQueryKey, useChannelsQuery } from "@/features/channels/hooks"; +import { isModerationDm } from "@/features/moderation/lib/moderationDm"; +import { + relaySelfQueryKey, + useRelaySelfQuery, +} from "@/features/moderation/hooks"; +import { getTimeoutSnapshot } from "@/features/moderation/lib/timeoutStore"; +import { isTimeoutActive } from "@/features/moderation/lib/timeout"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { useSendMessageMutation } from "@/features/messages/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels"; +import type { Channel, Identity } from "@/shared/api/types"; + +// ── Public types ────────────────────────────────────────────────────────────── + +export type SendPhase = + | "idle" + | "preparing" + | "uploading" + | "sending" + | "done" + | "error"; + +export type SnapshotSendState = { + phase: SendPhase; + error: string | null; +}; + +/** + * A channel annotated with a resolved display label. For non-DM channels the + * label equals `ch.name`; for DMs it resolves participant display names so the + * picker, memory-gate warning, and success copy are consistent. + */ +export type ResolvedChannel = Channel & { + /** Human-readable label for the channel (participant names for DMs). */ + displayLabel: string; +}; + +/** + * A joined, non-archived, non-moderation-DM destination: channelType "stream" + * or "dm", isMember true, archivedAt null. + * + * Moderation DM exclusion requires the relay `self` pubkey and the current + * user pubkey; those are applied in `useSendableChannels` below so callers + * always receive a fully-filtered list. + */ +export function isSendableDestination(ch: Channel): boolean { + return ch.isMember && ch.archivedAt === null && ch.channelType !== "forum"; +} + +/** + * Pure factory for a single-concurrency action guard. + * + * Returns `{ runGuarded }` where `runGuarded(action)` executes `action()` + * only when no other call is currently in flight; any concurrent call receives + * `false` immediately. The guard is the same mechanism used by + * `beginSend` in `useSnapshotSendController` — exported so unit tests can + * exercise the production guard logic directly without requiring a React + * rendering context. + * + * @example + * ```ts + * const { runGuarded } = createSendGuard(); + * const [r1, r2] = await Promise.all([ + * runGuarded(async () => { ...encode/upload/send... }), + * runGuarded(async () => { ...encode/upload/send... }), + * ]); + * // r1 === true (ran), r2 === false (blocked) + * ``` + */ +export function createSendGuard(): { + runGuarded: (action: () => Promise) => Promise; + get inFlight(): boolean; +} { + let inFlight = false; + return { + runGuarded: async (action) => { + if (inFlight) return false; + inFlight = true; + try { + return await action(); + } finally { + inFlight = false; + } + }, + get inFlight() { + return inFlight; + }, + }; +} + +/** + * Read current eligibility for `channelId` directly from live query-cache + * sources and the timeout external store. Does NOT read rendered React state + * or component refs; safe to call inside a `runGuarded` action where render + * state may be stale. + * + * Returns `null` when the channel is eligible; returns a human-readable error + * string when it is not. + * + * Fail-closed on DM classification: a DM destination is blocked unless both + * identity and relay-self have successfully resolved (`status === "success"`). + * This covers pending, fetching, absent (never fetched), and errored states. + * The semantic distinction is preserved: a successfully resolved + * `relaySelf === null` means the relay advertises no self pubkey — that IS a + * known result and the generic moderation helper's fail-open behavior applies. + * Any other state (absent, errored, or in-flight) is unknown and must block. + */ +export function checkSendEligibility( + queryClient: QueryClient, + channelId: string, + nowMs: number = Date.now(), +): string | null { + // ── Timeout check ───────────────────────────────────────────────────────── + // Read the module-level snapshot from timeoutStore directly — this is the + // same value `useTimeoutState` serves but without requiring a render cycle. + const timeoutState = getTimeoutSnapshot(); + if (timeoutState.active && isTimeoutActive(timeoutState.expiresAtMs, nowMs)) { + return "You are currently timed out and cannot send messages."; + } + + // ── Channel-cache check ─────────────────────────────────────────────────── + const channels = queryClient.getQueryData(channelsQueryKey) ?? []; + const channel = channels.find((ch) => ch.id === channelId); + + if (!channel) { + return "The selected destination is no longer available. Please pick another."; + } + if (!isSendableDestination(channel)) { + return "The selected destination is no longer available. Please pick another."; + } + + // ── Moderation-DM check (fail-closed) ──────────────────────────────────── + if (channel.channelType === "dm") { + const identityState = queryClient.getQueryState(["identity"]); + const relaySelfState = queryClient.getQueryState( + relaySelfQueryKey, + ); + + // Fail-closed: block the DM unless both identity AND relay-self have + // successfully resolved. Absent state (undefined — never fetched or + // cache evicted), pending/fetching, and errored states are all unknown + // and must block to prevent misclassification. + // + // The only exception is a successfully resolved `relaySelf === null`: + // that means the relay advertises no self pubkey (a valid, known answer), + // and we pass it to isModerationDm, which fails open by its own contract. + if (identityState?.status !== "success") { + return "The selected destination is no longer available. Please pick another."; + } + if (relaySelfState?.status !== "success") { + return "The selected destination is no longer available. Please pick another."; + } + + const identity = queryClient.getQueryData(["identity"]); + const relaySelf = queryClient.getQueryData( + relaySelfQueryKey, + ); + + if (isModerationDm(channel, identity?.pubkey, relaySelf ?? undefined)) { + return "The selected destination is no longer available. Please pick another."; + } + } + + return null; +} + +/** + * The core send pipeline: prepare → [eligibility] → encode → [eligibility] → + * upload → send. Extracted as a standalone async function so unit tests can + * import and exercise it directly with injected dependencies — the production + * hook calls it inside `runGuarded`. + * + * Dependencies are injected rather than closed-over from React scope so the + * function is pure-async and fully testable without a rendering context. + */ +export async function runSendPipeline(deps: { + encodeFn: () => Promise<{ fileBytes: number[]; fileName: string }>; + uploadFn: (bytes: number[], filename: string) => Promise; + sendFn: (args: { + channelId: string; + content: string; + mediaTags: string[][]; + }) => Promise; + setStateFn: (state: SnapshotSendState) => void; + buildMessageFn: (descriptor: BlobDescriptor) => { + content: string; + mediaTags: string[][] | null | undefined; + }; + checkEligibilityFn: () => string | null; + channelId: string; +}): Promise { + const { + encodeFn, + uploadFn, + sendFn, + setStateFn, + buildMessageFn, + checkEligibilityFn, + channelId, + } = deps; + + // ── Eligibility checkpoint 1: before encode ─────────────────────────────── + // Reads live sources directly — timeout store, channel cache, identity cache, + // relay-self cache. Not a render snapshot. + const reason1 = checkEligibilityFn(); + if (reason1 !== null) { + setStateFn({ phase: "error", error: reason1 }); + return false; + } + + // ── Prepare (encode) ───────────────────────────────────────────────────── + setStateFn({ phase: "preparing", error: null }); + + let fileBytes: number[]; + let fileName: string; + try { + const encoded = await encodeFn(); + fileBytes = encoded.fileBytes; + fileName = encoded.fileName; + } catch (err) { + setStateFn({ + phase: "error", + error: + err instanceof Error + ? `Encode failed: ${err.message}` + : "Encode failed.", + }); + return false; + } + + // ── Eligibility checkpoint 2: after encode, before upload ───────────────── + // State can change while encode is awaited (channel archived, membership + // lost, timeout received, relay-self resolves to classify DM). + const reason2 = checkEligibilityFn(); + if (reason2 !== null) { + setStateFn({ phase: "error", error: reason2 }); + return false; + } + + // ── Upload ──────────────────────────────────────────────────────────────── + setStateFn({ phase: "uploading", error: null }); + + let descriptor: BlobDescriptor; + try { + descriptor = await uploadFn(fileBytes, fileName); + } catch (err) { + setStateFn({ + phase: "error", + error: + err instanceof Error + ? `Upload failed: ${err.message}` + : "Upload failed.", + }); + return false; + } + + // Preserve the original filename so `buildImetaTags` emits a `filename` + // field and the recipient's FileCard renders the correct label. + const descriptorWithFilename: BlobDescriptor = { + ...descriptor, + filename: fileName, + }; + + // ── Build message content + NIP-92 imeta tags ───────────────────────────── + const { content, mediaTags } = buildMessageFn(descriptorWithFilename); + + // ── Send ────────────────────────────────────────────────────────────────── + setStateFn({ phase: "sending", error: null }); + + try { + await sendFn({ + channelId, + content, + mediaTags: mediaTags ?? [], + }); + } catch (err) { + setStateFn({ + phase: "error", + error: + err instanceof Error ? `Send failed: ${err.message}` : "Send failed.", + }); + return false; + } + + setStateFn({ phase: "done", error: null }); + return true; +} + +/** + * Compose the single-concurrency guard with the send pipeline. + * + * This is the exact production composition that `beginSend` uses: a second + * concurrent call to `runGuardedSend` with the same guard receives `false` + * immediately — encode never starts for the blocked call. + * + * Exported so unit tests can import and call this exact function twice + * concurrently with injected counters, proving one encode/upload/send and one + * blocked result. A test that stays green after encode is moved outside the + * guard is not a production-composition test; this function is. + */ +export function runGuardedSend( + guard: ReturnType, + pipelineDeps: Parameters[0], +): Promise { + return guard.runGuarded(() => runSendPipeline(pipelineDeps)); +} + +export type UseSnapshotSendControllerResult = { + /** + * Sendable destinations with resolved display labels. DMs are omitted + * while identity or relay-self are loading (fail-closed moderation-DM race). + */ + sendableChannels: ResolvedChannel[]; + /** True while channels, identity, or relay-self are loading. */ + isLoadingChannels: boolean; + state: SnapshotSendState; + /** + * Execute the full prepare → encode → upload → send sequence behind a + * single-concurrency guard. A second call while the first is in-flight + * returns `false` immediately — encode never starts for the blocked call. + * + * Eligibility is checked at two internal checkpoints by reading directly + * from the React Query cache and the timeout external store — not from + * rendered React state or component refs. This closes both the + * pre-flight/guard-entry race and the during-encode state-change race. + * + * The caller MUST have already obtained explicit destination-scoped + * confirmation for memory-bearing payloads before calling this. Returns + * false and sets error state if blocked, ineligible, or if any step fails. + * Never throws. + */ + beginSend: ( + encodeFn: () => Promise<{ fileBytes: number[]; fileName: string }>, + channelId: string, + ) => Promise; + /** Set state to error with a message (for pre-send gate failures). */ + setErrorState: (message: string) => void; + reset: () => void; +}; + +// ── Hook ────────────────────────────────────────────────────────────────────── + +export function useSnapshotSendController(): UseSnapshotSendControllerResult { + const channelsQuery = useChannelsQuery(); + const identityQuery = useIdentityQuery(); + const queryClient = useQueryClient(); + + // Only fetch relay self when there are DM candidates — same gate as ChannelPane. + const hasDmCandidates = React.useMemo( + () => + (channelsQuery.data ?? []).some( + (ch) => ch.channelType === "dm" && isSendableDestination(ch), + ), + [channelsQuery.data], + ); + const relaySelfQuery = useRelaySelfQuery(hasDmCandidates); + + // Collect the "other participant" pubkeys from all DM candidates so we can + // resolve their display names. Kept stable by memo so the batch query key + // doesn't flap on every render. + const dmParticipantPubkeys = React.useMemo(() => { + const currentPubkey = identityQuery.data?.pubkey?.toLowerCase(); + return (channelsQuery.data ?? []) + .filter((ch) => ch.channelType === "dm" && isSendableDestination(ch)) + .flatMap((ch) => + ch.participantPubkeys.filter( + (pk) => pk.toLowerCase() !== currentPubkey, + ), + ); + }, [channelsQuery.data, identityQuery.data]); + + const dmProfilesQuery = useUsersBatchQuery(dmParticipantPubkeys, { + enabled: dmParticipantPubkeys.length > 0, + }); + + const [state, setState] = React.useState({ + phase: "idle", + error: null, + }); + + // Single-concurrency guard covering the full encode → upload → send action. + // Stored in a ref so it survives re-renders without triggering effects. + const guardRef = React.useRef(createSendGuard()); + + // Pass null channel here — we supply the captured channelId per-send instead. + const sendMutation = useSendMessageMutation(null, identityQuery.data); + + const sendableChannels = React.useMemo(() => { + const currentPubkey = identityQuery.data?.pubkey; + const relaySelf = relaySelfQuery.data; + // Fail-closed: withhold ALL DMs until BOTH identity AND relay-self have + // successfully resolved (`status === "success"`). Absent, pending, + // fetching, and errored states are all unknown — we cannot classify + // whether a DM is a moderation DM without valid identity + relay-self. + // A successfully resolved `relaySelf === null` IS known: the relay + // advertises no self, and the moderation helper's fail-open applies. + const identitySuccess = identityQuery.status === "success"; + const relaySelfSuccess = relaySelfQuery.status === "success"; + const dmGateOpen = + !hasDmCandidates || (identitySuccess && relaySelfSuccess); + const dmProfiles = dmProfilesQuery.data?.profiles; + + return (channelsQuery.data ?? []) + .filter( + (ch) => + isSendableDestination(ch) && + !isModerationDm(ch, currentPubkey, relaySelf) && + (ch.channelType !== "dm" || dmGateOpen), + ) + .map((ch) => ({ + ...ch, + displayLabel: resolveChannelDisplayLabel(ch, currentPubkey, dmProfiles), + })); + }, [ + channelsQuery.data, + identityQuery.data, + identityQuery.status, + relaySelfQuery.data, + relaySelfQuery.status, + hasDmCandidates, + dmProfilesQuery.data, + ]); + + async function beginSend( + encodeFn: () => Promise<{ fileBytes: number[]; fileName: string }>, + channelId: string, + ): Promise { + return runGuardedSend(guardRef.current, { + encodeFn, + channelId, + // Eligibility is checked by reading directly from the React Query cache + // and the timeout external store — not from rendered React state. + checkEligibilityFn: () => checkSendEligibility(queryClient, channelId), + uploadFn: (bytes, filename) => uploadMediaBytes(bytes, filename), + sendFn: (args) => sendMutation.mutateAsync(args), + setStateFn: setState, + buildMessageFn: (descriptor) => buildOutgoingMessage("", [descriptor]), + }); + } + + function reset() { + if (!guardRef.current.inFlight) { + setState({ phase: "idle", error: null }); + } + } + + return { + sendableChannels, + isLoadingChannels: + channelsQuery.isLoading || + (hasDmCandidates && + (relaySelfQuery.isLoading || identityQuery.isLoading)), + state, + beginSend, + setErrorState: (message: string) => { + setState({ phase: "error", error: message }); + }, + reset, + }; +} diff --git a/desktop/src/features/moderation/lib/timeoutStore.ts b/desktop/src/features/moderation/lib/timeoutStore.ts index 2b7affe8f1..d5fa71ff7c 100644 --- a/desktop/src/features/moderation/lib/timeoutStore.ts +++ b/desktop/src/features/moderation/lib/timeoutStore.ts @@ -70,6 +70,15 @@ export function clearTimeoutState(): void { emit(); } +/** + * Synchronously read the current timeout state without subscribing to updates. + * Safe to call outside a React rendering context (e.g. inside a guarded send + * pipeline action where render state may be stale). + */ +export function getTimeoutSnapshot(): TimeoutState { + return snapshot; +} + export type TimeoutState = { /** True while the member is timed out (write-blocked). */ active: boolean; diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index ac2ca513a9..05d54a59f4 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -16,3 +16,29 @@ export async function fetchMediaBytes( const bytes = await invokeTauri("fetch_media_bytes", { url }); return new Uint8Array(bytes); } + +/** + * Fetch an agent snapshot attachment in memory, verifying size, SHA-256, and + * snapshot decode before returning the bytes. + * + * Inputs come directly from the message's imeta fields; validation is + * performed on the Rust side (same-relay URL, format-specific size cap, + * hash + size integrity, and snapshot decode). Returns the raw bytes as a + * number array so they can be passed to the existing preview/confirm APIs. + * + * Throws a human-readable error string on any validation failure. + */ +export async function fetchSnapshotBytes(args: { + url: string; + filename: string; + expectedSha256: string; + expectedSize: number; +}): Promise { + const buffer = await invokeTauri("fetch_snapshot_bytes", { + url: args.url, + filename: args.filename, + expectedSha256: args.expectedSha256, + expectedSize: args.expectedSize, + }); + return Array.from(new Uint8Array(buffer)); +} diff --git a/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs b/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs new file mode 100644 index 0000000000..8503a79349 --- /dev/null +++ b/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Pure type-level tests for the AgentSnapshotImportPreview shape and the +// AgentSnapshotImportResult partial-failure contract. These run in Node.js +// without a Tauri environment, so they test the pure mapping / guard logic +// used by the dialog, not the Tauri IPC itself. + +/** + * Build a minimal preview object as the backend would return it. + */ +function makePreview(overrides = {}) { + return { + displayName: "Test Agent", + systemPrompt: "You are helpful.", + avatarUrl: null, + memoryLevel: "none", + memoryEntryCount: 0, + hasSourceAllowlist: false, + sourceAllowlistCount: 0, + ...overrides, + }; +} + +/** + * Build a minimal import result as the backend would return it. + */ +function makeResult(overrides = {}) { + return { + displayName: "Test Agent", + newPubkey: "abc123", + personaId: "persona-uuid", + memoryWritten: 0, + memoryTotal: 0, + memoryErrors: [], + profileSyncError: null, + ...overrides, + }; +} + +// ── Preview: allowlist warning ──────────────────────────────────────────────── + +test("preview_has_source_allowlist_is_true_when_allowlist_non_empty", () => { + const preview = makePreview({ + hasSourceAllowlist: true, + sourceAllowlistCount: 2, + }); + assert.equal(preview.hasSourceAllowlist, true); + assert.equal(preview.sourceAllowlistCount, 2); +}); + +test("preview_has_source_allowlist_is_false_when_allowlist_empty", () => { + const preview = makePreview({ + hasSourceAllowlist: false, + sourceAllowlistCount: 0, + }); + assert.equal(preview.hasSourceAllowlist, false); +}); + +// ── Preview: memory warning ─────────────────────────────────────────────────── + +test("preview_memory_entry_count_drives_warning_display", () => { + const noMemory = makePreview({ memoryEntryCount: 0, memoryLevel: "none" }); + assert.equal(noMemory.memoryEntryCount, 0); + + const withMemory = makePreview({ + memoryEntryCount: 3, + memoryLevel: "everything", + }); + assert.equal(withMemory.memoryEntryCount, 3); + assert.equal(withMemory.memoryLevel, "everything"); +}); + +// ── Allowlist: default is clear (server-side enforced) ─────────────────────── + +test("allowlist_default_is_clear", () => { + // keepAllowlist defaults to false in the dialog — safe default. + const keepAllowlist = false; + const sourceAllowlist = ["aabb".repeat(16)]; + const appliedAllowlist = keepAllowlist ? sourceAllowlist : []; + assert.deepEqual(appliedAllowlist, []); +}); + +test("allowlist_keep_preserves_source", () => { + const keepAllowlist = true; + const sourceAllowlist = ["aabb".repeat(16)]; + const appliedAllowlist = keepAllowlist ? sourceAllowlist : []; + assert.deepEqual(appliedAllowlist, sourceAllowlist); +}); + +// ── Result: full success ────────────────────────────────────────────────────── + +test("result_full_success_has_no_errors", () => { + const result = makeResult({ + memoryWritten: 3, + memoryTotal: 3, + memoryErrors: [], + }); + assert.equal(result.memoryWritten, result.memoryTotal); + assert.equal(result.memoryErrors.length, 0); +}); + +// ── Result: partial failure ─────────────────────────────────────────────────── + +test("result_partial_failure_reports_incomplete_memory", () => { + const result = makeResult({ + memoryWritten: 1, + memoryTotal: 3, + memoryErrors: [ + 'slug "mem/foo": relay rejected engram: timeout', + 'slug "mem/bar": relay rejected engram: timeout', + ], + }); + assert.equal(result.memoryWritten < result.memoryTotal, true); + assert.equal(result.memoryErrors.length, 2); + // Agent itself was created: + assert.ok(result.newPubkey); + // Error count matches unwritten: + assert.equal( + result.memoryErrors.length, + result.memoryTotal - result.memoryWritten, + ); +}); + +test("result_partial_failure_agent_exists_despite_errors", () => { + const result = makeResult({ + newPubkey: "deadbeef", + memoryWritten: 0, + memoryTotal: 2, + memoryErrors: ['slug "core": build failed', 'slug "mem/x": timeout'], + }); + // Agent exists: + assert.equal(result.newPubkey, "deadbeef"); + // Memory failed: + assert.equal(result.memoryErrors.length, 2); +}); + +// ── Result: no memory snapshot ──────────────────────────────────────────────── + +test("result_no_memory_snapshot_has_zero_total", () => { + const result = makeResult({ memoryWritten: 0, memoryTotal: 0 }); + assert.equal(result.memoryTotal, 0); + assert.equal(result.memoryErrors.length, 0); +}); + +// ── Preview: memory level labelling ────────────────────────────────────────── + +test("preview_memory_level_labels_all_three_values", () => { + const levels = ["none", "core", "everything"]; + for (const level of levels) { + const preview = makePreview({ memoryLevel: level }); + assert.equal(preview.memoryLevel, level); + } +}); diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index b7520d7b5d..f499c868ce 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -195,6 +195,120 @@ export async function exportPersonaToJson(id: string): Promise { return invokeTauri("export_persona_to_json", { id }); } +export type SnapshotMemoryLevel = "none" | "core" | "everything"; +export type SnapshotFormat = "json" | "png"; + +export async function exportAgentSnapshot( + id: string, + memoryLevel: SnapshotMemoryLevel, + format: SnapshotFormat, + memorySourcePubkey?: string | null, +): Promise { + return invokeTauri("export_agent_snapshot", { + id, + memorySourcePubkey: memorySourcePubkey ?? null, + memoryLevel, + format, + }); +} + +/** The byte payload returned by `encode_agent_snapshot_for_send`. */ +export type EncodedSnapshotPayload = { + /** Raw snapshot bytes — pass directly to `uploadMediaBytes`. */ + fileBytes: number[]; + /** Suggested filename (e.g. `my-agent.agent.json`). */ + fileName: string; +}; + +/** + * Encode a snapshot in memory and return the bytes to the frontend without + * opening any file dialog. Use this for the native-send path; use + * `exportAgentSnapshot` for the local save-to-disk path. + * + * Both commands call the same shared Rust encoder, so byte output is + * identical for identical inputs. + */ +export async function encodeAgentSnapshotForSend( + id: string, + memoryLevel: SnapshotMemoryLevel, + format: SnapshotFormat, + memorySourcePubkey?: string | null, +): Promise { + return invokeTauri("encode_agent_snapshot_for_send", { + id, + memorySourcePubkey: memorySourcePubkey ?? null, + memoryLevel, + format, + }); +} + +// ── Snapshot import ─────────────────────────────────────────────────────────── + +/** Preview returned by `preview_agent_snapshot_import` before any write. */ +export type AgentSnapshotImportPreview = { + displayName: string; + systemPrompt: string | null; + /** Effective avatar: data URL if present, source URL fallback otherwise. */ + avatarUrl: string | null; + /** "none" | "core" | "everything" */ + memoryLevel: string; + memoryEntryCount: number; + /** True when the snapshot's respond_to_allowlist is non-empty. */ + hasSourceAllowlist: boolean; + sourceAllowlistCount: number; +}; + +/** Confirmation sent to `confirm_agent_snapshot_import`. */ +export type AgentSnapshotImportConfirm = { + fileBytes: number[]; + fileName: string; + /** When true, copy source allowlist to the new agent. Default: false (Clear). */ + keepAllowlist: boolean; +}; + +/** Structured result from a confirmed import. */ +export type AgentSnapshotImportResult = { + displayName: string; + /** Hex pubkey of the newly minted agent. */ + newPubkey: string; + /** Persona ID created for the agent. */ + personaId: string; + memoryWritten: number; + memoryTotal: number; + /** Non-empty when some memory entries failed to publish. */ + memoryErrors: string[]; + /** Non-empty when profile sync encountered a non-fatal relay error. */ + profileSyncError: string | null; +}; + +/** + * Decode and validate a snapshot file, returning a preview for the + * confirmation UI. No writes of any kind are performed. + */ +export async function previewAgentSnapshotImport( + fileBytes: number[], + fileName: string, +): Promise { + return invokeTauri( + "preview_agent_snapshot_import", + { fileBytes, fileName }, + ); +} + +/** + * Import a `buzz-agent-snapshot v1` file as a brand-new agent with fresh + * keys. Returns a structured result describing what was created and whether + * memory restoration was complete. + */ +export async function confirmAgentSnapshotImport( + input: AgentSnapshotImportConfirm, +): Promise { + return invokeTauri( + "confirm_agent_snapshot_import", + { input }, + ); +} + // Patches a single inbound persona/team/agent projection event into the local // store (personas.json). The backend resolves the match key and the // pending-edit race; the frontend only forwards the raw Nostr event JSON. diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index ab1f3ae865..681bd81af9 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -12,6 +12,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { requestOpenSnapshotImport } from "@/features/agents/openSnapshotImportFromUrlEvent"; import { parseMessageLink, resolveMessageLinkRenderTarget, @@ -72,7 +73,8 @@ import { MarkdownRuntimeContext, useMarkdownRuntime, } from "./markdown/runtimeContext"; -import { resolveFileCard } from "./markdownFileCard"; +import { AgentSnapshotCard } from "./markdown/AgentSnapshotCard"; +import { resolveFileCard, resolveSnapshotCard } from "./markdownFileCard"; import type { MarkdownProps, MarkdownRuntime } from "./markdown/types"; import { SpoilerInline } from "./markdown/SpoilerInline"; import { @@ -1507,7 +1509,8 @@ function createMarkdownComponents( href, ...props }: React.ComponentPropsWithoutRef<"a">) { - const { channels, imetaByUrl, onOpenMessageLink } = useMarkdownRuntime(); + const { channels, imetaByUrl, onOpenMessageLink, onImportSnapshotFromUrl } = + useMarkdownRuntime(); if (!interactive) { return {children}; } @@ -1521,6 +1524,27 @@ function createMarkdownComponents( const label = getReactNodeText(children); + // Agent snapshot attachment: classify before generic FileCard. + // resolveSnapshotCard checks the filename suffix + SHA-256 field. + const snapshotCard = resolveSnapshotCard( + href ? imetaByUrl?.get(href) : undefined, + href, + label, + ); + if (snapshotCard) { + return ( + { + onImportSnapshotFromUrl?.(fileBytes, fileName); + }} + /> + ); + } + // Generic file attachment: a `[filename](url)` link whose href matches an // imeta entry with a non-image, non-video MIME. Render a download card // instead of a plain link. (Media uses the `img` renderer, not this path.) @@ -1965,7 +1989,7 @@ function MarkdownInner({ }: MarkdownProps) { const { channels: rawChannels } = useChannelNavigation(); const channels = useStableArray(rawChannels); - const { goChannel } = useAppNavigation(); + const { goChannel, goAgents } = useAppNavigation(); const onOpenChannel = React.useCallback( (channelId: string) => { void goChannel(channelId); @@ -2004,6 +2028,10 @@ function MarkdownInner({ mentionPubkeysByName, onOpenChannel, onOpenMessageLink, + onImportSnapshotFromUrl: (fileBytes: number[], fileName: string) => { + requestOpenSnapshotImport({ fileBytes, fileName }); + void goAgents(); + }, }), [ agentMentionPubkeysByName, @@ -2012,6 +2040,7 @@ function MarkdownInner({ mentionPubkeysByName, onOpenChannel, onOpenMessageLink, + goAgents, ], ); diff --git a/desktop/src/shared/ui/markdown/AgentSnapshotCard.tsx b/desktop/src/shared/ui/markdown/AgentSnapshotCard.tsx new file mode 100644 index 0000000000..2e93bc3db1 --- /dev/null +++ b/desktop/src/shared/ui/markdown/AgentSnapshotCard.tsx @@ -0,0 +1,153 @@ +import * as React from "react"; +import { AlertCircle, Bot, Download, Loader2 } from "lucide-react"; + +import { invokeTauri } from "@/shared/api/tauri"; +import { fetchSnapshotBytes } from "@/shared/api/tauriMedia"; +import { useSmoothCorners } from "@/shared/ui/smoothCorners"; + +export type AgentSnapshotCardProps = { + href: string; + filename: string; + size?: number; + sha256: string; + /** + * Called after bytes are successfully fetched and decoded. The card + * navigates to /agents and triggers the existing importer flow via this + * callback. The caller (markdown renderer) must supply the app-level + * navigation + pending-import wiring. + */ + onImport: (fileBytes: number[], fileName: string) => void; +}; + +type ImportState = + | { phase: "idle" } + | { phase: "fetching" } + | { phase: "error"; message: string }; + +/** + * Snapshot attachment card rendered in a message timeline when an `.agent.json` + * or `.agent.png` attachment is classified as an agent snapshot candidate. + * + * Shows two independent actions: + * - **Import agent** — bounded, verified in-memory fetch → existing importer + * - **Download** — native save dialog via the unchanged `download_file` command + * + * The card text identifies the attachment as "Agent snapshot" (untrusted label) + * until the bytes have been verified by the Rust decoder; the sender-supplied + * agent name is never shown here. + */ +export function AgentSnapshotCard({ + href, + filename, + size, + sha256, + onImport, +}: AgentSnapshotCardProps) { + const cardRef = React.useRef(null); + useSmoothCorners(cardRef); + + const [importState, setImportState] = React.useState({ + phase: "idle", + }); + const inFlightRef = React.useRef(false); + + async function handleImport() { + if (inFlightRef.current) return; // prevent double-click + inFlightRef.current = true; + setImportState({ phase: "fetching" }); + try { + const fileBytes = await fetchSnapshotBytes({ + url: href, + filename, + expectedSha256: sha256, + expectedSize: size ?? 0, + }); + setImportState({ phase: "idle" }); + onImport(fileBytes, filename); + } catch (err) { + setImportState({ + phase: "error", + message: + err instanceof Error ? err.message : "Failed to fetch snapshot.", + }); + } finally { + inFlightRef.current = false; + } + } + + function handleDownload() { + invokeTauri("download_file", { url: href, filename }).catch(() => { + /* download errors are surfaced by the Rust side via toast */ + }); + } + + const isFetching = importState.phase === "fetching"; + + return ( +
+ {/* Header row: icon + filename */} +
+ + + +
+ + {filename} + + + Agent snapshot + {size != null + ? ` · ${size < 1024 ? `${size} B` : size < 1024 * 1024 ? `${(size / 1024).toFixed(1)} KB` : `${(size / (1024 * 1024)).toFixed(1)} MB`}` + : ""} + +
+
+ + {/* Error row */} + {importState.phase === "error" && ( +
+ + {importState.message} +
+ )} + + {/* Action row */} +
+ + +
+
+ ); +} diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index d5ae941a43..f0d87654ed 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -11,6 +11,8 @@ export type ImetaEntry = { size?: number; filename?: string; duration?: number; + /** SHA-256 hex of the attachment bytes (from imeta `x` field). */ + x?: string; }; export type ImetaLookup = Map; @@ -30,6 +32,14 @@ export type MarkdownRuntime = { mentionPubkeysByName?: Record; onOpenChannel: (channelId: string) => void; onOpenMessageLink: (link: ParsedMessageLink) => void; + /** + * Called by AgentSnapshotCard after a successful verified in-memory fetch. + * The implementation should navigate to /agents and trigger the existing + * snapshot import flow with the supplied bytes. Optional — when absent the + * Import button is present but falls back to a no-op (the card is still + * rendered on read-only surfaces such as the forum post renderer). + */ + onImportSnapshotFromUrl?: (fileBytes: number[], fileName: string) => void; }; export type MarkdownProps = { diff --git a/desktop/src/shared/ui/markdownFileCard.test.mjs b/desktop/src/shared/ui/markdownFileCard.test.mjs index 2207efbfae..aadf28d1c5 100644 --- a/desktop/src/shared/ui/markdownFileCard.test.mjs +++ b/desktop/src/shared/ui/markdownFileCard.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { resolveFileCard } from "./markdownFileCard.ts"; +import { resolveFileCard, resolveSnapshotCard } from "./markdownFileCard.ts"; // A generic-file URL (non-media extension) does not match the relay-media // proxy regex, so `rewriteRelayUrl` passes it through unchanged — assertions @@ -75,3 +75,135 @@ test("resolveFileCard: octet-stream (no magic bytes) is treated as a file", () = ); assert.equal(card?.filename, "notes.txt"); }); + +// ── resolveSnapshotCard ─────────────────────────────────────────────────────── + +const SHA256 = "a".repeat(64); +const JSON_URL = `https://relay.example/media/${SHA256}.json`; +const PNG_URL = `https://relay.example/media/${SHA256}.png`; + +test("resolveSnapshotCard: .agent.json with sha256 returns snapshot card", () => { + const card = resolveSnapshotCard( + { + m: "application/json", + size: 1234, + filename: "analyst.agent.json", + x: SHA256, + }, + JSON_URL, + "", + ); + assert.ok(card !== null, "expected a snapshot card"); + assert.equal(card.filename, "analyst.agent.json"); + assert.equal(card.sha256, SHA256); + assert.equal(card.snapshotKind, "agent"); + assert.equal(card.size, 1234); +}); + +test("resolveSnapshotCard: .agent.png with image/png mime returns snapshot card", () => { + const card = resolveSnapshotCard( + { m: "image/png", size: 2048, filename: "analyst.agent.png", x: SHA256 }, + PNG_URL, + "", + ); + assert.ok(card !== null); + assert.equal(card.filename, "analyst.agent.png"); + assert.equal(card.snapshotKind, "agent"); +}); + +test("resolveSnapshotCard: .agent.json with octet-stream mime is eligible", () => { + // JSON snapshots often arrive as application/octet-stream. + const card = resolveSnapshotCard( + { + m: "application/octet-stream", + size: 1234, + filename: "analyst.agent.json", + x: SHA256, + }, + JSON_URL, + "", + ); + assert.ok(card !== null, "octet-stream JSON should be a snapshot candidate"); +}); + +test("resolveSnapshotCard: .agent.png with wrong mime is rejected", () => { + const card = resolveSnapshotCard( + { m: "application/json", filename: "analyst.agent.png", x: SHA256 }, + PNG_URL, + "", + ); + assert.equal(card, null, "PNG with non-image MIME must be rejected"); +}); + +test("resolveSnapshotCard: missing sha256 (no x field) returns null", () => { + const card = resolveSnapshotCard( + { m: "application/json", filename: "analyst.agent.json" }, + JSON_URL, + "", + ); + assert.equal(card, null, "no sha256 must not produce a snapshot card"); +}); + +test("resolveSnapshotCard: plain .json is not a snapshot candidate", () => { + const card = resolveSnapshotCard( + { m: "application/json", filename: "data.json", x: SHA256 }, + JSON_URL, + "", + ); + assert.equal(card, null, "plain .json must not be a snapshot candidate"); +}); + +test("resolveSnapshotCard: plain .png is not a snapshot candidate", () => { + const card = resolveSnapshotCard( + { m: "image/png", filename: "photo.png", x: SHA256 }, + PNG_URL, + "", + ); + assert.equal(card, null, "plain .png must not be a snapshot candidate"); +}); + +test("resolveSnapshotCard: deceptive double-extension foo.agent.json.exe rejected", () => { + const card = resolveSnapshotCard( + { + m: "application/octet-stream", + filename: "foo.agent.json.exe", + x: SHA256, + }, + JSON_URL, + "", + ); + assert.equal(card, null, "deceptive .exe extension must be rejected"); +}); + +test("resolveSnapshotCard: agent.json without leading dot is rejected", () => { + // The file must end with .agent.json, not just contain "agent.json". + const card = resolveSnapshotCard( + { m: "application/json", filename: "agent.json", x: SHA256 }, + JSON_URL, + "", + ); + // "agent.json" does NOT end with ".agent.json" (no leading dot prefix) + assert.equal(card, null, "agent.json without prefix must be rejected"); +}); + +test("resolveSnapshotCard: falls back to URL filename when no imeta filename", () => { + const url = `https://relay.example/media/${SHA256}.agent.json`; + const card = resolveSnapshotCard( + { m: "application/json", x: SHA256 }, + url, + "", + ); + assert.ok(card !== null, "should classify from URL filename"); + assert.ok(card.filename.endsWith(".agent.json")); +}); + +test("resolveSnapshotCard: generic FileCard not affected", () => { + // A generic PDF should still return null from the snapshot resolver + // (and fall through to resolveFileCard). + const card = resolveSnapshotCard( + { m: "application/pdf", filename: "report.pdf", x: SHA256 }, + JSON_URL, + "", + ); + assert.equal(card, null); +}); diff --git a/desktop/src/shared/ui/markdownFileCard.ts b/desktop/src/shared/ui/markdownFileCard.ts index b20338fdc1..af6300f2d9 100644 --- a/desktop/src/shared/ui/markdownFileCard.ts +++ b/desktop/src/shared/ui/markdownFileCard.ts @@ -5,6 +5,8 @@ export type FileCardImetaEntry = { m?: string; size?: number; filename?: string; + /** SHA-256 of the attachment bytes (from imeta `x` field). */ + x?: string; }; export type ResolvedFileCard = { @@ -13,6 +15,77 @@ export type ResolvedFileCard = { size?: number; }; +/** + * A snapshot candidate resolved from an imeta entry. The card shows both + * an **Import agent** and a **Download** action. + * + * `snapshotKind` is discriminated so a future `.team.*` resolver can share + * the same routing path without adding agent-only assumptions. + */ +export type ResolvedSnapshotCard = { + href: string; + filename: string; + size?: number; + /** SHA-256 hex from the imeta `x` field — required for verified fetch. */ + sha256: string; + /** Discriminant for the snapshot kind — currently only "agent". */ + snapshotKind: "agent"; +}; + +/** + * Classify a markdown link as a snapshot candidate. + * + * A link is a candidate when: + * - The filename ends with `.agent.json` or `.agent.png` (exact suffix, case- + * insensitive, after extracting from the URL if imeta has no filename). + * - For `.agent.png`, the MIME must be `image/png` or absent (upload MIME is + * authoritative only for PNG because generic JSON often arrives as + * `application/octet-stream`). + * - The imeta entry carries a non-empty `x` (SHA-256) field — required for + * the verified bounded fetch; without it the card cannot enable Import. + * + * Returns `null` to fall through to generic FileCard handling. + */ +export function resolveSnapshotCard( + entry: FileCardImetaEntry | undefined, + href: string | undefined, + childText: string, +): ResolvedSnapshotCard | null { + if (!href || !entry) return null; + + const filename = + entry.filename || childText.trim() || (href.split("/").pop() ?? ""); + + if (!filename) return null; + + const lower = filename.toLowerCase(); + const isJson = lower.endsWith(".agent.json"); + const isPng = lower.endsWith(".agent.png"); + + if (!isJson && !isPng) return null; + + // For PNG: MIME must be image/png when present; other MIMEs are inconsistent. + if (isPng && entry.m && entry.m !== "image/png") return null; + + // SHA-256 is required for the bounded verified fetch. + const sha256 = entry.x?.trim(); + if (!sha256 || sha256.length !== 64) return null; + + // Reject deceptive double-extensions: the filename itself must end with + // exactly .agent.json or .agent.png — no additional extensions after. + // (sanitize_filename on the Rust side provides the second line of defence.) + if (isJson && !filename.endsWith(".agent.json")) return null; + if (isPng && !filename.endsWith(".agent.png")) return null; + + return { + href: rewriteRelayUrl(href), + filename, + size: entry.size, + sha256, + snapshotKind: "agent", + }; +} + /** * Decide whether a markdown link should render as a generic-file download * card. A link qualifies when its href matches an imeta entry whose MIME is diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6fa3aedd88..883f19e8f2 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9,6 +9,7 @@ import type { ConnectionState } from "@/shared/api/relayClientShared"; import type { RelayEvent } from "@/shared/api/types"; import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache"; import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore"; +import { recordTimeoutFromRejection } from "@/features/moderation/lib/timeoutStore"; import { injectObserverEventsForE2E, syncAgentObserverEvents, @@ -167,6 +168,17 @@ type E2eConfig = { // tests/helpers/bridge.ts:MockBridgeOptions.uploadDescriptors. meshReporterPubkey?: string; uploadDelayMs?: number; + /** Delay (ms) applied to `encode_agent_snapshot_for_send` so E2E tests can + * observe the "preparing" phase before the upload begins. 0/undefined = instant. */ + encodeDelayMs?: number; + /** Delay (ms) applied to `get_relay_self` so E2E tests can prove the + * fail-closed race: DMs are withheld while classification is unresolved. */ + relaySelfDelayMs?: number; + /** + * When set to a non-empty string, `fetch_snapshot_bytes` throws with this + * message — lets specs prove malformed/hash/size-mismatch error paths. + */ + snapshotFetchError?: string; uploadDescriptors?: RawBlobDescriptor[]; // Seed rows returned by `list_save_subscriptions`. Each entry uses the same // snake_case wire shape the Rust backend returns so tests can drive the @@ -783,6 +795,32 @@ declare global { invalidateQueries: (filters: { queryKey: readonly unknown[] }) => unknown; }; __BUZZ_E2E_MD_PARSE_COUNT__?: () => number; + /** + * Activate the community timeout store as if a send was rejected with a + * timeout message. Lets E2E tests prove the timeout gate fires before encode. + * Call after page load. Pass expiresAtMs (epoch ms) or 0 for unknown expiry. + */ + __BUZZ_E2E_ACTIVATE_TIMEOUT__?: (expiresAtMs: number) => void; + /** + * Invalidate the channels React Query cache so E2E tests can trigger a + * re-fetch after calling archive_channel / update_channel via + * __BUZZ_E2E_INVOKE_MOCK_COMMAND__. Call after the mutation to make the + * updated channel state visible to subscribers. + */ + __BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise; + /** + * Directly mutate a mock channel's properties without going through a + * command handler. Use for E2E regressions that need to change + * channel_type or remove isMember in a single synchronous step, then + * follow up with __BUZZ_E2E_INVALIDATE_CHANNELS__ to flush the cache. + * + * Only the listed fields are writeable; omitted fields are left unchanged. + */ + __BUZZ_E2E_MUTATE_CHANNEL__?: (opts: { + channelId: string; + channelType?: "stream" | "forum" | "dm"; + removeMemberPubkey?: string; + }) => void; } } @@ -2241,6 +2279,70 @@ const mockChannels: MockChannel[] = [ createMockMember(MOCK_IDENTITY_PUBKEY, "member", 700), ], }), + // Generic-named DM — name is "DM" so resolveChannelDisplayLabel must resolve + // the participant display name instead of returning the raw channel name. + // Used by agent-snapshot-send.spec.ts to prove picker/search/memgate/done + // all use the same resolved label from useUsersBatchQuery. + createMockChannel({ + id: "d1ec7000-d000-4000-8000-000000000001", + name: "DM", + channel_type: "dm", + visibility: "private", + description: "Generic-named DM with charlie", + topic: null, + purpose: null, + last_message_at: null, + archived_at: null, + created_by: CHARLIE_PUBKEY, + topic_set_by: null, + topic_set_at: null, + purpose_set_by: null, + purpose_set_at: null, + topic_required: false, + max_members: 2, + nip29_group_id: null, + created_minutes_ago: 680, + updated_minutes_ago: 680, + participants: ["charlie", "tyler"], + participant_pubkeys: [CHARLIE_PUBKEY, MOCK_IDENTITY_PUBKEY], + members: [ + createMockMember(CHARLIE_PUBKEY, "member", 680), + createMockMember(MOCK_IDENTITY_PUBKEY, "member", 680), + ], + }), + // Generic-named Group DM — name "Group DM (3)" so resolveChannelDisplayLabel + // must resolve all OTHER participants' display names (bob, charlie). + // Used by agent-snapshot-send.spec.ts group-DM label test. + // NOTE: participants are BOB + CHARLIE (not ALICE, which conflicts with + // ANALYST_PUBKEY in managed-agent tests). + createMockChannel({ + id: "d1ec7000-d000-4000-8000-000000000003", + name: "Group DM (3)", + channel_type: "dm", + visibility: "private", + description: "Generic-named group DM with bob and charlie", + topic: null, + purpose: null, + last_message_at: null, + archived_at: null, + created_by: BOB_PUBKEY, + topic_set_by: null, + topic_set_at: null, + purpose_set_by: null, + purpose_set_at: null, + topic_required: false, + max_members: 3, + nip29_group_id: null, + created_minutes_ago: 660, + updated_minutes_ago: 660, + participants: ["bob", "charlie", "tyler"], + participant_pubkeys: [BOB_PUBKEY, CHARLIE_PUBKEY, MOCK_IDENTITY_PUBKEY], + members: [ + createMockMember(BOB_PUBKEY, "member", 660), + createMockMember(CHARLIE_PUBKEY, "member", 660), + createMockMember(MOCK_IDENTITY_PUBKEY, "member", 660), + ], + }), // Deep history channel for the load-older-under-virtualization E2E. Seeded // with more messages than CHANNEL_HISTORY_LIMIT (300) so the initial load // windows to the newest page and a `fetchOlder` (until-cursor) prepend has @@ -7978,6 +8080,37 @@ export function maybeInstallE2eTauriMocks() { return item; }; window.__BUZZ_E2E_MD_PARSE_COUNT__ = getMarkdownParseCount; + window.__BUZZ_E2E_ACTIVATE_TIMEOUT__ = (expiresAtMs: number) => { + const expiresAtSec = expiresAtMs > 0 ? Math.floor(expiresAtMs / 1000) : 0; + const msg = + expiresAtSec > 0 + ? `restricted: you are timed out until ${expiresAtSec}` + : "restricted: you are timed out until 0"; + recordTimeoutFromRejection(msg); + }; + window.__BUZZ_E2E_INVALIDATE_CHANNELS__ = async () => { + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + }; + window.__BUZZ_E2E_MUTATE_CHANNEL__ = ({ + channelId, + channelType, + removeMemberPubkey, + }) => { + const channel = mockChannels.find((ch) => ch.id === channelId); + if (!channel) return; + if (channelType !== undefined) { + channel.channel_type = channelType; + } + if (removeMemberPubkey !== undefined) { + channel.members = channel.members.filter( + (m) => m.pubkey !== removeMemberPubkey, + ); + syncMockChannel(channel); + } + touchMockChannel(channel); + }; window.__BUZZ_E2E_EMIT_MOCK_READ_STATE__ = ({ clientId, contexts, @@ -8633,6 +8766,63 @@ export function maybeInstallE2eTauriMocks() { ); case "export_persona_to_json": return handleExportPersonaToJson(payload as { id: string }); + case "export_agent_snapshot": + // Mimics the save-to-disk path: report success without a real dialog. + // Specs assert invocation via __BUZZ_E2E_COMMANDS__. + return true; + case "encode_agent_snapshot_for_send": { + // Return a minimal valid `.agent.json` payload so the send flow can + // proceed through upload_media_bytes without a real Rust encode step. + // Optional encodeDelayMs lets specs observe the "preparing" phase before + // the upload begins. + const encodeDelayMs = activeConfig?.mock?.encodeDelayMs ?? 0; + if (encodeDelayMs > 0) { + await new Promise((resolve) => + window.setTimeout(resolve, encodeDelayMs), + ); + } + const jsonBytes = Array.from( + new TextEncoder().encode( + JSON.stringify({ + format: "buzz-agent-snapshot", + version: 1, + definition: { system_prompt: null }, + profile: { display_name: "E2E Agent" }, + memory: { level: "none", entries: [] }, + }), + ), + ); + return { + fileBytes: jsonBytes, + fileName: "e2e-agent.agent.json", + }; + } + case "preview_agent_snapshot_import": { + // Return a minimal preview — no writes performed. + return { + displayName: "Imported Agent", + systemPrompt: null, + avatarUrl: null, + memoryLevel: "none", + memoryEntryCount: 0, + hasSourceAllowlist: false, + sourceAllowlistCount: 0, + }; + } + case "confirm_agent_snapshot_import": { + // Return a successful import result with fresh synthetic keys. + const importResult = { + displayName: "Imported Agent", + newPubkey: + "e2e000000000000000000000000000000000000000000000000000000000000ff", + personaId: `e2e-persona-${Date.now()}`, + memoryWritten: 0, + memoryTotal: 0, + memoryErrors: [], + profileSyncError: null, + }; + return importResult; + } case "list_managed_agents": return handleListManagedAgents(activeConfig); case "get_agent_memory": @@ -8889,6 +9079,29 @@ export function maybeInstallE2eTauriMocks() { if (!response.ok) throw new Error(`fetch failed: ${response.status}`); return await response.arrayBuffer(); } + case "fetch_snapshot_bytes": { + // The real command fetches + validates a snapshot attachment in memory + // (size cap, SHA-256, decode). In E2E the bridge returns a minimal + // valid .agent.json payload so the import flow can proceed without a + // real relay. A non-null snapshotFetchError config forces a rejection. + const err = activeConfig?.mock?.snapshotFetchError; + if (err) throw new Error(err); + const jsonBytes = Array.from( + new TextEncoder().encode( + JSON.stringify({ + format: "buzz-agent-snapshot", + version: 1, + definition: { system_prompt: "E2E imported agent prompt." }, + profile: { display_name: "Imported Agent" }, + memory: { level: "none", entries: [] }, + }), + ), + ); + // Return as ArrayBuffer to mirror the real Tauri ipc::Response. + const buf = new ArrayBuffer(jsonBytes.length); + new Uint8Array(buf).set(jsonBytes); + return buf; + } case "download_image": case "download_file": // The save dialog can't run headlessly; report a successful save so the @@ -9052,6 +9265,11 @@ export function maybeInstallE2eTauriMocks() { return { archived }; } case "get_relay_self": + if ((activeConfig?.mock?.relaySelfDelayMs ?? 0) > 0) { + await new Promise((resolve) => + window.setTimeout(resolve, activeConfig!.mock!.relaySelfDelayMs), + ); + } return activeConfig?.mock?.relaySelf ?? null; case "archive_identity": case "unarchive_identity": diff --git a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts new file mode 100644 index 0000000000..584d75bd34 --- /dev/null +++ b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts @@ -0,0 +1,287 @@ +/** + * Recipient-native agent snapshot card/import E2E spec. + * + * These tests exercise the AgentSnapshotCard rendered in a message timeline + * when an .agent.json or .agent.png attachment is detected, and the full + * Import agent → preview → confirm flow. + */ +import { expect, test } from "@playwright/test"; +import { + installMockBridge, + createMockAgentMemoryListing, +} from "../helpers/bridge"; + +type CommandLogEntry = { command: string; payload: unknown }; + +async function readCommandLog(page: import("@playwright/test").Page) { + return page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: CommandLogEntry[]; + } + ).__BUZZ_E2E_COMMAND_LOG__ ?? [], + ); +} + +const ANALYST_PERSONA_ID = "test-analyst"; +const ANALYST_PUBKEY = + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; +const SHA256 = "a".repeat(64); + +/** Full imeta descriptor for a .agent.json attachment. */ +const SNAPSHOT_UPLOAD_DESCRIPTOR = { + url: `https://mock.relay/media/${SHA256}.json`, + sha256: SHA256, + size: 1234, + type: "application/json", + uploaded: Math.floor(Date.now() / 1000), + filename: "e2e-agent.agent.json", +}; + +/** Malformed snapshot — bridge will be seeded with snapshotFetchError */ +const BAD_UPLOAD_DESCRIPTOR = { + ...SNAPSHOT_UPLOAD_DESCRIPTOR, + filename: "bad.agent.json", +}; + +// ── Helper: seed bridge, send snapshot to #general, navigate to timeline ───── + +async function seedAndSendSnapshot( + page: import("@playwright/test").Page, + opts: { snapshotFetchError?: string } = {}, +) { + await installMockBridge(page, { + personas: [ + { + id: ANALYST_PERSONA_ID, + displayName: "Analyst", + systemPrompt: "You are an analyst.", + }, + ], + managedAgents: [ + { + pubkey: ANALYST_PUBKEY, + name: "Analyst", + personaId: ANALYST_PERSONA_ID, + }, + ], + uploadDescriptors: [SNAPSHOT_UPLOAD_DESCRIPTOR], + ...(opts.snapshotFetchError + ? { snapshotFetchError: opts.snapshotFetchError } + : {}), + }); + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + + // Open the send dialog and send to #general. + await page.getByLabel("Open actions for Analyst").click(); + await page.getByRole("menuitem", { name: "Export snapshot" }).click(); + const sendBtn = page.getByRole("button", { name: "Send in Buzz" }); + if (await sendBtn.isVisible()) await sendBtn.click(); + await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible(); + await page + .getByTestId("agent-snapshot-send-channel-list") + .getByText("general") + .click(); + await page.getByTestId("agent-snapshot-send-confirm").click(); + await expect(page.getByTestId("agent-snapshot-send-done")).toBeVisible({ + timeout: 8000, + }); + await page.getByRole("button", { name: "Close" }).click(); + + // Navigate to #general. + await page.getByTestId("channel-general").click(); +} + +// ── Timeline renders AgentSnapshotCard, not generic FileCard ────────────────── + +test("recipient_timeline_renders_agent_snapshot_card_not_file_card", async ({ + page, +}) => { + await seedAndSendSnapshot(page); + + // The sent attachment must render as AgentSnapshotCard. + const card = page.getByTestId("agent-snapshot-card").last(); + await expect(card).toBeVisible({ timeout: 5000 }); + + // Must show filename. + await expect(card).toContainText("e2e-agent.agent.json"); + + // Must show "Agent snapshot" label (untrusted until decode). + await expect(card).toContainText("Agent snapshot"); + + // Both actions must be present. + await expect(card.getByTestId("agent-snapshot-card-import")).toBeVisible(); + await expect(card.getByTestId("agent-snapshot-card-download")).toBeVisible(); + + // Generic FileCard must NOT be present for this attachment. + await expect(page.getByTestId("file-card")).toHaveCount(0); +}); + +// ── Download still invokes download_file, not fetch_snapshot_bytes ─────────── + +test("recipient_download_invokes_download_file_only", async ({ page }) => { + await seedAndSendSnapshot(page); + + const card = page.getByTestId("agent-snapshot-card").last(); + await expect(card).toBeVisible({ timeout: 5000 }); + + await card.getByTestId("agent-snapshot-card-download").click(); + + const log = await readCommandLog(page); + const downloadCmds = log.filter((e) => e.command === "download_file"); + const fetchSnapshotCmds = log.filter( + (e) => e.command === "fetch_snapshot_bytes", + ); + expect(downloadCmds.length).toBeGreaterThanOrEqual(1); + expect(fetchSnapshotCmds.length).toBe(0); +}); + +// ── Import agent: fetch → navigate to agents → open preview ────────────────── + +test("recipient_import_navigates_to_agents_and_opens_preview", async ({ + page, +}) => { + await seedAndSendSnapshot(page); + + const card = page.getByTestId("agent-snapshot-card").last(); + await expect(card).toBeVisible({ timeout: 5000 }); + + // Click Import agent. + await card.getByTestId("agent-snapshot-card-import").click(); + + // fetch_snapshot_bytes must have been called. + await expect(async () => { + const log = await readCommandLog(page); + const fetchCmds = log.filter((e) => e.command === "fetch_snapshot_bytes"); + expect(fetchCmds.length).toBeGreaterThanOrEqual(1); + }).toPass({ timeout: 5000 }); + + // download_file must NOT have been called for Import. + const log = await readCommandLog(page); + const downloadCmds = log.filter((e) => e.command === "download_file"); + expect(downloadCmds.length).toBe(0); + + // Preview dialog must open on the agents view. + const dialog = page.getByTestId("agent-snapshot-import-dialog"); + await expect(dialog).toBeVisible({ timeout: 8000 }); + + // Decoded display name must appear. + await expect(dialog).toContainText("Imported Agent"); +}); + +// ── Confirm imports the agent ───────────────────────────────────────────────── + +test("recipient_import_confirm_calls_confirm_once_and_shows_result", async ({ + page, +}) => { + await seedAndSendSnapshot(page); + + const card = page.getByTestId("agent-snapshot-card").last(); + await expect(card).toBeVisible({ timeout: 5000 }); + await card.getByTestId("agent-snapshot-card-import").click(); + + const dialog = page.getByTestId("agent-snapshot-import-dialog"); + await expect(dialog).toBeVisible({ timeout: 8000 }); + + // Confirm the import. + await dialog.getByTestId("agent-snapshot-import-confirm").click(); + + // confirm_agent_snapshot_import must have been called exactly once. + await expect(async () => { + const log = await readCommandLog(page); + const confirmCmds = log.filter( + (e) => e.command === "confirm_agent_snapshot_import", + ); + expect(confirmCmds.length).toBe(1); + }).toPass({ timeout: 5000 }); +}); + +// ── Malformed/error candidate shows error, never opens preview ──────────────── + +test("recipient_fetch_error_shows_error_and_download_remains", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: ANALYST_PERSONA_ID, + displayName: "Analyst", + systemPrompt: "You are an analyst.", + }, + ], + managedAgents: [ + { + pubkey: ANALYST_PUBKEY, + name: "Analyst", + personaId: ANALYST_PERSONA_ID, + }, + ], + uploadDescriptors: [ + { + ...SNAPSHOT_UPLOAD_DESCRIPTOR, + filename: "bad.agent.json", + }, + ], + snapshotFetchError: "hash mismatch: fetched bytes do not match", + }); + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + + // Send to #general. + await page.getByLabel("Open actions for Analyst").click(); + await page.getByRole("menuitem", { name: "Export snapshot" }).click(); + const sendBtn = page.getByRole("button", { name: "Send in Buzz" }); + if (await sendBtn.isVisible()) await sendBtn.click(); + await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible(); + await page + .getByTestId("agent-snapshot-send-channel-list") + .getByText("general") + .click(); + await page.getByTestId("agent-snapshot-send-confirm").click(); + await expect(page.getByTestId("agent-snapshot-send-done")).toBeVisible({ + timeout: 8000, + }); + await page.getByRole("button", { name: "Close" }).click(); + await page.getByTestId("channel-general").click(); + + const card = page.getByTestId("agent-snapshot-card").last(); + await expect(card).toBeVisible({ timeout: 5000 }); + await card.getByTestId("agent-snapshot-card-import").click(); + + // Error must appear; preview must NOT open. + const errorEl = card.getByTestId("agent-snapshot-card-error"); + await expect(errorEl).toBeVisible({ timeout: 5000 }); + await expect(errorEl).toContainText("hash mismatch"); + await expect( + page.getByTestId("agent-snapshot-import-dialog"), + ).not.toBeVisible(); + + // Download must remain available after error. + await expect(card.getByTestId("agent-snapshot-card-download")).toBeVisible(); +}); + +// ── Double-click produces one fetch/preview flow ────────────────────────────── + +test("recipient_double_click_import_opens_one_preview", async ({ page }) => { + await seedAndSendSnapshot(page); + + const card = page.getByTestId("agent-snapshot-card").last(); + await expect(card).toBeVisible({ timeout: 5000 }); + const importBtn = card.getByTestId("agent-snapshot-card-import"); + + // Click Import agent. + await importBtn.click(); + + // Import dialog must open exactly once (not duplicated by rapid clicks). + const dialog = page.getByTestId("agent-snapshot-import-dialog"); + await expect(dialog).toHaveCount(1, { timeout: 5000 }); + + // Exactly one fetch_snapshot_bytes call. + const log = await readCommandLog(page); + const fetchCount = log.filter( + (e) => e.command === "fetch_snapshot_bytes", + ).length; + expect(fetchCount).toBe(1); +}); diff --git a/desktop/tests/e2e/agent-snapshot-send.spec.ts b/desktop/tests/e2e/agent-snapshot-send.spec.ts new file mode 100644 index 0000000000..41d0f04964 --- /dev/null +++ b/desktop/tests/e2e/agent-snapshot-send.spec.ts @@ -0,0 +1,1225 @@ +import { expect, test } from "@playwright/test"; + +import { + installMockBridge, + createMockAgentMemoryListing, +} from "../helpers/bridge"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +type CommandLogEntry = { command: string; payload: unknown }; + +async function readCommandLog(page: import("@playwright/test").Page) { + return page.evaluate(() => { + return ( + ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: CommandLogEntry[]; + } + ).__BUZZ_E2E_COMMAND_LOG__ ?? [] + ); + }); +} + +async function gotoAgentsPage(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); +} + +// Seeded persona ID used across tests. +const ANALYST_PERSONA_ID = "test-analyst"; +const ANALYST_PUBKEY = + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; + +const MOCK_UPLOAD_DESCRIPTOR = { + url: `https://mock.relay/media/${"a".repeat(64)}.json`, + sha256: "a".repeat(64), + size: 1234, + type: "application/json", + uploaded: Math.floor(Date.now() / 1000), + filename: "analyst.agent.json", +}; + +// ── Destination picker: channel/DM visibility ───────────────────────────────── + +test("snapshot_send_dialog_shows_joined_channels_and_dms", async ({ page }) => { + await installMockBridge(page, { + personas: [ + { + id: ANALYST_PERSONA_ID, + displayName: "Analyst", + systemPrompt: "You are an analyst.", + }, + ], + managedAgents: [ + { + pubkey: ANALYST_PUBKEY, + name: "Analyst", + personaId: ANALYST_PERSONA_ID, + }, + ], + uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR], + }); + await gotoAgentsPage(page); + + await page.getByLabel("Open actions for Analyst").click(); + await page.getByRole("menuitem", { name: "Export snapshot" }).click(); + // Pick the "Send in Buzz" option (if shown via format modal) or directly + // expect the dialog. The export dialog offers save vs send — click Send in Buzz. + const sendBtn = page.getByRole("button", { name: "Send in Buzz" }); + if (await sendBtn.isVisible()) { + await sendBtn.click(); + } + + await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible(); + + const list = page.getByTestId("agent-snapshot-send-channel-list"); + await expect(list).toBeVisible(); + + // Joined streams (general, random) must appear. + await expect(list).toContainText("general"); + await expect(list).toContainText("random"); + + // DMs (alice-tyler, bob-tyler) must appear. + await expect(list).toContainText("alice-tyler"); + await expect(list).toContainText("bob-tyler"); +}); + +test("snapshot_send_dialog_excludes_forum_archived_and_moderation_dm", async ({ + page, +}) => { + // RELAY_SELF_PUBKEY matches alice (the DM peer) so alice-tyler becomes a moderation DM. + const ALICE_PUBKEY = + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; + await installMockBridge(page, { + personas: [ + { + id: ANALYST_PERSONA_ID, + displayName: "Analyst", + systemPrompt: "You are an analyst.", + }, + ], + managedAgents: [ + { + pubkey: ANALYST_PUBKEY, + name: "Analyst", + personaId: ANALYST_PERSONA_ID, + }, + ], + relaySelf: ALICE_PUBKEY, + uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR], + }); + await gotoAgentsPage(page); + + await page.getByLabel("Open actions for Analyst").click(); + await page.getByRole("menuitem", { name: "Export snapshot" }).click(); + const sendBtn = page.getByRole("button", { name: "Send in Buzz" }); + if (await sendBtn.isVisible()) await sendBtn.click(); + + await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible(); + + const list = page.getByTestId("agent-snapshot-send-channel-list"); + await expect(list).toBeVisible(); + + // The DM whose only other participant is ALICE_PUBKEY (= relaySelf) must + // NOT appear — it is a moderation DM. + await expect(list.getByText("alice-tyler")).toHaveCount(0); + + // Forums must not appear (watercooler and announcements are seeded forums). + await expect(list).not.toContainText("watercooler"); + await expect(list).not.toContainText("announcements"); + + // Non-member channels must not appear (design and sales exclude the mock user). + await expect(list).not.toContainText("design"); + await expect(list).not.toContainText("sales"); +}); + +// ── Moderation-DM fail-closed race: DMs withheld during relay-self loading ──── + +test("snapshot_send_moderation_dm_not_selectable_during_relay_self_loading", async ({ + page, +}) => { + // ALICE_PUBKEY is relaySelf but the response is delayed — alice-tyler must + // NOT appear in the picker while get_relay_self is in-flight. + const ALICE_PUBKEY = + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; + await installMockBridge(page, { + personas: [ + { + id: ANALYST_PERSONA_ID, + displayName: "Analyst", + systemPrompt: "You are an analyst.", + }, + ], + managedAgents: [ + { + pubkey: ANALYST_PUBKEY, + name: "Analyst", + personaId: ANALYST_PERSONA_ID, + }, + ], + relaySelf: ALICE_PUBKEY, + // Delay get_relay_self so we can observe the fail-closed window. + relaySelfDelayMs: 2000, + uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR], + }); + await gotoAgentsPage(page); + + await page.getByLabel("Open actions for Analyst").click(); + await page.getByRole("menuitem", { name: "Export snapshot" }).click(); + const sendBtn = page.getByRole("button", { name: "Send in Buzz" }); + if (await sendBtn.isVisible()) await sendBtn.click(); + + await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible(); + + const list = page.getByTestId("agent-snapshot-send-channel-list"); + await expect(list).toBeVisible(); + + // While relay-self is loading, ALL DMs must be withheld (fail-closed). + // alice-tyler, bob-tyler, and the generic "DM" channel must not appear. + await expect(list.getByText("alice-tyler")).toHaveCount(0); + await expect(list.getByText("bob-tyler")).toHaveCount(0); + await expect(list.getByText("charlie")).toHaveCount(0); + + // Attempting to confirm must not be possible (no DM is selectable). + // Streams (general, random) are still available — confirm that. + await expect(list).toContainText("general"); + + // Select general and confirm — must proceed to done without any DM encode. + await list.getByText("general").click(); + await page.getByTestId("agent-snapshot-send-confirm").click(); + await expect(page.getByTestId("agent-snapshot-send-done")).toBeVisible({ + timeout: 8000, + }); + + // No DM channel was sent to. + const log = await readCommandLog(page); + const sendEntry = log.find((e) => e.command === "send_channel_message"); + const sendPayload = sendEntry?.payload as { channelId?: string } | undefined; + // general's id is the well-known seed value. + expect(sendPayload?.channelId).toBe("9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"); +}); + +// ── Config-only send flow: encode → upload → send, correct destination ──────── + +test("snapshot_send_config_only_calls_encode_upload_send_in_order", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: ANALYST_PERSONA_ID, + displayName: "Analyst", + systemPrompt: "You are an analyst.", + }, + ], + managedAgents: [ + { + pubkey: ANALYST_PUBKEY, + name: "Analyst", + personaId: ANALYST_PERSONA_ID, + }, + ], + uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR], + }); + await gotoAgentsPage(page); + + await page.getByLabel("Open actions for Analyst").click(); + await page.getByRole("menuitem", { name: "Export snapshot" }).click(); + const sendBtn = page.getByRole("button", { name: "Send in Buzz" }); + if (await sendBtn.isVisible()) await sendBtn.click(); + + await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible(); + + // Select #general. + await page + .getByTestId("agent-snapshot-send-channel-list") + .getByText("general") + .click(); + + // Confirm send. + await page.getByTestId("agent-snapshot-send-confirm").click(); + + // Dialog transitions: progress → done. + await expect(page.getByTestId("agent-snapshot-send-progress")).toBeVisible(); + await expect(page.getByTestId("agent-snapshot-send-done")).toBeVisible({ + timeout: 8000, + }); + + // Verify command order: encode → upload → send_channel_message. + const log = await readCommandLog(page); + const relevantCommands = log + .filter((e) => + [ + "encode_agent_snapshot_for_send", + "upload_media_bytes", + "send_channel_message", + ].includes(e.command), + ) + .map((e) => e.command); + + expect(relevantCommands).toEqual([ + "encode_agent_snapshot_for_send", + "upload_media_bytes", + "send_channel_message", + ]); + + // Confirm send_channel_message targeted #general (its id from the seed). + const sendEntry = log.find((e) => e.command === "send_channel_message"); + expect(sendEntry).toBeTruthy(); + const sendPayload = sendEntry?.payload as + | { channelId?: string; mediaTags?: string[][] } + | undefined; + // The general channel id is fixed in the e2eBridge seed. + expect(sendPayload?.channelId).toBe("9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"); + + // The imeta tag must carry exact URL / MIME / hash / size / filename from + // the mock upload descriptor — proves the descriptor is threaded through + // buildImetaTags without field drops or substitutions. + const imeta = sendPayload?.mediaTags?.[0]; + expect(imeta).toBeDefined(); + const sha = "a".repeat(64); + const expectedUrl = `https://mock.relay/media/${sha}.json`; + expect(imeta).toContain(`url ${expectedUrl}`); + expect(imeta).toContain("m application/json"); + expect(imeta).toContain(`x ${sha}`); + expect(imeta).toContain("size 1234"); + // The filename in the imeta comes from the encode payload's fileName — the + // controller sets descriptorWithFilename.filename = fileName (the file produced + // by encode_agent_snapshot_for_send), which the bridge hardcodes as + // "e2e-agent.agent.json". + expect(imeta).toContain("filename e2e-agent.agent.json"); + + // The encode command itself produces "e2e-agent.agent.json" (bridge fixture). + const encodeEntry = log.find( + (e) => e.command === "encode_agent_snapshot_for_send", + ); + expect(encodeEntry).toBeTruthy(); + + // Close the dialog and navigate to #general to verify the AgentSnapshotCard renders. + await page.getByRole("button", { name: "Close" }).click(); + await page.getByTestId("channel-general").click(); + + // The sent attachment must appear as an AgentSnapshotCard (not a generic + // FileCard) with the exact filename that the encode step produced. + const snapshotCard = page.getByTestId("agent-snapshot-card").last(); + await expect(snapshotCard).toBeVisible({ timeout: 5000 }); + await expect(snapshotCard).toContainText("e2e-agent.agent.json"); +}); + +// ── Memory-bearing flow: gate stops before encode/upload/send ───────────────── + +test("snapshot_send_memory_gate_stops_before_encode_on_cancel", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: ANALYST_PERSONA_ID, + displayName: "Analyst", + systemPrompt: "You are an analyst.", + }, + ], + managedAgents: [ + { + pubkey: ANALYST_PUBKEY, + name: "Analyst", + personaId: ANALYST_PERSONA_ID, + status: "running", + }, + ], + agentMemory: createMockAgentMemoryListing(), + uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR], + }); + await gotoAgentsPage(page); + + await page.getByLabel("Open actions for Analyst").click(); + await page.getByRole("menuitem", { name: "Export snapshot" }).click(); + + // Select memory level = "core" using the accessible label on the radio input. + // The radio inputs are wrapped in