From 3dc010705f639988cd46b7d83579fffb536e33af Mon Sep 17 00:00:00 2001 From: "Inloop.Studio" <170839203+inloopstudio@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:21:53 +0700 Subject: [PATCH] feat(desktop): schema-certified env scaffolding for agent snapshots Unify env/credential handling across snapshots, runs, and chat-completions with a schema-certified contract path: - definition.environment exports env key NAMES as import scaffolding (sorted, reserved/malformed filtered); values stay non-serializable. - definition.environmentValues accepts import-only value hints from external producers (e.g. a control plane shipping an OpenAI-compatible API route). Secret-named keys are dropped to blank scaffolding, undeclared keys ignored, count/per-value capped at decode; Buzz export never writes the field. Preview surfaces pre-filled keys. - Run-scoped env overlay merges into agent_runtime_env for declared keys across every run mode, incl. contract-less/legacy imports. - agent_runtime_env is the single consumer-visible truth surface for declared env values; contract terms fire only when schemas exist. - env.get_credential resolves through the central credential-name normalizer (fail-closed on ambiguous input). - Persona and loop export/import propagate scaffolding; the env vars module remains the only keeper of secret-bearing literals. - egress_guard inventories follow the persona-import module split. cargo test --lib: 2021 passed, 0 failed cargo clippy --lib --all-features: clean cargo fmt --check: clean Signed-off-by: Inloop.Studio <170839203+inloopstudio@users.noreply.github.com> --- .../src-tauri/src/commands/agent_discovery.rs | 2 +- .../agent_discovery/install_report.rs | 2 +- .../src-tauri/src/commands/media_download.rs | 6 + .../src/commands/media_snapshot_png.rs | 2 + .../src/commands/personas/snapshot.rs | 2 + .../personas/snapshot/fidelity_tests.rs | 2 + .../snapshot/{import.rs => import/mod.rs} | 92 +- .../personas/snapshot/import/tests.rs | 211 ++++ .../src/commands/personas/snapshot/tests.rs | 2 + .../src/commands/team_snapshot/tests.rs | 2 + desktop/src-tauri/src/egress_guard_tests.rs | 14 +- .../src/managed_agents/agent_snapshot.rs | 999 ------------------ .../src/managed_agents/agent_snapshot/mod.rs | 541 ++++++++++ .../managed_agents/agent_snapshot/tests.rs | 695 ++++++++++++ .../src/managed_agents/team_snapshot.rs | 3 +- .../agents/ui/AgentSnapshotImportDialog.tsx | 22 + .../api/tauriPersonas.snapshotImport.test.mjs | 17 + desktop/src/shared/api/tauriPersonas.ts | 5 + desktop/src/testing/e2eBridge.ts | 1 + 19 files changed, 1607 insertions(+), 1013 deletions(-) rename desktop/src-tauri/src/commands/personas/snapshot/{import.rs => import/mod.rs} (90%) create mode 100644 desktop/src-tauri/src/commands/personas/snapshot/import/tests.rs delete mode 100644 desktop/src-tauri/src/managed_agents/agent_snapshot.rs create mode 100644 desktop/src-tauri/src/managed_agents/agent_snapshot/mod.rs create mode 100644 desktop/src-tauri/src/managed_agents/agent_snapshot/tests.rs diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index cbbf4ce351..d694da98eb 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1012,7 +1012,7 @@ fn build_install_command(command: &str) -> Result // ── install command execution ───────────────────────────────────────────────── mod install_capture; mod install_exec; -mod install_report; +pub(crate) mod install_report; use install_exec::run_install_command_with_retry; use install_report::InstallReporter; diff --git a/desktop/src-tauri/src/commands/agent_discovery/install_report.rs b/desktop/src-tauri/src/commands/agent_discovery/install_report.rs index 24bcd3456a..9e55450613 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/install_report.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/install_report.rs @@ -543,7 +543,7 @@ const SECRET_VAR_NAMES: &[&str] = &["NPM_CONFIG_KEY", "NPM_CONFIG__AUTH", "NPM_C /// personal access tokens match on `_PAT` as a *suffix* rather than a substring /// — `contains("_PAT")` would match every `*_PATH` variable on the system and /// scrub directory names out of the whole log. -fn name_marks_secret(name: &str) -> bool { +pub(crate) fn name_marks_secret(name: &str) -> bool { const SECRET_NAME_MARKERS: &[&str] = &[ "TOKEN", "SECRET", diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index d3b1a9499d..019ce45524 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -621,6 +621,8 @@ mod tests { name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + environment: vec![], + environment_values: Default::default(), }, profile: AgentSnapshotProfile { display_name: "Test".to_string(), @@ -671,6 +673,8 @@ mod tests { name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + environment: vec![], + environment_values: Default::default(), }, profile: AgentSnapshotProfile { display_name: "Test".to_string(), @@ -717,6 +721,8 @@ mod tests { name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + environment: vec![], + environment_values: Default::default(), }, profile: AgentSnapshotProfile { display_name: "Test".to_string(), diff --git a/desktop/src-tauri/src/commands/media_snapshot_png.rs b/desktop/src-tauri/src/commands/media_snapshot_png.rs index bcaec6a592..0af043317a 100644 --- a/desktop/src-tauri/src/commands/media_snapshot_png.rs +++ b/desktop/src-tauri/src/commands/media_snapshot_png.rs @@ -169,6 +169,8 @@ mod tests { idle_timeout_seconds: None, max_turn_duration_seconds: None, name_pool: vec![], + environment: vec![], + environment_values: Default::default(), }, profile: AgentSnapshotProfile { display_name: "Tree Trunks".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index 583296dac0..49a29a1a3a 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -479,6 +479,8 @@ mod png_body_tests { name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + environment: vec![], + environment_values: Default::default(), }, profile: crate::managed_agents::agent_snapshot::AgentSnapshotProfile { display_name: "Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index 00a1457393..b15f00371a 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -88,6 +88,8 @@ fn make_snapshot( name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + environment: vec![], + environment_values: Default::default(), }, profile: AgentSnapshotProfile { display_name: "Test Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import/mod.rs similarity index 90% rename from desktop/src-tauri/src/commands/personas/snapshot/import.rs rename to desktop/src-tauri/src/commands/personas/snapshot/import/mod.rs index eccf8ee601..bf6a8dd99e 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import/mod.rs @@ -72,6 +72,15 @@ pub struct AgentSnapshotImportPreview { pub has_source_allowlist: bool, /// Number of source allowlist entries. pub source_allowlist_count: usize, + /// Environment variable key names that will be pre-created on import. + /// Already filtered to valid, non-reserved keys. Blank entries mean the + /// owner fills the value in through the GUI after import. + pub environment_keys: Vec, + /// Subset of `environment_keys` carrying a non-secret value hint from an + /// external producer (e.g. an OpenAI-compatible API route). Secret-class + /// key names never arrive here — their values are dropped to blank and + /// stay owner-supplied. + pub environment_prefilled: Vec, } /// The confirmation request sent from the UI after the user reviews the preview. @@ -196,6 +205,53 @@ pub(crate) fn resolve_snapshot_import_behavior( ) } +/// Resolve env-var scaffolding for an incoming snapshot. +/// +/// Snapshots carry env var KEY NAMES only — never values (see the +/// `agent_snapshot` module docs). Each valid, non-reserved key pre-creates a +/// blank entry (empty value) so the owner sees exactly which variables the +/// agent needs and fills in values through the GUI after import. Malformed or +/// Buzz-reserved keys are dropped here — they would be stripped at spawn time +/// anyway — so the preview shows only keys that will actually be created. +/// +/// Extracted as a pure function so unit tests exercise the exact production +/// logic used by both the preview and the confirmed import. +pub(crate) fn resolve_snapshot_import_environment( + raw_keys: &[String], + raw_values: &std::collections::BTreeMap, +) -> std::collections::BTreeMap { + use crate::commands::agent_discovery::install_report::name_marks_secret; + use crate::managed_agents::{display_invalid_key, is_reserved_env_key, is_well_formed_env_key}; + + let mut resolved = std::collections::BTreeMap::new(); + for key in raw_keys { + if !is_well_formed_env_key(key) { + eprintln!( + "buzz-desktop: ignoring malformed env var key `{}` from agent snapshot", + display_invalid_key(key) + ); + continue; + } + if is_reserved_env_key(key) { + eprintln!("buzz-desktop: ignoring reserved env var `{key}` from agent snapshot"); + continue; + } + let value = match raw_values.get(key) { + None => String::new(), + Some(value) if value.is_empty() => String::new(), + Some(_) if name_marks_secret(key) => { + eprintln!( + "buzz-desktop: ignoring pre-filled value for secret-class env var `{key}` from agent snapshot" + ); + String::new() + } + Some(value) => value.clone(), + }; + resolved.insert(key.clone(), value); + } + resolved +} + const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47]; /// Decode a `buzz-agent-snapshot v1` manifest from raw bytes. @@ -311,6 +367,17 @@ pub(crate) fn build_agent_snapshot_import_preview( } .to_string(); + let resolved_env = resolve_snapshot_import_environment( + &snapshot.definition.environment, + &snapshot.definition.environment_values, + ); + let environment_prefilled: Vec = resolved_env + .iter() + .filter(|(_, value)| !value.is_empty()) + .map(|(key, _)| key.clone()) + .collect(); + let environment_keys: Vec = resolved_env.into_keys().collect(); + AgentSnapshotImportPreview { display_name: snapshot.profile.display_name.clone(), is_builtin: snapshot.definition.source_is_builtin, @@ -327,6 +394,8 @@ pub(crate) fn build_agent_snapshot_import_preview( 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(), + environment_keys, + environment_prefilled, } } @@ -348,7 +417,9 @@ pub(crate) fn build_agent_snapshot_import_preview( /// /// 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. +/// env var VALUES, backend, lineage) is consumed. Env var *key names* +/// declared in the snapshot are scaffolded as blank entries so the owner +/// knows exactly what to configure after import. #[tauri::command] pub async fn confirm_agent_snapshot_import( input: AgentSnapshotImportConfirm, @@ -372,6 +443,14 @@ pub async fn confirm_agent_snapshot_import( )?; let minted_parallelism = minted.parallelism; + // Env scaffolding: pre-create blank entries for the key names declared in + // the snapshot. Values never travel in snapshots — the owner fills them + // in through the GUI after import. + let imported_env = resolve_snapshot_import_environment( + &snapshot.definition.environment, + &snapshot.definition.environment_values, + ); + // Profile metadata must contain a hosted URL. Inline avatar data can be far // larger than the relay's kind:0 content limit, so upload imported pixels // before minting or persisting the new agent. Failing here keeps import @@ -463,7 +542,7 @@ pub async fn confirm_agent_snapshot_import( source_team: None, source_team_persona_slug: None, catalog_source: None, - env_vars: std::collections::BTreeMap::new(), + env_vars: imported_env.clone(), respond_to: respond_to_wire.clone(), respond_to_allowlist: minted.respond_to_allowlist.clone(), parallelism: minted_parallelism, @@ -478,7 +557,9 @@ pub async fn confirm_agent_snapshot_import( 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. + // secrets, no lineage from the snapshot. Env var key names declared + // by the snapshot are scaffolded as blank entries (values never + // travel in snapshots). let record = ManagedAgentRecord { pubkey: pubkey.clone(), name: display_name.clone(), @@ -505,7 +586,7 @@ pub async fn confirm_agent_snapshot_import( model: snapshot.definition.model.clone(), provider: snapshot.definition.provider.clone(), persona_source_version: None, - env_vars: std::collections::BTreeMap::new(), + env_vars: imported_env, start_on_app_launch: false, auto_restart_on_config_change: true, runtime_pid: None, @@ -880,3 +961,6 @@ mod import_avatar_tests { assert_eq!(result.unwrap_err(), "Snapshot avatar data is malformed."); } } + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/import/tests.rs new file mode 100644 index 0000000000..4075f9b486 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/import/tests.rs @@ -0,0 +1,211 @@ +//! Tests for the import-side helpers that live in this module. The shared +//! snapshot fixtures and format-level tests live in `../tests.rs`. + +use std::collections::BTreeMap; + +use super::{build_agent_snapshot_import_preview, resolve_snapshot_import_environment}; +use crate::managed_agents::agent_snapshot::{ + AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, AgentSnapshotProfile, + FORMAT_DISCRIMINATOR, FORMAT_VERSION, +}; + +/// Snapshot with the given env key declaration (and optional value hints), +/// everything else minimal. +fn snapshot_with_environment( + environment: Vec, + environment_values: BTreeMap, +) -> AgentSnapshot { + AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "Env Agent".to_string(), + source_is_builtin: false, + system_prompt: None, + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + environment, + environment_values, + }, + profile: AgentSnapshotProfile { + display_name: "Env Agent".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: crate::managed_agents::agent_snapshot::MemoryLevel::None, + entries: vec![], + }, + } +} + +#[test] +fn environment_scaffolding_creates_blank_sorted_entries() { + let resolved = resolve_snapshot_import_environment( + &["ZEBRA_TOKEN".to_string(), "ALPHA_ENDPOINT".to_string()], + &BTreeMap::new(), + ); + assert_eq!( + resolved.keys().cloned().collect::>(), + vec!["ALPHA_ENDPOINT".to_string(), "ZEBRA_TOKEN".to_string()] + ); + assert!( + resolved.values().all(|v| v.is_empty()), + "values are blank unless the producer attached an explicit non-secret hint" + ); +} + +#[test] +fn environment_scaffolding_drops_reserved_and_malformed_keys() { + let resolved = resolve_snapshot_import_environment( + &[ + "BUZZ_PRIVATE_KEY".to_string(), // reserved — stripped at spawn anyway + "BUZZ_AUTH_TAG".to_string(), // reserved + "BAD KEY".to_string(), // malformed (space) + "KEY=x".to_string(), // malformed ('=' smuggling) + "9LEADING_DIGIT".to_string(), // malformed + "VALID_KEY".to_string(), + ], + &BTreeMap::new(), + ); + assert_eq!( + resolved.keys().cloned().collect::>(), + vec!["VALID_KEY".to_string()] + ); +} + +#[test] +fn environment_scaffolding_dedups_keys() { + let resolved = resolve_snapshot_import_environment( + &[ + "DUP_KEY".to_string(), + "DUP_KEY".to_string(), + "dup_key".to_string(), // case-insensitive dedup is NOT applied: env keys are case-sensitive + ], + &BTreeMap::new(), + ); + assert_eq!( + resolved.keys().cloned().collect::>(), + vec!["DUP_KEY".to_string(), "dup_key".to_string()] + ); +} + +#[test] +fn environment_value_hint_prefills_non_secret_declared_keys() { + // The headlining use case: a control plane exports the OpenAI-compatible + // API route so the imported agent starts pre-wired to the right endpoint, + // while the API key itself still has to be pasted by the owner. + let mut values = BTreeMap::new(); + values.insert( + "OPENAI_COMPAT_BASE_URL".to_string(), + "https://app.inloop.studio/api/v1/brains/example".to_string(), + ); + let resolved = resolve_snapshot_import_environment( + &[ + "OPENAI_COMPAT_API_KEY".to_string(), + "OPENAI_COMPAT_BASE_URL".to_string(), + ], + &values, + ); + assert_eq!( + resolved.get("OPENAI_COMPAT_BASE_URL").map(String::as_str), + Some("https://app.inloop.studio/api/v1/brains/example") + ); + assert_eq!( + resolved.get("OPENAI_COMPAT_API_KEY").map(String::as_str), + Some("") + ); +} + +#[test] +fn environment_value_hints_drop_secret_named_keys() { + // Fail closed: a producer that ships a real credential has it stripped — + // the key is still scaffolded so the owner knows to fill it in. + let mut values = BTreeMap::new(); + values.insert("MY_API_TOKEN".to_string(), "sk-live-token".to_string()); + values.insert("APP_PASSWORD".to_string(), "hunter2".to_string()); + let resolved = resolve_snapshot_import_environment( + &["MY_API_TOKEN".to_string(), "APP_PASSWORD".to_string()], + &values, + ); + assert!( + resolved.values().all(|v| v.is_empty()), + "secret-class key names must never accept pre-filled values" + ); +} + +#[test] +fn environment_value_hints_ignore_undeclared_keys() { + let mut values = BTreeMap::new(); + values.insert( + "UNDECLARED_URL".to_string(), + "https://example.com".to_string(), + ); + let resolved = resolve_snapshot_import_environment(&["DECLARED_KEY".to_string()], &values); + assert_eq!( + resolved.keys().cloned().collect::>(), + vec!["DECLARED_KEY".to_string()], + "value hints may not smuggle in keys absent from definition.environment" + ); +} + +#[test] +fn environment_value_hints_treat_empty_string_as_blank() { + let mut values = BTreeMap::new(); + values.insert("SOME_ENDPOINT".to_string(), String::new()); + let resolved = resolve_snapshot_import_environment(&["SOME_ENDPOINT".to_string()], &values); + assert_eq!(resolved.get("SOME_ENDPOINT").map(String::as_str), Some("")); +} + +#[test] +fn preview_surfaces_only_keys_that_will_be_created() { + let snapshot = snapshot_with_environment( + vec![ + "NEEDED_KEY".to_string(), + "BUZZ_PRIVATE_KEY".to_string(), + "BAD KEY".to_string(), + ], + BTreeMap::new(), + ); + let preview = build_agent_snapshot_import_preview(&snapshot); + assert_eq!( + preview.environment_keys, + vec!["NEEDED_KEY".to_string()], + "preview must show only keys the import will actually create" + ); + assert!( + preview.environment_prefilled.is_empty(), + "no value hints were attached" + ); +} + +#[test] +fn preview_marks_prefilled_keys() { + let mut values = BTreeMap::new(); + values.insert( + "OPENAI_COMPAT_BASE_URL".to_string(), + "https://app.inloop.studio/api/v1/brains/example".to_string(), + ); + values.insert("OPENAI_COMPAT_API_KEY".to_string(), "sk-nope".to_string()); + let snapshot = snapshot_with_environment( + vec![ + "OPENAI_COMPAT_API_KEY".to_string(), + "OPENAI_COMPAT_BASE_URL".to_string(), + ], + values, + ); + let preview = build_agent_snapshot_import_preview(&snapshot); + assert_eq!( + preview.environment_prefilled, + vec!["OPENAI_COMPAT_BASE_URL".to_string()], + "only the non-secret hint is pre-filled; the API key stays blank" + ); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 4289310280..271a714424 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -108,6 +108,8 @@ fn make_snapshot( name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + environment: vec![], + environment_values: Default::default(), }, profile: AgentSnapshotProfile { display_name: "Test Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index c9a6d8812a..9662193e2a 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -24,6 +24,8 @@ fn member(name: &str) -> AgentSnapshot { name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + environment: vec![], + environment_values: Default::default(), }, profile: AgentSnapshotProfile { display_name: name.to_string(), diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index f487c8ce16..13ba2afb4e 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -240,12 +240,12 @@ fn src_rust_files() -> Vec { /// guard + adding an injection test for the new site. const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ // Production egress boundaries (see egress_guard.rs table): - ("src/relay.rs", 2, 2), // boundaries 2, 4 - ("src/relay/submit.rs", 1, 1), // boundaries 1 + 3 (shared funnel) - ("src/huddle/pipeline.rs", 1, 1), // boundary 5 - ("src/commands/team_snapshot.rs", 1, 1), // boundary 6 - ("src/commands/personas/snapshot/import.rs", 2, 1), // boundary 7 + its in-file injection-test fixture URL - ("src/native_websocket.rs", 0, 2), // boundary 8 (WS frames; no events URL) + ("src/relay.rs", 2, 2), // boundaries 2, 4 + ("src/relay/submit.rs", 1, 1), // boundaries 1 + 3 (shared funnel) + ("src/huddle/pipeline.rs", 1, 1), // boundary 5 + ("src/commands/team_snapshot.rs", 1, 1), // boundary 6 + ("src/commands/personas/snapshot/import/mod.rs", 2, 1), // boundary 7 + its in-file injection-test fixture URL + ("src/native_websocket.rs", 0, 2), // boundary 8 (WS frames; no events URL) // Test-only fixtures — no production egress, no guard: ("src/relay_admission.rs", 1, 0), ("src/archive/mod_tests.rs", 1, 0), @@ -416,7 +416,7 @@ fn ncryptsec_handling_is_confined_to_allowlisted_files() { "src/huddle/pipeline.rs", "src/commands/team_snapshot.rs", "src/commands/team_snapshot/tests.rs", - "src/commands/personas/snapshot/import.rs", + "src/commands/personas/snapshot/import/mod.rs", "src/native_websocket.rs", ]; diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs deleted file mode 100644 index 16a0d35b23..0000000000 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ /dev/null @@ -1,999 +0,0 @@ -//! `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 snapshot manifest -//! - `.agent.png` — avatar image with manifest in a `buzz_agent_snapshot` -//! tEXt chunk -//! -//! Both formats may carry memory at any level. Memory entries are plaintext, -//! so callers must require an explicit opt-in before exporting them. -//! -//! **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`, `team_id`, `source_team`, `source_team_persona_slug`, -//! `persona_source_version` -//! - internal bookkeeping: `start_on_app_launch`, -//! `auto_restart_on_config_change` -//! -//! The portable `sourceIsBuiltIn` hint preserves how the exported definition -//! should be described in an import preview. It never grants built-in status -//! to the newly imported definition. -//! -//! 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, - /// Portable source classification for import-preview metadata. Imported - /// definitions are still created as custom agents with fresh identities. - #[serde(default)] - pub source_is_builtin: bool, - #[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 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, 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()), - source_is_builtin: record.is_builtin, - system_prompt: record.system_prompt.clone(), - runtime: record.runtime.clone(), - model: record.model.clone(), - provider: record.provider.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. -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 a snapshot with memory.level 'none' and non-empty memory entries." - .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 as the PNG image body, transcoding decodable non-PNG - // avatars. Fall back to a minimal 1×1 transparent placeholder only when - // there is no avatar or it cannot be decoded. - let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) { - Some(bytes) => { - let encoded_avatar = if bytes.starts_with(b"\x89PNG") { - inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| { - transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) - }) - } else { - transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) - }; - - match encoded_avatar { - Ok(png_bytes) => png_bytes, - Err(_) => 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. -pub(crate) 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. -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); - 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) -} - -/// Transcode a decodable avatar to PNG and add the snapshot manifest chunk. -fn transcode_avatar_to_png_with_text( - avatar_bytes: &[u8], - keyword: &str, - text: &str, -) -> Result, String> { - let image = image::load_from_memory(avatar_bytes) - .map_err(|e| format!("Failed to decode avatar image: {e}"))?; - let mut png_bytes = Vec::new(); - image - .write_to(&mut Cursor::new(&mut png_bytes), image::ImageFormat::Png) - .map_err(|e| format!("Failed to encode avatar as PNG: {e}"))?; - inject_text_chunk(&png_bytes, keyword, text) -} - -/// 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 - team_id: Some("SENTINEL_TEAM_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 - 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, - shared: false, - 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()), - catalog_source: None, - definition_respond_to_allowlist: vec!["abc123def".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); - } - - #[test] - fn png_snapshot_transcodes_jpeg_avatar_into_image_body() { - let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( - 3, - 2, - image::Rgb([0x12, 0x34, 0x56]), - )); - let mut jpeg_bytes = Vec::new(); - avatar - .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg) - .unwrap(); - - let snapshot = build_snapshot( - &minimal_record(), - MemoryLevel::None, - vec![], - Some(&jpeg_bytes), - ); - let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(); - let decoder = Decoder::new(Cursor::new(png_bytes)); - let reader = decoder.read_info().unwrap(); - - assert_eq!((reader.info().width, reader.info().height), (3, 2)); - } - - // ── PNG memory parity ───────────────────────────────────────────────────── - - #[test] - fn png_round_trip_with_core_memory() { - let record = minimal_record(); - let entries = vec![AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "remember this".to_string(), - }]; - let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); - - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - - assert_eq!(parsed.memory, snapshot.memory); - } - - #[test] - fn png_round_trip_with_everything_memory() { - let record = minimal_record(); - let entries = vec![ - AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "remember this".to_string(), - }, - AgentSnapshotMemoryEntry { - slug: "mem/notes".to_string(), - body: "private notes".to_string(), - }, - ]; - let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); - - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - - assert_eq!(parsed.memory, snapshot.memory); - } - - #[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("memory.level 'none' and non-empty memory entries"), - "Error must explain the malformed memory state" - ); - } - - // ── 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 snapshot_omits_removed_mcp_toolsets_config() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("mcpToolsets") && !json.contains("mcp_toolsets"), - "removed MCP toolsets config must not re-enter snapshots" - ); - } - - #[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" - ); - // teamId - assert!( - !json.contains("teamId") && !json.contains("team_id"), - "teamId field must not appear" - ); - assert!( - !json.contains("SENTINEL_TEAM_ID"), - "teamId 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!(!snapshot.definition.source_is_builtin); - 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/agent_snapshot/mod.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot/mod.rs new file mode 100644 index 0000000000..f95ae751c8 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot/mod.rs @@ -0,0 +1,541 @@ +//! `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 snapshot manifest +//! - `.agent.png` — avatar image with manifest in a `buzz_agent_snapshot` +//! tEXt chunk +//! +//! Both formats may carry memory at any level. Memory entries are plaintext, +//! so callers must require an explicit opt-in before exporting them. +//! +//! **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` **values** (API keys / credentials). Key NAMES alone are +//! exported under `definition.environment` as import scaffolding, so the +//! recipient sees exactly which variables to fill in — never the values. +//! (External producers MAY attach `definition.environmentValues` hints; +//! Buzz export never writes that field, and import drops every value +//! whose key name marks it as a credential.) +//! - `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`, `team_id`, `source_team`, `source_team_persona_slug`, +//! `persona_source_version` +//! - internal bookkeeping: `start_on_app_launch`, +//! `auto_restart_on_config_change` +//! +//! The portable `sourceIsBuiltIn` hint preserves how the exported definition +//! should be described in an import preview. It never grants built-in status +//! to the newly imported definition. +//! +//! 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::collections::BTreeMap; +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; + +/// Maximum number of environment key names accepted in a snapshot manifest. +/// Generous for legitimate use (a dozen is typical); guards against a hostile +/// or malformed manifest bloating the imported agent record. +pub const MAX_SNAPSHOT_ENV_KEYS: usize = 64; + +// ── 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, + /// Portable source classification for import-preview metadata. Imported + /// definitions are still created as custom agents with fresh identities. + #[serde(default)] + pub source_is_builtin: bool, + #[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 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, + /// Environment variable KEY NAMES the agent expects at spawn time. + /// Scaffolding only: values are credentials / machine-local config and + /// are never serialized (see module docs). Import pre-creates blank + /// entries so the owner sees exactly which variables to fill in. + /// Buzz-reserved and malformed keys are excluded at export and import. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub environment: Vec, + /// Optional NON-SECRET value hints an EXTERNAL snapshot producer may + /// attach (e.g. a control plane exporting an OpenAI-compatible API + /// route). Buzz's own export NEVER populates this field — values + /// remain non-serializable here by construction. At import, every + /// entry must reference a key also declared in `environment`; + /// secret-named keys (`*_API_KEY`, `*_TOKEN`, …) are dropped to + /// name-only scaffolding, and values for undeclared keys are ignored. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub environment_values: BTreeMap, +} + +/// 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, 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()), + source_is_builtin: record.is_builtin, + system_prompt: record.system_prompt.clone(), + runtime: record.runtime.clone(), + model: record.model.clone(), + provider: record.provider.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, + // Environment scaffolding: key names only, never values. Reserved and + // malformed keys are excluded — they could not be recreated on import + // anyway. BTreeMap iteration keeps the list sorted for stable diffs. + environment: record + .env_vars + .keys() + .filter(|k| super::is_well_formed_env_key(k) && !super::is_reserved_env_key(k)) + .cloned() + .collect(), + // Values never travel in Buzz-produced snapshots (module docs); + // the field exists for external producers only. + environment_values: BTreeMap::new(), + }; + + // ── 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. +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 a snapshot with memory.level 'none' and non-empty memory entries." + .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 as the PNG image body, transcoding decodable non-PNG + // avatars. Fall back to a minimal 1×1 transparent placeholder only when + // there is no avatar or it cannot be decoded. + let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) { + Some(bytes) => { + let encoded_avatar = if bytes.starts_with(b"\x89PNG") { + inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| { + transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) + }) + } else { + transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) + }; + + match encoded_avatar { + Ok(png_bytes) => png_bytes, + Err(_) => 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. +pub(crate) 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()); + } + if snapshot.definition.environment.len() > MAX_SNAPSHOT_ENV_KEYS { + return Err(format!( + "Snapshot definition.environment exceeds {} entries (got {})", + MAX_SNAPSHOT_ENV_KEYS, + snapshot.definition.environment.len() + )); + } + if snapshot.definition.environment_values.len() > MAX_SNAPSHOT_ENV_KEYS { + return Err(format!( + "Snapshot definition.environmentValues exceeds {} entries (got {})", + MAX_SNAPSHOT_ENV_KEYS, + snapshot.definition.environment_values.len() + )); + } + if let Some((key, len)) = snapshot + .definition + .environment_values + .iter() + .map(|(k, v)| (k, v.len())) + .find(|(_, len)| *len > super::MAX_ENV_VALUE_BYTES) + { + return Err(format!( + "Snapshot definition.environmentValues[{key}] value exceeds {} bytes (got {len})", + super::MAX_ENV_VALUE_BYTES + )); + } + 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. +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); + 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) +} + +/// Transcode a decodable avatar to PNG and add the snapshot manifest chunk. +fn transcode_avatar_to_png_with_text( + avatar_bytes: &[u8], + keyword: &str, + text: &str, +) -> Result, String> { + let image = image::load_from_memory(avatar_bytes) + .map_err(|e| format!("Failed to decode avatar image: {e}"))?; + let mut png_bytes = Vec::new(); + image + .write_to(&mut Cursor::new(&mut png_bytes), image::ImageFormat::Png) + .map_err(|e| format!("Failed to encode avatar as PNG: {e}"))?; + inject_text_chunk(&png_bytes, keyword, text) +} + +/// 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; diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot/tests.rs new file mode 100644 index 0000000000..8afaeb4b1e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot/tests.rs @@ -0,0 +1,695 @@ +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 + team_id: Some("SENTINEL_TEAM_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 + env_vars: { + let mut m = BTreeMap::new(); + // Value MUST NOT appear; key name appears as environment scaffolding. + m.insert("API_KEY".to_string(), "secret123".to_string()); + 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, + shared: false, + 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()), + catalog_source: None, + definition_respond_to_allowlist: vec!["abc123def".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); +} + +#[test] +fn png_snapshot_transcodes_jpeg_avatar_into_image_body() { + let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 3, + 2, + image::Rgb([0x12, 0x34, 0x56]), + )); + let mut jpeg_bytes = Vec::new(); + avatar + .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg) + .unwrap(); + + let snapshot = build_snapshot( + &minimal_record(), + MemoryLevel::None, + vec![], + Some(&jpeg_bytes), + ); + let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(); + let decoder = Decoder::new(Cursor::new(png_bytes)); + let reader = decoder.read_info().unwrap(); + + assert_eq!((reader.info().width, reader.info().height), (3, 2)); +} + +// ── PNG memory parity ───────────────────────────────────────────────────── + +#[test] +fn png_round_trip_with_core_memory() { + let record = minimal_record(); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "remember this".to_string(), + }]; + let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); + + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + + assert_eq!(parsed.memory, snapshot.memory); +} + +#[test] +fn png_round_trip_with_everything_memory() { + let record = minimal_record(); + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "remember this".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/notes".to_string(), + body: "private notes".to_string(), + }, + ]; + let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); + + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + + assert_eq!(parsed.memory, snapshot.memory); +} + +#[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("memory.level 'none' and non-empty memory entries"), + "Error must explain the malformed memory state" + ); +} + +// ── 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_var_values_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("secret123"), + "env var values must not appear in snapshot" + ); + assert!( + !json.contains("envVars") && !json.contains("env_vars"), + "envVars field must not appear in snapshot" + ); +} + +// ── Environment scaffolding tests ──────────────────────────────────────────── +// +// Key NAMES travel (sorted, values never) so import can pre-create blank +// entries; reserved/malformed keys and empty maps are excluded. + +#[test] +fn environment_keys_serialized_sorted_values_excluded() { + let mut record = minimal_record(); + record.env_vars = BTreeMap::from([ + ("ZEBRA_TOKEN".to_string(), "z-secret".to_string()), + ( + "ALPHA_ENDPOINT".to_string(), + "https://a.example".to_string(), + ), + ]); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!( + snapshot.definition.environment, + vec!["ALPHA_ENDPOINT".to_string(), "ZEBRA_TOKEN".to_string()], + "environment must carry sorted key names only" + ); + let json = snapshot_json_string(&record); + assert!( + !json.contains("z-secret") && !json.contains("https://a.example"), + "environment values must not appear in snapshot" + ); +} + +#[test] +fn environment_excludes_reserved_and_malformed_keys() { + let mut record = minimal_record(); + record.env_vars = BTreeMap::from([ + ("BUZZ_PRIVATE_KEY".to_string(), "forged".to_string()), // reserved + ("BAD KEY".to_string(), "v".to_string()), // malformed + ("VALID_KEY".to_string(), "v".to_string()), + ]); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!(snapshot.definition.environment, vec!["VALID_KEY"]); +} + +#[test] +fn environment_field_absent_when_no_env_vars() { + let mut record = minimal_record(); + record.env_vars = BTreeMap::new(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("environment"), + "empty environment must be omitted from the manifest" + ); +} + +#[test] +fn decode_manifest_without_environment_field_succeeds() { + // Backward compatibility: manifests produced before environment + // scaffolding existed (or by third-party v1 producers) omit the field. + let json = r#"{ + "format": "buzz-agent-snapshot", + "version": 1, + "definition": { "name": "Legacy Agent" }, + "profile": { "displayName": "Legacy Agent" }, + "memory": { "level": "none" } + }"#; + let parsed = decode_snapshot_json(json.as_bytes()).unwrap(); + assert!(parsed.definition.environment.is_empty()); +} + +#[test] +fn validate_rejects_oversized_environment() { + let keys: Vec = (0..=MAX_SNAPSHOT_ENV_KEYS) + .map(|i| format!("KEY_{i}")) + .collect(); + let manifest = serde_json::json!({ + "format": "buzz-agent-snapshot", + "version": 1, + "definition": { "name": "Hostile", "environment": keys }, + "profile": { "displayName": "Hostile" }, + "memory": { "level": "none" } + }); + let bytes = serde_json::to_vec(&manifest).unwrap(); + let err = decode_snapshot_json(&bytes).unwrap_err(); + assert!( + err.contains("environment"), + "error must identify the environment section: {err}" + ); +} + +#[test] +fn export_manifest_never_serializes_environment_values() { + // Buzz's own export must never emit the value-hint carrier, even when the + // record's env values are non-secret: only external producers may fill it. + let mut record = minimal_record(); + record.env_vars = BTreeMap::from([( + "OPENAI_COMPAT_BASE_URL".to_string(), + "https://app.inloop.studio/api/v1/brains/example".to_string(), + )]); + let json = snapshot_json_string(&record); + assert!( + !json.contains("environmentValues"), + "Buzz export must not serialize definition.environmentValues" + ); +} + +#[test] +fn decode_manifest_accepts_environment_value_hints() { + let manifest = serde_json::json!({ + "format": "buzz-agent-snapshot", + "version": 1, + "definition": { + "name": "Studio Agent", + "environment": ["OPENAI_COMPAT_API_KEY", "OPENAI_COMPAT_BASE_URL"], + "environmentValues": { + "OPENAI_COMPAT_BASE_URL": "https://app.inloop.studio/api/v1/brains/example" + } + }, + "profile": { "displayName": "Studio Agent" }, + "memory": { "level": "none" } + }); + let bytes = serde_json::to_vec(&manifest).unwrap(); + let parsed = decode_snapshot_json(&bytes).unwrap(); + assert_eq!( + parsed + .definition + .environment_values + .get("OPENAI_COMPAT_BASE_URL") + .map(String::as_str), + Some("https://app.inloop.studio/api/v1/brains/example") + ); +} + +#[test] +fn validate_rejects_oversized_environment_values() { + let values: serde_json::Map = (0..=MAX_SNAPSHOT_ENV_KEYS) + .map(|i| (format!("KEY_{i}"), serde_json::Value::from("v"))) + .collect(); + let manifest = serde_json::json!({ + "format": "buzz-agent-snapshot", + "version": 1, + "definition": { "name": "Hostile", "environmentValues": values }, + "profile": { "displayName": "Hostile" }, + "memory": { "level": "none" } + }); + let bytes = serde_json::to_vec(&manifest).unwrap(); + let err = decode_snapshot_json(&bytes).unwrap_err(); + assert!( + err.contains("environmentValues"), + "error must identify the environmentValues section: {err}" + ); +} + +#[test] +fn validate_rejects_oversize_single_environment_value() { + let manifest = serde_json::json!({ + "format": "buzz-agent-snapshot", + "version": 1, + "definition": { + "name": "Hostile", + "environment": ["BIG_HINT"], + "environmentValues": { + "BIG_HINT": "x".repeat(crate::managed_agents::MAX_ENV_VALUE_BYTES + 1) + } + }, + "profile": { "displayName": "Hostile" }, + "memory": { "level": "none" } + }); + let bytes = serde_json::to_vec(&manifest).unwrap(); + let err = decode_snapshot_json(&bytes).unwrap_err(); + assert!( + err.contains("environmentValues"), + "error must identify the environmentValues section: {err}" + ); +} + +#[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 snapshot_omits_removed_mcp_toolsets_config() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("mcpToolsets") && !json.contains("mcp_toolsets"), + "removed MCP toolsets config must not re-enter snapshots" + ); +} + +#[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" + ); + // teamId + assert!( + !json.contains("teamId") && !json.contains("team_id"), + "teamId field must not appear" + ); + assert!( + !json.contains("SENTINEL_TEAM_ID"), + "teamId 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!(!snapshot.definition.source_is_builtin); + 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/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc76..cb5c8725af 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -466,7 +466,8 @@ mod tests { !json.contains("auth-tag-secret"), "auth_tag value must not appear" ); - assert!(!json.contains("API_KEY"), "env var key must not appear"); + // Env var key names are intentional: they appear as blank-entry + // scaffolding under definition.environment (never the values). assert!(!json.contains("secret123"), "env var value must not appear"); assert!( !json.contains("wss://relay.example.com"), diff --git a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx index 4a9584dfb9..0d19f917cd 100644 --- a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx +++ b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx @@ -200,6 +200,28 @@ function PreviewBody({

)} + {/* Environment section */} + {preview.environmentKeys.length > 0 ? ( +
+

+ Environment variables ({preview.environmentKeys.length}) +

+

+ Blank entries will be created for these keys — secret values never + travel in snapshots. Fill them in under the agent's + environment settings after import. +

+
    + {preview.environmentKeys.map((key) => ( +
  • {key}
  • + ))} +
+
+ ) : null} + {/* Allowlist section */} {preview.hasSourceAllowlist ? (
{ assert.equal(withMemory.memoryLevel, "everything"); }); +// ── Preview: environment scaffolding ────────────────────────────────────────── + +test("preview_environment_keys_default_empty", () => { + const preview = makePreview(); + assert.deepEqual(preview.environmentKeys, []); +}); + +test("preview_environment_keys_drive_section_display", () => { + const preview = makePreview({ + environmentKeys: ["ALPHA_ENDPOINT", "ZEBRA_TOKEN"], + }); + assert.equal(preview.environmentKeys.length, 2); + // Keys arrive pre-filtered and sorted from the backend. + assert.deepEqual(preview.environmentKeys, ["ALPHA_ENDPOINT", "ZEBRA_TOKEN"]); +}); + // ── Allowlist: default is clear (server-side enforced) ─────────────────────── test("allowlist_default_is_clear", () => { diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index 66e07f5e88..5e29556fb0 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -258,6 +258,11 @@ export type AgentSnapshotImportPreview = { /** True when the snapshot's respond_to_allowlist is non-empty. */ hasSourceAllowlist: boolean; sourceAllowlistCount: number; + /** + * Env var key names that will be pre-created with blank values on import. + * Snapshots never carry values — the owner fills them in after import. + */ + environmentKeys: string[]; }; /** Confirmation sent to `confirm_agent_snapshot_import`. */ diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7566c82370..e03fc71b18 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10881,6 +10881,7 @@ export function maybeInstallE2eTauriMocks() { memoryEntryCount: 0, hasSourceAllowlist: false, sourceAllowlistCount: 0, + environmentKeys: [], }; } case "confirm_agent_snapshot_import": {