diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 26c2490810..d4de242d97 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -5,8 +5,13 @@ 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, PNG_MAGIC, +use crate::commands::{ + personas::{ + decode_snapshot_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, PNG_MAGIC, + }, + team_snapshot::{ + decode_team_snapshot_from_bytes, MAX_TEAM_SNAPSHOT_JSON_BYTES, MAX_TEAM_SNAPSHOT_PNG_BYTES, + }, }; use crate::relay::{classify_request_error, relay_api_base_url_with_override, relay_error_message}; @@ -275,16 +280,35 @@ async fn fetch_blob_bytes_with_cap( #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum SnapshotFileKind { /// `.agent.json` — plaintext JSON; accepts memory; 5 MiB cap. - Json, + AgentJson, /// `.agent.png` — PNG with embedded metadata; no memory; 10 MiB cap. - Png, + AgentPng, + /// `.team.json` — team template; 25 MiB cap. + TeamJson, + /// `.team.png` — team template PNG; 50 MiB cap. + TeamPng, } impl SnapshotFileKind { fn cap(self) -> u64 { match self { - SnapshotFileKind::Json => MAX_SNAPSHOT_JSON_BYTES as u64, - SnapshotFileKind::Png => MAX_SNAPSHOT_PNG_BYTES as u64, + SnapshotFileKind::AgentJson => MAX_SNAPSHOT_JSON_BYTES as u64, + SnapshotFileKind::AgentPng => MAX_SNAPSHOT_PNG_BYTES as u64, + SnapshotFileKind::TeamJson => MAX_TEAM_SNAPSHOT_JSON_BYTES as u64, + SnapshotFileKind::TeamPng => MAX_TEAM_SNAPSHOT_PNG_BYTES as u64, + } + } + + fn is_png(self) -> bool { + matches!(self, SnapshotFileKind::AgentPng | SnapshotFileKind::TeamPng) + } + + fn label(self) -> &'static str { + match self { + SnapshotFileKind::AgentJson => ".agent.json", + SnapshotFileKind::AgentPng => ".agent.png", + SnapshotFileKind::TeamJson => ".team.json", + SnapshotFileKind::TeamPng => ".team.png", } } } @@ -296,14 +320,18 @@ impl SnapshotFileKind { /// fails closed before any bytes reach the frontend. fn ensure_bytes_match_kind(bytes: &[u8], kind: SnapshotFileKind) -> Result<(), String> { let has_png_magic = bytes.len() >= 4 && bytes[..4] == PNG_MAGIC; - match kind { - SnapshotFileKind::Png if !has_png_magic => { - Err("format mismatch: filename is .agent.png but bytes are not a PNG".to_string()) - } - SnapshotFileKind::Json if has_png_magic => { - Err("format mismatch: filename is .agent.json but bytes are a PNG".to_string()) - } - _ => Ok(()), + if kind.is_png() && !has_png_magic { + Err(format!( + "format mismatch: filename is {} but bytes are not a PNG", + kind.label() + )) + } else if !kind.is_png() && has_png_magic { + Err(format!( + "format mismatch: filename is {} but bytes are a PNG", + kind.label() + )) + } else { + Ok(()) } } @@ -312,35 +340,60 @@ fn ensure_bytes_match_kind(bytes: &[u8], kind: SnapshotFileKind) -> Result<(), S fn snapshot_kind_for_filename(filename: &str) -> Result { let lower = filename.to_ascii_lowercase(); if lower.ends_with(".agent.json") { - Ok(SnapshotFileKind::Json) + Ok(SnapshotFileKind::AgentJson) } else if lower.ends_with(".agent.png") { - Ok(SnapshotFileKind::Png) + Ok(SnapshotFileKind::AgentPng) + } else if lower.ends_with(".team.json") { + Ok(SnapshotFileKind::TeamJson) + } else if lower.ends_with(".team.png") { + Ok(SnapshotFileKind::TeamPng) } else { Err(format!( - "\"{}\" is not a snapshot filename — expected .agent.json or .agent.png", + "\"{}\" is not a snapshot filename — expected .agent.json, .agent.png, .team.json, or .team.png", filename )) } } -/// Fetch and validate an agent snapshot attachment in memory. +/// Reject a metadata-declared size before opening the media stream. +/// +/// Keeping this as a pure helper makes the per-kind cap a testable production +/// boundary, rather than relying on a duplicated test-side comparison. +fn ensure_declared_size_within_cap( + expected_size: usize, + kind: SnapshotFileKind, +) -> Result<(), String> { + if expected_size as u64 > kind.cap() { + return Err(format!( + "declared size {} exceeds the {} MiB cap for this format", + expected_size, + kind.cap() / (1024 * 1024) + )); + } + Ok(()) +} + +/// Fetch and validate an agent or team 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`. +/// - Filename must end case-insensitively with `.agent.json`, `.agent.png`, +/// `.team.json`, or `.team.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. +/// During fetch, `SnapshotFileKind::cap()` enforces the kind-specific cap via +/// Content-Length and streamed byte count: 5 MiB JSON / 10 MiB PNG for agents, +/// or 25 MiB JSON / 50 MiB PNG for teams. /// /// 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. +/// 3. The byte magic matches the filename-selected kind. +/// 4. Agent kinds pass `decode_snapshot_from_bytes`; team kinds pass +/// `decode_team_snapshot_from_bytes`. /// /// 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). +/// than a JSON number array (which would be ~3× the size at the applicable cap). #[tauri::command] pub async fn fetch_snapshot_bytes( url: String, @@ -369,13 +422,7 @@ pub async fn fetch_snapshot_bytes( 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) - )); - } + ensure_declared_size_within_cap(expected_size, kind)?; // ── Bounded fetch ───────────────────────────────────────────────────── let bytes = fetch_blob_bytes_with_cap(&url, &state, cap).await?; @@ -401,10 +448,19 @@ pub async fn fetch_snapshot_bytes( // JSON) and .agent.json delivering PNG bytes. ensure_bytes_match_kind(&bytes, kind)?; - // 4. Bytes must parse as a valid agent snapshot. This rejects malformed - // payloads, memory-bearing PNGs, JSON with inconsistent memory fields, - // and any format/extension mismatch not caught by magic-byte check. - decode_snapshot_from_bytes(&bytes).map_err(|e| format!("invalid snapshot: {e}"))?; + // 4. Bytes must parse as the snapshot type selected by the filename. + // Team parsing rejects retired flat JSON and persona-pack ZIP inputs + // before anything reaches the frontend. + match kind { + SnapshotFileKind::AgentJson | SnapshotFileKind::AgentPng => { + decode_snapshot_from_bytes(&bytes) + .map_err(|e| format!("invalid agent snapshot: {e}"))?; + } + SnapshotFileKind::TeamJson | SnapshotFileKind::TeamPng => { + decode_team_snapshot_from_bytes(&bytes) + .map_err(|e| format!("invalid team snapshot: {e}"))?; + } + } Ok(tauri::ipc::Response::new(bytes)) } @@ -416,14 +472,14 @@ mod tests { #[test] fn snapshot_kind_json_returns_json_kind_and_correct_cap() { let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap(); - assert_eq!(kind, SnapshotFileKind::Json); + assert_eq!(kind, SnapshotFileKind::AgentJson); assert_eq!(kind.cap(), MAX_SNAPSHOT_JSON_BYTES as u64); } #[test] fn snapshot_kind_png_returns_png_kind_and_correct_cap() { let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); - assert_eq!(kind, SnapshotFileKind::Png); + assert_eq!(kind, SnapshotFileKind::AgentPng); assert_eq!(kind.cap(), MAX_SNAPSHOT_PNG_BYTES as u64); } @@ -449,6 +505,33 @@ mod tests { assert!(snapshot_kind_for_filename("agentjson").is_err()); } + #[test] + fn snapshot_kind_team_extensions_are_case_insensitive_and_scale_caps() { + let json = snapshot_kind_for_filename("review.TEAM.JSON").unwrap(); + let png = snapshot_kind_for_filename("review.TEAM.PNG").unwrap(); + assert_eq!(json, SnapshotFileKind::TeamJson); + assert_eq!(png, SnapshotFileKind::TeamPng); + assert_eq!(json.cap(), 25 * 1024 * 1024); + assert_eq!(png.cap(), 50 * 1024 * 1024); + } + + #[test] + fn fetch_boundary_team_png_filename_with_json_bytes_rejected() { + let bytes = br#"{"format":"buzz-team-snapshot","version":1}"#; + let kind = snapshot_kind_for_filename("review.team.png").unwrap(); + let error = ensure_bytes_match_kind(bytes, kind).unwrap_err(); + assert!(error.contains(".team.png") && error.contains("not a PNG")); + } + + #[test] + fn fetch_boundary_team_declared_size_over_cap_rejected() { + let kind = snapshot_kind_for_filename("review.team.json").unwrap(); + assert!(ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES, kind).is_ok()); + let error = + ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES + 1, kind).unwrap_err(); + assert!(error.contains("25 MiB")); + } + // ── Focused boundary tests: format mismatch and consistency ────────────── // // These tests exercise the guard logic that fetch_snapshot_bytes applies diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index e22cce8467..87802dee0a 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -38,6 +38,7 @@ mod project_terminal; mod relay_members; mod relay_reconnect; mod social; +mod team_snapshot; mod teams; mod updater; mod window_vibrancy; @@ -80,6 +81,7 @@ pub use project_terminal::*; pub use relay_members::*; pub use relay_reconnect::*; pub use social::*; +pub use team_snapshot::*; pub use teams::*; pub use updater::*; pub use window_vibrancy::*; diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index bb54571f60..7e2cd9caac 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -31,7 +31,7 @@ fn trim_optional(value: Option) -> Option { } mod pending; -use pending::retain_persona_pending; +pub(in crate::commands) use pending::retain_persona_pending; pub(super) use pending::tombstone_persona_pending; #[tauri::command] @@ -973,11 +973,11 @@ pub async fn set_persona_active( } pub(crate) const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47]; - 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, + decode_snapshot_from_bytes, resolve_snapshot_import_behavior, 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 728fc9c182..4d887ca39e 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -23,7 +23,7 @@ use crate::managed_agents::AgentDefinition; /// 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(in crate::commands::personas) fn retain_persona_pending( +pub(in crate::commands) fn retain_persona_pending( app: &AppHandle, state: &AppState, persona: &AgentDefinition, diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs new file mode 100644 index 0000000000..8f8b4dfc43 --- /dev/null +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -0,0 +1,406 @@ +//! Tauri commands for exporting and importing `buzz-team-snapshot v1` files. +//! +//! Team snapshots are definition-only templates: importing creates key-less +//! agent definitions and one team record. It never mints agent keys, auth tags, +//! managed-agent instances, or restores member memory. + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; +use uuid::Uuid; + +use crate::{ + app_state::AppState, + commands::{export_util::save_bytes_with_dialog, personas::resolve_snapshot_import_behavior}, + managed_agents::team_snapshot::{ + build_team_snapshot, decode_team_snapshot_json, decode_team_snapshot_png, + encode_team_snapshot_json, encode_team_snapshot_png, TeamSnapshot, + }, + managed_agents::{ + agent_snapshot::{build_snapshot, AgentSnapshot, MemoryLevel}, + load_personas, load_teams, save_personas, save_teams, AgentDefinition, TeamRecord, + }, + util::now_iso, +}; + +/// Team snapshots have a combined 25 MiB JSON / 50 MiB PNG payload cap. +/// Every member is validated before any persistent write. +pub(crate) const MAX_TEAM_SNAPSHOT_JSON_BYTES: usize = 25 * 1024 * 1024; +pub(crate) const MAX_TEAM_SNAPSHOT_PNG_BYTES: usize = 50 * 1024 * 1024; + +const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47]; +const ZIP_MAGIC_PREFIX: [u8; 2] = [0x50, 0x4b]; +const LEGACY_TEAM_ERROR: &str = + "Legacy team files are no longer supported. Export a buzz-team-snapshot v1 .team.json or .team.png instead."; + +/// Decode a canonical team snapshot, rejecting retired flat team JSON and +/// persona-pack ZIP files with a migration-oriented error. +pub(crate) fn decode_team_snapshot_from_bytes(file_bytes: &[u8]) -> Result { + if file_bytes.starts_with(&PNG_MAGIC) { + if file_bytes.len() > MAX_TEAM_SNAPSHOT_PNG_BYTES { + return Err(format!( + "Team snapshot file is too large ({} MiB). PNG snapshots must be under 50 MiB.", + file_bytes.len() / (1024 * 1024) + )); + } + return decode_team_snapshot_png(file_bytes); + } + + if file_bytes.len() > MAX_TEAM_SNAPSHOT_JSON_BYTES { + return Err(format!( + "Team snapshot file is too large ({} MiB). JSON snapshots must be under 25 MiB.", + file_bytes.len() / (1024 * 1024) + )); + } + + // Detect the retired schema before attempting canonical deserialization so + // old `.team.json` attachments never look like malformed new snapshots. + let value: serde_json::Value = match serde_json::from_slice(file_bytes) { + Ok(value) => value, + Err(_) if file_bytes.starts_with(&ZIP_MAGIC_PREFIX) => { + return Err(LEGACY_TEAM_ERROR.to_string()); + } + Err(error) => return Err(format!("Invalid team snapshot JSON: {error}")), + }; + if value.get("format").and_then(serde_json::Value::as_str) + != Some(crate::managed_agents::team_snapshot::FORMAT_DISCRIMINATOR) + && value.get("version").and_then(serde_json::Value::as_u64) == Some(1) + && value.get("type").and_then(serde_json::Value::as_str) == Some("team") + { + return Err(LEGACY_TEAM_ERROR.to_string()); + } + + decode_team_snapshot_json(file_bytes) +} + +fn parse_format_is_png(format: &str) -> Result { + match format { + "json" | "" => Ok(false), + "png" => Ok(true), + other => Err(format!( + "Invalid format: {other:?} (expected 'json' or 'png')" + )), + } +} + +fn effective_avatar(member: &AgentSnapshot) -> Option { + member + .profile + .avatar_data_url + .clone() + .or_else(|| member.profile.avatar_url.clone()) +} + +/// Build a definition from a team member snapshot without consuming its memory. +/// The ignored `memory` field is deliberate: definition-only imports have no +/// owner-to-agent key material or live instance to which memory could belong. +fn definition_from_snapshot( + member: &AgentSnapshot, + keep_allowlist: bool, + now: &str, +) -> Result { + let behavior = resolve_snapshot_import_behavior( + member.definition.respond_to.as_deref(), + &member.definition.respond_to_allowlist, + member.definition.parallelism, + keep_allowlist, + )?; + let respond_to = (behavior.respond_to != crate::managed_agents::RespondTo::default()) + .then(|| behavior.respond_to.as_str().to_string()); + + Ok(AgentDefinition { + id: Uuid::new_v4().to_string(), + display_name: member.profile.display_name.trim().to_string(), + avatar_url: effective_avatar(member), + system_prompt: member.definition.system_prompt.clone().unwrap_or_default(), + runtime: member.definition.runtime.clone(), + model: member.definition.model.clone(), + provider: member.definition.provider.clone(), + name_pool: member.definition.name_pool.clone(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: Default::default(), + respond_to, + respond_to_allowlist: behavior.respond_to_allowlist, + parallelism: behavior.parallelism, + created_at: now.to_string(), + updated_at: now.to_string(), + }) +} + +fn build_import_definitions( + snapshot: &TeamSnapshot, + keep_allowlist: bool, + now: &str, +) -> Result, String> { + snapshot + .members + .iter() + .map(|member| definition_from_snapshot(member, keep_allowlist, now)) + .collect() +} + +/// Assemble the one new team record that references freshly built definitions. +/// Keeping this pure lets tests verify the definition-only import shape without +/// creating an `AppHandle` or touching the on-disk stores. +fn build_import_team( + snapshot: &TeamSnapshot, + persona_ids: Vec, + now: &str, +) -> Result { + let name = snapshot.team.name.trim(); + if name.is_empty() { + return Err("Team snapshot name is empty.".to_string()); + } + + Ok(TeamRecord { + id: Uuid::new_v4().to_string(), + name: name.to_string(), + description: snapshot.team.description.clone(), + persona_ids, + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: now.to_string(), + updated_at: now.to_string(), + }) +} + +fn member_preview(member: &AgentSnapshot) -> TeamSnapshotMemberPreview { + TeamSnapshotMemberPreview { + display_name: member.profile.display_name.clone(), + system_prompt: member.definition.system_prompt.clone(), + avatar_url: effective_avatar(member), + has_source_allowlist: !member.definition.respond_to_allowlist.is_empty(), + source_allowlist_count: member.definition.respond_to_allowlist.len(), + } +} + +/// Preview metadata for one definition that will be imported with a team. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshotMemberPreview { + pub display_name: String, + pub system_prompt: Option, + pub avatar_url: Option, + pub has_source_allowlist: bool, + pub source_allowlist_count: usize, +} + +/// Materialized team snapshot preview. No write happens before confirmation. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshotImportPreview { + pub name: String, + pub description: Option, + pub members: Vec, + pub has_source_allowlist: bool, +} + +/// Confirmation input for a definition-only team snapshot import. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshotImportConfirm { + pub file_bytes: Vec, + /// Applied uniformly to every member in v1. + pub keep_allowlist: bool, +} + +/// Result of a definition-only team snapshot import. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshotImportResult { + pub team: TeamRecord, + pub persona_ids: Vec, +} + +/// In-memory bytes for the native team sharing flow. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EncodedTeamSnapshotPayload { + pub file_bytes: Vec, + pub file_name: String, +} + +fn build_team_export_snapshot( + team: &TeamRecord, + personas: &[AgentDefinition], +) -> Result { + let members = team + .persona_ids + .iter() + .map(|id| { + let persona = personas + .iter() + .find(|persona| persona.id == *id) + .ok_or_else(|| { + format!("team {} references missing agent definition {id}", team.id) + })?; + Ok(build_snapshot( + &persona.clone().into_agent_record(), + MemoryLevel::None, + Vec::new(), + None, + )) + }) + .collect::, String>>()?; + Ok(build_team_snapshot(team, members)) +} + +async fn materialize_team_snapshot_bytes( + id: String, + is_png: bool, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let (team, personas) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let team = load_teams(&app)? + .into_iter() + .find(|team| team.id == id) + .ok_or_else(|| format!("team {id} not found"))?; + (team, load_personas(&app)?) + }; + + let snapshot = build_team_export_snapshot(&team, &personas)?; + let slug = crate::util::slugify(&team.name, "team", 50); + let (file_bytes, file_name) = if is_png { + let bytes = encode_team_snapshot_png(&snapshot) + .map_err(|e| format!("Failed to encode .team.png: {e}"))?; + if bytes.len() > MAX_TEAM_SNAPSHOT_PNG_BYTES { + return Err( + "Team snapshot exceeds the 50 MiB size limit for .team.png files.".to_string(), + ); + } + (bytes, format!("{slug}.team.png")) + } else { + let bytes = encode_team_snapshot_json(&snapshot) + .map_err(|e| format!("Failed to encode .team.json: {e}"))?; + if bytes.len() > MAX_TEAM_SNAPSHOT_JSON_BYTES { + return Err( + "Team snapshot exceeds the 25 MiB size limit for .team.json files.".to_string(), + ); + } + (bytes, format!("{slug}.team.json")) + }; + Ok(EncodedTeamSnapshotPayload { + file_bytes, + file_name, + }) +} + +/// Export a team template with each member's memory explicitly set to `none`. +#[tauri::command] +pub async fn export_team_snapshot( + id: String, + format: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let is_png = parse_format_is_png(&format)?; + let payload = materialize_team_snapshot_bytes(id, is_png, app.clone(), state).await?; + if is_png { + save_bytes_with_dialog( + &app, + &payload.file_name, + "PNG image", + &["png"], + &payload.file_bytes, + ) + .await + } else { + save_bytes_with_dialog( + &app, + &payload.file_name, + "Team snapshot", + &["json"], + &payload.file_bytes, + ) + .await + } +} + +/// Encode a team template for the native send flow without opening a dialog. +#[tauri::command] +pub async fn encode_team_snapshot_for_send( + id: String, + format: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + materialize_team_snapshot_bytes(id, parse_format_is_png(&format)?, app, state).await +} + +/// Decode a team snapshot into a confirmation preview without writing anything. +#[tauri::command] +pub async fn preview_team_snapshot_import( + file_bytes: Vec, + _file_name: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let snapshot = decode_team_snapshot_from_bytes(&file_bytes)?; + let members: Vec<_> = snapshot.members.iter().map(member_preview).collect(); + Ok(TeamSnapshotImportPreview { + name: snapshot.team.name, + description: snapshot.team.description, + has_source_allowlist: members.iter().any(|member| member.has_source_allowlist), + members, + }) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +/// Import a team snapshot as key-less agent definitions plus one team record. +/// No keypair, NIP-OA auth tag, managed-agent instance, or memory entry is +/// created; member memory is intentionally inert template data. +#[tauri::command] +pub async fn confirm_team_snapshot_import( + input: TeamSnapshotImportConfirm, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?; + let now = now_iso(); + // Resolve every member before locking or writing so an invalid allowlist + // cannot leave a partially imported team behind. + let definitions = build_import_definitions(&snapshot, input.keep_allowlist, &now)?; + let persona_ids: Vec = definitions + .iter() + .map(|definition| definition.id.clone()) + .collect(); + let imported_team = build_import_team(&snapshot, persona_ids.clone(), &now)?; + + let team = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut personas = load_personas(&app)?; + personas.extend(definitions.iter().cloned()); + save_personas(&app, &personas)?; + for definition in &definitions { + crate::commands::personas::retain_persona_pending(&app, &state, definition); + } + + let mut teams = load_teams(&app)?; + teams.push(imported_team.clone()); + let team = imported_team; + save_teams(&app, &teams)?; + crate::commands::teams::retain_team_pending(&app, &state, &team); + crate::managed_agents::try_regenerate_nest(&app); + let _ = app.emit("agents-data-changed", ()); + team + }; + + Ok(TeamSnapshotImportResult { team, persona_ids }) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs new file mode 100644 index 0000000000..facf6b0dfe --- /dev/null +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -0,0 +1,198 @@ +use super::*; +use crate::managed_agents::{ + agent_snapshot::{ + AgentSnapshotDefinition, AgentSnapshotMemory, AgentSnapshotMemoryEntry, + AgentSnapshotProfile, + }, + team_snapshot::{TeamSnapshotMeta, FORMAT_DISCRIMINATOR, FORMAT_VERSION}, +}; + +fn member(name: &str) -> AgentSnapshot { + AgentSnapshot { + format: crate::managed_agents::agent_snapshot::FORMAT_DISCRIMINATOR.to_string(), + version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: name.to_string(), + system_prompt: Some(format!("{name} prompt")), + runtime: Some("goose".to_string()), + model: None, + provider: None, + parallelism: Some(2), + respond_to: Some("allowlist".to_string()), + respond_to_allowlist: vec!["ab".repeat(32)], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: name.to_string(), + about: None, + avatar_data_url: None, + avatar_url: Some(format!("https://example.test/{name}.png")), + }, + memory: AgentSnapshotMemory { + level: MemoryLevel::None, + entries: vec![], + }, + } +} + +fn snapshot(members: Vec) -> TeamSnapshot { + TeamSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + team: TeamSnapshotMeta { + name: "Review Team".to_string(), + description: Some("Reviews changes".to_string()), + }, + members, + } +} + +#[test] +fn team_export_round_trip_preserves_team_and_excludes_member_memory() { + let definitions = vec![ + AgentDefinition { + id: "alice".to_string(), + display_name: "Alice".to_string(), + avatar_url: None, + system_prompt: "Alice prompt".to_string(), + runtime: Some("goose".to_string()), + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: Default::default(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: "now".to_string(), + updated_at: "now".to_string(), + }, + AgentDefinition { + id: "bob".to_string(), + display_name: "Bob".to_string(), + avatar_url: None, + system_prompt: "Bob prompt".to_string(), + runtime: Some("goose".to_string()), + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: Default::default(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: "now".to_string(), + updated_at: "now".to_string(), + }, + ]; + let team = TeamRecord { + id: "review".to_string(), + name: "Review Team".to_string(), + description: Some("Reviews changes".to_string()), + persona_ids: vec!["alice".to_string(), "bob".to_string()], + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "now".to_string(), + updated_at: "now".to_string(), + }; + + let bytes = + encode_team_snapshot_json(&build_team_export_snapshot(&team, &definitions).unwrap()) + .unwrap(); + let decoded = decode_team_snapshot_from_bytes(&bytes).unwrap(); + + assert_eq!(decoded.team.name, "Review Team"); + assert_eq!(decoded.team.description.as_deref(), Some("Reviews changes")); + assert_eq!(decoded.members.len(), 2); + assert!(decoded.members.iter().all(|member| { + member.memory.level == MemoryLevel::None && member.memory.entries.is_empty() + })); +} + +#[test] +fn team_import_creates_definitions_without_instances_or_memory() { + let mut memory_bearing = member("Alice"); + memory_bearing.memory = AgentSnapshotMemory { + level: MemoryLevel::Everything, + entries: vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "must remain inert".to_string(), + }], + }; + let decoded = decode_team_snapshot_from_bytes( + &encode_team_snapshot_json(&snapshot(vec![memory_bearing, member("Bob")])).unwrap(), + ) + .unwrap(); + let definitions = build_import_definitions(&decoded, false, "now").unwrap(); + let team = build_import_team( + &decoded, + definitions + .iter() + .map(|definition| definition.id.clone()) + .collect(), + "now", + ) + .unwrap(); + + assert_eq!(definitions.len(), 2); + assert_eq!(team.persona_ids.len(), 2); + assert_eq!( + team.persona_ids, + definitions + .iter() + .map(|definition| definition.id.clone()) + .collect::>() + ); + assert!(definitions.iter().all(|definition| { + definition.id.len() == 36 + && definition.source_team.is_none() + && definition.env_vars.is_empty() + && definition.respond_to_allowlist.is_empty() + })); + assert_eq!(definitions[0].system_prompt, "Alice prompt"); + // `AgentDefinition` has no key/auth/memory fields: the exact import plan + // creates N definitions + one TeamRecord, never a managed instance/key. +} + +#[test] +fn team_import_keeps_or_clears_every_member_allowlist_with_one_toggle() { + let source = snapshot(vec![member("Alice"), member("Bob")]); + let kept = build_import_definitions(&source, true, "now").unwrap(); + let cleared = build_import_definitions(&source, false, "now").unwrap(); + + assert!(kept.iter().all(|definition| { + definition.respond_to.as_deref() == Some("allowlist") + && definition.respond_to_allowlist == vec!["ab".repeat(32)] + })); + assert!(cleared.iter().all(|definition| { + definition.respond_to.is_none() && definition.respond_to_allowlist.is_empty() + })); +} + +#[test] +fn legacy_flat_team_and_pack_zip_return_actionable_error() { + let old_flat = br#"{"version":1,"type":"team","name":"Old"}"#; + for bytes in [old_flat.as_slice(), b"PK\x05\x06empty-pack".as_slice()] { + let error = decode_team_snapshot_from_bytes(bytes).unwrap_err(); + assert_eq!(error, LEGACY_TEAM_ERROR); + } +} + +#[test] +fn canonical_team_json_is_accepted_without_extension_case_policy() { + let bytes = encode_team_snapshot_json(&snapshot(vec![member("Alice")])).unwrap(); + // Preview/confirm intentionally decode content rather than file names, so + // canonical lowercase and uppercase extensions reach this same safe path. + assert!(decode_team_snapshot_from_bytes(&bytes).is_ok()); +} diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index 4ca3182a9b..61362cf26c 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -40,7 +40,7 @@ fn trim_optional(value: Option) -> Option { /// Unlike `retain_managed_agent_pending`, this has no projection-equality /// short-circuit: teams have no start/stop runtime churn, so a republish only /// happens on an actual user edit. The guard is intentionally omitted. -fn retain_team_pending(app: &AppHandle, state: &AppState, team: &TeamRecord) { +pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &TeamRecord) { use crate::managed_agents::{ managed_agents_base_dir, persona_events::monotonic_created_at, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 57b834d90b..6fadf13265 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -810,6 +810,10 @@ pub fn run() { preview_agent_snapshot_import, confirm_agent_snapshot_import, encode_agent_snapshot_for_send, + export_team_snapshot, + encode_team_snapshot_for_send, + preview_team_snapshot_import, + confirm_team_snapshot_import, 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 index 90c50c3c3b..d4ccbcc22e 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -348,7 +348,7 @@ pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { /// 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> { +pub(crate) fn validate_snapshot(snapshot: &AgentSnapshot) -> Result<(), String> { if snapshot.format != FORMAT_DISCRIMINATOR { return Err(format!( "Unsupported snapshot format: {:?} (expected {:?})", @@ -386,7 +386,7 @@ pub fn decode_avatar_data_url(url: &str) -> Option> { } /// Build a minimal 1×1 transparent PNG with a single tEXt chunk. -fn make_png_with_text(keyword: &str, text: &str) -> Result, String> { +pub(crate) 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); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 947934c1bd..c9d0c5f86a 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,6 +1,7 @@ mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; +pub(crate) mod team_snapshot; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs new file mode 100644 index 0000000000..279308a07e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -0,0 +1,627 @@ +//! `buzz-team-snapshot v1` — manifest type, encoder, and decoder. +//! +//! A team snapshot is a portable, shareable representation of a team: its +//! header (name, description) plus a `members` array where each member +//! reuses the existing `AgentSnapshot` type from `agent_snapshot.rs`. +//! +//! Two encodings: +//! - `.team.json` — canonical; memory inclusion is a P4.2 product decision. +//! - `.team.png` — 1×1 placeholder PNG with manifest in a `buzz_team_snapshot` +//! tEXt chunk; **all members MUST have `memory.level == None` and empty +//! `memory.entries`**. PNG files are casually shared — plaintext memory must +//! never appear in them. Since `TeamRecord` has no team-level avatar, the +//! image body is always the 1×1 placeholder; member avatars are carried in +//! each `AgentSnapshot.profile.avatar_data_url`. +//! +//! **Old `.team.json` files** (flat `{version:1, type:"team", …}` schema) carry +//! no `format` discriminator. The caller's legacy-reject path handles them; this +//! decoder never sees them. +//! +//! **ZIP is NOT in v1** — consistent with the agent-snapshot policy. +//! +//! # Secret exclusion +//! +//! Per-member exclusions are inherited from `AgentSnapshot` — secrets are +//! excluded by construction in `agent_snapshot::build_snapshot`. No +//! team-level secrets are introduced by this wrapper. +//! +//! # Memory-consistency invariant +//! +//! Any member with `memory.level == None` and non-empty `memory.entries` is +//! malformed. `validate_member_memory_consistency` enforces this on both the +//! JSON decode path (via `validate_team_snapshot`) and the PNG encode path (via +//! `encode_team_snapshot_png`) so the rule is single-sourced. + +// Items are `pub(crate)` for P4.2 callers; suppress dead-code lint until then. +#![allow(dead_code)] + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use png::Decoder; +use serde::{Deserialize, Serialize}; +use std::io::Cursor; + +use crate::managed_agents::{ + agent_snapshot::{make_png_with_text, validate_snapshot, AgentSnapshot, MemoryLevel}, + TeamRecord, +}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/// tEXt chunk keyword used in `.team.png` files. +pub const PNG_CHUNK_KEYWORD: &str = "buzz_team_snapshot"; + +/// Format discriminator — used for sniffing and validation. +pub const FORMAT_DISCRIMINATOR: &str = "buzz-team-snapshot"; + +/// Version of the manifest format produced by this module. +pub const FORMAT_VERSION: u32 = 1; + +// ── Manifest sub-types ──────────────────────────────────────────────────────── + +/// Team-level metadata carried in the snapshot header. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshotMeta { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +// ── Top-level manifest ──────────────────────────────────────────────────────── + +/// The top-level `buzz-team-snapshot v1` manifest. +/// +/// Serializes to / from JSON. Embedded in `.team.json` directly, or in the +/// `buzz_team_snapshot` tEXt chunk of a `.team.png` (base64-encoded). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshot { + /// Fixed discriminator for format sniffing. + pub format: String, + /// Schema version. This module produces version 1. + pub version: u32, + /// Team-level metadata. + pub team: TeamSnapshotMeta, + /// One `AgentSnapshot` per team member. Order is preserved. + pub members: Vec, +} + +// ── Builder ─────────────────────────────────────────────────────────────────── + +/// Construct a `TeamSnapshot` from a `TeamRecord` and pre-built member snapshots. +/// +/// Members are pre-built by the caller via `agent_snapshot::build_snapshot` — +/// this function does not call into Tauri or perform I/O. Same purity contract +/// as `build_snapshot`: pure assembly, deterministic, testable without AppHandle. +pub fn build_team_snapshot(team: &TeamRecord, members: Vec) -> TeamSnapshot { + TeamSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + team: TeamSnapshotMeta { + name: team.name.clone(), + description: team.description.clone(), + }, + members, + } +} + +// ── JSON encoding / decoding ────────────────────────────────────────────────── + +/// Encode the manifest to pretty-printed JSON bytes. +pub fn encode_team_snapshot_json(snapshot: &TeamSnapshot) -> Result, String> { + serde_json::to_vec_pretty(snapshot) + .map_err(|e| format!("Failed to serialize team snapshot: {e}")) +} + +/// Decode a manifest from JSON bytes. +pub fn decode_team_snapshot_json(bytes: &[u8]) -> Result { + let snapshot: TeamSnapshot = + serde_json::from_slice(bytes).map_err(|e| format!("Invalid team snapshot JSON: {e}"))?; + validate_team_snapshot(&snapshot)?; + Ok(snapshot) +} + +// ── PNG encoding / decoding ─────────────────────────────────────────────────── + +/// Encode a team snapshot into a `.team.png`. +/// +/// The image body is a 1×1 transparent placeholder — `TeamRecord` has no +/// team-level avatar; each member's avatar lives in their own +/// `AgentSnapshot.profile.avatar_data_url`. The manifest is embedded in the +/// `buzz_team_snapshot` tEXt chunk (base64-encoded JSON). +/// +/// **Rejects** any snapshot where ANY member carries memory — either via a +/// non-`None` `memory.level` or a non-empty `memory.entries`. PNG images are +/// casually shared and would expose plaintext memory. +pub fn encode_team_snapshot_png(snapshot: &TeamSnapshot) -> Result, String> { + validate_team_png_has_no_member_memory(snapshot)?; + + let json_bytes = encode_team_snapshot_json(snapshot)?; + let chunk_text = STANDARD.encode(&json_bytes); + + // No team-level avatar: always use the 1×1 placeholder. + // Each member's avatar is carried within their own AgentSnapshot. + make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text) +} + +/// Decode a manifest from a `.team.png` tEXt chunk. +pub fn decode_team_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_team_snapshot tEXt chunk".to_string())?; + + let json_bytes = STANDARD + .decode(chunk_text.trim()) + .map_err(|e| format!("Invalid base64 in PNG chunk: {e}"))?; + + let snapshot = decode_team_snapshot_json(&json_bytes)?; + validate_team_png_has_no_member_memory(&snapshot)?; + Ok(snapshot) +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +/// Enforce the PNG no-member-memory invariant on both the encode and decode paths. +/// +/// PNG files are casually shared — any member carrying memory (level != `None` +/// or non-empty entries) would expose plaintext memory. This is a hard format +/// invariant: our encoder never produces such a PNG, so any that passes this +/// check on decode is malformed or malicious. +fn validate_team_png_has_no_member_memory(snapshot: &TeamSnapshot) -> Result<(), String> { + for (i, member) in snapshot.members.iter().enumerate() { + if member.memory.level != MemoryLevel::None || !member.memory.entries.is_empty() { + return Err(format!( + "Cannot write memory to a .team.png file — member {i} ({:?}) has memory. \ + PNG images are casually shared and would expose memory as plaintext.", + member.definition.name + )); + } + } + Ok(()) +} + +/// Assert that a member's memory section is internally consistent. +/// +/// Rejects `memory.level == None` with non-empty `memory.entries` — this is a +/// malformed state that the builder can never produce but a crafted payload +/// could. Single-sourced so the same rule applies on both the PNG encode path +/// and the JSON decode path. +fn validate_member_memory_consistency(idx: usize, member: &AgentSnapshot) -> Result<(), String> { + if member.memory.level == MemoryLevel::None && !member.memory.entries.is_empty() { + return Err(format!( + "member {idx} ({:?}) has memory.level 'none' but non-empty entries — \ + this is a malformed snapshot", + member.definition.name + )); + } + Ok(()) +} + +/// Validate that the manifest has the correct format/version and required +/// fields. Returns an error string on failure. +/// +/// Calls `agent_snapshot::validate_snapshot` for each member so the per-member +/// contract (non-empty name, correct format/version) is enforced at decode time. +/// Also checks the memory-consistency invariant per member via +/// `validate_member_memory_consistency`. +pub(crate) fn validate_team_snapshot(snapshot: &TeamSnapshot) -> Result<(), String> { + if snapshot.format != FORMAT_DISCRIMINATOR { + return Err(format!( + "Unsupported team snapshot format: {:?} (expected {:?})", + snapshot.format, FORMAT_DISCRIMINATOR + )); + } + if snapshot.version != 1 { + return Err(format!( + "Unsupported team snapshot version: {} (expected 1)", + snapshot.version + )); + } + if snapshot.team.name.trim().is_empty() { + return Err("Team snapshot team.name is empty".to_string()); + } + if snapshot.members.is_empty() { + return Err("Team snapshot must have at least one member".to_string()); + } + for (i, member) in snapshot.members.iter().enumerate() { + validate_snapshot(member).map_err(|e| format!("Team member {i} is invalid: {e}"))?; + validate_member_memory_consistency(i, member) + .map_err(|e| format!("Team member {i} memory is malformed: {e}"))?; + } + Ok(()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::{ + agent_snapshot::{build_snapshot, AgentSnapshotMemory, AgentSnapshotMemoryEntry}, + types::{BackendKind, ManagedAgentRecord, RespondTo}, + }; + use std::collections::BTreeMap; + + /// Build a minimal `TeamRecord` for testing. + fn team_record(name: &str) -> TeamRecord { + TeamRecord { + id: format!("{name}-id"), + name: name.to_string(), + description: Some(format!("{name} description")), + persona_ids: vec![], + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), + } + } + + /// Build a minimal `ManagedAgentRecord` for use as a team member. + fn agent_record(name: &str) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: format!("{name}-pubkey"), + name: name.to_string(), + display_name: Some(format!("{name} Display")), + persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear + private_key_nsec: "nsec1secret".to_string(), // MUST NOT appear + auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear + relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear + avatar_url: Some(format!("https://example.com/{name}.png")), + acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear + agent_command: "goose".to_string(), // MUST NOT appear + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 120, + idle_timeout_seconds: Some(30), + max_turn_duration_seconds: Some(600), + parallelism: 1, + system_prompt: Some(format!("You are {name}.")), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + persona_source_version: None, + env_vars: { + let mut m = BTreeMap::new(); + m.insert("API_KEY".to_string(), "secret123".to_string()); // MUST NOT appear + m + }, + start_on_app_launch: false, + auto_restart_on_config_change: true, + 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: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + 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![], + slug: Some(name.to_string()), + runtime: Some("goose".to_string()), + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: Some("SENTINEL_SOURCE_TEAM".to_string()), // MUST NOT appear + source_team_persona_slug: Some("SENTINEL_SLUG".to_string()), // MUST NOT appear + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + relay_mesh: None, + } + } + + /// Build a canonical two-member team snapshot with no memory. + fn two_member_team() -> TeamSnapshot { + let alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + let bob = build_snapshot(&agent_record("Bob"), MemoryLevel::None, vec![], None); + build_team_snapshot(&team_record("My Team"), vec![alice, bob]) + } + + // ── Round-trip tests ────────────────────────────────────────────────────── + + #[test] + fn json_round_trip_no_memory() { + let snapshot = two_member_team(); + let bytes = encode_team_snapshot_json(&snapshot).unwrap(); + let parsed = decode_team_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed, snapshot); + } + + #[test] + fn png_round_trip_no_memory() { + let snapshot = two_member_team(); + let png_bytes = encode_team_snapshot_png(&snapshot).unwrap(); + assert!(png_bytes.starts_with(b"\x89PNG"), "output must be a PNG"); + let parsed = decode_team_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.team.name, snapshot.team.name); + assert_eq!(parsed.members.len(), 2); + assert_eq!(parsed.members[0].definition.name, "Alice Display"); + assert_eq!(parsed.members[1].definition.name, "Bob Display"); + } + + // ── PNG memory guard ────────────────────────────────────────────────────── + + #[test] + fn png_export_rejected_when_any_member_has_memory() { + // 2-member team: member[0] has no memory, member[1] has Everything. + let alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "mem/notes".to_string(), + body: "private notes".to_string(), + }]; + let bob = build_snapshot(&agent_record("Bob"), MemoryLevel::Everything, entries, None); + let snapshot = build_team_snapshot(&team_record("Team"), vec![alice, bob]); + + let result = encode_team_snapshot_png(&snapshot); + assert!( + result.is_err(), + "PNG export must fail when any member has memory" + ); + let err = result.unwrap_err(); + assert!( + err.contains("Cannot write memory to a .team.png"), + "Error must explain the PNG memory restriction, got: {err}" + ); + assert!(err.contains("Bob"), "Error must name the offending member"); + } + + #[test] + fn png_export_rejected_when_none_level_with_nonempty_entries() { + // Inconsistent state: level == None but entries non-empty on a member. + // Both the PNG encoder and JSON decoder must reject this via the shared + // validate_member_memory_consistency helper. + let mut alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + alice.memory = AgentSnapshotMemory { + level: MemoryLevel::None, + entries: vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "leaked".to_string(), + }], + }; + // PNG path + let snapshot = build_team_snapshot(&team_record("Team"), vec![alice.clone()]); + assert!( + encode_team_snapshot_png(&snapshot).is_err(), + "PNG encoder must reject member with level=None + non-empty entries" + ); + // JSON decode path: craft the JSON manually and decode it + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!( + result.is_err(), + "JSON decoder must also reject member with level=None + non-empty entries" + ); + } + + #[test] + fn png_export_succeeds_when_all_members_have_no_memory() { + let snapshot = two_member_team(); + assert!(encode_team_snapshot_png(&snapshot).is_ok()); + } + + // ── Validation tests ────────────────────────────────────────────────────── + + #[test] + fn validate_rejects_wrong_format() { + let mut snapshot = two_member_team(); + snapshot.format = "not-a-team-snapshot".to_string(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Unsupported team snapshot format")); + } + + #[test] + fn validate_rejects_wrong_version() { + let mut snapshot = two_member_team(); + snapshot.version = 99; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Unsupported team snapshot version")); + } + + #[test] + fn validate_rejects_empty_team_name() { + let mut snapshot = two_member_team(); + snapshot.team.name = " ".to_string(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("team.name is empty")); + } + + #[test] + fn validate_rejects_zero_members() { + let alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + let mut snapshot = build_team_snapshot(&team_record("Team"), vec![alice]); + snapshot.members.clear(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("at least one member")); + } + + #[test] + fn validate_rejects_member_with_wrong_format() { + let mut snapshot = two_member_team(); + snapshot.members[0].format = "not-an-agent".to_string(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("is invalid")); + } + + // ── Secret exclusion tests (wrapper-level) ──────────────────────────────── + // + // Per-member exclusions are already proven in agent_snapshot::tests. + // These tests assert the team wrapper does not re-introduce any secret + // field on top of the per-member snapshots. + + fn team_json_string() -> String { + let bytes = encode_team_snapshot_json(&two_member_team()).unwrap(); + String::from_utf8(bytes).unwrap() + } + + #[test] + fn wrapper_does_not_introduce_secret_fields() { + let json = team_json_string(); + assert!(!json.contains("nsec1secret"), "nsec must not appear"); + assert!( + !json.contains("auth-tag-secret"), + "auth_tag value must not appear" + ); + assert!(!json.contains("API_KEY"), "env var key must not appear"); + assert!(!json.contains("secret123"), "env var value must not appear"); + assert!( + !json.contains("wss://relay.example.com"), + "relay_url must not appear" + ); + assert!( + !json.contains("SENTINEL_SOURCE_TEAM"), + "source_team must not appear" + ); + assert!( + !json.contains("SENTINEL_SLUG"), + "source_team_persona_slug must not appear" + ); + assert!( + !json.contains("SENTINEL_PERSONA_ID"), + "persona_id must not appear" + ); + } + + // ── Structural / shape tests ────────────────────────────────────────────── + + #[test] + fn member_order_is_preserved() { + let alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + let bob = build_snapshot(&agent_record("Bob"), MemoryLevel::None, vec![], None); + let carol = build_snapshot(&agent_record("Carol"), MemoryLevel::None, vec![], None); + let snapshot = build_team_snapshot(&team_record("Ordered Team"), vec![alice, bob, carol]); + + let bytes = encode_team_snapshot_json(&snapshot).unwrap(); + let parsed = decode_team_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed.members[0].definition.name, "Alice Display"); + assert_eq!(parsed.members[1].definition.name, "Bob Display"); + assert_eq!(parsed.members[2].definition.name, "Carol Display"); + } + + #[test] + fn description_absent_when_none() { + let mut record = team_record("No Desc"); + record.description = None; + let alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + let snapshot = build_team_snapshot(&record, vec![alice]); + let bytes = encode_team_snapshot_json(&snapshot).unwrap(); + let parsed = decode_team_snapshot_json(&bytes).unwrap(); + assert!(parsed.team.description.is_none()); + let json = String::from_utf8(bytes).unwrap(); + assert!( + !json.contains("\"description\""), + "absent description must not serialize" + ); + } + + #[test] + fn format_and_version_correct_in_output() { + let json = team_json_string(); + assert!( + json.contains("\"buzz-team-snapshot\""), + "format discriminator must be present" + ); + assert!(json.contains("\"version\": 1"), "version must be 1"); + } + + #[test] + fn build_preserves_team_metadata() { + let snapshot = two_member_team(); + assert_eq!(snapshot.team.name, "My Team"); + assert_eq!( + snapshot.team.description.as_deref(), + Some("My Team description") + ); + assert_eq!(snapshot.members.len(), 2); + assert_eq!(snapshot.format, FORMAT_DISCRIMINATOR); + assert_eq!(snapshot.version, FORMAT_VERSION); + } + + #[test] + fn decode_rejects_agent_snapshot_json_as_team_snapshot() { + // An agent snapshot JSON (format: "buzz-agent-snapshot") must NOT + // parse as a team snapshot — serde rejects missing `team`/`members` + // fields, or the discriminator check catches it either way. + use crate::managed_agents::agent_snapshot::{build_snapshot, encode_snapshot_json}; + let agent = build_snapshot(&agent_record("Solo"), MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&agent).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!( + result.is_err(), + "agent snapshot JSON must not parse as team snapshot" + ); + } + + #[test] + fn decode_rejects_team_png_as_agent_snapshot() { + // A .team.png must not silently succeed when passed to the agent PNG + // decoder — the keyword differs so the agent decoder returns an error. + use crate::managed_agents::agent_snapshot::decode_snapshot_png; + let snapshot = two_member_team(); + let png_bytes = encode_team_snapshot_png(&snapshot).unwrap(); + let result = decode_snapshot_png(&png_bytes); + assert!(result.is_err(), ".team.png must not parse as .agent.png"); + } + + #[test] + fn decode_team_png_rejects_member_memory() { + // Craft a memory-bearing .team.png by bypassing the encoder guard: + // build the manifest with a memory-bearing member, serialize JSON, + // base64, and write it into a PNG tEXt chunk directly. + // decode_team_snapshot_png must reject it — defense-in-depth, since + // the encoder refuses to produce this and any such PNG is malformed + // or malicious. + let alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "mem/notes".to_string(), + body: "private".to_string(), + }]; + let bob = build_snapshot(&agent_record("Bob"), MemoryLevel::Everything, entries, None); + let snapshot = build_team_snapshot(&team_record("Team"), vec![alice, bob]); + + // Bypass encode_team_snapshot_png's guard — write the chunk directly. + let json = encode_team_snapshot_json(&snapshot).unwrap(); + let b64 = STANDARD.encode(&json); + let png = make_png_with_text(PNG_CHUNK_KEYWORD, &b64).unwrap(); + + let result = decode_team_snapshot_png(&png); + assert!( + result.is_err(), + "decode must reject a memory-bearing .team.png" + ); + assert!(result + .unwrap_err() + .contains("Cannot write memory to a .team.png")); + } +}