From 5bca0227f52711da78fb84af645b3fe1f8b9af3d Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Tue, 28 Jul 2026 01:45:03 -0400 Subject: [PATCH] feat(desktop): optional NIP-44 locked agent cards (Rust envelope + import) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tyler's encryption requirement, per Wren's wire-contract spec: - New agent_snapshot_envelope.rs: typed outer envelope (format "buzz-agent-snapshot-encrypted", version 1, scheme nip44-v2) riding the same allowlisted buzz_agent_snapshot chunk. Plain cards keep today's encoder byte-for-byte. NIP-44 v2 conversation key over the (owner, agent) pair — both nsecs decrypt, nobody else's. - Inner caps enforced per NIP-AE's local contract: 65,535-byte plaintext cap on the serialized manifest before encryption, envelope JSON/ciphertext caps before base64/decrypt work, decrypted UTF-8 cap before snapshot parsing. - Exact-endpoint key resolution only (owner identity key match, or a hydrated managed-agent record whose pubkey + derived-secret pubkey both equal the embedded agentPubkey) — no trial decryption. All unlock failures surface only the constant LOCKED_CARD_REFUSAL. - mint path: lock flag on mint_agent_card; encrypted round-trip verifies by extracting the chunk, decrypting with the same endpoint key, and comparing logical manifests (not ciphertext). - preview/confirm import wired through decode_snapshot_for_import with owner keys + loaded records; AgentSnapshotImportPreview gains locked: bool (true = unlocked by local keys, full payload disclosed). - fetch_snapshot_bytes validates locked envelopes structurally in transit without decrypting. - Test vectors per Wren's list: owner/agent unlock, unrelated-key refusal, tampered ciphertext, swapped/malformed pubkeys, unknown format/version/scheme, plaintext + ciphertext caps, encrypted final-PNG round trip, plain-card bytes unchanged. - File-size gate: inline test modules split to sibling #[path] files (agent_snapshot_tests.rs, snapshot/tests_locked.rs) following the storage_tests.rs precedent; no new size-gate exceptions. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../src-tauri/src/commands/media_download.rs | 9 +- .../src-tauri/src/commands/personas/card.rs | 104 ++- .../src-tauri/src/commands/personas/mod.rs | 7 +- .../src/commands/personas/snapshot/import.rs | 149 ++++- .../src/commands/personas/snapshot/tests.rs | 5 + .../personas/snapshot/tests_locked.rs | 129 ++++ .../src/managed_agents/agent_snapshot.rs | 559 +--------------- .../managed_agents/agent_snapshot_envelope.rs | 629 ++++++++++++++++++ .../managed_agents/agent_snapshot_tests.rs | 524 +++++++++++++++ desktop/src-tauri/src/managed_agents/mod.rs | 1 + 10 files changed, 1542 insertions(+), 574 deletions(-) create mode 100644 desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs create mode 100644 desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs create mode 100644 desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 016865878..a175524a9 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -8,7 +8,8 @@ use crate::commands::export_util::save_bytes_with_dialog; use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth, sanitize_filename}; use crate::commands::{ personas::{ - decode_snapshot_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, PNG_MAGIC, + parse_snapshot_payload_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, + PNG_MAGIC, }, team_snapshot::{ decode_team_snapshot_from_bytes, MAX_TEAM_SNAPSHOT_JSON_BYTES, MAX_TEAM_SNAPSHOT_PNG_BYTES, @@ -505,10 +506,12 @@ pub async fn fetch_snapshot_bytes( // 4. Bytes must parse as the snapshot type selected by the filename. // Team parsing rejects retired flat JSON and persona-pack ZIP inputs - // before anything reaches the frontend. + // before anything reaches the frontend. Agent kinds accept both plain + // manifests and structurally valid locked (encrypted) card envelopes — + // transit validation never decrypts; unlock happens at import time. match kind { SnapshotFileKind::AgentJson | SnapshotFileKind::AgentPng => { - decode_snapshot_from_bytes(&bytes) + parse_snapshot_payload_from_bytes(&bytes) .map_err(|e| format!("invalid agent snapshot: {e}"))?; } SnapshotFileKind::TeamJson | SnapshotFileKind::TeamPng => { diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 856ceabac..bfdadc88f 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -30,7 +30,10 @@ use crate::{ managed_agents::{ agent_snapshot::{ build_snapshot, decode_avatar_data_url, decode_snapshot_png, encode_snapshot_png, - MemoryLevel, + extract_chunk_payload_png, MemoryLevel, + }, + agent_snapshot_envelope::{ + decrypt_envelope, encode_locked_snapshot_png, parse_chunk_payload, ChunkPayload, }, load_agent_definitions, load_global_agent_config, load_managed_agents, load_personas, }, @@ -68,6 +71,9 @@ pub struct MintedCard { pub file_name: String, /// Designer commentary emitted alongside the image (may be empty). pub designer_notes: String, + /// True when the embedded snapshot is NIP-44-encrypted to the + /// (owner, agent) pair — only their nsecs can import this card. + pub locked: bool, } // ── Key resolution ──────────────────────────────────────────────────────────── @@ -194,17 +200,24 @@ pub(crate) fn extract_card_output(resp: &serde_json::Value) -> Result<(String, S /// Mint a trading card for the agent identified by `id` (instance pubkey, /// instance slug, or definition slug — same resolution as snapshot export). /// +/// When `lock` is true the embedded manifest is NIP-44-encrypted to the +/// (owner, agent) pair per the locked-envelope contract — this requires a +/// linked agent instance (the second key endpoint); bare definitions cannot +/// be locked. +/// /// Returns the final, chunk-injected, round-trip-verified `.agent.png` bytes. /// Reroll = call again; the command holds no session state. #[tauri::command] pub async fn mint_agent_card( id: String, style_notes: Option, + lock: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { + let lock = lock.unwrap_or(false); // ── Resolve the record + API key under lock ────────────────────────────── - let (record, api_key) = { + let (record, is_definition, api_key) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -212,7 +225,7 @@ pub async fn mint_agent_card( let instances = load_managed_agents(&app)?; let definitions = load_agent_definitions(&app)?; - let (record, _is_definition) = + let (record, is_definition) = resolve_from_lists(&id, &instances, &definitions).map(|(r, d)| (r.clone(), d))?; let global = load_global_agent_config(&app).unwrap_or_default(); @@ -237,7 +250,28 @@ pub async fn mint_agent_card( ) })?; - (record, api_key) + (record, is_definition, api_key) + }; + + // ── Locking needs its two exact key endpoints up front, BEFORE the + // API spend: the owner identity secret and the agent instance pubkey. + let lock_keys = if lock { + if is_definition { + return Err( + "Locked cards need a linked agent instance — this persona has never been \ + started, so there is no agent key to lock to." + .to_string(), + ); + } + let owner_keys = state.signing_keys()?; + let agent_pubkey = nostr::PublicKey::from_hex(&record.pubkey) + .map_err(|e| format!("Agent record has an invalid pubkey: {e}"))?; + if owner_keys.public_key() == agent_pubkey { + return Err("Cannot lock a card to itself: owner and agent keys match.".to_string()); + } + Some((owner_keys, agent_pubkey)) + } else { + None }; let display_name = record @@ -270,6 +304,20 @@ pub async fn mint_agent_card( ); // ── One Responses API call ─────────────────────────────────────────────── + // For locked mints, prove the manifest fits the NIP-44 plaintext cap + // BEFORE spending minutes on the API call (same fail-early rule as the + // memory guard above). + if lock_keys.is_some() { + let json_len = + crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot)?.len(); + if json_len > buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX { + return Err(format!( + "Agent manifest is too large to lock ({json_len} bytes; the encrypted \ + format caps at {}). Reduce the avatar size or mint an unlocked card.", + buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX + )); + } + } let instructions = build_card_instructions( &display_name, snapshot.definition.system_prompt.as_deref().unwrap_or(""), @@ -347,13 +395,42 @@ pub async fn mint_agent_card( ) .map_err(|e| format!("Failed to encode card PNG: {e}"))?; - let final_bytes = encode_snapshot_png(&snapshot, Some(&card_png)) - .map_err(|e| format!("Failed to embed agent snapshot in card: {e}"))?; + let final_bytes = match &lock_keys { + None => encode_snapshot_png(&snapshot, Some(&card_png)) + .map_err(|e| format!("Failed to embed agent snapshot in card: {e}"))?, + Some((owner_keys, agent_pubkey)) => { + encode_locked_snapshot_png(&snapshot, owner_keys, agent_pubkey, Some(&card_png)) + .map_err(|e| format!("Failed to embed locked agent snapshot in card: {e}"))? + } + }; // ── Verify: size ceiling + round-trip on the FINAL bytes ──────────────── + // Locked cards: extract the actual chunk, parse the envelope, decrypt + // with the owner key, then compare the logical manifest (ciphertext is + // nondeterministic — never compare bytes). validate_snapshot_encode_size(final_bytes.len(), true)?; - let decoded = decode_snapshot_png(&final_bytes) - .map_err(|e| format!("Card failed round-trip verification: {e}"))?; + let decoded = match &lock_keys { + None => decode_snapshot_png(&final_bytes) + .map_err(|e| format!("Card failed round-trip verification: {e}"))?, + Some((owner_keys, _)) => { + let payload = extract_chunk_payload_png(&final_bytes) + .map_err(|e| format!("Card failed round-trip verification: {e}"))?; + match parse_chunk_payload(&payload) + .map_err(|e| format!("Card failed round-trip verification: {e}"))? + { + ChunkPayload::Locked(envelope) => { + decrypt_envelope(&envelope, owner_keys.secret_key()) + .map_err(|e| format!("Card failed round-trip verification: {e}"))? + } + ChunkPayload::Plain(_) => { + return Err( + "Card round-trip verification failed: expected a locked envelope." + .to_string(), + ) + } + } + } + }; if decoded != snapshot { return Err("Card round-trip verification failed: manifest mismatch.".to_string()); } @@ -363,6 +440,7 @@ pub async fn mint_agent_card( card_png_base64: STANDARD.encode(&final_bytes), file_name: format!("{slug}.agent.png"), designer_notes, + locked: lock_keys.is_some(), }) } @@ -416,8 +494,10 @@ fn append_within_avatar_cap(buf: &mut Vec, chunk: &[u8]) -> Result<(), Strin /// Save previously minted card bytes to disk via the OS save dialog. /// -/// Re-validates the bytes (chunk decodes, size within the import ceiling) -/// so a corrupted preview can never be written as a `.agent.png`. +/// Re-validates the bytes (chunk parses as a plain manifest or a +/// structurally valid locked envelope, size within the import ceiling) so a +/// corrupted preview can never be written as a `.agent.png`. No decryption +/// happens here — the mint already round-trip-verified with the real key. #[tauri::command] pub async fn save_agent_card( card_png_base64: String, @@ -428,7 +508,9 @@ pub async fn save_agent_card( .decode(card_png_base64.as_bytes()) .map_err(|e| format!("Card bytes were not valid base64: {e}"))?; validate_snapshot_encode_size(bytes.len(), true)?; - decode_snapshot_png(&bytes) + let payload = extract_chunk_payload_png(&bytes) + .map_err(|e| format!("Refusing to save: card failed snapshot validation: {e}"))?; + parse_chunk_payload(&payload) .map_err(|e| format!("Refusing to save: card failed snapshot validation: {e}"))?; let safe_name = if file_name.ends_with(".agent.png") && !file_name.contains(['/', '\\']) { diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index a1e2a8738..17e3ad3e6 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -973,10 +973,11 @@ pub(crate) const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47]; mod card; mod snapshot; pub use card::{mint_agent_card, save_agent_card}; -pub use snapshot::encode_agent_snapshot_for_send; -pub use snapshot::export_agent_snapshot; +#[cfg(test)] +pub(crate) use snapshot::import::decode_snapshot_from_bytes; pub(crate) use snapshot::import::{ - decode_snapshot_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES, + parse_snapshot_payload_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, }; pub use snapshot::{confirm_agent_snapshot_import, preview_agent_snapshot_import}; +pub use snapshot::{encode_agent_snapshot_for_send, export_agent_snapshot}; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 4b49dd7ee..a6c05c1d6 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -13,7 +13,11 @@ use tauri::{AppHandle, Emitter, State}; use crate::{ app_state::AppState, managed_agents::{ - agent_snapshot::{decode_snapshot_json, decode_snapshot_png, MemoryLevel}, + agent_snapshot::{extract_chunk_payload_png, MemoryLevel}, + agent_snapshot_envelope::{ + decrypt_envelope, parse_chunk_payload, resolve_unlock_secret, ChunkPayload, + LOCKED_CARD_REFUSAL, + }, load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, ManagedAgentRecord, RespondTo, }, @@ -71,6 +75,10 @@ pub struct AgentSnapshotImportPreview { /// Pretty-printed, validated manifest exactly as decoded from the file. /// The UI makes this available before confirmation for full payload review. pub manifest_json: String, + /// True when the snapshot came from a locked (encrypted) card that this + /// machine successfully unlocked. Cards that cannot be unlocked never + /// reach a preview — they fail closed with the locked-card refusal. + pub locked: bool, } /// The confirmation request sent from the UI after the user reviews the preview. @@ -209,42 +217,91 @@ const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47]; /// /// **Size cap:** PNG inputs over 10 MiB and JSON inputs over 5 MiB are rejected /// before allocation to avoid avoidable large-input work. -pub(crate) fn decode_snapshot_from_bytes( - file_bytes: &[u8], -) -> Result { - if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC { +/// +/// **Locked cards:** a structurally valid locked envelope parses successfully +/// as `ChunkPayload::Locked` — no decryption happens here. Callers that can +/// unlock go through [`decode_snapshot_for_import`]; callers that only need +/// transit validation (e.g. `fetch_snapshot_bytes`) accept `Locked` as-is. +pub(crate) fn parse_snapshot_payload_from_bytes(file_bytes: &[u8]) -> Result { + let payload: ChunkPayload = if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC { if file_bytes.len() > MAX_SNAPSHOT_PNG_BYTES { return Err(format!( "Snapshot file is too large ({} MiB). PNG snapshots must be under 10 MiB.", file_bytes.len() / (1024 * 1024) )); } - let snapshot = decode_snapshot_png(file_bytes)?; - if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { - return Err( - "Snapshot is malformed: memory.level is 'none' but entries are present." - .to_string(), - ); + let chunk_json = extract_chunk_payload_png(file_bytes)?; + parse_chunk_payload(&chunk_json)? + } else { + // JSON path — apply size cap before serde allocation. + if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES { + return Err(format!( + "Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.", + file_bytes.len() / (1024 * 1024) + )); } - return Ok(snapshot); - } - // JSON path — apply size cap before serde allocation. - if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES { - return Err(format!( - "Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.", - file_bytes.len() / (1024 * 1024) - )); - } - let snapshot = decode_snapshot_json(file_bytes)?; + parse_chunk_payload(file_bytes)? + }; // Consistency check: none + non-empty entries is always malformed, - // regardless of format. Mirrors the PNG path above so the rule is - // enforced at decode time for both formats. - if !snapshot.memory.entries.is_empty() && snapshot.memory.level == MemoryLevel::None { + // regardless of enclosing format. Enforced at decode time for plain + // payloads here, and after decryption for locked ones (see + // `enforce_memory_consistency` callers). + if let ChunkPayload::Plain(snapshot) = &payload { + enforce_memory_consistency(snapshot)?; + } + Ok(payload) +} + +/// The shared malformed-memory guard: `memory.level == none` with non-empty +/// entries is always rejected before any write. +fn enforce_memory_consistency( + snapshot: &crate::managed_agents::agent_snapshot::AgentSnapshot, +) -> Result<(), String> { + if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { return Err( "Snapshot is malformed: memory.level is 'none' but entries are present.".to_string(), ); } - Ok(snapshot) + Ok(()) +} + +/// Decode a plain snapshot from raw bytes, refusing locked cards. +/// +/// Test-only convenience: production call sites either unlock through +/// [`decode_snapshot_for_import`] or validate structurally through +/// [`parse_snapshot_payload_from_bytes`]. +#[cfg(test)] +pub(crate) fn decode_snapshot_from_bytes( + file_bytes: &[u8], +) -> Result { + match parse_snapshot_payload_from_bytes(file_bytes)? { + ChunkPayload::Plain(snapshot) => Ok(*snapshot), + ChunkPayload::Locked(_) => Err(LOCKED_CARD_REFUSAL.to_string()), + } +} + +/// Decode a snapshot for import, unlocking locked cards when — and only +/// when — this machine holds one of the envelope's two exact key endpoints +/// (the owner identity or the named local agent record). +/// +/// Returns the decoded manifest and whether it came from a locked envelope. +/// When neither endpoint exists, fails closed with the locked-card refusal — +/// never partial plaintext, never crypto details. +pub(crate) fn decode_snapshot_for_import( + file_bytes: &[u8], + owner_keys: Option<&nostr::Keys>, + records: &[ManagedAgentRecord], +) -> Result<(crate::managed_agents::agent_snapshot::AgentSnapshot, bool), String> { + match parse_snapshot_payload_from_bytes(file_bytes)? { + ChunkPayload::Plain(snapshot) => Ok((*snapshot, false)), + ChunkPayload::Locked(envelope) => { + let secret = resolve_unlock_secret(&envelope, owner_keys, records) + .ok_or_else(|| LOCKED_CARD_REFUSAL.to_string())?; + let snapshot = decrypt_envelope(&envelope, &secret)?; + enforce_memory_consistency(&snapshot)?; + Ok((snapshot, true)) + } + } } // ── `preview_agent_snapshot_import` ────────────────────────────────────────── @@ -256,17 +313,36 @@ pub(crate) fn decode_snapshot_from_bytes( /// `.agent.png` file. The format is sniffed from the content, not the /// extension, so an incorrectly-named file is handled correctly. /// +/// Locked cards are unlocked here when this machine holds one of the +/// envelope's two exact key endpoints; a card that cannot be unlocked fails +/// with the locked-card refusal (shown directly to the user), never a +/// partial preview. Identity-recovery mode is tolerated: owner keys are +/// simply unavailable, so only the agent-record endpoint can unlock. +/// /// Returns an `AgentSnapshotImportPreview` or a descriptive error. Errors -/// represent irrecoverable failures (corrupt / unsupported file) and are -/// shown directly to the user. +/// represent irrecoverable failures (corrupt / unsupported / locked-to- +/// someone-else file) and are shown directly to the user. #[tauri::command] pub async fn preview_agent_snapshot_import( file_bytes: Vec, file_name: String, + app: AppHandle, + state: State<'_, AppState>, ) -> Result { + // Key material + records are gathered up front (cheap, lock-scoped) so + // the blocking decode below owns plain data. + let owner_keys = state.signing_keys().ok(); + let records = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + load_managed_agents(&app)? + }; tokio::task::spawn_blocking(move || { reject_legacy_persona_filename(&file_name)?; - let snapshot = decode_snapshot_from_bytes(&file_bytes)?; + let (snapshot, locked) = + decode_snapshot_for_import(&file_bytes, owner_keys.as_ref(), &records)?; let memory_level = match snapshot.memory.level { MemoryLevel::None => "none", @@ -294,6 +370,7 @@ pub async fn preview_agent_snapshot_import( has_source_allowlist: !source_allowlist.is_empty(), source_allowlist, manifest_json, + locked, }) }) .await @@ -325,8 +402,20 @@ pub async fn confirm_agent_snapshot_import( app: AppHandle, state: State<'_, AppState>, ) -> Result { - // ── Phase 1: validate (no I/O) ─────────────────────────────────────────── - let snapshot = decode_snapshot_from_bytes(&input.file_bytes)?; + // ── Phase 1: validate (no writes) ──────────────────────────────────────── + // Locked cards unlock only via this machine's exact key endpoints; + // anything else fails closed here, before key generation. + let snapshot = { + let owner_keys = state.signing_keys().ok(); + let records = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + load_managed_agents(&app)? + }; + decode_snapshot_for_import(&input.file_bytes, owner_keys.as_ref(), &records)?.0 + }; let display_name = snapshot.profile.display_name.trim().to_string(); if display_name.is_empty() { diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index b1d19f06b..89c4cce43 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -969,3 +969,8 @@ fn validate_encode_size_png_over_boundary_is_rejected() { "error must mention size limit, got: {err}" ); } + +// ── Import: decode_snapshot_for_import (locked cards) ───────────────────── + +#[path = "tests_locked.rs"] +mod locked_import; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs new file mode 100644 index 000000000..296444f78 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs @@ -0,0 +1,129 @@ +//! Locked-card import tests for `decode_snapshot_for_import`. +//! +//! Kept in a sibling file so `snapshot/tests.rs` stays under the +//! 1000-line gate; `#[path]`-included from there as a child module, +//! so `super::*` still resolves to the shared test helpers. + +use super::*; +use crate::commands::personas::snapshot::import::{ + decode_snapshot_for_import, parse_snapshot_payload_from_bytes, +}; +use crate::managed_agents::agent_snapshot_envelope::{ + encode_locked_snapshot_png, encrypt_snapshot_envelope, ChunkPayload, LOCKED_CARD_REFUSAL, +}; + +/// Build a keyed instance record holding real key material, so the +/// agent-endpoint unlock path resolves exactly as production does. +fn record_for(agent: &nostr::Keys) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: agent.public_key().to_hex(), + slug: None, + persona_id: Some("locked-test".to_string()), + private_key_nsec: nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(), + ..make_definition("") + } +} + +fn locked_png(owner: &nostr::Keys, agent: &nostr::Keys) -> (AgentSnapshot, Vec) { + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let png = encode_locked_snapshot_png(&snapshot, owner, &agent.public_key(), None).unwrap(); + (snapshot, png) +} + +/// Owner identity key unlocks a locked card; `locked` is reported true. +#[test] +fn owner_endpoint_unlocks_locked_png() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (snapshot, png) = locked_png(&owner, &agent); + let (decoded, locked) = decode_snapshot_for_import(&png, Some(&owner), &[]).unwrap(); + assert_eq!(decoded, snapshot); + assert!(locked); +} + +/// A local managed-agent record holding the agent nsec unlocks the card +/// even when the owner identity does not match (e.g. re-import on the +/// agent's own machine under a different owner identity). +#[test] +fn agent_record_endpoint_unlocks_locked_png() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (snapshot, png) = locked_png(&owner, &agent); + let other_identity = nostr::Keys::generate(); + let records = vec![record_for(&agent)]; + let (decoded, locked) = + decode_snapshot_for_import(&png, Some(&other_identity), &records).unwrap(); + assert_eq!(decoded, snapshot); + assert!(locked); +} + +/// No matching endpoint → only the locked-card refusal, nothing else. +#[test] +fn stranger_fails_closed_with_refusal_only() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (_snapshot, png) = locked_png(&owner, &agent); + let stranger = nostr::Keys::generate(); + let unrelated_record = record_for(&nostr::Keys::generate()); + let err = decode_snapshot_for_import(&png, Some(&stranger), &[unrelated_record]).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + // And with no key material at all. + let err = decode_snapshot_for_import(&png, None, &[]).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); +} + +/// Plain snapshots pass through unchanged with `locked == false`, with or +/// without key material in scope. +#[test] +fn plain_snapshot_passes_through_unlocked() { + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let png = encode_snapshot_png(&snapshot, None).unwrap(); + let owner = nostr::Keys::generate(); + let (decoded, locked) = decode_snapshot_for_import(&png, Some(&owner), &[]).unwrap(); + assert_eq!(decoded, snapshot); + assert!(!locked); + let (decoded, locked) = decode_snapshot_for_import(&png, None, &[]).unwrap(); + assert_eq!(decoded, snapshot); + assert!(!locked); +} + +/// The memory-consistency guard fires AFTER decryption too: a locked +/// envelope whose plaintext declares level none + non-empty entries is +/// rejected even for a legitimate endpoint. +#[test] +fn decrypted_manifest_memory_consistency_enforced() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let malformed = make_snapshot( + MemoryLevel::None, + vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "leaked".to_string(), + }], + ); + // encrypt_snapshot_envelope does not guard memory consistency (the + // PNG encoder does), so this constructs the malicious payload. + let envelope = encrypt_snapshot_envelope(&malformed, &owner, &agent.public_key()).unwrap(); + let json = serde_json::to_vec(&envelope).unwrap(); + let err = decode_snapshot_for_import(&json, Some(&owner), &[]).unwrap_err(); + assert!( + err.contains("'none' but entries are present"), + "post-decrypt consistency guard must fire, got: {err}" + ); +} + +/// Transit validation (`fetch_snapshot_bytes` path) accepts a locked PNG +/// without any key material — structural validation only, no decryption. +#[test] +fn transit_validation_accepts_locked_png_without_keys() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (_snapshot, png) = locked_png(&owner, &agent); + let payload = parse_snapshot_payload_from_bytes(&png).unwrap(); + assert!(matches!(payload, ChunkPayload::Locked(_))); +} + +/// The keyless plain decoder refuses locked cards with the refusal. +#[test] +fn plain_decoder_refuses_locked_cards() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (_snapshot, png) = locked_png(&owner, &agent); + let err = decode_snapshot_from_bytes(&png).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); +} diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index b0bf8f599..8f8ead1bc 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -297,9 +297,22 @@ pub fn encode_snapshot_png( ); } - // Manifest → JSON → base64 for the tEXt chunk payload. + // Manifest → JSON for the tEXt chunk payload. The payload/PNG composition + // is shared with the locked-card encoder in `agent_snapshot_envelope`; + // plain cards remain byte-identical to the pre-envelope encoder. let json_bytes = encode_snapshot_json(snapshot)?; - let chunk_text = STANDARD.encode(&json_bytes); + encode_chunk_payload_png(&json_bytes, avatar_bytes) +} + +/// Encode arbitrary chunk-payload JSON (plain manifest or locked envelope) +/// into a PNG carrying it base64-encoded in the `buzz_agent_snapshot` tEXt +/// chunk. Shared by the plain encoder above and +/// `agent_snapshot_envelope::encode_locked_snapshot_png`. +pub(crate) fn encode_chunk_payload_png( + json_bytes: &[u8], + avatar_bytes: Option<&[u8]>, +) -> Result, String> { + 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 @@ -325,8 +338,11 @@ pub fn encode_snapshot_png( Ok(png_bytes) } -/// Decode a manifest from a `.agent.png` tEXt chunk. -pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { +/// Extract and base64-decode the raw `buzz_agent_snapshot` chunk payload +/// (JSON bytes) from a PNG, without interpreting it. The payload may be a +/// plain manifest or a locked envelope — callers dispatch on the parsed +/// `format` via `agent_snapshot_envelope::parse_chunk_payload`. +pub(crate) fn extract_chunk_payload_png(png_bytes: &[u8]) -> Result, String> { let decoder = Decoder::new(Cursor::new(png_bytes)); let reader = decoder .read_info() @@ -340,10 +356,18 @@ pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { .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 + STANDARD .decode(chunk_text.trim()) - .map_err(|e| format!("Invalid base64 in PNG chunk: {e}"))?; + .map_err(|e| format!("Invalid base64 in PNG chunk: {e}")) +} +/// Decode a manifest from a `.agent.png` tEXt chunk. +/// +/// Plain snapshots only — a locked (encrypted) chunk payload fails here with +/// the manifest format error. Import paths that must handle locked cards go +/// through `agent_snapshot_envelope::parse_chunk_payload` instead. +pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { + let json_bytes = extract_chunk_payload_png(png_bytes)?; decode_snapshot_json(&json_bytes) } @@ -464,524 +488,5 @@ fn inject_text_chunk(png_bytes: &[u8], keyword: &str, text: &str) -> Result 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, - source_team: Some("team-id-123".to_string()), // MUST NOT appear - source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear - definition_respond_to: Some("allowlist".to_string()), - definition_respond_to_allowlist: vec!["abc123def".to_string()], - definition_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_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")); - } -} +#[path = "agent_snapshot_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs new file mode 100644 index 000000000..8c0f0c351 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -0,0 +1,629 @@ +//! Locked (encrypted) agent-card envelope — NIP-44 v2 over the snapshot manifest. +//! +//! A locked card carries the same `buzz_agent_snapshot` tEXt chunk as a plain +//! card, but the chunk JSON is a typed outer envelope whose ciphertext +//! decrypts to the ordinary manifest. The NIP-44 v2 conversation key is +//! symmetric over the (owner, agent) pair, so BOTH the owner's and the +//! agent's nsec decrypt the card — nobody else's does (NIP-AE's scheme). +//! +//! Wire contract (agreed with Wren, buzz-agent-trading-cards thread): +//! - Plain cards keep today's exact bytes; detection dispatches once on the +//! exact `format` discriminator and rejects unknown versions/schemes +//! rather than falling through to manifest parsing. +//! - Key lookup is exact-endpoint only: the owner identity key when its +//! pubkey equals `ownerPubkey`, or a hydrated local managed-agent record +//! whose record pubkey AND derived-secret pubkey equal `agentPubkey`. +//! No trial decryption; anything else fails closed as locked. +//! - Caps beyond the outer 10 MiB PNG gate: 65,535-byte NIP-44 plaintext +//! limit on the serialized manifest BEFORE encryption; envelope JSON and +//! ciphertext are capped before serde/base64/decrypt work; decrypted bytes +//! are capped before snapshot parsing. +//! - Decrypt/auth failures return only the locked-card refusal — never +//! partial plaintext or crypto details. + +use buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX; +use nostr::nips::nip44::{self, Version}; +use nostr::{Keys, PublicKey, SecretKey}; +use serde::{Deserialize, Serialize}; + +use super::agent_snapshot::{ + decode_snapshot_json, encode_chunk_payload_png, encode_snapshot_json, AgentSnapshot, + MemoryLevel, FORMAT_DISCRIMINATOR, +}; +use super::types::ManagedAgentRecord; + +/// Discriminator for the locked envelope. Distinct from the plain manifest's +/// `buzz-agent-snapshot` so detection never guesses. +pub const LOCKED_FORMAT: &str = "buzz-agent-snapshot-encrypted"; +/// Envelope schema version this module produces and accepts. +pub const LOCKED_VERSION: u32 = 1; +/// Encryption scheme identifier this module produces and accepts. +pub const LOCKED_SCHEME: &str = "nip44-v2"; + +/// A max-size NIP-44 v2 payload (1 version + 32 nonce + 2 len + 65,536 +/// padded + 32 MAC = 65,603 bytes) base64-encodes to 87,472 chars. +/// Anything larger is rejected before base64/decrypt work. +pub const MAX_LOCKED_CIPHERTEXT_BYTES: usize = 90_000; +/// Envelope JSON = ciphertext + two pubkeys + fixed keys. Rejected before +/// typed deserialization. +pub const MAX_LOCKED_ENVELOPE_JSON_BYTES: usize = MAX_LOCKED_CIPHERTEXT_BYTES + 1024; + +/// The only error a failed unlock may surface. Deliberately says nothing +/// about which key was tried or why decryption failed. +pub const LOCKED_CARD_REFUSAL: &str = + "This card is locked to its owner and agent. Only they can import it."; + +// ── Envelope types ──────────────────────────────────────────────────────────── + +/// Typed outer envelope stored (base64 JSON) in the `buzz_agent_snapshot` +/// chunk of a locked card. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LockedSnapshotEnvelope { + /// Always [`LOCKED_FORMAT`]. + pub format: String, + /// Always [`LOCKED_VERSION`]. + pub version: u32, + pub encryption: LockedEncryption, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LockedEncryption { + /// Always [`LOCKED_SCHEME`]. + pub scheme: String, + /// Owner identity pubkey (64 lowercase hex). Plaintext so a decryptor + /// knows which counterparty to pair with. + pub owner_pubkey: String, + /// Agent instance pubkey (64 lowercase hex). + pub agent_pubkey: String, + /// NIP-44 v2 ciphertext (base64) of the plain manifest JSON. + pub ciphertext: String, +} + +/// Result of parsing a chunk payload: either today's plain manifest or a +/// validated locked envelope. The plain manifest is boxed because it may +/// inline a multi-KB avatar data URL, dwarfing the envelope variant. +#[derive(Debug)] +pub enum ChunkPayload { + Plain(Box), + Locked(LockedSnapshotEnvelope), +} + +/// Minimal probe used to read the `format` discriminator without building a +/// full JSON tree for large plain manifests. +#[derive(Deserialize)] +struct FormatProbe { + #[serde(default)] + format: Option, +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +/// Canonical pubkey check: exactly 64 lowercase hex chars that parse as a +/// valid x-only pubkey. Lowercase is required so string comparisons against +/// record pubkeys (always `to_hex()` output) stay sound. +fn parse_canonical_pubkey(field: &str, value: &str) -> Result { + if value.len() != 64 + || !value + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) + { + return Err(format!( + "Locked card envelope has a malformed {field} (expected 64 lowercase hex chars)." + )); + } + PublicKey::from_hex(value).map_err(|_| format!("Locked card envelope has an invalid {field}.")) +} + +/// Structural validation of a locked envelope: exact version + scheme, +/// canonical pubkeys, distinct endpoints, bounded ciphertext. Does no +/// key lookup or crypto. +pub fn validate_envelope( + envelope: &LockedSnapshotEnvelope, +) -> Result<(PublicKey, PublicKey), String> { + if envelope.format != LOCKED_FORMAT { + return Err(format!( + "Unsupported locked card format: {:?} (expected {LOCKED_FORMAT:?})", + envelope.format + )); + } + if envelope.version != LOCKED_VERSION { + return Err(format!( + "Unsupported locked card envelope version: {} (expected {LOCKED_VERSION})", + envelope.version + )); + } + if envelope.encryption.scheme != LOCKED_SCHEME { + return Err(format!( + "Unsupported locked card encryption scheme: {:?} (expected {LOCKED_SCHEME:?})", + envelope.encryption.scheme + )); + } + let owner = parse_canonical_pubkey("ownerPubkey", &envelope.encryption.owner_pubkey)?; + let agent = parse_canonical_pubkey("agentPubkey", &envelope.encryption.agent_pubkey)?; + if owner == agent { + return Err("Locked card envelope owner and agent pubkeys must differ.".to_string()); + } + if envelope.encryption.ciphertext.len() > MAX_LOCKED_CIPHERTEXT_BYTES { + return Err("Locked card ciphertext exceeds the maximum size.".to_string()); + } + if envelope.encryption.ciphertext.is_empty() { + return Err("Locked card ciphertext is empty.".to_string()); + } + Ok((owner, agent)) +} + +// ── Dispatch ────────────────────────────────────────────────────────────────── + +/// Parse a raw chunk payload (JSON bytes from `extract_chunk_payload_png` or +/// an `.agent.json` file) and dispatch on the exact `format` discriminator. +/// +/// - `buzz-agent-snapshot` → full plain-manifest decode + validation. +/// - `buzz-agent-snapshot-encrypted` → size caps, typed envelope parse, +/// structural validation. No decryption happens here. +/// - anything else (including missing `format`) → error, never a fall-through. +pub fn parse_chunk_payload(json_bytes: &[u8]) -> Result { + let probe: FormatProbe = + serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid snapshot JSON: {e}"))?; + match probe.format.as_deref() { + Some(f) if f == FORMAT_DISCRIMINATOR => Ok(ChunkPayload::Plain(Box::new( + decode_snapshot_json(json_bytes)?, + ))), + Some(f) if f == LOCKED_FORMAT => { + // Cap the envelope JSON before typed deserialization; a locked + // envelope is small by construction (unlike plain manifests, + // which may inline a multi-MB avatar). + if json_bytes.len() > MAX_LOCKED_ENVELOPE_JSON_BYTES { + return Err("Locked card envelope exceeds the maximum size.".to_string()); + } + let envelope: LockedSnapshotEnvelope = serde_json::from_slice(json_bytes) + .map_err(|e| format!("Invalid locked card envelope: {e}"))?; + validate_envelope(&envelope)?; + Ok(ChunkPayload::Locked(envelope)) + } + Some(other) => Err(format!("Unsupported snapshot format: {other:?}")), + None => Err("Snapshot payload has no format discriminator.".to_string()), + } +} + +// ── Encrypt ─────────────────────────────────────────────────────────────────── + +/// Encrypt a snapshot manifest into a locked envelope under the NIP-44 v2 +/// conversation key for (owner secret, agent pubkey). +/// +/// Fails clearly (never silently truncates) when the serialized manifest +/// exceeds the NIP-44 plaintext limit. +pub fn encrypt_snapshot_envelope( + snapshot: &AgentSnapshot, + owner_keys: &Keys, + agent_pubkey: &PublicKey, +) -> Result { + let json_bytes = encode_snapshot_json(snapshot)?; + if json_bytes.len() > NIP44_PLAINTEXT_MAX { + return Err(format!( + "Agent manifest is too large to lock ({} bytes; the encrypted \ + format caps at {NIP44_PLAINTEXT_MAX}). Reduce the avatar size \ + or mint an unlocked card.", + json_bytes.len() + )); + } + let plaintext = std::str::from_utf8(&json_bytes) + .map_err(|e| format!("Manifest JSON was not UTF-8: {e}"))?; + let ciphertext = nip44::encrypt( + owner_keys.secret_key(), + agent_pubkey, + plaintext, + Version::V2, + ) + .map_err(|e| format!("Failed to encrypt card manifest: {e}"))?; + + Ok(LockedSnapshotEnvelope { + format: LOCKED_FORMAT.to_string(), + version: LOCKED_VERSION, + encryption: LockedEncryption { + scheme: LOCKED_SCHEME.to_string(), + owner_pubkey: owner_keys.public_key().to_hex(), + agent_pubkey: agent_pubkey.to_hex(), + ciphertext, + }, + }) +} + +/// Encode a snapshot into a LOCKED `.agent.png`: encrypt the manifest into +/// the envelope, then compose the PNG through the same chunk encoder plain +/// cards use. Mirrors `encode_snapshot_png`'s structural memory guard. +pub fn encode_locked_snapshot_png( + snapshot: &AgentSnapshot, + owner_keys: &Keys, + agent_pubkey: &PublicKey, + 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(), + ); + } + let envelope = encrypt_snapshot_envelope(snapshot, owner_keys, agent_pubkey)?; + let envelope_json = serde_json::to_vec(&envelope) + .map_err(|e| format!("Failed to serialize locked card envelope: {e}"))?; + encode_chunk_payload_png(&envelope_json, avatar_bytes) +} + +// ── Decrypt ─────────────────────────────────────────────────────────────────── + +/// Exact-endpoint key resolution (no trial decryption): +/// - the owner identity secret, only when its pubkey equals `ownerPubkey`; +/// - a hydrated local managed-agent record whose record pubkey AND +/// derived-secret pubkey both equal `agentPubkey`. +/// +/// Returns `None` when neither exact endpoint exists — callers fail closed +/// with [`LOCKED_CARD_REFUSAL`]. +pub fn resolve_unlock_secret( + envelope: &LockedSnapshotEnvelope, + owner_keys: Option<&Keys>, + records: &[ManagedAgentRecord], +) -> Option { + if let Some(keys) = owner_keys { + if keys.public_key().to_hex() == envelope.encryption.owner_pubkey { + return Some(keys.secret_key().clone()); + } + } + let record = records + .iter() + .find(|r| r.pubkey == envelope.encryption.agent_pubkey)?; + let agent_keys = Keys::parse(record.private_key_nsec.trim()).ok()?; + if agent_keys.public_key().to_hex() != envelope.encryption.agent_pubkey { + return None; + } + Some(agent_keys.secret_key().clone()) +} + +/// Decrypt a validated envelope with `my_secret`, which must be one of the +/// envelope's two exact endpoints (its derived pubkey selects the +/// counterparty). Returns the decoded, validated snapshot manifest. +/// +/// Every auth/crypto failure maps to [`LOCKED_CARD_REFUSAL`] — nothing about +/// the failure mode leaks. Manifest decode errors after a successful decrypt +/// are surfaced normally (the caller proved key possession). +pub fn decrypt_envelope( + envelope: &LockedSnapshotEnvelope, + my_secret: &SecretKey, +) -> Result { + let (owner_pub, agent_pub) = validate_envelope(envelope)?; + let my_pub = Keys::new(my_secret.clone()).public_key(); + let counterparty = if my_pub == owner_pub { + agent_pub + } else if my_pub == agent_pub { + owner_pub + } else { + return Err(LOCKED_CARD_REFUSAL.to_string()); + }; + + let plaintext = nip44::decrypt(my_secret, &counterparty, &envelope.encryption.ciphertext) + .map_err(|_| LOCKED_CARD_REFUSAL.to_string())?; + if plaintext.len() > NIP44_PLAINTEXT_MAX { + return Err(LOCKED_CARD_REFUSAL.to_string()); + } + decode_snapshot_json(plaintext.as_bytes()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::agent_snapshot::{ + extract_chunk_payload_png, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotProfile, FORMAT_VERSION, + }; + + fn sample_snapshot() -> AgentSnapshot { + AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "Locked Test".to_string(), + system_prompt: Some("You are a locked test agent.".to_string()), + runtime: None, + model: None, + provider: None, + parallelism: Some(1), + respond_to: None, + respond_to_allowlist: Vec::new(), + name_pool: Vec::new(), + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Locked Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: MemoryLevel::None, + entries: Vec::new(), + }, + } + } + + fn owner_agent_keys() -> (Keys, Keys) { + (Keys::generate(), Keys::generate()) + } + + /// Minimal hydrated record for endpoint-resolution tests. Only the + /// pubkey/nsec pair matters here. + fn record_with_keys(pubkey: String, private_key_nsec: String) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey, + name: "Locked Test".to_string(), + persona_id: None, + private_key_nsec, + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: crate::managed_agents::types::BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: crate::managed_agents::types::RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + agent_command_override: None, + persona_source_version: None, + provider: None, + } + } + + fn locked_envelope() -> (LockedSnapshotEnvelope, Keys, Keys) { + let (owner, agent) = owner_agent_keys(); + let env = + encrypt_snapshot_envelope(&sample_snapshot(), &owner, &agent.public_key()).unwrap(); + (env, owner, agent) + } + + #[test] + fn owner_secret_decrypts() { + let (env, owner, _agent) = locked_envelope(); + let decoded = decrypt_envelope(&env, owner.secret_key()).unwrap(); + assert_eq!(decoded, sample_snapshot()); + } + + #[test] + fn agent_secret_decrypts() { + let (env, _owner, agent) = locked_envelope(); + let decoded = decrypt_envelope(&env, agent.secret_key()).unwrap(); + assert_eq!(decoded, sample_snapshot()); + } + + #[test] + fn unrelated_key_fails_closed_with_refusal_only() { + let (env, _owner, _agent) = locked_envelope(); + let stranger = Keys::generate(); + let err = decrypt_envelope(&env, stranger.secret_key()).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + } + + #[test] + fn tampered_ciphertext_fails_with_refusal_only() { + let (mut env, owner, _agent) = locked_envelope(); + // Flip a character mid-ciphertext (keep valid base64 alphabet). + let mid = env.encryption.ciphertext.len() / 2; + let mut bytes = env.encryption.ciphertext.into_bytes(); + bytes[mid] = if bytes[mid] == b'A' { b'B' } else { b'A' }; + env.encryption.ciphertext = String::from_utf8(bytes).unwrap(); + let err = decrypt_envelope(&env, owner.secret_key()).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + } + + #[test] + fn swapped_pubkeys_fail_closed_at_endpoint_resolution() { + let (mut env, owner, agent) = locked_envelope(); + std::mem::swap( + &mut env.encryption.owner_pubkey, + &mut env.encryption.agent_pubkey, + ); + // The NIP-44 conversation key is symmetric over the pair, so a swap + // cannot grant a stranger anything — but it desyncs the routing + // hints, and exact-endpoint resolution fails closed rather than + // guessing: the owner identity no longer matches `ownerPubkey`, and + // no local record holds the pubkey now in `agentPubkey`. + assert!(resolve_unlock_secret(&env, Some(&owner), &[]).is_none()); + let nsec = nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(); + let record = record_with_keys(agent.public_key().to_hex(), nsec); + assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&record)).is_none()); + } + + #[test] + fn mislabeled_pubkey_fails_decryption_with_refusal_only() { + // Replacing `agentPubkey` with a third party's key makes the owner + // derive the wrong conversation key — the NIP-44 MAC fails and only + // the refusal surfaces. + let (mut env, owner, _agent) = locked_envelope(); + env.encryption.agent_pubkey = Keys::generate().public_key().to_hex(); + let err = decrypt_envelope(&env, owner.secret_key()).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + } + + #[test] + fn malformed_pubkeys_rejected_structurally() { + let (env, owner, _agent) = locked_envelope(); + + let mut short = env.clone(); + short.encryption.owner_pubkey = "abc123".to_string(); + assert!(validate_envelope(&short).unwrap_err().contains("malformed")); + + let mut upper = env.clone(); + upper.encryption.agent_pubkey = upper.encryption.agent_pubkey.to_uppercase(); + assert!(validate_envelope(&upper).unwrap_err().contains("malformed")); + + // A 64-hex string that is not a curve point passes the string check + // (nostr's PublicKey defers lift-x validation) but can never decrypt: + // the owner still selects it as counterparty, NIP-44 derivation/MAC + // fails, and only the refusal surfaces. + let mut not_a_point = env.clone(); + not_a_point.encryption.agent_pubkey = "f".repeat(64); + assert_eq!( + decrypt_envelope(¬_a_point, owner.secret_key()).unwrap_err(), + LOCKED_CARD_REFUSAL + ); + + let mut same = env; + same.encryption.agent_pubkey = same.encryption.owner_pubkey.clone(); + assert!(validate_envelope(&same).unwrap_err().contains("differ")); + } + + #[test] + fn unknown_format_version_scheme_rejected() { + let (env, ..) = locked_envelope(); + + let mut bad_version = env.clone(); + bad_version.version = 2; + assert!(validate_envelope(&bad_version) + .unwrap_err() + .contains("version")); + + let mut bad_scheme = env.clone(); + bad_scheme.encryption.scheme = "nip44-v3".to_string(); + assert!(validate_envelope(&bad_scheme) + .unwrap_err() + .contains("scheme")); + + // Unknown top-level format never falls through to manifest parsing. + let unknown = serde_json::json!({"format": "buzz-agent-snapshot-v9", "version": 1}); + let err = parse_chunk_payload(unknown.to_string().as_bytes()).unwrap_err(); + assert!(err.contains("Unsupported snapshot format"), "{err}"); + + let missing = serde_json::json!({"version": 1}); + let err = parse_chunk_payload(missing.to_string().as_bytes()).unwrap_err(); + assert!(err.contains("no format discriminator"), "{err}"); + } + + #[test] + fn plaintext_cap_enforced_before_encryption() { + let (owner, agent) = owner_agent_keys(); + let mut snapshot = sample_snapshot(); + // Inflate the manifest beyond the NIP-44 plaintext limit. + snapshot.definition.system_prompt = Some("x".repeat(NIP44_PLAINTEXT_MAX)); + let err = encrypt_snapshot_envelope(&snapshot, &owner, &agent.public_key()).unwrap_err(); + assert!(err.contains("too large to lock"), "{err}"); + } + + #[test] + fn ciphertext_and_envelope_caps_enforced_before_crypto() { + let (mut env, ..) = locked_envelope(); + env.encryption.ciphertext = "A".repeat(MAX_LOCKED_CIPHERTEXT_BYTES + 1); + assert!(validate_envelope(&env) + .unwrap_err() + .contains("maximum size")); + + // Oversized envelope JSON is rejected before typed deserialization. + let huge = format!( + r#"{{"format":"{LOCKED_FORMAT}","version":1,"pad":"{}","encryption":{{}}}}"#, + "p".repeat(MAX_LOCKED_ENVELOPE_JSON_BYTES) + ); + let err = parse_chunk_payload(huge.as_bytes()).unwrap_err(); + assert!(err.contains("maximum size"), "{err}"); + } + + #[test] + fn locked_png_round_trips_through_chunk_and_decrypt() { + let (owner, agent) = owner_agent_keys(); + let snapshot = sample_snapshot(); + let png = encode_locked_snapshot_png(&snapshot, &owner, &agent.public_key(), None).unwrap(); + + let payload = extract_chunk_payload_png(&png).unwrap(); + let ChunkPayload::Locked(env) = parse_chunk_payload(&payload).unwrap() else { + panic!("locked PNG must parse as a locked envelope"); + }; + // Both endpoints decrypt to the same logical manifest (compare + // manifests, never ciphertext — the NIP-44 nonce is random). + assert_eq!( + decrypt_envelope(&env, owner.secret_key()).unwrap(), + snapshot + ); + assert_eq!( + decrypt_envelope(&env, agent.secret_key()).unwrap(), + snapshot + ); + } + + #[test] + fn plain_manifest_dispatches_to_plain() { + let json = encode_snapshot_json(&sample_snapshot()).unwrap(); + let ChunkPayload::Plain(decoded) = parse_chunk_payload(&json).unwrap() else { + panic!("plain manifest must parse as Plain"); + }; + assert_eq!(*decoded, sample_snapshot()); + } + + #[test] + fn resolve_unlock_secret_owner_exact_endpoint() { + let (env, owner, _agent) = locked_envelope(); + let secret = resolve_unlock_secret(&env, Some(&owner), &[]).unwrap(); + assert_eq!(&secret, owner.secret_key()); + + // A different identity key is NOT tried. + let other = Keys::generate(); + assert!(resolve_unlock_secret(&env, Some(&other), &[]).is_none()); + assert!(resolve_unlock_secret(&env, None, &[]).is_none()); + } + + #[test] + fn resolve_unlock_secret_agent_requires_record_and_derived_match() { + let (env, _owner, agent) = locked_envelope(); + let nsec = nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(); + + let record = record_with_keys(agent.public_key().to_hex(), nsec); + let secret = resolve_unlock_secret(&env, None, std::slice::from_ref(&record)).unwrap(); + assert_eq!(&secret, agent.secret_key()); + + // Record pubkey matches but the stored secret derives a DIFFERENT + // pubkey → refused (no trial decryption on mismatched material). + let mut forged = record.clone(); + forged.private_key_nsec = + nostr::ToBech32::to_bech32(Keys::generate().secret_key()).unwrap(); + assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&forged)).is_none()); + + // Record for some other agent → not an endpoint. + let mut unrelated = record; + unrelated.pubkey = Keys::generate().public_key().to_hex(); + assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&unrelated)).is_none()); + } +} 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 000000000..4aa98b896 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -0,0 +1,524 @@ +//! Unit tests for `managed_agents/agent_snapshot.rs`. +//! +//! Kept in a sibling file so `agent_snapshot.rs` stays under the +//! 1000-line gate; `#[path]`-included from there. + +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, + source_team: Some("team-id-123".to_string()), // MUST NOT appear + source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear + definition_respond_to: Some("allowlist".to_string()), + definition_respond_to_allowlist: vec!["abc123def".to_string()], + definition_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_eq!( + snapshot.definition.system_prompt.as_deref(), + Some("You are a test agent.") + ); + assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose")); + assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4")); + assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic")); + assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]); + // definition_respond_to maps to respond_to in the snapshot definition + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + // definition_respond_to_allowlist should be included + assert!(!snapshot.definition.respond_to_allowlist.is_empty()); +} + +#[test] +fn profile_fields_present_in_snapshot() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!(snapshot.profile.display_name, "Test Agent Display"); + // No bytes → should fall back to avatar_url + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/avatar.png") + ); + assert!(snapshot.profile.avatar_data_url.is_none()); +} + +#[test] +fn avatar_inlined_when_under_size_limit() { + let record = minimal_record(); + let small_png = make_png_with_text("k", "v").unwrap(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png)); + assert!(snapshot.profile.avatar_data_url.is_some()); + assert!(snapshot.profile.avatar_url.is_none()); +} + +#[test] +fn avatar_url_fallback_when_over_size_limit() { + let mut record = minimal_record(); + record.avatar_url = Some("https://example.com/big.png".to_string()); + // Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec. + let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1]; + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes)); + assert!(snapshot.profile.avatar_data_url.is_none()); + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/big.png") + ); +} + +// ── Format/version validation ───────────────────────────────────────────── + +#[test] +fn invalid_format_discriminator_is_rejected() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.format = "not-a-buzz-snapshot".to_string(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported snapshot format")); +} + +#[test] +fn unsupported_version_is_rejected() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.version = 99; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported snapshot version")); +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index b0e86f8ed..04fa58f6a 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,6 +1,7 @@ mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; +pub(crate) mod agent_snapshot_envelope; pub(crate) mod team_snapshot; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor,