feat(desktop): optional NIP-44 locked agent cards (Rust envelope + import)

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 <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-28 01:45:03 -04:00
co-authored by Tyler Longwell
parent 19b449f010
commit 5bca0227f5
10 changed files with 1542 additions and 574 deletions
@@ -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 => {
+93 -11
View File
@@ -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<String>,
lock: Option<bool>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<MintedCard, String> {
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<u8>, 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(['/', '\\']) {
@@ -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};
@@ -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<crate::managed_agents::agent_snapshot::AgentSnapshot, String> {
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<ChunkPayload, String> {
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<crate::managed_agents::agent_snapshot::AgentSnapshot, String> {
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<u8>,
file_name: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<AgentSnapshotImportPreview, String> {
// 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<AgentSnapshotImportResult, String> {
// ── 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() {
@@ -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;
@@ -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<u8>) {
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);
}
@@ -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<Vec<u8>, 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<AgentSnapshot, String> {
/// 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<Vec<u8>, 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<AgentSnapshot, String> {
.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<AgentSnapshot, String> {
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<Vec<
// ── 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,
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;
@@ -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<AgentSnapshot>),
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<String>,
}
// ── 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<PublicKey, String> {
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<ChunkPayload, String> {
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<LockedSnapshotEnvelope, String> {
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<Vec<u8>, 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<SecretKey> {
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<AgentSnapshot, String> {
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(&not_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());
}
}
@@ -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"));
}
@@ -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,