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": {