mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tyler Longwell
parent
19b449f010
commit
5bca0227f5
@@ -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 => {
|
||||
|
||||
@@ -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(¬_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,
|
||||
|
||||
Reference in New Issue
Block a user