feat(desktop): Agent Trading Cards — mintable agent-snapshot card PNGs with optional NIP-44 lock (#3278)

## Agent Trading Cards

"Create Agent Card" action in the agent panel that mints an AI-generated
trading card PNG which **is** the agent: the card carries the
`buzz_agent_snapshot` tEXt chunk and is drag-in importable like any
snapshot PNG.

### What's in here
- **Mint pipeline (Rust):** one OpenAI Responses call — `gpt-5.6-sol` as
card designer with `gpt-image-2` via the `image_generation` tool (~2–3
min). New `mint_agent_card` / `save_agent_card` commands; preview with
reroll; save or send as `.agent.png` with round-trip verification before
any bytes leave the app.
- **Snapshot/chunk work stays in Rust,** reusing the existing
encoder/decoder seams (byte-compat golden vector proves the plain path
is identical to the pre-envelope encoder for placeholder, PNG-injection,
and JPEG-transcode paths).
- **Locked cards (NIP-44):** optional `buzz-agent-snapshot-encrypted`
envelope encrypted to the (owner, agent) pair. `parse_canonical_pubkey`
performs lift-x curve validation before any API spend; wrong-key decrypt
returns a fixed refusal; the plain decoder refuses locked cards.
- **Guardrails:** 10 MiB ceiling on final bytes, memory structurally
`none` in the snapshot, full-manifest import disclosure, API-key hygiene
via env layering (record > persona > global > process), fail-early
validation ordering (all key/lock/NIP-44-cap checks before Responses
spend).
- **Import side:** full-manifest disclosure dialog, locked-card import
disclosure, bounded avatar fetch.

### Review
Code reviewed by Wren across the full arc; final locked-card
cross-review **APPROVED 9/9/9** at exactly this head (`64f819dc8`), with
independent same-SHA verification: Rust lib 1,843/1,843, clippy
`--all-targets -D warnings`, desktop file-size gate.

### Live-mint evidence (real API, shipping seams, this SHA)
- **Plain (Honey):** 188s, 1500x2250, 5,101,503 bytes (< 10 MiB);
decoded manifest == built manifest; memory=none.
- **Locked (Fizz):** 176s, 4,670,184 bytes; owner-key and agent-key
decrypt both verified via logical manifest compare; wrong-key refusal
exact; plain decoder refuses.
- **Live finding:** built-in agents' ~171 KB inline avatars exceed the
NIP-44 65,535-byte plaintext cap and the fail-early guard fires before
API spend — clean error path, noted as a UX follow-up for large-avatar
agents choosing lock.

Full evidence (cards + dialog screenshots) posted in the originating
thread.

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
This commit is contained in:
Tyler
2026-07-31 22:01:17 -04:00
committed by GitHub
parent b1b283cd4c
commit eb049ddf81
35 changed files with 4985 additions and 853 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

View File

@@ -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 => {

View File

@@ -0,0 +1,972 @@
//! `mint_agent_card` / `save_agent_card` Tauri commands — Agent Trading Cards.
//!
//! Mints a collectible trading-card PNG for an agent via one OpenAI Responses
//! API call (designer model + native `image_generation` tool), then embeds the
//! agent's `buzz_agent_snapshot` manifest through the existing snapshot
//! encoder so the card IS an importable `.agent.png`.
//!
//! Boundary rules (agreed with Wren, buzz-agent-trading-cards thread):
//! - Snapshot construction/injection reuses `agent_snapshot.rs` — cards
//! inherit manifest-v1 behavior, exclusions, and size checks. No card-only
//! wire format exists.
//! - Memory inclusion is opt-in and shares the export flow's semantics: the
//! same three levels (`none`/`core`/`everything`), the same owner-gated
//! `get_agent_memory` fetch, and a memory source DERIVED from the resolved
//! instance (never caller-supplied), so cross-agent memory pairing is
//! structurally impossible. The default is `none`; the encoder still
//! rejects `none` + entries.
//! - The 10 MiB `.agent.png` ceiling is enforced on the FINAL bytes (after
//! resize + chunk injection) via `validate_snapshot_encode_size`.
//! - Round-trip verification decodes the final bytes and compares the logical
//! manifest before anything is returned to the frontend.
//! - The API key is resolved through the same env layering the agent runtime
//! uses (global config < persona < agent record) and never leaves Rust.
//! It is never logged.
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, State};
use super::super::export_util::save_bytes_with_dialog;
use super::snapshot::{
memory_entries_from_listing, parse_memory_level, resolve_from_lists,
validate_snapshot_encode_size,
};
use crate::{
app_state::AppState,
commands::engrams::get_agent_memory,
managed_agents::{
agent_snapshot::{
build_snapshot, decode_avatar_data_url, decode_snapshot_png, encode_snapshot_png,
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,
save_global_agent_config, validate_global_config,
},
};
/// The Buzz card frame template — Tyler's gold-honeycomb base. Generation
/// input only: it never participates in the snapshot manifest, PNG chunk,
/// import decoder, or attachment validation. Embedded at compile time for
/// deterministic packaging (see `card_template_decodes` test).
const CARD_TEMPLATE_PNG: &[u8] = include_bytes!("../../../assets/card_template.png");
/// Designer model driving copy + art direction.
const DESIGNER_MODEL: &str = "gpt-5.6-sol";
/// Image model invoked natively via the Responses `image_generation` tool.
const IMAGE_MODEL: &str = "gpt-image-2";
/// Final card width in pixels (2:3 portrait → 1500x2250).
const CARD_WIDTH: u32 = 1500;
/// Longest edge for the real avatar inlined into an unlocked card's manifest.
/// Kind:0 pictures render small; 512px keeps the doubly-base64-encoded
/// manifest chunk modest next to the 1500-wide card body.
const MANIFEST_AVATAR_MAX_DIM: u32 = 512;
/// Upper bound for a fetched avatar (pre-resize input to the model).
const MAX_AVATAR_FETCH_BYTES: usize = 10 * 1024 * 1024;
/// One mint is a single long API call (~2–3 minutes observed).
const MINT_TIMEOUT_SECS: u64 = 600;
/// Error prefix the frontend matches to route the user to provider settings
/// instead of showing a raw failure.
pub(crate) const NO_KEY_ERROR_PREFIX: &str = "NO_OPENAI_KEY:";
/// Wire shape returned by `mint_agent_card`.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MintedCard {
/// Final `.agent.png` bytes (chunk-injected, round-trip verified),
/// base64-encoded for the IPC boundary.
pub card_png_base64: String,
/// Suggested filename, e.g. `eva.agent.png`.
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,
/// How much memory is embedded in the card's snapshot ("none"/"core"/
/// "everything"). The viewer's import disclosure depends on this.
pub memory_level: MemoryLevel,
}
// ── Card archive ──────────────────────────────────────────────────────────────
/// Sidecar metadata for one archived card PNG. Stored as `<stem>.json` next
/// to `<stem>.agent.png` in the cards dir — two plain files per mint, no
/// shared index to corrupt. Listing scans sidecars; a card whose PNG is
/// missing is skipped rather than failing the whole list.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArchivedCardMeta {
/// Unique on-disk PNG file name within the cards dir.
pub stored_file_name: String,
/// Suggested save-as name, e.g. `eva.agent.png`.
pub file_name: String,
/// The id the card was minted for (instance pubkey or definition slug).
pub agent_id: String,
pub agent_name: String,
pub designer_notes: String,
pub locked: bool,
/// Memory embedded in this card's snapshot. Defaults to `None` when the
/// sidecar predates the field — every pre-field mint was minted with
/// `MemoryLevel::None` (it was structural), so the default is honest.
#[serde(default)]
pub memory_level: MemoryLevel,
/// ISO-8601 mint timestamp.
pub minted_at: String,
/// Small JPEG preview for gallery grids, base64. Populated by
/// `list_agent_cards` from the sidecar thumb file — never stored in the
/// JSON sidecar itself.
#[serde(default, skip_deserializing)]
pub thumb_jpeg_base64: Option<String>,
}
fn cards_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let dir = crate::managed_agents::managed_agents_base_dir(app)?.join("cards");
std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create cards dir: {e}"))?;
Ok(dir)
}
/// Persist a freshly minted card to the archive. Failures are surfaced to the
/// caller (which logs and continues) — an archive write must never fail a
/// mint the user already paid for.
fn archive_minted_card(
app: &AppHandle,
agent_id: &str,
agent_name: &str,
card: &MintedCard,
bytes: &[u8],
) -> Result<ArchivedCardMeta, String> {
let dir = cards_dir(app)?;
let stem = format!(
"{}-{}",
crate::util::slugify(agent_name, "agent", 50),
uuid::Uuid::new_v4()
);
let stored_file_name = format!("{stem}.agent.png");
let meta = ArchivedCardMeta {
stored_file_name: stored_file_name.clone(),
file_name: card.file_name.clone(),
agent_id: agent_id.to_string(),
agent_name: agent_name.to_string(),
designer_notes: card.designer_notes.clone(),
locked: card.locked,
memory_level: card.memory_level,
minted_at: crate::util::now_iso(),
thumb_jpeg_base64: None,
};
// PNG first, sidecar second: a crash between the two leaves an orphaned
// PNG (invisible to the list), never a sidecar pointing at nothing.
std::fs::write(dir.join(&stored_file_name), bytes)
.map_err(|e| format!("failed to write archived card: {e}"))?;
let meta_json = serde_json::to_string_pretty(&meta)
.map_err(|e| format!("failed to serialize card metadata: {e}"))?;
std::fs::write(dir.join(format!("{stem}.json")), meta_json)
.map_err(|e| format!("failed to write card metadata: {e}"))?;
// Thumb last and best-effort: the gallery grid falls back to lazy
// full-card loading for a card whose thumb is missing.
if let Ok(thumb) = encode_card_thumb(bytes) {
let _ = std::fs::write(dir.join(format!("{stem}.thumb.jpg")), thumb);
}
Ok(meta)
}
/// Downscale card PNG bytes to a small JPEG for gallery grids. The full card
/// is ~1500x2250 PNG (megabytes); shipping that per card over IPC just to
/// draw a grid tile is waste.
fn encode_card_thumb(bytes: &[u8]) -> Result<Vec<u8>, String> {
const THUMB_WIDTH: u32 = 300;
let img = image::load_from_memory(bytes).map_err(|e| format!("thumb decode: {e}"))?;
let scale = THUMB_WIDTH as f64 / img.width() as f64;
let thumb = img.resize(
THUMB_WIDTH,
(img.height() as f64 * scale).round().max(1.0) as u32,
image::imageops::FilterType::Triangle,
);
let mut out = Vec::new();
// JPEG has no alpha; cards are opaque, so flatten unconditionally.
let rgb = image::DynamicImage::ImageRgb8(thumb.to_rgb8());
rgb.write_to(
&mut std::io::Cursor::new(&mut out),
image::ImageFormat::Jpeg,
)
.map_err(|e| format!("thumb encode: {e}"))?;
Ok(out)
}
/// Reject any archive file name that could escape the cards dir or name a
/// non-archive file. Archive names are generated by `archive_minted_card`
/// (slug + UUID), so a strict shape check loses nothing legitimate.
fn validate_archive_file_name(stored_file_name: &str) -> Result<(), String> {
let valid = stored_file_name.ends_with(".agent.png")
&& !stored_file_name.contains(['/', '\\'])
&& !stored_file_name.contains("..");
if !valid {
return Err("Invalid archived card file name.".to_string());
}
Ok(())
}
/// List all archived cards, newest first.
#[tauri::command]
pub fn list_agent_cards(app: AppHandle) -> Result<Vec<ArchivedCardMeta>, String> {
let dir = cards_dir(&app)?;
let entries = std::fs::read_dir(&dir).map_err(|e| format!("failed to read cards dir: {e}"))?;
let mut cards = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(meta) = serde_json::from_str::<ArchivedCardMeta>(&content) else {
// A malformed sidecar hides one card, never the archive.
eprintln!(
"buzz-desktop: card-archive: skipping malformed sidecar {}",
path.display()
);
continue;
};
let mut meta = meta;
if validate_archive_file_name(&meta.stored_file_name).is_ok()
&& dir.join(&meta.stored_file_name).is_file()
{
// Attach the pre-rendered grid thumb when present (best-effort).
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
meta.thumb_jpeg_base64 = std::fs::read(dir.join(format!("{stem}.thumb.jpg")))
.ok()
.map(|b| STANDARD.encode(&b));
}
cards.push(meta);
}
}
// ISO-8601 sorts lexicographically; newest first.
cards.sort_by(|a, b| b.minted_at.cmp(&a.minted_at));
Ok(cards)
}
/// Load one archived card's PNG bytes as base64, keyed by its stored file
/// name (as returned by `list_agent_cards`).
#[tauri::command]
pub fn load_agent_card(stored_file_name: String, app: AppHandle) -> Result<String, String> {
validate_archive_file_name(&stored_file_name)?;
let bytes = std::fs::read(cards_dir(&app)?.join(&stored_file_name))
.map_err(|e| format!("failed to read archived card: {e}"))?;
Ok(STANDARD.encode(&bytes))
}
// ── Key resolution ────────────────────────────────────────────────────────────
/// Pure layering: global env < persona env < agent record env, then the
/// process environment as a development fallback. Returns the first
/// non-empty value for `key`.
pub(crate) fn resolve_env_from_layers(
key: &str,
global_env: &std::collections::BTreeMap<String, String>,
persona_env: &std::collections::BTreeMap<String, String>,
record_env: &std::collections::BTreeMap<String, String>,
process_value: Option<String>,
) -> Option<String> {
for layer in [record_env, persona_env, global_env] {
if let Some(v) = layer.get(key) {
let v = v.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
process_value.filter(|k| !k.trim().is_empty())
}
/// The Responses endpoint to post mints to. `OPENAI_BASE_URL` (same env
/// layering as the key) overrides the default host, supporting endpoints and
/// proxies that speak the OpenAI Responses shape with Bearer auth. Azure
/// OpenAI is NOT covered by this override alone — it uses its own URL scheme
/// and `api-key` auth header, which would need a real driver.
pub(crate) fn responses_url(base_url: Option<String>) -> String {
let base = base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
format!("{}/responses", base.trim_end_matches('/'))
}
// ── Prompt construction ───────────────────────────────────────────────────────
/// Build the designer instructions. Pure so tests can pin the contract:
/// style-match-the-avatar is DEFAULT behavior; owner directions (art AND
/// card text) take primacy over those style defaults, but never over the
/// fixed contract (frame identity, geometry, text fidelity).
pub(crate) fn build_card_instructions(
agent_name: &str,
persona_notes: &str,
style_notes: &str,
) -> String {
let owner_directions = if style_notes.trim().is_empty() {
String::new()
} else {
format!(
"\nOWNER'S DIRECTIONS — these override the default art-style and copy guidance \
below wherever they conflict (they cannot change the frame, layout, or \
text-fidelity requirements). The owner may direct the art, the card text \
(type line, ability, flavor), or both:\n{style_notes}\n"
)
};
format!(
r#"You are designing one premium collectible trading card for the Buzz agent "{agent_name}".
Input image 1 is the official Buzz card frame template (gold honeycomb border, dark interior, name banner top, hex badge top-right, text box lower third). Input image 2 is the agent's avatar — study its exact art style: medium, pixel grid if any, palette, shading, background motifs.
Persona notes for the card copy:
{persona_notes}
{owner_directions}
First, write professional trading-card copy at Magic: The Gathering editorial quality:
- a type line (e.g. "Legendary Agent — Team Lead"),
- ONE keyworded ability: short bolded ability name + one sentence of crisp rules text written like real MTG rules (present tense, precise, no fluff),
- ONE italic flavor-text line, evocative and short, the kind that gets quoted.
Where the owner's directions specify card text, use their wording within the 220-character text-box limit below (edited only for spelling; if their text exceeds the limit, condense it minimally while keeping their words and intent); invent copy only for the parts they left open.
Keep total text-box copy under 220 characters so it renders cleanly.
Then generate the finished card with the image tool, exactly 1024x1536 portrait:
- The frame must follow input image 1 faithfully: same gold honeycomb border, same layout, honey drip detail.
- Default art style: match input image 2's art style EXACTLY — same medium, same pixel density if pixel art, same palette, same background honeycomb-lattice sky. It must look like the same artist drew a larger scene: the character in a confident pose, conjuring glowing golden hexagons. The owner's directions above override any of this default styling where they conflict.
- Name banner: "{agent_name}" plus the type line beneath it in smaller type.
- Text box: the ability name in bold, rules text in regular, then the flavor line in italics, cleanly typeset like a real MTG card — professional kerning, no misspellings, hyphenate nothing.
- Top-right hex badge: one small emblem of your choice, no text.
Render all text with perfect fidelity."#
)
}
/// Encode raw image bytes as a `data:image/png;base64,` URL, downscaling to
/// `max_dim` on the longest edge so request payloads stay small.
fn image_data_url(bytes: &[u8], max_dim: u32) -> Result<String, String> {
Ok(format!(
"data:image/png;base64,{}",
STANDARD.encode(png_bytes_resized(bytes, max_dim)?)
))
}
/// Re-encode an image as PNG, downscaling so neither side exceeds `max_dim`.
fn png_bytes_resized(bytes: &[u8], max_dim: u32) -> Result<Vec<u8>, String> {
let img = image::load_from_memory(bytes).map_err(|e| format!("Failed to decode image: {e}"))?;
let img = if img.width().max(img.height()) > max_dim {
img.resize(max_dim, max_dim, image::imageops::FilterType::Lanczos3)
} else {
img
};
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode image: {e}"))?;
Ok(png)
}
// ── Response parsing ──────────────────────────────────────────────────────────
/// Extract the generated image (base64) and any designer text from a
/// Responses API payload. Pure for testability.
pub(crate) fn extract_card_output(resp: &serde_json::Value) -> Result<(String, String), String> {
let output = resp
.get("output")
.and_then(|o| o.as_array())
.ok_or_else(|| "Responses payload has no output array".to_string())?;
let mut image_b64 = None;
let mut notes = Vec::new();
for item in output {
match item.get("type").and_then(|t| t.as_str()) {
Some("image_generation_call") => {
if let Some(result) = item.get("result").and_then(|r| r.as_str()) {
image_b64 = Some(result.to_string());
}
}
Some("message") => {
if let Some(content) = item.get("content").and_then(|c| c.as_array()) {
for c in content {
if c.get("type").and_then(|t| t.as_str()) == Some("output_text") {
if let Some(text) = c.get("text").and_then(|t| t.as_str()) {
notes.push(text.to_string());
}
}
}
}
}
_ => {}
}
}
let image_b64 = image_b64.ok_or_else(|| {
let types: Vec<&str> = output
.iter()
.filter_map(|i| i.get("type").and_then(|t| t.as_str()))
.collect();
format!("No image in Responses output (item types: {types:?})")
})?;
Ok((image_b64, notes.join("\n")))
}
// ── Commands ──────────────────────────────────────────────────────────────────
/// Save an `OPENAI_API_KEY` into the global Agent Defaults env for card
/// minting — a narrow seam with deliberately different semantics from the
/// general `set_global_agent_config`:
///
/// - **No agent restarts.** The general command stops/restarts every running
/// local agent whose effective env changes, because agent env is baked at
/// spawn time. The mint command re-reads the config from disk on every
/// mint, so minting needs no restart — and a card setup must never disrupt
/// running agents as a side effect. Agents pick the key up naturally on
/// their next (re)start.
/// - **Read-modify-write of the latest on-disk config.** The config is
/// re-read immediately before the single-key insert + write (under the
/// managed-agents store lock, which serializes it against the other card
/// and agent-store commands), so a settings save that landed after this
/// dialog opened is not clobbered with a stale dialog-open snapshot.
/// (The general settings editor performs its own whole-config write; as
/// today, the last writer wins between the two surfaces.)
///
/// Standard global-config validation still applies (POSIX key shape,
/// reserved-key reject, size caps) — this is not a validation bypass.
#[tauri::command]
pub fn card_mint_save_openai_key(
key: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let key = key.trim().to_string();
if key.is_empty() {
return Err("API key cannot be empty.".to_string());
}
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut config = load_global_agent_config(&app)?;
config.env_vars.insert("OPENAI_API_KEY".to_string(), key);
validate_global_config(&config)?;
save_global_agent_config(&app, &config)
}
/// Report whether an OpenAI key would resolve for a card mint of agent `id`,
/// using exactly the same env layering as `mint_agent_card`. Lets the mint
/// dialog offer inline key setup BEFORE the user commits to a mint, instead
/// of failing after the fact. Never returns the key itself.
#[tauri::command]
pub fn card_mint_key_status(
id: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<bool, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let instances = load_managed_agents(&app)?;
let definitions = load_agent_definitions(&app)?;
let (record, _) = resolve_from_lists(&id, &instances, &definitions)?;
let global = load_global_agent_config(&app).unwrap_or_default();
let personas = load_personas(&app).unwrap_or_default();
let persona_env = record
.persona_id
.as_deref()
.and_then(|pid| personas.iter().find(|p| p.id == pid))
.map(|p| p.env_vars.clone())
.unwrap_or_default();
Ok(resolve_env_from_layers(
"OPENAI_API_KEY",
&global.env_vars,
&persona_env,
&record.env_vars,
std::env::var("OPENAI_API_KEY").ok(),
)
.is_some())
}
/// 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.
///
/// When `memory_level` is `"core"` or `"everything"`, the owner's decrypted
/// memory for the agent is embedded in the manifest — same levels and fetch
/// as snapshot export. The memory source is always the resolved instance
/// itself (derived, never caller-supplied), so it requires a linked instance;
/// bare definitions can only mint `"none"` (the default).
///
/// 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>,
memory_level: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<MintedCard, String> {
let lock = lock.unwrap_or(false);
let memory_level = parse_memory_level(memory_level.as_deref().unwrap_or(""))?;
// ── Resolve the record + API key under lock ──────────────────────────────
let (mut record, is_definition, api_key, base_url) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let instances = load_managed_agents(&app)?;
let definitions = load_agent_definitions(&app)?;
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();
let personas = load_personas(&app).unwrap_or_default();
let persona_env = record
.persona_id
.as_deref()
.and_then(|pid| personas.iter().find(|p| p.id == pid))
.map(|p| p.env_vars.clone())
.unwrap_or_default();
let api_key = resolve_env_from_layers(
"OPENAI_API_KEY",
&global.env_vars,
&persona_env,
&record.env_vars,
std::env::var("OPENAI_API_KEY").ok(),
)
.ok_or_else(|| {
format!(
"{NO_KEY_ERROR_PREFIX} No OPENAI_API_KEY found. Add one in the agent's \
environment variables or global agent settings to mint cards."
)
})?;
let base_url = resolve_env_from_layers(
"OPENAI_BASE_URL",
&global.env_vars,
&persona_env,
&record.env_vars,
std::env::var("OPENAI_BASE_URL").ok(),
);
(record, is_definition, api_key, base_url)
};
// ── 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()?;
// Same canonical check the envelope decoder enforces (incl. curve
// validation) — a non-point record pubkey must fail BEFORE the API
// spend, not at post-mint encryption.
let agent_pubkey = crate::managed_agents::agent_snapshot_envelope::parse_canonical_pubkey(
"agentPubkey",
&record.pubkey,
)
.map_err(|_| {
"Agent record has an invalid pubkey (not a canonical x-only key).".to_string()
})?;
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
};
// ── Memory needs a keyed instance, resolved up front BEFORE the API
// spend — the memory source is always the resolved instance itself
// (derived, never caller-supplied), so cross-agent pairing cannot be
// expressed. A failed fetch fails the mint here, not after payment.
let memory_entries = if memory_level == MemoryLevel::None {
Vec::new()
} else {
if is_definition {
return Err(
"Cards with memory need a linked agent instance — this persona has never \
been started, so there is no agent memory to include."
.to_string(),
);
}
let listing = get_agent_memory(record.pubkey.clone(), app.clone(), state.clone()).await?;
memory_entries_from_listing(listing, memory_level)
};
let display_name = record
.display_name
.clone()
.unwrap_or_else(|| record.name.clone());
// ── Prefer the agent's own kind:0 profile picture ────────────────────────
// The record's `avatar_url` is a stale presentation snapshot: with
// agent-managed profiles the agent updates its own kind:0 `picture` and
// desktop reconciliation is disabled (`agent_settings.rs`), so the relay
// profile — not the local record — is the live source of truth for how the
// agent looks. Definitions have no keypair and thus no kind:0; they keep
// the record's avatar. A relay error fails the mint here, BEFORE the API
// spend (same fail-early rule as the key/memory guards above) — minting
// with the wrong face wastes the spend it was supposed to protect.
if !is_definition {
let relay_url = crate::relay::effective_agent_relay_url(
&record.relay_url,
&crate::relay::relay_ws_url_with_override(&state),
);
let profile = crate::relay::query_agent_profile(&state, &relay_url, &record.pubkey)
.await
.map_err(|e| format!("Could not read the agent's profile for its avatar: {e}"))?;
record.avatar_url = preferred_avatar_url(
profile.and_then(|info| info.picture),
record.avatar_url.take(),
);
}
// ── Resolve avatar bytes (data URL, else fetch) ──────────────────────────
let avatar_bytes = match record.avatar_url.as_deref() {
Some(url) if url.starts_with("data:") => decode_avatar_data_url(url)
.ok_or_else(|| "Agent avatar data URL could not be decoded.".to_string())?,
Some(url) if url.starts_with("http://") || url.starts_with("https://") => {
// Relay-hosted avatars (kind:0 pictures under the relay's /media/)
// may require Blossom get-auth (`require_media_get_auth`). Mint the
// header ONLY for same-origin URLs so the token never leaves the
// relay (same contract as `media_download.rs`).
let relay_base = crate::relay::relay_api_base_url_with_override(&state);
let auth = is_same_origin(url, &relay_base)
.then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base))
.flatten();
fetch_avatar(url, auth.as_deref()).await?
}
_ => {
return Err(
"Agent has no avatar image. Set an avatar before minting a card.".to_string(),
)
}
};
// ── Build the manifest now (with any requested memory) so a broken agent
// fails before we spend minutes on the API call. ───────────────────────
let manifest_avatar = manifest_avatar_bytes(
lock_keys.is_some(),
&avatar_bytes,
record.avatar_url.as_deref(),
)?;
let snapshot = build_snapshot(
&record,
memory_level,
memory_entries,
manifest_avatar.as_deref(),
);
// ── One Responses API call ───────────────────────────────────────────────
// For locked mints, prove the manifest (including any embedded memory)
// 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 {
let hint = if memory_level == MemoryLevel::None {
"Reduce the avatar size or mint an unlocked card."
} else {
"Include less memory, reduce the avatar size, or mint an unlocked card."
};
return Err(format!(
"Agent manifest is too large to lock ({json_len} bytes; the encrypted \
format caps at {}). {hint}",
buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX
));
}
}
let instructions = build_card_instructions(
&display_name,
snapshot.definition.system_prompt.as_deref().unwrap_or(""),
style_notes.as_deref().unwrap_or(""),
);
let body = serde_json::json!({
"model": DESIGNER_MODEL,
"reasoning": {"effort": "high"},
"instructions": "You are a senior TCG card designer and MTG rules editor.",
"input": [{
"role": "user",
"content": [
{"type": "input_text", "text": instructions},
{"type": "input_image", "image_url": image_data_url(CARD_TEMPLATE_PNG, 1024)?},
{"type": "input_image", "image_url": image_data_url(&avatar_bytes, 1024)?},
],
}],
"tools": [{
"type": "image_generation",
"model": IMAGE_MODEL,
"quality": "high",
"size": "1024x1536",
"output_format": "png",
}],
"tool_choice": "required",
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS))
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let resp = client
.post(responses_url(base_url))
.bearer_auth(&api_key)
.json(&body)
.send()
.await
.map_err(|e| format!("Card mint request failed: {e}"))?;
let status = resp.status();
let payload: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Card mint response was not JSON: {e}"))?;
if !status.is_success() {
// Never echo the request (it embeds nothing secret, but keep the
// failure surface small); the OpenAI error body is safe to surface.
let detail = payload
.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.unwrap_or("unknown error");
return Err(format!("Card mint failed (HTTP {status}): {detail}"));
}
let (image_b64, designer_notes) = extract_card_output(&payload)?;
let raw_card = STANDARD
.decode(image_b64.as_bytes())
.map_err(|e| format!("Generated image was not valid base64: {e}"))?;
// ── Resize to 1500-wide, inject chunk via the existing encoder ──────────
let card_img = image::load_from_memory(&raw_card)
.map_err(|e| format!("Generated image could not be decoded: {e}"))?;
let scale = CARD_WIDTH as f64 / card_img.width() as f64;
let card_img = card_img.resize(
CARD_WIDTH,
(card_img.height() as f64 * scale).round() as u32,
image::imageops::FilterType::Lanczos3,
);
let mut card_png = Vec::new();
card_img
.write_to(
&mut std::io::Cursor::new(&mut card_png),
image::ImageFormat::Png,
)
.map_err(|e| format!("Failed to encode card PNG: {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 = 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());
}
let slug = crate::util::slugify(&display_name, "agent", 50);
let minted = MintedCard {
card_png_base64: STANDARD.encode(&final_bytes),
file_name: format!("{slug}.agent.png"),
designer_notes,
locked: lock_keys.is_some(),
memory_level,
};
// Archive best-effort: the mint is already paid for and verified, so a
// failed archive write logs and continues — it never fails the mint.
if let Err(e) = archive_minted_card(&app, &id, &display_name, &minted, &final_bytes) {
eprintln!("buzz-desktop: card-archive: failed to archive minted card: {e}");
}
Ok(minted)
}
/// The avatar the mint should use: the agent's kind:0 `picture` when one is
/// published and non-blank, else the local record's `avatar_url`.
///
/// Pure so the precedence is unit-testable without a relay: a blank or
/// whitespace-only `picture` must NOT shadow a real record avatar.
fn preferred_avatar_url(
kind0_picture: Option<String>,
record_avatar_url: Option<String>,
) -> Option<String> {
kind0_picture
.filter(|p| !p.trim().is_empty())
.or(record_avatar_url)
}
/// The avatar bytes the card manifest should inline.
///
/// Unlocked cards must carry the agent's REAL avatar inline: the PNG body is
/// the generated card artwork, and the importer only adopts the body as the
/// avatar when the manifest carries no inline bytes (`import.rs`) — without
/// these bytes an imported agent would wear the card as its face. Downscaled
/// to [`MANIFEST_AVATAR_MAX_DIM`] so the manifest tEXt chunk stays small.
///
/// Locked cards keep the data-URL-only behavior: the whole manifest must fit
/// the NIP-44 plaintext cap (65 KB), which cannot carry inline pixels, and a
/// locked envelope never reaches the import body override anyway.
fn manifest_avatar_bytes(
locked: bool,
avatar_bytes: &[u8],
record_avatar_url: Option<&str>,
) -> Result<Option<Vec<u8>>, String> {
if locked {
return Ok(decode_avatar_data_url(record_avatar_url.unwrap_or("")));
}
png_bytes_resized(avatar_bytes, MANIFEST_AVATAR_MAX_DIM)
.map(Some)
.map_err(|e| format!("Failed to inline the agent avatar into the card manifest: {e}"))
}
/// True when `url` shares an origin (scheme, host, port) with `relay_base`.
///
/// Gate for attaching the minted media get-auth header — the token must never
/// be sent to a non-relay origin (same contract as `validate_download_url` in
/// `media_download.rs`, but non-fatal: a foreign origin just fetches
/// unauthenticated instead of failing the mint).
fn is_same_origin(url: &str, relay_base: &str) -> bool {
match (url::Url::parse(url), url::Url::parse(relay_base)) {
(Ok(u), Ok(b)) => u.origin() == b.origin(),
_ => false,
}
}
/// Fetch an avatar over HTTP with a hard size cap.
///
/// `auth` is an optional pre-minted Blossom get-auth header value, attached
/// verbatim — the caller is responsible for only supplying it for
/// relay-origin URLs. Redirects are not followed when auth is present
/// (redirect-hop guard, same rule as `media_download.rs`).
///
/// The cap bounds network and memory, not just the final buffer: the
/// Content-Length header is checked before any body bytes are read, and the
/// body is streamed with a running count so a missing or dishonest header
/// still cannot exceed the cap (same contract as `media_download.rs`).
async fn fetch_avatar(url: &str, auth: Option<&str>) -> Result<Vec<u8>, String> {
use futures_util::StreamExt;
let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(30));
if auth.is_some() {
// Never let a relay 3xx forward the auth header across origins.
builder = builder.redirect(reqwest::redirect::Policy::none());
}
let client = builder
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let mut req = client.get(url);
if let Some(auth) = auth {
req = req.header("authorization", auth);
}
let resp = req
.send()
.await
.map_err(|e| format!("Failed to fetch agent avatar: {e}"))?;
if !resp.status().is_success() {
return Err(format!("Avatar fetch failed: HTTP {}", resp.status()));
}
if let Some(content_length) = resp.content_length() {
if content_length > MAX_AVATAR_FETCH_BYTES as u64 {
return Err("Agent avatar is too large to use as card input.".to_string());
}
}
let mut bytes = Vec::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Failed to read avatar bytes: {e}"))?;
append_within_avatar_cap(&mut bytes, &chunk)?;
}
Ok(bytes)
}
/// Append a body chunk to the avatar buffer, rejecting before the append if
/// the total would cross `MAX_AVATAR_FETCH_BYTES`. Split out so the cap
/// boundary is unit-testable without an HTTP server.
fn append_within_avatar_cap(buf: &mut Vec<u8>, chunk: &[u8]) -> Result<(), String> {
if buf.len() + chunk.len() > MAX_AVATAR_FETCH_BYTES {
return Err("Agent avatar is too large to use as card input.".to_string());
}
buf.extend_from_slice(chunk);
Ok(())
}
/// Save previously minted card bytes to disk via the OS save dialog.
///
/// 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,
file_name: String,
app: AppHandle,
) -> Result<bool, String> {
let bytes = STANDARD
.decode(card_png_base64.as_bytes())
.map_err(|e| format!("Card bytes were not valid base64: {e}"))?;
validate_snapshot_encode_size(bytes.len(), true)?;
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(['/', '\\']) {
file_name
} else {
"card.agent.png".to_string()
};
save_bytes_with_dialog(&app, &safe_name, "Agent card", &["png"], &bytes).await
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,324 @@
//! Unit tests for `card.rs` — split into a child module file so the parent
//! stays under the 1000-line gate (same layout as `snapshot/tests.rs`).
use super::*;
use std::collections::BTreeMap;
#[test]
fn archive_file_name_validation_rejects_escapes() {
assert!(validate_archive_file_name("eva-1234.agent.png").is_ok());
for bad in [
"../escape.agent.png",
"sub/dir.agent.png",
"sub\\dir.agent.png",
"not-a-card.png",
"plain.json",
"",
] {
assert!(
validate_archive_file_name(bad).is_err(),
"expected rejection: {bad:?}"
);
}
}
#[test]
fn card_template_decodes_with_expected_shape() {
// The embedded template is generation input only, but a corrupt or
// accidentally swapped asset should fail the build's test gate, not a
// user's first mint.
let img = image::load_from_memory(CARD_TEMPLATE_PNG).expect("template must decode");
// 2:3-ish portrait frame.
assert!(img.height() > img.width(), "template must be portrait");
assert!(img.width() >= 512, "template unexpectedly small");
}
#[test]
fn key_resolution_layering_record_wins() {
let mut global = BTreeMap::new();
global.insert("OPENAI_API_KEY".to_string(), "global".to_string());
let mut persona = BTreeMap::new();
persona.insert("OPENAI_API_KEY".to_string(), "persona".to_string());
let mut record = BTreeMap::new();
record.insert("OPENAI_API_KEY".to_string(), "record".to_string());
assert_eq!(
resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(),
Some("record")
);
record.clear();
assert_eq!(
resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(),
Some("persona")
);
persona.clear();
assert_eq!(
resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(),
Some("global")
);
global.clear();
assert_eq!(
resolve_env_from_layers(
"OPENAI_API_KEY",
&global,
&persona,
&record,
Some("process".to_string())
)
.as_deref(),
Some("process")
);
assert!(resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).is_none());
}
#[test]
fn key_resolution_skips_blank_values() {
let mut record = BTreeMap::new();
record.insert("OPENAI_API_KEY".to_string(), " ".to_string());
let mut persona = BTreeMap::new();
persona.insert("OPENAI_API_KEY".to_string(), "persona".to_string());
assert_eq!(
resolve_env_from_layers("OPENAI_API_KEY", &BTreeMap::new(), &persona, &record, None)
.as_deref(),
Some("persona")
);
}
#[test]
fn responses_url_default_and_override() {
assert_eq!(responses_url(None), "https://api.openai.com/v1/responses");
// Trailing slashes must not produce a double-slash path.
assert_eq!(
responses_url(Some("https://proxy.example/v1/".to_string())),
"https://proxy.example/v1/responses"
);
assert_eq!(
responses_url(Some("https://proxy.example/v1".to_string())),
"https://proxy.example/v1/responses"
);
}
#[test]
fn instructions_pin_style_match_default_and_owner_primacy() {
let base = build_card_instructions("Eva", "leads the team", "");
assert!(base.contains("match input image 2's art style EXACTLY"));
assert!(base.contains("\"Eva\""));
assert!(!base.contains("OWNER'S DIRECTIONS"));
let directed = build_card_instructions("Eva", "leads the team", "make it stormy");
// Owner directions take primacy over style defaults...
assert!(directed.contains("OWNER'S DIRECTIONS"));
assert!(directed.contains("make it stormy"));
assert!(directed.contains("override the default art-style and copy guidance"));
// ...but the fixed contract survives: frame, style anchor (as an
// overridable default), and text-fidelity requirements stay present.
assert!(directed.contains("match input image 2's art style EXACTLY"));
assert!(directed.contains("cannot change the frame, layout, or"));
assert!(directed.contains("Render all text with perfect fidelity"));
// Card-text direction is an explicitly named capability, and the
// owner-wording rule acknowledges the fixed 220-char text-box limit
// (no mutually impossible "verbatim" vs "under 220 chars" pair).
assert!(directed.contains("card text"));
assert!(directed.contains("use their wording within the 220-character text-box limit"));
}
#[test]
fn extract_card_output_happy_path_and_missing_image() {
let ok = serde_json::json!({
"output": [
{"type": "reasoning"},
{"type": "image_generation_call", "result": "aW1n"},
{"type": "message", "content": [
{"type": "output_text", "text": "notes here"}
]}
]
});
let (img, notes) = extract_card_output(&ok).unwrap();
assert_eq!(img, "aW1n");
assert_eq!(notes, "notes here");
let missing = serde_json::json!({"output": [{"type": "message", "content": []}]});
let err = extract_card_output(&missing).unwrap_err();
assert!(err.contains("No image"), "{err}");
let no_output = serde_json::json!({});
assert!(extract_card_output(&no_output).is_err());
}
#[test]
fn kind0_picture_wins_over_record_avatar_unless_blank() {
let some = |s: &str| Some(s.to_string());
// Published picture wins.
assert_eq!(
preferred_avatar_url(some("https://relay/media/k0.png"), some("data:image/png;x")),
some("https://relay/media/k0.png")
);
// No profile / no picture: record avatar survives.
assert_eq!(
preferred_avatar_url(None, some("data:image/png;x")),
some("data:image/png;x")
);
// Blank or whitespace picture must not shadow a real avatar.
assert_eq!(
preferred_avatar_url(some(""), some("data:image/png;x")),
some("data:image/png;x")
);
assert_eq!(
preferred_avatar_url(some(" "), some("data:image/png;x")),
some("data:image/png;x")
);
// Nothing anywhere: None (caller surfaces the "no avatar" error).
assert_eq!(preferred_avatar_url(None, None), None);
}
#[test]
fn unlocked_manifest_inlines_real_avatar_bytes_downscaled() {
// 700px source (over MANIFEST_AVATAR_MAX_DIM) in a solid color.
let avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
700,
700,
image::Rgba([9, 120, 33, 255]),
));
let mut avatar_png = std::io::Cursor::new(Vec::new());
avatar
.write_to(&mut avatar_png, image::ImageFormat::Png)
.unwrap();
let inlined = manifest_avatar_bytes(false, avatar_png.get_ref(), None)
.unwrap()
.expect("unlocked mints must inline the real avatar");
let img = image::load_from_memory(&inlined).unwrap();
assert_eq!(
(img.width(), img.height()),
(MANIFEST_AVATAR_MAX_DIM, MANIFEST_AVATAR_MAX_DIM)
);
assert_eq!(img.to_rgba8().get_pixel(0, 0).0, [9, 120, 33, 255]);
// Undecodable avatar bytes fail the mint (pre-spend), never silently
// produce a card whose import would wear the artwork as a face.
assert!(manifest_avatar_bytes(false, b"not a png", None).is_err());
}
#[test]
fn locked_manifest_keeps_data_url_only_avatar() {
// Locked mints must not inline fetched bytes (NIP-44 cap): only a record
// data URL carries over, exactly as before.
let unused = [0u8; 4];
assert_eq!(
manifest_avatar_bytes(true, &unused, Some("data:image/png;base64,aGk="))
.unwrap()
.as_deref(),
Some(b"hi".as_slice())
);
assert_eq!(
manifest_avatar_bytes(true, &unused, Some("https://relay/media/a.png")).unwrap(),
None
);
assert_eq!(manifest_avatar_bytes(true, &unused, None).unwrap(), None);
}
#[test]
fn media_get_auth_gate_is_same_origin_only() {
// The minted Blossom get-auth header may only travel to the relay's own
// origin — scheme, host, and port all count (same contract as
// `validate_download_url` in `media_download.rs`).
let relay = "https://relay.example.com";
assert!(is_same_origin(
"https://relay.example.com/media/abc.png",
relay
));
// Different host, scheme, or port: no auth.
assert!(!is_same_origin(
"https://evil.example.com/media/abc.png",
relay
));
assert!(!is_same_origin(
"http://relay.example.com/media/abc.png",
relay
));
assert!(!is_same_origin(
"https://relay.example.com:8443/media/abc.png",
relay
));
// Unparseable inputs fail closed.
assert!(!is_same_origin("not a url", relay));
assert!(!is_same_origin(
"https://relay.example.com/x",
"also not a url"
));
// Explicit port on both sides matches.
assert!(is_same_origin(
"http://localhost:3100/media/abc.png",
"http://localhost:3100"
));
}
#[test]
fn avatar_cap_rejects_before_appending_crossing_chunk() {
// The streaming accumulator must reject a chunk that would cross the
// cap BEFORE buffering it — this is what bounds memory when
// Content-Length is missing or dishonest.
let mut buf = vec![0u8; MAX_AVATAR_FETCH_BYTES - 1];
assert!(append_within_avatar_cap(&mut buf, &[0u8]).is_ok());
assert_eq!(buf.len(), MAX_AVATAR_FETCH_BYTES);
// Exactly at the cap: one more byte must fail and not grow the buffer.
assert!(append_within_avatar_cap(&mut buf, &[0u8]).is_err());
assert_eq!(buf.len(), MAX_AVATAR_FETCH_BYTES);
// A single oversized chunk is rejected outright.
let mut fresh = Vec::new();
let oversized = vec![0u8; MAX_AVATAR_FETCH_BYTES + 1];
assert!(append_within_avatar_cap(&mut fresh, &oversized).is_err());
assert!(fresh.is_empty());
}
#[test]
fn save_rejects_plain_png_without_snapshot_chunk() {
// A plain PNG (no buzz_agent_snapshot chunk) must not be saveable as
// a card. Exercise the same validation the command runs.
let img = image::DynamicImage::new_rgba8(4, 4);
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
assert!(decode_snapshot_png(&png).is_err());
}
#[test]
fn archived_sidecar_without_memory_level_defaults_to_none() {
// Every mint before the memory option existed embedded MemoryLevel::None
// structurally, so old sidecars (no memoryLevel field) must deserialize
// to None — the gallery's disclosure depends on this being honest.
let legacy = r#"{
"storedFileName": "eva-1234.agent.png",
"fileName": "eva.agent.png",
"agentId": "abc",
"agentName": "Eva",
"designerNotes": "",
"locked": false,
"mintedAt": "2026-07-28T00:00:00Z"
}"#;
let meta: ArchivedCardMeta = serde_json::from_str(legacy).unwrap();
assert_eq!(meta.memory_level, MemoryLevel::None);
let with_level = legacy.replace(
"\"locked\": false,",
"\"locked\": false, \"memoryLevel\": \"everything\",",
);
let meta: ArchivedCardMeta = serde_json::from_str(&with_level).unwrap();
assert_eq!(meta.memory_level, MemoryLevel::Everything);
}
#[test]
fn minted_card_serializes_memory_level_snake_case_value() {
// The TS layer narrows on the exact wire strings "none"/"core"/
// "everything" — pin the serde representation the frontend will see.
let minted = MintedCard {
card_png_base64: String::new(),
file_name: "eva.agent.png".to_string(),
designer_notes: String::new(),
locked: false,
memory_level: MemoryLevel::Core,
};
let json = serde_json::to_value(&minted).unwrap();
assert_eq!(json["memoryLevel"], "core");
}

View File

@@ -306,11 +306,14 @@ pub async fn set_persona_active(
}
pub(crate) const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47];
mod card;
mod snapshot;
pub use snapshot::encode_agent_snapshot_for_send;
pub use snapshot::export_agent_snapshot;
pub use card::*;
#[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};

View File

@@ -142,7 +142,7 @@ pub(crate) fn validate_snapshot_encode_size(bytes_len: usize, is_png: bool) -> R
}
/// Parse a `memory_level` string to `MemoryLevel`.
fn parse_memory_level(s: &str) -> Result<MemoryLevel, String> {
pub(crate) fn parse_memory_level(s: &str) -> Result<MemoryLevel, String> {
match s {
"none" | "" => Ok(MemoryLevel::None),
"core" => Ok(MemoryLevel::Core),
@@ -153,6 +153,32 @@ fn parse_memory_level(s: &str) -> Result<MemoryLevel, String> {
}
}
/// Flatten an owner-decrypted memory listing into manifest entries for
/// `memory_level`: `Core` takes the core entry only; `Everything` appends all
/// `mem/*` entries after it. Pure so both the export and card-mint paths share
/// (and tests can pin) the level → entries selection.
pub(crate) fn memory_entries_from_listing(
listing: crate::commands::engrams::AgentMemoryListing,
memory_level: MemoryLevel,
) -> Vec<AgentSnapshotMemoryEntry> {
let mut entries = Vec::new();
if let Some(core) = listing.core {
entries.push(AgentSnapshotMemoryEntry {
slug: core.slug,
body: core.body,
});
}
if memory_level == MemoryLevel::Everything {
for mem in listing.memories {
entries.push(AgentSnapshotMemoryEntry {
slug: mem.slug,
body: mem.body,
});
}
}
entries
}
/// Parse a `format` string to a PNG flag.
fn parse_format_is_png(s: &str) -> Result<bool, String> {
match s {
@@ -267,22 +293,7 @@ pub(crate) async fn materialize_snapshot_bytes(
// ── Fetch memory ─────────────────────────────────────────────────────────
let memory_entries: Vec<AgentSnapshotMemoryEntry> = if let Some(pubkey) = memory_pubkey {
let listing = get_agent_memory(pubkey, app.clone(), state).await?;
let mut entries = Vec::new();
if let Some(core) = listing.core {
entries.push(AgentSnapshotMemoryEntry {
slug: core.slug,
body: core.body,
});
}
if memory_level == MemoryLevel::Everything {
for mem in listing.memories {
entries.push(AgentSnapshotMemoryEntry {
slug: mem.slug,
body: mem.body,
});
}
}
entries
memory_entries_from_listing(listing, memory_level)
} else {
Vec::new()
};

View File

@@ -208,3 +208,64 @@ fn import_png_placeholder_keeps_manifest_avatar_fallback() {
assert!(decoded.profile.avatar_data_url.is_none());
assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url);
}
/// An unlocked trading card imports the agent's REAL avatar, never the card.
///
/// Mint-shaped input: the PNG body is the generated card artwork, while the
/// manifest inlines the source avatar (`manifest_avatar_bytes` in `card.rs`).
/// The #3578 body-wins override must not fire when the manifest already
/// carries inline avatar bytes — otherwise the imported agent publishes the
/// 1500-wide card as its kind:0 picture.
#[test]
fn import_unlocked_card_uses_manifest_avatar_not_card_artwork() {
use crate::managed_agents::agent_snapshot::{decode_avatar_data_url, encode_snapshot_png};
use base64::{engine::general_purpose::STANDARD, Engine as _};
// The real avatar: 4×3 solid blue, inlined in the manifest at mint time.
let real_avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
4,
3,
image::Rgba([23, 91, 177, 255]),
));
let mut real_avatar_png = std::io::Cursor::new(Vec::new());
real_avatar
.write_to(&mut real_avatar_png, image::ImageFormat::Png)
.unwrap();
let mut snapshot = make_snapshot(MemoryLevel::None, vec![]);
snapshot.profile.avatar_data_url = Some(format!(
"data:image/png;base64,{}",
STANDARD.encode(real_avatar_png.get_ref())
));
snapshot.profile.avatar_url = Some("https://relay.example/media/live-kind0.png".to_string());
// The card artwork: a distinct 1500×2250 solid red "trading card" as the
// PNG body — the exact dimensions the minter encodes for unlocked cards.
// Size matters: 2250px exceeds `snapshot_avatar`'s 2048px decode limit,
// so reaching the body override here wouldn't just import the wrong
// face — it would fail the import outright.
let card_art = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
1500,
2250,
image::Rgba([200, 16, 16, 255]),
));
let mut card_png = std::io::Cursor::new(Vec::new());
card_art
.write_to(&mut card_png, image::ImageFormat::Png)
.unwrap();
let file_bytes = encode_snapshot_png(&snapshot, Some(card_png.get_ref())).unwrap();
// Production import decode: the effective avatar must be the real one.
let decoded = decode_snapshot_from_bytes(&file_bytes).unwrap();
let avatar_bytes =
decode_avatar_data_url(decoded.profile.avatar_data_url.as_deref().unwrap()).unwrap();
let imported = image::load_from_memory(&avatar_bytes).unwrap();
assert_eq!(
(imported.width(), imported.height()),
(4, 3),
"imported avatar must be the source avatar, not the card artwork"
);
assert_eq!(imported.to_rgba8().get_pixel(0, 0).0, [23, 91, 177, 255]);
// The live kind:0 URL fallback survives untouched.
assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url);
}

View File

@@ -13,7 +13,11 @@ use tauri::{AppHandle, Emitter, State};
use crate::{
app_state::AppState,
managed_agents::{
agent_snapshot::{decode_snapshot_json, decode_snapshot_png, AgentSnapshot, MemoryLevel},
agent_snapshot::{extract_chunk_payload_png, AgentSnapshot, 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,
},
@@ -72,6 +76,16 @@ pub struct AgentSnapshotImportPreview {
pub has_source_allowlist: bool,
/// Number of source allowlist entries.
pub source_allowlist_count: usize,
/// Full source allowlist entries, surfaced before import so hidden access
/// configuration is never reduced to a count.
pub source_allowlist: Vec<String>,
/// 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.
@@ -210,50 +224,112 @@ 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 mut snapshot = decode_snapshot_png(file_bytes)?;
let chunk_json = extract_chunk_payload_png(file_bytes)?;
let mut payload = parse_chunk_payload(&chunk_json)?;
// The PNG image body is the portable avatar. It deliberately wins over
// manifest avatar fields, whose URL may only be reachable by the
// sender. A 1×1 export placeholder leaves the manifest fallback intact.
if let Some(avatar_data_url) =
crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url(file_bytes)?
{
snapshot.profile.avatar_data_url = Some(avatar_data_url);
// a manifest avatar *URL*, which may only be reachable by the sender.
// A 1×1 export placeholder leaves the manifest fallback intact.
// Inline manifest avatar *bytes* are authoritative and never
// overridden: trading cards supply the generated card artwork as the
// PNG body and carry the agent's real avatar inline — adopting the
// body there would import the card as the agent's face.
// Locked envelopes stay opaque here — there is no manifest to override
// until the unlock path decrypts one.
if let ChunkPayload::Plain(snapshot) = &mut payload {
if snapshot.profile.avatar_data_url.is_none() {
if let Some(avatar_data_url) =
crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url(
file_bytes,
)?
{
snapshot.profile.avatar_data_url = Some(avatar_data_url);
}
}
}
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(),
);
payload
} 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))
}
}
}
async fn materialize_import_avatar<F, Fut>(
@@ -283,19 +359,38 @@ where
/// `.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)?;
Ok(build_agent_snapshot_import_preview(&snapshot))
build_agent_snapshot_import_preview(&snapshot, locked)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
@@ -303,7 +398,8 @@ pub async fn preview_agent_snapshot_import(
pub(crate) fn build_agent_snapshot_import_preview(
snapshot: &AgentSnapshot,
) -> AgentSnapshotImportPreview {
locked: bool,
) -> Result<AgentSnapshotImportPreview, String> {
let memory_level = match snapshot.memory.level {
MemoryLevel::None => "none",
MemoryLevel::Core => "core",
@@ -311,7 +407,11 @@ pub(crate) fn build_agent_snapshot_import_preview(
}
.to_string();
AgentSnapshotImportPreview {
let manifest_json = serde_json::to_string_pretty(snapshot)
.map_err(|e| format!("failed to render snapshot manifest: {e}"))?;
let source_allowlist = snapshot.definition.respond_to_allowlist.clone();
Ok(AgentSnapshotImportPreview {
display_name: snapshot.profile.display_name.clone(),
is_builtin: snapshot.definition.source_is_builtin,
model: snapshot.definition.model.clone(),
@@ -325,9 +425,12 @@ pub(crate) fn build_agent_snapshot_import_preview(
.or_else(|| snapshot.profile.avatar_url.clone()),
memory_level,
memory_entry_count: snapshot.memory.entries.len(),
source_allowlist_count: snapshot.definition.respond_to_allowlist.len(),
has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(),
}
source_allowlist_count: source_allowlist.len(),
has_source_allowlist: !source_allowlist.is_empty(),
source_allowlist,
manifest_json,
locked,
})
}
// ── `confirm_agent_snapshot_import` ──────────────────────────────────────────
@@ -355,8 +458,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() {

View File

@@ -564,7 +564,7 @@ fn import_preview_includes_exported_definition_metadata() {
let bytes = crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot).unwrap();
let decoded = decode_snapshot_from_bytes(&bytes).unwrap();
let preview = build_agent_snapshot_import_preview(&decoded);
let preview = build_agent_snapshot_import_preview(&decoded, false).unwrap();
assert!(preview.is_builtin);
assert_eq!(preview.model.as_deref(), Some("claude-opus-4-5"));
@@ -949,51 +949,14 @@ fn test_parse_format_is_png_invalid_returns_error() {
}
// ── Export: validate_snapshot_encode_size ────────────────────────────────────
//
// Tests call `validate_snapshot_encode_size` directly so they prove the exact
// production guard — not a manual reconstruction. Removing or reversing the
// check in production code will cause these tests to fail.
/// JSON: boundary-1 passes, boundary is the last legal byte count.
#[test]
fn validate_encode_size_json_at_boundary_minus_1_passes() {
assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES - 1, false).is_ok());
}
#[path = "tests_memory_entries.rs"]
mod memory_entries;
/// JSON: exactly at the boundary is the last accepted size.
#[test]
fn validate_encode_size_json_at_boundary_passes() {
assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES, false).is_ok());
}
#[path = "tests_encode_size.rs"]
mod encode_size;
/// JSON: boundary+1 is rejected.
#[test]
fn validate_encode_size_json_over_boundary_is_rejected() {
let err = super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES + 1, false).unwrap_err();
assert!(
err.contains("size limit"),
"error must mention size limit, got: {err}"
);
}
// ── Import: decode_snapshot_for_import (locked cards) ─────────────────────
/// PNG: boundary-1 passes.
#[test]
fn validate_encode_size_png_at_boundary_minus_1_passes() {
assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES - 1, true).is_ok());
}
/// PNG: exactly at the boundary passes.
#[test]
fn validate_encode_size_png_at_boundary_passes() {
assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES, true).is_ok());
}
/// PNG: boundary+1 is rejected.
#[test]
fn validate_encode_size_png_over_boundary_is_rejected() {
let err = super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES + 1, true).unwrap_err();
assert!(
err.contains("size limit"),
"error must mention size limit, got: {err}"
);
}
#[path = "tests_locked.rs"]
mod locked_import;

View File

@@ -0,0 +1,55 @@
//! Export-size guard tests for `validate_snapshot_encode_size`.
//!
//! 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 imports.
//!
//! Tests call `validate_snapshot_encode_size` directly so they prove the
//! exact production guard — not a manual reconstruction. Removing or
//! reversing the check in production code will cause these tests to fail.
use super::*;
/// JSON: boundary-1 passes, boundary is the last legal byte count.
#[test]
fn validate_encode_size_json_at_boundary_minus_1_passes() {
assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES - 1, false).is_ok());
}
/// JSON: exactly at the boundary is the last accepted size.
#[test]
fn validate_encode_size_json_at_boundary_passes() {
assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES, false).is_ok());
}
/// JSON: boundary+1 is rejected.
#[test]
fn validate_encode_size_json_over_boundary_is_rejected() {
let err = validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES + 1, false).unwrap_err();
assert!(
err.contains("size limit"),
"error must mention size limit, got: {err}"
);
}
/// PNG: boundary-1 passes.
#[test]
fn validate_encode_size_png_at_boundary_minus_1_passes() {
assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES - 1, true).is_ok());
}
/// PNG: exactly at the boundary passes.
#[test]
fn validate_encode_size_png_at_boundary_passes() {
assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES, true).is_ok());
}
/// PNG: boundary+1 is rejected.
#[test]
fn validate_encode_size_png_over_boundary_is_rejected() {
let err = validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES + 1, true).unwrap_err();
assert!(
err.contains("size limit"),
"error must mention size limit, got: {err}"
);
}

View File

@@ -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);
}

View File

@@ -0,0 +1,55 @@
//! Tests for `memory_entries_from_listing` — the shared level → entries
//! selection used by both snapshot export and card minting. Split from
//! `tests.rs` to keep that file under the 1000-line gate; `#[path]`-included
//! from there as a child module, so `super::*` resolves to `tests`'s parent
//! scope re-exports.
use super::*;
fn listing_fixture() -> crate::commands::engrams::AgentMemoryListing {
let entry = |slug: &str, body: &str| crate::commands::engrams::EngramEntry {
slug: slug.to_string(),
body: body.to_string(),
event_id: "e".repeat(64),
created_at: 1,
outgoing_refs: vec![],
};
crate::commands::engrams::AgentMemoryListing {
core: Some(entry("core", "core body")),
memories: vec![entry("mem/a", "a body"), entry("mem/b", "b body")],
truncated: false,
fetched_at: 1,
}
}
#[test]
fn memory_entries_core_takes_core_only() {
let entries = memory_entries_from_listing(listing_fixture(), MemoryLevel::Core);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].slug, "core");
assert_eq!(entries[0].body, "core body");
}
#[test]
fn memory_entries_everything_appends_mem_entries_after_core() {
let entries = memory_entries_from_listing(listing_fixture(), MemoryLevel::Everything);
assert_eq!(
entries.iter().map(|e| e.slug.as_str()).collect::<Vec<_>>(),
vec!["core", "mem/a", "mem/b"]
);
}
#[test]
fn memory_entries_missing_core_still_yields_mem_entries_for_everything() {
let mut listing = listing_fixture();
listing.core = None;
let entries = memory_entries_from_listing(listing, MemoryLevel::Everything);
assert_eq!(
entries.iter().map(|e| e.slug.as_str()).collect::<Vec<_>>(),
vec!["mem/a", "mem/b"]
);
let mut core_only = listing_fixture();
core_only.core = None;
assert!(memory_entries_from_listing(core_only, MemoryLevel::Core).is_empty());
}

View File

@@ -65,10 +65,7 @@ use mesh_llm_stubs::*;
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown};
use shutdown::{is_restart_request, shut_down_app};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc};
use tauri::{Emitter, Manager, RunEvent};
#[cfg(target_os = "macos")]
use tauri::{Listener, WindowEvent};
@@ -851,6 +848,12 @@ pub fn run() {
update_team,
delete_team,
export_agent_snapshot,
card_mint_key_status,
card_mint_save_openai_key,
mint_agent_card,
save_agent_card,
list_agent_cards,
load_agent_card,
preview_agent_snapshot_import,
confirm_agent_snapshot_import,
encode_agent_snapshot_for_send,

View File

@@ -306,9 +306,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
@@ -334,8 +347,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()
@@ -349,10 +365,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)
}
@@ -473,527 +497,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,
shared: false,
source_team: Some("team-id-123".to_string()), // MUST NOT appear
source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear
definition_respond_to: Some("allowlist".to_string()),
catalog_source: None,
definition_respond_to_allowlist: vec!["abc123def".to_string()],
definition_parallelism: Some(4),
relay_mesh: None,
}
}
// ── Round-trip tests ──────────────────────────────────────────────────────
#[test]
fn json_round_trip_config_only() {
let record = minimal_record();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
let bytes = encode_snapshot_json(&snapshot).unwrap();
let parsed = decode_snapshot_json(&bytes).unwrap();
assert_eq!(parsed, snapshot);
}
#[test]
fn json_round_trip_with_memory() {
let record = minimal_record();
let entries = vec![
AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "I am a test agent.".to_string(),
},
AgentSnapshotMemoryEntry {
slug: "mem/research".to_string(),
body: "Some research notes.".to_string(),
},
];
let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None);
let bytes = encode_snapshot_json(&snapshot).unwrap();
let parsed = decode_snapshot_json(&bytes).unwrap();
assert_eq!(parsed, snapshot);
}
#[test]
fn png_round_trip_no_memory() {
let record = minimal_record();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
let parsed = decode_snapshot_png(&png_bytes).unwrap();
assert_eq!(parsed.definition.name, snapshot.definition.name);
assert_eq!(parsed.profile.display_name, snapshot.profile.display_name);
assert_eq!(parsed.memory.level, MemoryLevel::None);
}
#[test]
fn png_round_trip_with_avatar_png() {
// Build a minimal PNG avatar.
let avatar = make_png_with_text("dummy", "value").unwrap();
let record = minimal_record();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar));
// Avatar should be inlined as a data URL.
assert!(snapshot
.profile
.avatar_data_url
.as_deref()
.unwrap_or("")
.starts_with("data:image/png;base64,"));
let png_bytes = encode_snapshot_png(&snapshot, Some(&avatar)).unwrap();
let parsed = decode_snapshot_png(&png_bytes).unwrap();
assert_eq!(parsed.definition.name, snapshot.definition.name);
}
#[test]
fn png_snapshot_transcodes_jpeg_avatar_into_image_body() {
let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
3,
2,
image::Rgb([0x12, 0x34, 0x56]),
));
let mut jpeg_bytes = Vec::new();
avatar
.write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg)
.unwrap();
let snapshot = build_snapshot(
&minimal_record(),
MemoryLevel::None,
vec![],
Some(&jpeg_bytes),
);
let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap();
let decoder = Decoder::new(Cursor::new(png_bytes));
let reader = decoder.read_info().unwrap();
assert_eq!((reader.info().width, reader.info().height), (3, 2));
}
// ── PNG memory parity ─────────────────────────────────────────────────────
#[test]
fn png_round_trip_with_core_memory() {
let record = minimal_record();
let entries = vec![AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "remember this".to_string(),
}];
let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None);
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
let parsed = decode_snapshot_png(&png_bytes).unwrap();
assert_eq!(parsed.memory, snapshot.memory);
}
#[test]
fn png_round_trip_with_everything_memory() {
let record = minimal_record();
let entries = vec![
AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "remember this".to_string(),
},
AgentSnapshotMemoryEntry {
slug: "mem/notes".to_string(),
body: "private notes".to_string(),
},
];
let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None);
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
let parsed = decode_snapshot_png(&png_bytes).unwrap();
assert_eq!(parsed.memory, snapshot.memory);
}
#[test]
fn png_export_with_no_memory_succeeds() {
let record = minimal_record();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
assert!(encode_snapshot_png(&snapshot, None).is_ok());
}
#[test]
fn png_export_rejects_none_level_with_nonempty_entries() {
// Inconsistent state: level == None but entries is non-empty.
// The encoder must reject this to prevent a memory-leak bypass.
let record = minimal_record();
let entries = vec![AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "leaked memory".to_string(),
}];
// Build with entries, then override level to None in the struct.
let mut snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None);
snapshot.memory.level = MemoryLevel::None; // force inconsistency
let result = encode_snapshot_png(&snapshot, None);
assert!(
result.is_err(),
"PNG encoder must reject level=None with non-empty entries"
);
assert!(
result
.unwrap_err()
.contains("memory.level 'none' and non-empty memory entries"),
"Error must explain the malformed memory state"
);
}
// ── Secret exclusion tests ────────────────────────────────────────────────
//
// These tests assert that every field in the exclusion list is absent from
// the serialized snapshot. We serialize to JSON and assert the key is NOT
// present.
fn snapshot_json_string(record: &ManagedAgentRecord) -> String {
let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None);
let bytes = encode_snapshot_json(&snapshot).unwrap();
String::from_utf8(bytes).unwrap()
}
#[test]
fn secret_exclusion_private_key_nsec_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("nsec1secret"),
"nsec must not appear in snapshot"
);
assert!(
!json.contains("privateKeyNsec") && !json.contains("private_key_nsec"),
"privateKeyNsec field must not appear in snapshot"
);
}
#[test]
fn secret_exclusion_auth_tag_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("auth-tag-secret"),
"auth_tag value must not appear in snapshot"
);
assert!(
!json.contains("authTag") && !json.contains("auth_tag"),
"authTag field must not appear in snapshot"
);
}
#[test]
fn secret_exclusion_env_vars_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("API_KEY") && !json.contains("secret123"),
"env_vars content must not appear in snapshot"
);
assert!(
!json.contains("envVars") && !json.contains("env_vars"),
"envVars field must not appear in snapshot"
);
}
#[test]
fn secret_exclusion_relay_url_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("wss://relay.example.com"),
"relay_url value must not appear in snapshot"
);
assert!(
!json.contains("relayUrl") && !json.contains("relay_url"),
"relayUrl field must not appear in snapshot"
);
}
#[test]
fn snapshot_omits_removed_mcp_toolsets_config() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("mcpToolsets") && !json.contains("mcp_toolsets"),
"removed MCP toolsets config must not re-enter snapshots"
);
}
#[test]
fn secret_exclusion_machine_commands_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
// acp_command / agent_command / agent_command_override / agent_args / mcp_command
assert!(
!json.contains("/usr/local/bin/acp"),
"acp_command path must not appear"
);
assert!(
!json.contains("acpCommand") && !json.contains("acp_command"),
"acpCommand field must not appear"
);
assert!(
!json.contains("agentCommand") && !json.contains("agent_command"),
"agentCommand field must not appear"
);
assert!(
!json.contains("mcpCommand") && !json.contains("mcp_command"),
"mcpCommand field must not appear"
);
}
#[test]
fn secret_exclusion_runtime_state_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("runtimePid") && !json.contains("runtime_pid"),
"runtimePid must not appear"
);
assert!(
!json.contains("backendAgentId") && !json.contains("backend_agent_id"),
"backendAgentId must not appear"
);
assert!(
!json.contains("SENTINEL_BACKEND_AGENT_ID"),
"backendAgentId value must not appear"
);
assert!(
!json.contains("providerBinaryPath") && !json.contains("provider_binary_path"),
"providerBinaryPath must not appear"
);
assert!(
!json.contains("SENTINEL_PROVIDER_BINARY"),
"providerBinaryPath value must not appear"
);
assert!(
!json.contains("lastStartedAt") && !json.contains("last_started_at"),
"lastStartedAt must not appear"
);
assert!(
!json.contains("lastExitCode") && !json.contains("last_exit_code"),
"lastExitCode must not appear"
);
// backend blob — neither the type tag nor provider secret must leak.
assert!(
!json.contains("\"backend\"") && !json.contains("backend"),
"backend field must not appear"
);
assert!(
!json.contains("SENTINEL_BACKEND_ID") && !json.contains("SENTINEL_BACKEND_SECRET"),
"backend config values must not appear"
);
// last_error / last_error_code
assert!(
!json.contains("lastError") && !json.contains("last_error"),
"lastError must not appear"
);
assert!(
!json.contains("SENTINEL_LAST_ERROR"),
"lastError value must not appear"
);
assert!(
!json.contains("lastErrorCode") && !json.contains("last_error_code"),
"lastErrorCode must not appear"
);
}
#[test]
fn secret_exclusion_lineage_ids_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("team-id-123"),
"source_team value must not appear"
);
assert!(
!json.contains("sourceTeam") && !json.contains("source_team"),
"sourceTeam field must not appear"
);
assert!(
!json.contains("sourceTeamPersonaSlug"),
"sourceTeamPersonaSlug must not appear"
);
assert!(
!json.contains("personaSourceVersion") && !json.contains("persona_source_version"),
"personaSourceVersion must not appear"
);
// personaId
assert!(
!json.contains("personaId") && !json.contains("persona_id"),
"personaId field must not appear"
);
assert!(
!json.contains("SENTINEL_PERSONA_ID"),
"personaId value must not appear"
);
// teamId
assert!(
!json.contains("teamId") && !json.contains("team_id"),
"teamId field must not appear"
);
assert!(
!json.contains("SENTINEL_TEAM_ID"),
"teamId value must not appear"
);
// personaTeamDir
assert!(
!json.contains("personaTeamDir") && !json.contains("persona_team_dir"),
"personaTeamDir field must not appear"
);
assert!(
!json.contains("SENTINEL_TEAM_DIR"),
"personaTeamDir value must not appear"
);
// personaNameInTeam
assert!(
!json.contains("personaNameInTeam") && !json.contains("persona_name_in_team"),
"personaNameInTeam field must not appear"
);
assert!(
!json.contains("SENTINEL_NAME_IN_TEAM"),
"personaNameInTeam value must not appear"
);
}
// ── Definition field presence tests ──────────────────────────────────────
#[test]
fn definition_fields_present_in_snapshot() {
let record = minimal_record();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
assert_eq!(snapshot.definition.name, "Test Agent Display");
assert!(!snapshot.definition.source_is_builtin);
assert_eq!(
snapshot.definition.system_prompt.as_deref(),
Some("You are a test agent.")
);
assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose"));
assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4"));
assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic"));
assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]);
// definition_respond_to maps to respond_to in the snapshot definition
assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist"));
// definition_respond_to_allowlist should be included
assert!(!snapshot.definition.respond_to_allowlist.is_empty());
}
#[test]
fn profile_fields_present_in_snapshot() {
let record = minimal_record();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
assert_eq!(snapshot.profile.display_name, "Test Agent Display");
// No bytes → should fall back to avatar_url
assert_eq!(
snapshot.profile.avatar_url.as_deref(),
Some("https://example.com/avatar.png")
);
assert!(snapshot.profile.avatar_data_url.is_none());
}
#[test]
fn avatar_inlined_when_under_size_limit() {
let record = minimal_record();
let small_png = make_png_with_text("k", "v").unwrap();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png));
assert!(snapshot.profile.avatar_data_url.is_some());
assert!(snapshot.profile.avatar_url.is_none());
}
#[test]
fn avatar_url_fallback_when_over_size_limit() {
let mut record = minimal_record();
record.avatar_url = Some("https://example.com/big.png".to_string());
// Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec.
let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1];
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes));
assert!(snapshot.profile.avatar_data_url.is_none());
assert_eq!(
snapshot.profile.avatar_url.as_deref(),
Some("https://example.com/big.png")
);
}
// ── Format/version validation ─────────────────────────────────────────────
#[test]
fn invalid_format_discriminator_is_rejected() {
let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None);
snapshot.format = "not-a-buzz-snapshot".to_string();
let bytes = serde_json::to_vec(&snapshot).unwrap();
let result = decode_snapshot_json(&bytes);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unsupported snapshot format"));
}
#[test]
fn unsupported_version_is_rejected() {
let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None);
snapshot.version = 99;
let bytes = serde_json::to_vec(&snapshot).unwrap();
let result = decode_snapshot_json(&bytes);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unsupported snapshot version"));
}
}
#[path = "agent_snapshot_tests.rs"]
mod tests;

View File

@@ -0,0 +1,638 @@
//! 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. Curve validation is
/// explicit: nostr's `PublicKey::from_hex` only decodes 32 bytes and defers
/// lift-x validation to `xonly()`, so a non-point like `"f" * 64` would
/// otherwise pass structurally and fail only at decrypt time.
pub(crate) 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)."
));
}
let pubkey = PublicKey::from_hex(value)
.map_err(|_| format!("Locked card envelope has an invalid {field}."))?;
pubkey
.xonly()
.map_err(|_| format!("Locked card envelope has an invalid {field} (not a curve point)."))?;
Ok(pubkey)
}
/// 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,
source_is_builtin: false,
},
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,
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: 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 (lift-x fails for
// x = p-1... all-f) must be rejected STRUCTURALLY — before any key
// lookup or decrypt work — per the wire contract.
let mut not_a_point = env.clone();
not_a_point.encryption.agent_pubkey = "f".repeat(64);
assert!(validate_envelope(&not_a_point)
.unwrap_err()
.contains("not a curve point"));
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());
}
}

View File

@@ -0,0 +1,599 @@
//! 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,
shared: false,
source_team: Some("team-id-123".to_string()), // MUST NOT appear
source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear
definition_respond_to: Some("allowlist".to_string()),
catalog_source: None,
definition_respond_to_allowlist: vec!["abc123def".to_string()],
definition_parallelism: Some(4),
relay_mesh: None,
}
}
// ── Round-trip tests ──────────────────────────────────────────────────────
#[test]
fn json_round_trip_config_only() {
let record = minimal_record();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
let bytes = encode_snapshot_json(&snapshot).unwrap();
let parsed = decode_snapshot_json(&bytes).unwrap();
assert_eq!(parsed, snapshot);
}
#[test]
fn json_round_trip_with_memory() {
let record = minimal_record();
let entries = vec![
AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "I am a test agent.".to_string(),
},
AgentSnapshotMemoryEntry {
slug: "mem/research".to_string(),
body: "Some research notes.".to_string(),
},
];
let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None);
let bytes = encode_snapshot_json(&snapshot).unwrap();
let parsed = decode_snapshot_json(&bytes).unwrap();
assert_eq!(parsed, snapshot);
}
#[test]
fn png_round_trip_no_memory() {
let record = minimal_record();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
let parsed = decode_snapshot_png(&png_bytes).unwrap();
assert_eq!(parsed.definition.name, snapshot.definition.name);
assert_eq!(parsed.profile.display_name, snapshot.profile.display_name);
assert_eq!(parsed.memory.level, MemoryLevel::None);
}
#[test]
fn png_round_trip_with_avatar_png() {
// Build a minimal PNG avatar.
let avatar = make_png_with_text("dummy", "value").unwrap();
let record = minimal_record();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar));
// Avatar should be inlined as a data URL.
assert!(snapshot
.profile
.avatar_data_url
.as_deref()
.unwrap_or("")
.starts_with("data:image/png;base64,"));
let png_bytes = encode_snapshot_png(&snapshot, Some(&avatar)).unwrap();
let parsed = decode_snapshot_png(&png_bytes).unwrap();
assert_eq!(parsed.definition.name, snapshot.definition.name);
}
/// Plain-card byte compatibility: `encode_snapshot_png` was refactored
/// through the shared `encode_chunk_payload_png` when locked cards were
/// added. Plain cards must emit byte-identical PNGs to the pre-envelope
/// encoder. This vector reimplements the legacy encoder body verbatim and
/// asserts equality on all three composition paths: placeholder (no avatar),
/// PNG-avatar (where tEXt chunk injection ordering matters), and
/// JPEG-avatar transcode.
#[test]
fn plain_encoder_bytes_identical_to_pre_envelope_encoder() {
// Verbatim pre-refactor `encode_snapshot_png` body (post memory guard).
fn legacy_encode(
snapshot: &AgentSnapshot,
avatar_bytes: Option<&[u8]>,
) -> Result<Vec<u8>, String> {
let json_bytes = encode_snapshot_json(snapshot)?;
let chunk_text = STANDARD.encode(&json_bytes);
let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) {
Some(bytes) => {
let encoded_avatar = if bytes.starts_with(b"\x89PNG") {
inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| {
transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text)
})
} else {
transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text)
};
match encoded_avatar {
Ok(png_bytes) => png_bytes,
Err(_) => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?,
}
}
None => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?,
};
Ok(png_bytes)
}
let record = minimal_record();
// Placeholder path (no avatar).
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
assert_eq!(
encode_snapshot_png(&snapshot, None).unwrap(),
legacy_encode(&snapshot, None).unwrap(),
"placeholder-path plain PNG bytes must match the pre-envelope encoder"
);
// PNG-avatar path: chunk injected into the avatar image body.
let avatar = make_png_with_text("dummy", "value").unwrap();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar));
assert_eq!(
encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(),
legacy_encode(&snapshot, Some(&avatar)).unwrap(),
"avatar-path plain PNG bytes must match the pre-envelope encoder"
);
// JPEG-avatar path: transcode-to-PNG composition.
let jpeg_avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
4,
4,
image::Rgb([0x10, 0x20, 0x30]),
));
let mut jpeg_bytes = Vec::new();
jpeg_avatar
.write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg)
.unwrap();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&jpeg_bytes));
assert_eq!(
encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(),
legacy_encode(&snapshot, Some(&jpeg_bytes)).unwrap(),
"transcode-path plain PNG bytes must match the pre-envelope encoder"
);
}
#[test]
fn png_snapshot_transcodes_jpeg_avatar_into_image_body() {
let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
3,
2,
image::Rgb([0x12, 0x34, 0x56]),
));
let mut jpeg_bytes = Vec::new();
avatar
.write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg)
.unwrap();
let snapshot = build_snapshot(
&minimal_record(),
MemoryLevel::None,
vec![],
Some(&jpeg_bytes),
);
let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap();
let decoder = Decoder::new(Cursor::new(png_bytes));
let reader = decoder.read_info().unwrap();
assert_eq!((reader.info().width, reader.info().height), (3, 2));
}
// ── PNG memory parity ─────────────────────────────────────────────────────
#[test]
fn png_round_trip_with_core_memory() {
let record = minimal_record();
let entries = vec![AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "remember this".to_string(),
}];
let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None);
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
let parsed = decode_snapshot_png(&png_bytes).unwrap();
assert_eq!(parsed.memory, snapshot.memory);
}
#[test]
fn png_round_trip_with_everything_memory() {
let record = minimal_record();
let entries = vec![
AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "remember this".to_string(),
},
AgentSnapshotMemoryEntry {
slug: "mem/notes".to_string(),
body: "private notes".to_string(),
},
];
let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None);
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
let parsed = decode_snapshot_png(&png_bytes).unwrap();
assert_eq!(parsed.memory, snapshot.memory);
}
#[test]
fn png_export_with_no_memory_succeeds() {
let record = minimal_record();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
assert!(encode_snapshot_png(&snapshot, None).is_ok());
}
#[test]
fn png_export_rejects_none_level_with_nonempty_entries() {
// Inconsistent state: level == None but entries is non-empty.
// The encoder must reject this to prevent a memory-leak bypass.
let record = minimal_record();
let entries = vec![AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "leaked memory".to_string(),
}];
// Build with entries, then override level to None in the struct.
let mut snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None);
snapshot.memory.level = MemoryLevel::None; // force inconsistency
let result = encode_snapshot_png(&snapshot, None);
assert!(
result.is_err(),
"PNG encoder must reject level=None with non-empty entries"
);
assert!(
result
.unwrap_err()
.contains("memory.level 'none' and non-empty memory entries"),
"Error must explain the malformed memory state"
);
}
// ── Secret exclusion tests ────────────────────────────────────────────────
//
// These tests assert that every field in the exclusion list is absent from
// the serialized snapshot. We serialize to JSON and assert the key is NOT
// present.
fn snapshot_json_string(record: &ManagedAgentRecord) -> String {
let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None);
let bytes = encode_snapshot_json(&snapshot).unwrap();
String::from_utf8(bytes).unwrap()
}
#[test]
fn secret_exclusion_private_key_nsec_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("nsec1secret"),
"nsec must not appear in snapshot"
);
assert!(
!json.contains("privateKeyNsec") && !json.contains("private_key_nsec"),
"privateKeyNsec field must not appear in snapshot"
);
}
#[test]
fn secret_exclusion_auth_tag_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("auth-tag-secret"),
"auth_tag value must not appear in snapshot"
);
assert!(
!json.contains("authTag") && !json.contains("auth_tag"),
"authTag field must not appear in snapshot"
);
}
#[test]
fn secret_exclusion_env_vars_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("API_KEY") && !json.contains("secret123"),
"env_vars content must not appear in snapshot"
);
assert!(
!json.contains("envVars") && !json.contains("env_vars"),
"envVars field must not appear in snapshot"
);
}
#[test]
fn secret_exclusion_relay_url_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("wss://relay.example.com"),
"relay_url value must not appear in snapshot"
);
assert!(
!json.contains("relayUrl") && !json.contains("relay_url"),
"relayUrl field must not appear in snapshot"
);
}
#[test]
fn snapshot_omits_removed_mcp_toolsets_config() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("mcpToolsets") && !json.contains("mcp_toolsets"),
"removed MCP toolsets config must not re-enter snapshots"
);
}
#[test]
fn secret_exclusion_machine_commands_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
// acp_command / agent_command / agent_command_override / agent_args / mcp_command
assert!(
!json.contains("/usr/local/bin/acp"),
"acp_command path must not appear"
);
assert!(
!json.contains("acpCommand") && !json.contains("acp_command"),
"acpCommand field must not appear"
);
assert!(
!json.contains("agentCommand") && !json.contains("agent_command"),
"agentCommand field must not appear"
);
assert!(
!json.contains("mcpCommand") && !json.contains("mcp_command"),
"mcpCommand field must not appear"
);
}
#[test]
fn secret_exclusion_runtime_state_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("runtimePid") && !json.contains("runtime_pid"),
"runtimePid must not appear"
);
assert!(
!json.contains("backendAgentId") && !json.contains("backend_agent_id"),
"backendAgentId must not appear"
);
assert!(
!json.contains("SENTINEL_BACKEND_AGENT_ID"),
"backendAgentId value must not appear"
);
assert!(
!json.contains("providerBinaryPath") && !json.contains("provider_binary_path"),
"providerBinaryPath must not appear"
);
assert!(
!json.contains("SENTINEL_PROVIDER_BINARY"),
"providerBinaryPath value must not appear"
);
assert!(
!json.contains("lastStartedAt") && !json.contains("last_started_at"),
"lastStartedAt must not appear"
);
assert!(
!json.contains("lastExitCode") && !json.contains("last_exit_code"),
"lastExitCode must not appear"
);
// backend blob — neither the type tag nor provider secret must leak.
assert!(
!json.contains("\"backend\"") && !json.contains("backend"),
"backend field must not appear"
);
assert!(
!json.contains("SENTINEL_BACKEND_ID") && !json.contains("SENTINEL_BACKEND_SECRET"),
"backend config values must not appear"
);
// last_error / last_error_code
assert!(
!json.contains("lastError") && !json.contains("last_error"),
"lastError must not appear"
);
assert!(
!json.contains("SENTINEL_LAST_ERROR"),
"lastError value must not appear"
);
assert!(
!json.contains("lastErrorCode") && !json.contains("last_error_code"),
"lastErrorCode must not appear"
);
}
#[test]
fn secret_exclusion_lineage_ids_absent() {
let record = minimal_record();
let json = snapshot_json_string(&record);
assert!(
!json.contains("team-id-123"),
"source_team value must not appear"
);
assert!(
!json.contains("sourceTeam") && !json.contains("source_team"),
"sourceTeam field must not appear"
);
assert!(
!json.contains("sourceTeamPersonaSlug"),
"sourceTeamPersonaSlug must not appear"
);
assert!(
!json.contains("personaSourceVersion") && !json.contains("persona_source_version"),
"personaSourceVersion must not appear"
);
// personaId
assert!(
!json.contains("personaId") && !json.contains("persona_id"),
"personaId field must not appear"
);
assert!(
!json.contains("SENTINEL_PERSONA_ID"),
"personaId value must not appear"
);
// teamId
assert!(
!json.contains("teamId") && !json.contains("team_id"),
"teamId field must not appear"
);
assert!(
!json.contains("SENTINEL_TEAM_ID"),
"teamId value must not appear"
);
// personaTeamDir
assert!(
!json.contains("personaTeamDir") && !json.contains("persona_team_dir"),
"personaTeamDir field must not appear"
);
assert!(
!json.contains("SENTINEL_TEAM_DIR"),
"personaTeamDir value must not appear"
);
// personaNameInTeam
assert!(
!json.contains("personaNameInTeam") && !json.contains("persona_name_in_team"),
"personaNameInTeam field must not appear"
);
assert!(
!json.contains("SENTINEL_NAME_IN_TEAM"),
"personaNameInTeam value must not appear"
);
}
// ── Definition field presence tests ──────────────────────────────────────
#[test]
fn definition_fields_present_in_snapshot() {
let record = minimal_record();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
assert_eq!(snapshot.definition.name, "Test Agent Display");
assert!(!snapshot.definition.source_is_builtin);
assert_eq!(
snapshot.definition.system_prompt.as_deref(),
Some("You are a test agent.")
);
assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose"));
assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4"));
assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic"));
assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]);
// definition_respond_to maps to respond_to in the snapshot definition
assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist"));
// definition_respond_to_allowlist should be included
assert!(!snapshot.definition.respond_to_allowlist.is_empty());
}
#[test]
fn profile_fields_present_in_snapshot() {
let record = minimal_record();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
assert_eq!(snapshot.profile.display_name, "Test Agent Display");
// No bytes → should fall back to avatar_url
assert_eq!(
snapshot.profile.avatar_url.as_deref(),
Some("https://example.com/avatar.png")
);
assert!(snapshot.profile.avatar_data_url.is_none());
}
#[test]
fn avatar_inlined_when_under_size_limit() {
let record = minimal_record();
let small_png = make_png_with_text("k", "v").unwrap();
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png));
assert!(snapshot.profile.avatar_data_url.is_some());
assert!(snapshot.profile.avatar_url.is_none());
}
#[test]
fn avatar_url_fallback_when_over_size_limit() {
let mut record = minimal_record();
record.avatar_url = Some("https://example.com/big.png".to_string());
// Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec.
let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1];
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes));
assert!(snapshot.profile.avatar_data_url.is_none());
assert_eq!(
snapshot.profile.avatar_url.as_deref(),
Some("https://example.com/big.png")
);
}
// ── Format/version validation ─────────────────────────────────────────────
#[test]
fn invalid_format_discriminator_is_rejected() {
let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None);
snapshot.format = "not-a-buzz-snapshot".to_string();
let bytes = serde_json::to_vec(&snapshot).unwrap();
let result = decode_snapshot_json(&bytes);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unsupported snapshot format"));
}
#[test]
fn unsupported_version_is_rejected() {
let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None);
snapshot.version = 99;
let bytes = serde_json::to_vec(&snapshot).unwrap();
let result = decode_snapshot_json(&bytes);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unsupported snapshot version"));
}

View File

@@ -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,

View File

@@ -0,0 +1,170 @@
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
// cardMintStore drives the non-blocking mint flow: the dialog dispatches a
// job and closes; the composer chip, completion toast, and viewer all read
// this store. These tests exercise the job lifecycle with an injected mintFn
// (never the real Tauri command).
//
// The store imports sonner (toast); calling toast outside a mounted <Toaster>
// only queues, so no DOM is required.
import {
dismissCardMintJob,
getCardGalleryOpen,
getCardMintJobs,
getCardViewerState,
closeCardViewer,
openCardViewer,
resetCardMintStore,
runCardMintJob,
setCardGalleryOpen,
subscribeCardMintStore,
viewMintedCardJob,
} from "./cardMintStore.ts";
const CARD = {
cardPngBase64: "aGVsbG8=",
fileName: "eva.agent.png",
designerNotes: "notes",
locked: false,
memoryLevel: "none",
};
const INPUT = { agentId: "agent-1", agentName: "Eva" };
describe("cardMintStore", () => {
beforeEach(() => {
resetCardMintStore();
});
it("forwards the mint input — including memoryLevel — to mintFn", async () => {
const seen = [];
await runCardMintJob(
{ ...INPUT, styleNotes: "stormy", lock: true, memoryLevel: "core" },
(...args) => {
seen.push(args);
return Promise.resolve({ ...CARD, memoryLevel: "core" });
},
);
assert.deepEqual(seen, [["agent-1", "stormy", true, "core"]]);
// Omitted memoryLevel stays undefined so Rust applies its "none" default.
await runCardMintJob(INPUT, (...args) => {
seen.push(args);
return Promise.resolve(CARD);
});
assert.deepEqual(seen[1], ["agent-1", undefined, undefined, undefined]);
});
it("tracks a successful mint through minting → done", async () => {
let resolveMint;
const pending = new Promise((resolve) => {
resolveMint = resolve;
});
const run = runCardMintJob(INPUT, () => pending);
let jobs = getCardMintJobs();
assert.equal(jobs.length, 1);
assert.equal(jobs[0].phase, "minting");
assert.equal(jobs[0].input.agentName, "Eva");
assert.equal(jobs[0].card, null);
resolveMint(CARD);
await run;
jobs = getCardMintJobs();
assert.equal(jobs.length, 1);
assert.equal(jobs[0].phase, "done");
assert.deepEqual(jobs[0].card, CARD);
assert.equal(jobs[0].error, null);
});
it("records the error message on a failed mint", async () => {
await runCardMintJob(INPUT, () => Promise.reject(new Error("boom")));
const jobs = getCardMintJobs();
assert.equal(jobs.length, 1);
assert.equal(jobs[0].phase, "error");
assert.equal(jobs[0].error, "boom");
assert.equal(jobs[0].card, null);
});
it("strips the NO_OPENAI_KEY wire prefix from mint errors", async () => {
await runCardMintJob(INPUT, () =>
Promise.reject(new Error("NO_OPENAI_KEY: No OPENAI_API_KEY found.")),
);
assert.equal(getCardMintJobs()[0].error, "No OPENAI_API_KEY found.");
});
it("viewMintedCardJob moves a done job into the viewer and clears the chip", async () => {
await runCardMintJob(INPUT, () => Promise.resolve(CARD));
const jobId = getCardMintJobs()[0].jobId;
viewMintedCardJob(jobId);
assert.equal(getCardMintJobs().length, 0);
const viewer = getCardViewerState();
assert.ok(viewer);
assert.equal(viewer.agentName, "Eva");
assert.deepEqual(viewer.card, CARD);
// Fresh mints keep their input so the viewer can reroll.
assert.deepEqual(viewer.remint, INPUT);
});
it("viewMintedCardJob ignores jobs that are still minting", () => {
let resolveMint;
void runCardMintJob(
INPUT,
() => new Promise((resolve) => (resolveMint = resolve)),
);
const jobId = getCardMintJobs()[0].jobId;
viewMintedCardJob(jobId);
assert.equal(getCardMintJobs().length, 1);
assert.equal(getCardViewerState(), null);
resolveMint(CARD); // avoid a dangling promise
});
it("dismissCardMintJob removes only the named job", async () => {
await runCardMintJob(INPUT, () => Promise.reject(new Error("a")));
await runCardMintJob({ agentId: "agent-2", agentName: "Wren" }, () =>
Promise.reject(new Error("b")),
);
const [first, second] = getCardMintJobs();
dismissCardMintJob(first.jobId);
const jobs = getCardMintJobs();
assert.equal(jobs.length, 1);
assert.equal(jobs[0].jobId, second.jobId);
});
it("openCardViewer/closeCardViewer manage archive views without remint", () => {
openCardViewer({ card: CARD, agentName: "Eva", remint: null });
assert.equal(getCardViewerState()?.remint, null);
closeCardViewer();
assert.equal(getCardViewerState(), null);
});
it("gallery open flag toggles and notifies subscribers", () => {
let notified = 0;
const unsubscribe = subscribeCardMintStore(() => {
notified += 1;
});
setCardGalleryOpen(true);
assert.equal(getCardGalleryOpen(), true);
setCardGalleryOpen(true); // no-op must not notify
setCardGalleryOpen(false);
assert.equal(getCardGalleryOpen(), false);
assert.equal(notified, 2);
unsubscribe();
});
it("concurrent jobs keep distinct snapshots (referential updates)", async () => {
const before = getCardMintJobs();
await runCardMintJob(INPUT, () => Promise.resolve(CARD));
const after = getCardMintJobs();
assert.notEqual(before, after, "snapshot identity must change on update");
});
});

View File

@@ -0,0 +1,227 @@
import * as React from "react";
import { toast } from "sonner";
import {
mintAgentCard,
NO_OPENAI_KEY_PREFIX,
type MintedAgentCard,
type SnapshotMemoryLevel,
} from "@/shared/api/tauriPersonas";
/**
* Module store for agent-card mints (`useSyncExternalStore` pattern, same as
* `avatarPresentationStore`).
*
* A mint is one stateless ~2–3 minute Rust call. Owning the in-flight promise
* here — instead of inside the mint dialog — is what makes the dialog
* non-blocking: it dispatches and closes, the composer activity rail shows a
* live "Minting card…" chip, and completion lands as a clickable toast plus a
* persistent "card ready" chip, none of which need the dialog mounted.
*/
/** Everything needed to run (or re-run) one mint. */
export type CardMintInput = {
agentId: string;
agentName: string;
styleNotes?: string;
lock?: boolean;
/** Memory to embed in the card's snapshot. Omitted = "none". */
memoryLevel?: SnapshotMemoryLevel;
};
export type CardMintJob = {
jobId: string;
input: CardMintInput;
phase: "minting" | "done" | "error";
/** Populated when phase is "done". */
card: MintedAgentCard | null;
/** Populated when phase is "error". */
error: string | null;
startedAt: number;
};
/** Card content shown by the global viewer dialog. */
export type CardViewerState = {
card: MintedAgentCard;
agentName: string;
/**
* Present when the card can be rerolled (fresh mints carry their input;
* archive views do not — the original style notes are gone).
*/
remint: CardMintInput | null;
/**
* Monotonic per-open sequence, assigned by the store. The viewer keys its
* content on this so switching cards remounts (resetting recipients/menu
* state) — card bytes can't serve as the key because every card PNG shares
* the same header prefix and dimensions.
*/
viewerSeq: number;
};
let jobs: CardMintJob[] = [];
let viewer: CardViewerState | null = null;
let galleryOpen = false;
const listeners = new Set<() => void>();
let nextJobId = 1;
let nextViewerSeq = 1;
function emitChange(): void {
for (const listener of listeners) listener();
}
function updateJob(jobId: string, patch: Partial<CardMintJob>): void {
jobs = jobs.map((job) => (job.jobId === jobId ? { ...job, ...patch } : job));
emitChange();
}
/**
* Run one mint as a background job. `mintFn` is injectable for tests; the
* public `startCardMint` binds the real Tauri command.
*/
export async function runCardMintJob(
input: CardMintInput,
mintFn: (
id: string,
styleNotes?: string,
lock?: boolean,
memoryLevel?: SnapshotMemoryLevel,
) => Promise<MintedAgentCard>,
): Promise<void> {
const jobId = `card-mint-${nextJobId++}`;
jobs = [
...jobs,
{
jobId,
input,
phase: "minting",
card: null,
error: null,
startedAt: Date.now(),
},
];
emitChange();
try {
const card = await mintFn(
input.agentId,
input.styleNotes,
input.lock,
input.memoryLevel,
);
updateJob(jobId, { phase: "done", card });
toast.success(`${input.agentName}'s card is ready`, {
action: {
label: "View card",
onClick: () => viewMintedCardJob(jobId),
},
duration: 10_000,
});
} catch (error) {
let message = error instanceof Error ? error.message : "Card mint failed.";
if (message.startsWith(NO_OPENAI_KEY_PREFIX)) {
// The dialog pre-checks the key, so this only happens when the key was
// removed between dialog-open and mint. The dialog's key-setup panel is
// long gone — surface a plain instruction instead of the wire prefix.
message = message.slice(NO_OPENAI_KEY_PREFIX.length).trim();
}
updateJob(jobId, { phase: "error", error: message });
toast.error(`Minting ${input.agentName}'s card failed`, {
description: message,
});
}
}
/** Start a mint in the background. Fire-and-forget; state flows via the store. */
export function startCardMint(input: CardMintInput): void {
void runCardMintJob(input, mintAgentCard);
}
/** Open the finished card of a job in the viewer and clear its rail chip. */
export function viewMintedCardJob(jobId: string): void {
const job = jobs.find((candidate) => candidate.jobId === jobId);
if (job?.phase !== "done" || !job.card) return;
viewer = {
card: job.card,
agentName: job.input.agentName,
remint: job.input,
viewerSeq: nextViewerSeq++,
};
jobs = jobs.filter((candidate) => candidate.jobId !== jobId);
emitChange();
}
/** Remove a job chip (used for error dismissal). */
export function dismissCardMintJob(jobId: string): void {
const next = jobs.filter((candidate) => candidate.jobId !== jobId);
if (next.length === jobs.length) return;
jobs = next;
emitChange();
}
/** Open the viewer on an arbitrary card (e.g. one loaded from the archive). */
export function openCardViewer(
state: Omit<CardViewerState, "viewerSeq">,
): void {
viewer = { ...state, viewerSeq: nextViewerSeq++ };
emitChange();
}
export function closeCardViewer(): void {
if (!viewer) return;
viewer = null;
emitChange();
}
export function setCardGalleryOpen(open: boolean): void {
if (galleryOpen === open) return;
galleryOpen = open;
emitChange();
}
export function resetCardMintStore(): void {
jobs = [];
viewer = null;
galleryOpen = false;
emitChange();
}
export function getCardMintJobs(): CardMintJob[] {
return jobs;
}
export function getCardViewerState(): CardViewerState | null {
return viewer;
}
export function getCardGalleryOpen(): boolean {
return galleryOpen;
}
export function subscribeCardMintStore(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useCardMintJobs(): CardMintJob[] {
return React.useSyncExternalStore(
subscribeCardMintStore,
getCardMintJobs,
getCardMintJobs,
);
}
export function useCardViewerState(): CardViewerState | null {
return React.useSyncExternalStore(
subscribeCardMintStore,
getCardViewerState,
getCardViewerState,
);
}
export function useCardGalleryOpen(): boolean {
return React.useSyncExternalStore(
subscribeCardMintStore,
getCardGalleryOpen,
getCardGalleryOpen,
);
}

View File

@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
// Pins the gallery's query-state boundary: a rejected `list_agent_cards`
// MUST surface as an error state — never as "No cards yet". `data` falls
// back to `[]` at the call site, so an emptiness-first branch would render
// a permissions/IPC failure as a false empty archive of paid cards.
import { agentCardGalleryViewState } from "./agentCardGalleryState.ts";
describe("agentCardGalleryViewState", () => {
it("maps a rejected query to an error state with the message, not empty", () => {
const state = agentCardGalleryViewState({
isLoading: false,
isError: true,
error: new Error("cards directory unreadable"),
data: undefined,
});
assert.deepEqual(state, {
kind: "error",
message: "cards directory unreadable",
});
});
it("error wins even when data has fallen back to an empty array", () => {
// react-query keeps `data: undefined` on first failure, but the call
// site coalesces to []. Guard the exact shape that caused the bug.
const state = agentCardGalleryViewState({
isLoading: false,
isError: true,
error: new Error("ipc failure"),
data: [],
});
assert.equal(state.kind, "error");
});
it("stringifies non-Error rejections", () => {
const state = agentCardGalleryViewState({
isLoading: false,
isError: true,
error: "permission denied",
data: undefined,
});
assert.deepEqual(state, { kind: "error", message: "permission denied" });
});
it("null/undefined rejections still produce a message", () => {
const state = agentCardGalleryViewState({
isLoading: false,
isError: true,
error: null,
data: undefined,
});
assert.deepEqual(state, { kind: "error", message: "unknown error" });
});
it("loading while fetching", () => {
const state = agentCardGalleryViewState({
isLoading: true,
isError: false,
error: null,
data: undefined,
});
assert.deepEqual(state, { kind: "loading" });
});
it("no data yet (not loading, not error) stays loading, not empty", () => {
const state = agentCardGalleryViewState({
isLoading: false,
isError: false,
error: null,
data: undefined,
});
assert.deepEqual(state, { kind: "loading" });
});
it("resolved and empty is the only path to the empty state", () => {
const state = agentCardGalleryViewState({
isLoading: false,
isError: false,
error: null,
data: [],
});
assert.deepEqual(state, { kind: "empty" });
});
it("resolved with cards renders cards", () => {
const state = agentCardGalleryViewState({
isLoading: false,
isError: false,
error: null,
data: [{ storedFileName: "a.png" }],
});
assert.deepEqual(state, { kind: "cards" });
});
});

View File

@@ -0,0 +1,35 @@
/**
* View-state selection for the minted-card gallery.
*
* Kept as a pure function so the error boundary is testable without a DOM:
* a rejected `list_agent_cards` query MUST surface as an error state, never
* as an empty archive — "No cards yet" on a permissions/IPC failure tells
* someone their paid, persisted cards do not exist.
*/
export type AgentCardGalleryViewState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "empty" }
| { kind: "cards" };
export function agentCardGalleryViewState(query: {
isLoading: boolean;
isError: boolean;
error: unknown;
data: readonly unknown[] | undefined;
}): AgentCardGalleryViewState {
// Error wins over everything: `data` falls back to `[]` at the call site,
// so checking emptiness first would render a failure as a false empty.
if (query.isError) {
const message =
query.error instanceof Error
? query.error.message
: String(query.error ?? "unknown error");
return { kind: "error", message };
}
if (query.isLoading || query.data === undefined) {
return { kind: "loading" };
}
return query.data.length === 0 ? { kind: "empty" } : { kind: "cards" };
}

View File

@@ -0,0 +1,364 @@
import * as React from "react";
import {
AlertCircle,
Brain,
ExternalLink,
GalleryVerticalEnd,
KeyRound,
Lock,
Sparkles,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { openUrl } from "@tauri-apps/plugin-opener";
import { toast } from "sonner";
import {
setCardGalleryOpen,
startCardMint,
} from "@/features/agents/cardMintStore";
import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig";
import {
cardMintKeyStatus,
cardMintSaveOpenaiKey,
type SnapshotMemoryLevel,
} from "@/shared/api/tauriPersonas";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
import { SnapshotOptionMenu } from "./SnapshotOptionMenu";
const OPENAI_KEYS_URL = "https://platform.openai.com/api-keys";
/** Same three levels as snapshot export; "Agent only" is the safe default. */
const MEMORY_LEVELS: { value: SnapshotMemoryLevel; label: string }[] = [
{ value: "none", label: "Agent only" },
{ value: "core", label: "Agent + core memory" },
{ value: "everything", label: "Agent + all memories" },
];
/**
* The free alternative, as an action: ordinary snapshot export shares the
* same importable agent without card art or API spend. Rendered in both the
* key-setup panel and the normal pre-mint form (the cost disclosure and its
* escape hatch must be visible BEFORE any spend, not only during onboarding).
*/
function FreeSharePathRow({
disabled,
onExportInstead,
}: {
disabled: boolean;
onExportInstead?: () => void;
}) {
return (
<div
className="flex items-center justify-between gap-3"
data-testid="agent-card-free-path"
>
<p className="text-xs text-muted-foreground">
Don’t want to spend money? Ordinary export shares the same importable
agent — free, just without the card art.
</p>
{onExportInstead ? (
<Button
className="shrink-0"
data-testid="agent-card-export-instead"
disabled={disabled}
onClick={onExportInstead}
size="sm"
variant="outline"
>
Share without card art
</Button>
) : null}
</div>
);
}
/**
* Mint-a-trading-card dialog — the pre-mint half only: key setup (when
* needed) → optional style notes → "Mint card". Minting itself runs as a
* background job in `cardMintStore`: this dialog dispatches and closes, the
* composer activity rail shows live status, and the finished card opens in
* the global `AgentCardViewerDialog` (preview, reroll, save, share).
*
* The saved PNG carries the agent's `buzz_agent_snapshot` chunk, so sharing
* the card shares an importable agent (fresh identity, never secrets; memory
* only when the owner opts in below — plaintext unless the card is locked).
* All snapshot construction and verification happens in Rust.
*/
export function AgentCardMintDialog({
agentId,
agentName,
canLock,
onExportInstead,
onOpenChange,
}: {
/** Instance pubkey or definition slug — same resolution as snapshot export. */
agentId: string;
agentName: string;
/**
* True when the agent has a linked instance (a keypair to lock to).
* Locking is disabled — with an explanation — for bare definitions.
*/
canLock: boolean;
/**
* Free alternative: close this dialog and open the ordinary snapshot
* export flow (no API spend). Omitted = the action is not rendered.
*/
onExportInstead?: () => void;
onOpenChange: (open: boolean) => void;
}) {
const [styleNotes, setStyleNotes] = React.useState("");
const [lockCard, setLockCard] = React.useState(false);
const [memoryLevel, setMemoryLevel] =
React.useState<SnapshotMemoryLevel>("none");
const [keyDraft, setKeyDraft] = React.useState("");
const queryClient = useQueryClient();
const effectiveLock = canLock && lockCard;
// Embedded memory is plaintext in an unlocked card — and unlocked cards
// are meant to be shared. A locked card encrypts the whole manifest to the
// (owner, agent) pair, so the plaintext warning would be false there.
const showMemoryWarning = memoryLevel !== "none" && !effectiveLock;
// Whether a key already resolves through the agent's env layering. While
// unknown (loading/error) we show the normal mint form — the mint itself
// still fails cleanly if no key exists.
const keyStatusQuery = useQuery({
queryKey: ["cardMintKeyStatus", agentId],
queryFn: () => cardMintKeyStatus(agentId),
});
const needsKey = keyStatusQuery.data === false;
// Save the pasted key into the global Agent Defaults env — the same single
// source of truth every agent inherits. Narrow Rust seam: validated
// single-key merge, never restarts running agents (the mint re-reads
// config per call, so no restart is needed for minting).
const saveKeyMutation = useMutation({
mutationFn: (key: string) => cardMintSaveOpenaiKey(key),
onSuccess: () => {
queryClient.setQueryData(["cardMintKeyStatus", agentId], true);
// The Agent Defaults editor caches the whole config — refetch it so a
// later-opened settings view shows the key we just wrote.
void queryClient.invalidateQueries({
queryKey: globalAgentConfigQueryKey,
});
setKeyDraft("");
toast.success(
"API key saved to your agent defaults. Running agents pick it up on their next restart.",
);
},
onError: (error) =>
toast.error(typeof error === "string" ? error : "Couldn't save the key."),
});
function beginMint() {
// Dispatch to the background store and close: the composer rail shows
// "Minting card…" and the completion toast opens the viewer.
startCardMint({
agentId,
agentName,
styleNotes: styleNotes.trim() || undefined,
lock: effectiveLock,
memoryLevel: canLock ? memoryLevel : "none",
});
onOpenChange(false);
}
return (
<Dialog onOpenChange={onOpenChange} open>
<DialogContent className="max-w-md" data-testid="agent-card-mint-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
{`Create ${agentName}'s card`}
</DialogTitle>
<DialogDescription>
Mint a collectible trading card that doubles as a shareable,
importable copy of this agent.
</DialogDescription>
</DialogHeader>
{needsKey ? (
<div
className="flex flex-col gap-4"
data-testid="agent-card-key-setup"
>
<div className="flex flex-col gap-2 rounded-md border border-border p-3">
<span className="flex items-center gap-1.5 text-sm font-medium">
<KeyRound className="h-3.5 w-3.5" />
One-time setup: OpenAI API key
</span>
<p className="text-xs text-muted-foreground">
Minting a card costs money — it generates the art and card text
through the OpenAI API with your key (typically well under a
dollar per mint, billed by OpenAI). The key is saved to your
agent defaults, so you only do this once.
</p>
<Button
className="w-fit px-0 text-xs"
data-testid="agent-card-key-link"
onClick={() =>
void openUrl(OPENAI_KEYS_URL).catch(() => {
toast.error("Failed to open link");
})
}
size="sm"
variant="link"
>
<ExternalLink className="mr-1 h-3 w-3" />
Get a key at platform.openai.com
</Button>
<Input
autoFocus
data-testid="agent-card-key-input"
disabled={saveKeyMutation.isPending}
onChange={(e) => setKeyDraft(e.target.value)}
placeholder="sk-…"
type="password"
value={keyDraft}
/>
</div>
<FreeSharePathRow
disabled={saveKeyMutation.isPending}
onExportInstead={onExportInstead}
/>
<div className="flex justify-end">
<Button
data-testid="agent-card-key-save"
disabled={
saveKeyMutation.isPending || keyDraft.trim().length === 0
}
onClick={() => saveKeyMutation.mutate(keyDraft.trim())}
>
<KeyRound className="mr-2 h-4 w-4" />
{saveKeyMutation.isPending ? "Saving…" : "Save key & continue"}
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Textarea
onChange={(e) => setStyleNotes(e.target.value)}
placeholder="Optional style notes for the art and card text"
rows={3}
value={styleNotes}
/>
<p className="text-xs text-muted-foreground">
E.g. art “stormy night, lightning motif” or ability “Verify —
scry 2”. Your directions take priority; anything you leave open
is designed for you.
</p>
</div>
<div className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Brain className="h-3.5 w-3.5" />
Memories
</span>
<span className="text-xs text-muted-foreground">
{canLock
? "Choose how much memory the embedded agent carries."
: "Including memory needs a linked agent instance — start this agent once to enable it."}
</span>
</div>
{canLock ? (
<SnapshotOptionMenu
ariaLabel="Memories"
className="font-medium text-foreground"
onValueChange={(value) =>
setMemoryLevel(value as SnapshotMemoryLevel)
}
options={MEMORY_LEVELS}
testId="agent-card-memory-trigger"
value={memoryLevel}
/>
) : (
<span
className="inline-flex h-8 w-auto shrink-0 items-center justify-end px-2 text-sm font-medium"
data-testid="agent-card-memory-value"
>
Agent only
</span>
)}
</div>
{showMemoryWarning ? (
<div
className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400"
data-testid="agent-card-memory-warning"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Memory is stored as <strong>plaintext</strong> in the card,
readable by anyone who has the PNG — and cards are made for
sharing. Lock the card or only share it with people you trust.
</p>
</div>
) : null}
<div className="flex items-start justify-between gap-3 rounded-md border border-border p-3">
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Lock className="h-3.5 w-3.5" />
Lock card
</span>
<span className="text-xs text-muted-foreground">
{canLock
? "Encrypt the embedded agent so only you and this agent can import it. Anyone else sees just the image."
: "Locking needs a linked agent instance — start this agent once to enable it."}
</span>
</div>
<Switch
checked={canLock && lockCard}
data-testid="agent-card-lock-toggle"
disabled={!canLock}
onCheckedChange={setLockCard}
/>
</div>
<p
className="text-xs text-muted-foreground"
data-testid="agent-card-cost-note"
>
Minting calls the OpenAI API with your key and costs money —
typically well under a dollar per mint, billed by OpenAI. It runs
in the background (takes a few minutes); you can keep using Buzz
while it works.
</p>
<FreeSharePathRow
disabled={false}
onExportInstead={onExportInstead}
/>
<div className="flex items-center justify-between gap-2">
<Button
className="px-0 text-xs"
data-testid="agent-card-open-gallery"
onClick={() => {
onOpenChange(false);
setCardGalleryOpen(true);
}}
size="sm"
variant="link"
>
<GalleryVerticalEnd className="mr-1 h-3 w-3" />
View minted cards
</Button>
<Button onClick={beginMint} data-testid="agent-card-mint">
<Sparkles className="mr-2 h-4 w-4" />
Mint card
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,369 @@
import * as React from "react";
import { Download, Lock, RefreshCw, Send, Sparkles } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import {
closeCardViewer,
openCardViewer,
setCardGalleryOpen,
startCardMint,
useCardGalleryOpen,
useCardViewerState,
type CardMintInput,
} from "@/features/agents/cardMintStore";
import { agentCardGalleryViewState } from "@/features/agents/lib/agentCardGalleryState";
import {
useOpenDmMutation,
useUpsertCachedChannel,
} from "@/features/channels/hooks";
import {
listAgentCards,
loadAgentCard,
saveAgentCard,
type ArchivedAgentCard,
type MintedAgentCard,
} from "@/shared/api/tauriPersonas";
import type { UserSearchResult } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import {
MediaContextMenu,
type MediaContextMenuPosition,
useDismissMediaContextMenu,
} from "@/shared/ui/markdown/MediaContextMenu";
import { PersonaShareRecipients } from "./PersonaShareRecipients";
import { useSnapshotSendController } from "./useSnapshotSendController";
/** Decode base64 card bytes into the number[] shape the upload path expects. */
function cardBytesFromBase64(b64: string): number[] {
return Array.from(atob(b64), (char) => char.charCodeAt(0));
}
/**
* Global card surfaces, mounted once in `AppShell` next to the other global
* dialogs and driven by `cardMintStore`: the finished-card viewer plus the
* previous-mints gallery.
*/
export function AgentCardDialogs() {
return (
<>
<AgentCardViewerDialog />
<AgentCardGalleryDialog />
</>
);
}
/**
* Global viewer for a finished agent trading card — the post-mint half of the
* old blocking mint dialog, now openable from anywhere (completion toast,
* composer "card ready" chip, or the previous-cards gallery). Mounted once in
* `AppShell` next to the other global dialogs and driven by `cardMintStore`.
*
* The card image carries the same right-click download menu inline channel
* images have (`MediaContextMenu`), but routed through `save_agent_card`:
* `download_image` only accepts relay `/media/` URLs, and this card exists
* only as local base64.
*/
export function AgentCardViewerDialog() {
const state = useCardViewerState();
// Keyed remount resets recipients/menu state when a different card opens.
if (!state) return null;
return (
<AgentCardViewerContent
key={state.viewerSeq}
agentName={state.agentName}
card={state.card}
remint={state.remint}
/>
);
}
function AgentCardViewerContent({
agentName,
card,
remint,
}: {
agentName: string;
card: MintedAgentCard;
remint: CardMintInput | null;
}) {
const [recipients, setRecipients] = React.useState<UserSearchResult[]>([]);
const [menu, setMenu] = React.useState<MediaContextMenuPosition | null>(null);
const closeMenu = React.useCallback(() => setMenu(null), []);
useDismissMediaContextMenu(Boolean(menu), closeMenu);
const sendController = useSnapshotSendController(true);
const openDmMutation = useOpenDmMutation();
const upsertCachedChannel = useUpsertCachedChannel();
const saveMutation = useMutation({
mutationFn: () => saveAgentCard(card.cardPngBase64, card.fileName),
onSuccess: (saved) => {
if (saved) {
toast.success(
`Saved ${agentName}'s card. Share it — the card IS the agent.`,
);
}
},
onError: (error: Error) => toast.error(error.message),
});
const isSending =
sendController.state.phase !== "idle" &&
sendController.state.phase !== "done" &&
sendController.state.phase !== "error";
const isBusy = saveMutation.isPending || isSending;
async function sendToRecipients() {
if (recipients.length === 0) return;
const sent = await sendController.beginSend(
async () => ({
fileBytes: cardBytesFromBase64(card.cardPngBase64),
fileName: card.fileName,
}),
async () => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys: recipients.map((recipient) => recipient.pubkey),
});
await upsertCachedChannel(directMessage);
return directMessage.id;
},
agentName,
);
if (sent) {
toast.success(`Sent ${agentName}'s card.`);
closeCardViewer();
} else if (sent === false) {
toast.error("Couldn’t send the card. Try again.");
}
}
return (
<Dialog onOpenChange={(open) => !open && closeCardViewer()} open>
<DialogContent
className="max-w-md"
data-testid="agent-card-viewer-dialog"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
{`${agentName}'s card`}
</DialogTitle>
<DialogDescription>
{card.locked
? "This card is locked: only you and the agent can import it. Anyone else sees just the image."
: card.memoryLevel === "none"
? "The card carries the agent — anyone who imports this PNG gets a working copy (config only, fresh identity, no memories)."
: `The card carries the agent — anyone who imports this PNG gets a working copy (fresh identity) including its ${card.memoryLevel === "core" ? "core memory" : "memories"}, stored as plaintext.`}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<img
alt={`${agentName} trading card`}
className="mx-auto max-h-[28rem] rounded-lg border shadow-lg"
data-testid="agent-card-preview"
onContextMenu={(event) => {
event.preventDefault();
setMenu({ x: event.clientX, y: event.clientY });
}}
src={`data:image/png;base64,${card.cardPngBase64}`}
/>
{menu ? (
<MediaContextMenu
dataAttributes={["data-agent-card-context-menu"]}
items={[
{
label: "Download card",
onSelect: () => {
closeMenu();
saveMutation.mutate();
},
},
]}
position={menu}
/>
) : null}
<PersonaShareRecipients
disabled={isBusy || !sendController.isDmSafetyReady}
excludedPubkeys={
sendController.relaySelfPubkey
? [sendController.relaySelfPubkey]
: []
}
onSelectionChange={setRecipients}
open
selectedUsers={recipients}
testIdPrefix="agent-card-share"
/>
<div className="flex justify-end gap-2">
{remint ? (
<Button
disabled={isBusy}
data-testid="agent-card-reroll"
onClick={() => {
// Reroll = a fresh background mint with the same input; the
// viewer closes and the composer chip takes over.
startCardMint(remint);
closeCardViewer();
}}
variant="outline"
>
<RefreshCw className="mr-2 h-4 w-4" />
Reroll
</Button>
) : null}
<Button
disabled={isBusy}
onClick={() => saveMutation.mutate()}
data-testid="agent-card-save"
variant={recipients.length > 0 ? "outline" : "default"}
>
<Download className="mr-2 h-4 w-4" />
Save card
</Button>
{recipients.length > 0 ? (
<Button
disabled={isBusy}
onClick={() => void sendToRecipients()}
data-testid="agent-card-send"
>
<Send className="mr-2 h-4 w-4" />
{isSending ? "Sending…" : "Send"}
</Button>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
);
}
/**
* Gallery of all previously minted cards, newest first. Every successful mint
* is archived to the app data dir by Rust; clicking a tile loads the full PNG
* and opens it in the viewer (save/share, no reroll — the original style
* notes are not retained).
*/
export function AgentCardGalleryDialog() {
const open = useCardGalleryOpen();
if (!open) return null;
return <AgentCardGalleryContent />;
}
function AgentCardGalleryContent() {
const cardsQuery = useQuery({
queryKey: ["agentCardArchive"],
queryFn: listAgentCards,
});
const openMutation = useMutation({
mutationFn: async (entry: ArchivedAgentCard) => {
const cardPngBase64 = await loadAgentCard(entry.storedFileName);
return { entry, cardPngBase64 };
},
onSuccess: ({ entry, cardPngBase64 }) => {
setCardGalleryOpen(false);
openCardViewer({
agentName: entry.agentName,
card: {
cardPngBase64,
fileName: entry.fileName,
designerNotes: entry.designerNotes,
locked: entry.locked,
memoryLevel: entry.memoryLevel,
},
remint: null,
});
},
onError: (error: Error) => toast.error(error.message),
});
const cards = cardsQuery.data ?? [];
const viewState = agentCardGalleryViewState(cardsQuery);
return (
<Dialog onOpenChange={(open) => setCardGalleryOpen(open)} open>
<DialogContent
className="max-w-lg"
data-testid="agent-card-gallery-dialog"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
Minted cards
</DialogTitle>
<DialogDescription>
Every card you’ve minted, newest first. Click one to view, save, or
share it.
</DialogDescription>
</DialogHeader>
{viewState.kind === "loading" ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Loading cards…
</p>
) : viewState.kind === "error" ? (
<div
className="flex flex-col items-center gap-3 py-8"
data-testid="agent-card-gallery-error"
role="alert"
>
<p className="text-center text-sm text-destructive">
Couldn’t load your minted cards: {viewState.message}
</p>
<Button
disabled={cardsQuery.isFetching}
onClick={() => void cardsQuery.refetch()}
size="sm"
variant="outline"
>
<RefreshCw className="mr-2 h-4 w-4" />
{cardsQuery.isFetching ? "Retrying…" : "Retry"}
</Button>
</div>
) : viewState.kind === "empty" ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No cards yet — mint one from an agent’s profile.
</p>
) : (
<div className="grid max-h-[60vh] grid-cols-3 gap-3 overflow-y-auto pr-1">
{cards.map((entry) => (
<button
className="group flex flex-col gap-1.5 text-left"
data-testid="agent-card-gallery-item"
disabled={openMutation.isPending}
key={entry.storedFileName}
onClick={() => openMutation.mutate(entry)}
type="button"
>
{entry.thumbJpegBase64 ? (
<img
alt={`${entry.agentName} trading card`}
className="aspect-2/3 w-full rounded-lg border object-cover shadow-sm transition-transform group-hover:scale-[1.02]"
src={`data:image/jpeg;base64,${entry.thumbJpegBase64}`}
/>
) : (
<div className="flex aspect-2/3 w-full items-center justify-center rounded-lg border bg-muted/40">
<Sparkles className="h-6 w-6 text-muted-foreground" />
</div>
)}
<span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
{entry.locked ? <Lock className="h-3 w-3 shrink-0" /> : null}
<span className="truncate">{entry.agentName}</span>
</span>
</button>
))}
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,4 +1,5 @@
import { useAgentManagement } from "@/features/agents/useAgentManagement";
import { AgentCardDialogs } from "./AgentCardViewerDialog";
import { AgentDialog } from "./AgentDialog";
import { SecretRevealDialog } from "./SecretRevealDialog";
@@ -55,6 +56,7 @@ export function AgentManagementDialogs() {
title="Edit agent"
/>
) : null}
<AgentCardDialogs />
</>
);
}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { AlertCircle, Upload } from "lucide-react";
import { AlertCircle, Lock, Upload } from "lucide-react";
import type {
AgentSnapshotImportPreview,
@@ -72,7 +72,7 @@ export function AgentSnapshotImportDialog({
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-w-md"
className="max-h-[85vh] max-w-2xl overflow-y-auto"
data-testid="agent-snapshot-import-dialog"
showCloseButton={false}
>
@@ -141,7 +141,7 @@ export function AgentSnapshotImportDialog({
// ── Preview body ──────────────────────────────────────────────────────────────
function PreviewBody({
export function PreviewBody({
preview,
hasMemory,
memoryLevelLabel,
@@ -159,19 +159,49 @@ function PreviewBody({
{/* Agent identity */}
<div className="space-y-1">
<p className="text-sm font-medium">{preview.displayName}</p>
{preview.systemPrompt ? (
<p className="line-clamp-3 text-xs text-muted-foreground">
{preview.systemPrompt}
</p>
) : null}
</div>
{/* Locked-card provenance: this file was encrypted to this machine's
keys and has been unlocked for review. */}
{preview.locked ? (
<div
className="flex items-start gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm"
data-testid="agent-snapshot-import-locked-notice"
>
<Lock className="mt-0.5 h-4 w-4 shrink-0" />
<p>
This card is <strong>locked</strong> — its agent is encrypted to the
original owner and agent keys. Your keys unlocked it; the full
decrypted payload is shown below.
</p>
</div>
) : null}
<AgentDefinitionMetadata
isBuiltIn={preview.isBuiltIn}
model={preview.model}
runtime={preview.runtime}
/>
{/* Portable behavior — never hide executable configuration behind a summary. */}
<section
className="space-y-2 rounded-md border border-border p-3"
data-testid="agent-snapshot-import-behavior"
>
<div>
<p className="text-sm font-medium">Agent instructions</p>
<p className="text-xs text-muted-foreground">
Review the instructions this agent will follow after import.
</p>
</div>
<pre
className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/60 p-3 text-xs"
data-testid="agent-snapshot-import-system-prompt"
>
{preview.systemPrompt || "No system prompt included."}
</pre>
</section>
<p className="text-sm text-muted-foreground">
A new agent will be created with a fresh keypair. The imported agent is
independent of the source — identity never travels.
@@ -211,9 +241,19 @@ function PreviewBody({
{preview.sourceAllowlistCount === 1 ? "y" : "ies"})
</p>
<p className="text-xs text-muted-foreground">
This snapshot includes a source-environment pubkey allowlist. Those
identities are not meaningful on your relay.
This snapshot includes source-environment pubkeys. Review every key
before choosing Keep; Clear is safer when you do not recognize them.
</p>
<ul
className="max-h-28 space-y-1 overflow-y-auto rounded bg-muted/60 p-2 font-mono text-xs"
data-testid="agent-snapshot-import-allowlist-values"
>
{preview.sourceAllowlist.map((pubkey) => (
<li className="break-all" key={pubkey}>
{pubkey}
</li>
))}
</ul>
<div className="flex flex-col gap-1.5">
<label className="flex cursor-pointer items-center gap-2">
<input
@@ -242,6 +282,22 @@ function PreviewBody({
</div>
</div>
) : null}
<details
className="rounded-md border border-border p-3"
data-testid="agent-snapshot-import-manifest"
>
<summary className="cursor-pointer text-sm font-medium">
Full embedded manifest
</summary>
<p className="mt-2 text-xs text-muted-foreground">
This is the complete portable payload decoded from the file. Secrets,
credentials, and source identity are not part of the snapshot format.
</p>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/60 p-3 text-xs">
{preview.manifestJson}
</pre>
</details>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { Loader2, Sparkles, X } from "lucide-react";
import {
dismissCardMintJob,
useCardMintJobs,
viewMintedCardJob,
} from "@/features/agents/cardMintStore";
import { Shimmer } from "@/shared/ui/Shimmer";
/**
* Composer-rail status for background card mints: a live "Minting card…"
* row while the ~2–3 minute mint runs, then a clickable "card is ready" row
* (opens the viewer) or a dismissible failure row. Rendered inside the
* composer dock's bottom activity rail next to agent-working/typing rows.
*/
export function CardMintComposerChip() {
const jobs = useCardMintJobs();
if (jobs.length === 0) return null;
return (
<div
className="flex min-w-0 flex-col justify-center gap-0.5"
data-testid="card-mint-composer-chip"
>
{jobs.map((job) => {
if (job.phase === "minting") {
return (
<span
className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"
data-testid="card-mint-status-minting"
key={job.jobId}
>
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin opacity-70" />
<Shimmer className="-my-px truncate py-px">
{`Minting ${job.input.agentName}’s card… (takes a few minutes)`}
</Shimmer>
</span>
);
}
if (job.phase === "done") {
return (
<button
className="flex min-w-0 items-center gap-1.5 text-xs text-primary hover:underline"
data-testid="card-mint-status-done"
key={job.jobId}
onClick={() => viewMintedCardJob(job.jobId)}
type="button"
>
<Sparkles className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{`${job.input.agentName}’s card is ready — view it`}
</span>
</button>
);
}
return (
<span
className="flex min-w-0 items-center gap-1.5 text-xs text-destructive"
data-testid="card-mint-status-error"
key={job.jobId}
>
<span className="truncate">
{`Minting ${job.input.agentName}’s card failed`}
</span>
<button
aria-label="Dismiss failed mint"
className="shrink-0 opacity-70 hover:opacity-100"
onClick={() => dismissCardMintJob(job.jobId)}
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</span>
);
})}
</div>
);
}

View File

@@ -9,7 +9,7 @@ import test from "node:test";
// memoryErrors strings are carried through to a bounded list element with the
// correct data-testid. No DOM or test renderer is needed.
import { ResultBody } from "./AgentSnapshotImportDialog.tsx";
import { PreviewBody, ResultBody } from "./AgentSnapshotImportDialog.tsx";
/**
* Walk a React element tree (breadth-first) and collect all elements that
@@ -71,6 +71,68 @@ function makeResult(overrides = {}) {
};
}
function makePreview(overrides = {}) {
return {
displayName: "TestBot",
systemPrompt: "Inspect every boundary before changing code.",
avatarUrl: null,
memoryLevel: "none",
memoryEntryCount: 0,
hasSourceAllowlist: true,
sourceAllowlistCount: 2,
sourceAllowlist: ["a".repeat(64), "b".repeat(64)],
manifestJson: '{\n "format": "buzz-agent-snapshot"\n}',
locked: false,
...overrides,
};
}
// ── preview transparency ──────────────────────────────────────────────────────
test("preview_body_discloses_prompt_allowlist_and_full_manifest", () => {
const preview = makePreview();
const element = PreviewBody({
preview,
hasMemory: false,
memoryLevelLabel: "none",
keepAllowlist: false,
onKeepAllowlistChange: () => {},
});
const allText = collectText(element).join(" ");
assert.ok(allText.includes(preview.systemPrompt));
for (const pubkey of preview.sourceAllowlist) {
assert.ok(allText.includes(pubkey), `missing allowlist pubkey ${pubkey}`);
}
assert.ok(allText.includes(preview.manifestJson));
assert.equal(
findAll(
element,
(n) => n.props?.["data-testid"] === "agent-snapshot-import-manifest",
).length,
1,
);
});
// ── locked-card provenance notice ─────────────────────────────────────────────
test("preview_body_shows_locked_notice_only_for_locked_cards", () => {
const lockedNotice = (locked) =>
findAll(
PreviewBody({
preview: makePreview({ locked }),
hasMemory: false,
memoryLevelLabel: "none",
keepAllowlist: false,
onKeepAllowlistChange: () => {},
}),
(n) => n.props?.["data-testid"] === "agent-snapshot-import-locked-notice",
);
assert.equal(lockedNotice(true).length, 1);
assert.equal(lockedNotice(false).length, 0);
});
// ── memory errors detail list ─────────────────────────────────────────────────
test("result_body_renders_memory_errors_list_with_test_id", () => {

View File

@@ -1,5 +1,7 @@
import type { ComponentProps } from "react";
import { CardMintComposerChip } from "@/features/agents/ui/CardMintComposerChip";
import { useCardMintJobs } from "@/features/agents/cardMintStore";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
import { ComposerActivityAccessory } from "@/features/messages/ui/ComposerActivityAccessory";
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
@@ -31,6 +33,7 @@ export function ChannelComposerActivityAccessory({
visible,
workingBotPubkeys,
}: ChannelComposerActivityAccessoryProps) {
const cardMintJobs = useCardMintJobs();
return (
<ComposerActivityAccessory
className="px-5"
@@ -38,6 +41,7 @@ export function ChannelComposerActivityAccessory({
visible={visible}
>
<div className="flex w-full items-center gap-2 overflow-visible pl-2">
{cardMintJobs.length > 0 ? <CardMintComposerChip /> : null}
{workingBotPubkeys.length > 0 ? (
<div className="flex min-w-0 flex-1 overflow-visible">
<BotActivityComposerAction

View File

@@ -36,6 +36,7 @@ import { useThreadViewMode } from "@/features/channels/lib/threadViewModePrefere
import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch";
import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence";
import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
import { useCardMintJobs } from "@/features/agents/cardMintStore";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory";
import {
@@ -408,7 +409,11 @@ export const ChannelPane = React.memo(function ChannelPane({
activeChannel?.id ?? null,
);
const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0;
const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity;
// Background card mints surface in the same rail ("Minting card…" chip),
// so they must also reserve the activity row.
const hasCardMintActivity = useCardMintJobs().length > 0;
const hasComposerBottomActivity =
hasComposerBotActivity || hasTypingActivity || hasCardMintActivity;
const threadComposerBotTypingPubkeys = React.useMemo(() => {
if (!openThreadHeadId) return [];
return botTypingEntries

View File

@@ -71,8 +71,10 @@ import { UserProfileAgentSettingsMenuSlot } from "@/features/profile/ui/UserProf
import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion";
import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields";
import { submitProfilePersonaDialog } from "@/features/profile/ui/UserProfilePanelPersonaSubmit";
import { UserProfilePersonaDialogs } from "@/features/profile/ui/UserProfilePersonaDialogs";
import { UserProfileSnapshotExportDialog } from "@/features/profile/ui/UserProfileSnapshotExportDialog";
import {
type CardMintTarget,
UserProfilePersonaDialogs,
} from "@/features/profile/ui/UserProfilePersonaDialogs";
import {
deriveProfileChannels,
type ProfilePanelTab,
@@ -181,6 +183,8 @@ export function UserProfilePanel({
React.useState<AgentPersona | null>(null);
const [personaToExportSnapshot, setPersonaToExportSnapshot] =
React.useState<AgentPersona | null>(null);
const [cardMintTarget, setCardMintTarget] =
React.useState<CardMintTarget | null>(null);
const personasQuery = usePersonasQuery();
const managedAgentsQuery = useManagedAgentsQuery({ enabled: true });
@@ -824,6 +828,19 @@ export function UserProfilePanel({
onOpenInstructions={() => setView("instructions")}
onTabChange={setTab}
onOpenDm={onOpenDm}
onCreateCard={
canManagePersona && resolvedPersona
? () =>
setCardMintTarget({
// Prefer the live instance pubkey; fall back to the
// persona/definition id (same resolution as export).
id: managedAgent?.pubkey ?? resolvedPersona.id,
name: resolvedPersona.displayName,
// Locking needs an instance keypair to encrypt to.
canLock: Boolean(managedAgent?.pubkey),
})
: undefined
}
presenceStatus={presenceStatus}
profile={profile}
pubkey={effectivePubkey}
@@ -921,6 +938,7 @@ export function UserProfilePanel({
const personaDialogs = (
<>
<UserProfilePersonaDialogs
cardMintTarget={cardMintTarget}
createError={
createPersonaMutation.error instanceof Error
? createPersonaMutation.error
@@ -933,8 +951,11 @@ export function UserProfilePanel({
updateManagedAgentMutation.isPending ||
createAgentMutation.isPending
}
linkedAgentPubkey={managedAgent?.pubkey ?? null}
personaDialogState={personaDialogState}
personaToDelete={personaToDelete}
personaToExportSnapshot={personaToExportSnapshot}
resolvedPersona={resolvedPersona}
runtimes={acpRuntimesQuery.data ?? []}
runtimesLoading={acpRuntimesQuery.isLoading}
updateError={
@@ -942,22 +963,16 @@ export function UserProfilePanel({
? updatePersonaMutation.error
: null
}
onCloseCardMint={() => setCardMintTarget(null)}
onCloseDelete={() => setPersonaToDelete(null)}
onCloseDialog={() => setPersonaDialogState(null)}
onCloseExportSnapshot={() => setPersonaToExportSnapshot(null)}
onConfirmDelete={(selectedPersona) => {
void handleConfirmDeletePersona(selectedPersona);
}}
onExportSnapshot={setPersonaToExportSnapshot}
onSubmit={handleSubmitPersona}
/>
{personaToExportSnapshot ? (
<UserProfileSnapshotExportDialog
linkedAgentPubkey={managedAgent?.pubkey ?? null}
onOpenChange={(open) => {
if (!open) setPersonaToExportSnapshot(null);
}}
persona={personaToExportSnapshot}
/>
) : null}
</>
);
return (

View File

@@ -1,19 +1,11 @@
import * as React from "react";
import type { LucideIcon } from "lucide-react";
import {
ArrowUpRight,
ChevronDown,
ChevronUp,
CircleAlert,
MessageSquare,
Pencil,
Play,
RefreshCw,
Square,
UserMinus,
UserPlus,
} from "lucide-react";
import { toast } from "sonner";
import { MemorySection } from "@/features/agent-memory/ui/MemorySection";
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
@@ -44,19 +36,20 @@ import {
STATUS_DOT_MASK_CURVE,
} from "@/features/profile/ui/MaskedAvatarBadgeFrame";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
ProfilePersonaPrimaryActions,
ProfilePrimaryActions,
} from "@/features/profile/ui/UserProfilePrimaryActions";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
import { Spinner } from "@/shared/ui/spinner";
import type {
ProfileChannelLink,
ProfilePanelTab,
} from "@/features/profile/ui/UserProfilePanelUtils";
import { useFeatureEnabled } from "@/shared/features";
import { cn } from "@/shared/lib/cn";
import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
import { Badge } from "@/shared/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
export { AgentInstructionsFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails";
@@ -105,6 +98,8 @@ export type ProfileSummaryViewProps = {
onOpenInstructions: () => void;
onTabChange: (tab: ProfilePanelTab, options?: { replace?: boolean }) => void;
onOpenDm?: (pubkeys: string[]) => Promise<void> | void;
/** Mint an agent trading card. Present only for owner-managed personas. */
onCreateCard?: () => void;
presenceStatus: "online" | "away" | "offline" | undefined;
profile: ReturnType<typeof useUserProfileQuery>["data"];
pubkey: string | null;
@@ -218,6 +213,7 @@ export function ProfileSummaryView({
onOpenInstructions,
onTabChange,
onOpenDm,
onCreateCard,
presenceStatus,
profile,
pubkey,
@@ -349,6 +345,7 @@ export function ProfileSummaryView({
<ProfilePersonaPrimaryActions
canEditAgent={canEditAgent}
disabled={isAgentActionPending}
onCreateCard={onCreateCard}
onEditAgent={handleEditAgent}
onStartAgent={handleInstantiateAgent}
/>
@@ -356,6 +353,7 @@ export function ProfileSummaryView({
<ProfilePrimaryActions
canEditAgent={canEditAgent}
followMutation={followMutation}
onCreateCard={onCreateCard}
onEditAgent={handleEditAgent}
agentActionDisabled={isAgentActionPending}
agentActionLabel={
@@ -629,181 +627,6 @@ function ProfileHeroDescription({ about }: { about: string }) {
);
}
// ── Primary actions ──────────────────────────────────────────────────────────
function ProfilePrimaryActions({
agentActionDisabled,
agentActionLabel,
agentActionLive,
canEditAgent,
followMutation,
isFollowing,
messagePending,
onAgentPrimaryAction,
onAgentRestart,
onEditAgent,
onMessage,
pubkey,
unfollowMutation,
}: {
agentActionDisabled?: boolean;
agentActionLabel?: string;
agentActionLive?: boolean;
canEditAgent: boolean;
followMutation: ReturnType<typeof useFollowMutation>;
isFollowing: boolean;
messagePending?: boolean;
onAgentPrimaryAction?: () => void;
onAgentRestart?: () => void;
onEditAgent: () => void;
onMessage?: () => void;
pubkey: string;
unfollowMutation: ReturnType<typeof useUnfollowMutation>;
}) {
const showFollowAction = useFeatureEnabled("pulse");
const followToggleMutation = isFollowing ? unfollowMutation : followMutation;
const handleFollowClick = () => {
followToggleMutation.mutate(pubkey, {
onError: (error) =>
toast.error(
`${isFollowing ? "Unfollow" : "Follow"} failed: ${error.message}`,
),
});
};
return (
<div className="flex items-center justify-center gap-8">
{showFollowAction ? (
<ProfileQuickAction
active={isFollowing}
disabled={followToggleMutation.isPending}
icon={isFollowing ? UserMinus : UserPlus}
label={isFollowing ? "Unfollow" : "Follow"}
onClick={handleFollowClick}
/>
) : null}
{onMessage ? (
<ProfileQuickAction
disabled={messagePending}
icon={MessageSquare}
isLoading={messagePending}
label="Message"
onClick={onMessage}
testId="user-profile-message"
/>
) : null}
{canEditAgent ? (
<ProfileQuickAction
icon={Pencil}
label="Edit"
onClick={onEditAgent}
testId="user-profile-edit-agent"
/>
) : null}
{onAgentPrimaryAction && agentActionLabel ? (
<ProfileQuickAction
active={agentActionLive}
disabled={agentActionDisabled}
icon={agentActionLive ? Square : Play}
label={agentActionLabel}
onClick={onAgentPrimaryAction}
testId="user-profile-agent-primary-action"
/>
) : null}
{onAgentRestart ? (
<ProfileQuickAction
disabled={agentActionDisabled}
icon={RefreshCw}
label="Restart"
onClick={onAgentRestart}
testId="user-profile-agent-restart"
/>
) : null}
</div>
);
}
function ProfilePersonaPrimaryActions({
canEditAgent,
disabled,
onEditAgent,
onStartAgent,
}: {
canEditAgent: boolean;
disabled: boolean;
onEditAgent: () => void;
onStartAgent: () => void;
}) {
return (
<div className="flex items-center justify-center gap-8">
<ProfileQuickAction
disabled={disabled}
icon={Play}
label="Start agent"
onClick={onStartAgent}
testId="user-profile-start-agent"
/>
{canEditAgent ? (
<ProfileQuickAction
disabled={disabled}
icon={Pencil}
label="Edit"
onClick={onEditAgent}
testId="user-profile-edit-agent"
/>
) : null}
</div>
);
}
function ProfileQuickAction({
active,
disabled,
icon: Icon,
isLoading,
label,
onClick,
testId,
}: {
active?: boolean;
disabled?: boolean;
icon: LucideIcon;
isLoading?: boolean;
label: string;
onClick: () => void;
testId?: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={label}
className={cn(
"flex h-12 w-12 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50",
active
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted/60 text-foreground hover:bg-muted/80",
)}
data-testid={testId}
disabled={disabled}
onClick={onClick}
type="button"
>
{isLoading ? (
<Spinner aria-hidden="true" className="h-4 w-4 border-2" />
) : (
<Icon className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent align="center" side="top">
{label}
</TooltipContent>
</Tooltip>
);
}
// ── Focused views ────────────────────────────────────────────────────────────
export function MemoryFocusedView({

View File

@@ -4,36 +4,59 @@ import type {
CreatePersonaInput,
UpdatePersonaInput,
} from "@/shared/api/types";
import { AgentCardMintDialog } from "@/features/agents/ui/AgentCardMintDialog";
import { PersonaDeleteDialog } from "@/features/agents/ui/PersonaDeleteDialog";
import { AgentDialog } from "@/features/agents/ui/AgentDialog";
import type { PersonaDialogState } from "@/features/agents/ui/personaDialogState";
import { UserProfileSnapshotExportDialog } from "@/features/profile/ui/UserProfileSnapshotExportDialog";
/** Agent selected for card minting. */
export type CardMintTarget = {
id: string;
name: string;
canLock: boolean;
};
export function UserProfilePersonaDialogs({
cardMintTarget,
createError,
instanceCount,
isPending,
linkedAgentPubkey,
personaDialogState,
personaToDelete,
personaToExportSnapshot,
resolvedPersona,
runtimes,
runtimesLoading,
updateError,
onCloseCardMint,
onCloseDelete,
onCloseDialog,
onCloseExportSnapshot,
onConfirmDelete,
onExportSnapshot,
onSubmit,
}: {
cardMintTarget: CardMintTarget | null;
createError: Error | null;
/** Number of managed-agent instances backed by the persona being deleted. */
instanceCount: number;
isPending: boolean;
linkedAgentPubkey: string | null;
personaDialogState: PersonaDialogState | null;
personaToDelete: AgentPersona | null;
personaToExportSnapshot: AgentPersona | null;
resolvedPersona: AgentPersona | undefined;
runtimes: AcpRuntimeCatalogEntry[];
runtimesLoading: boolean;
updateError: Error | null;
onCloseCardMint: () => void;
onCloseDelete: () => void;
onCloseDialog: () => void;
onCloseExportSnapshot: () => void;
onConfirmDelete: (persona: AgentPersona) => void;
onExportSnapshot: (persona: AgentPersona) => void;
onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise<void>;
}) {
return (
@@ -67,6 +90,35 @@ export function UserProfilePersonaDialogs({
open={personaToDelete !== null}
persona={personaToDelete}
/>
{personaToExportSnapshot ? (
<UserProfileSnapshotExportDialog
linkedAgentPubkey={linkedAgentPubkey}
onOpenChange={(open) => {
if (!open) onCloseExportSnapshot();
}}
persona={personaToExportSnapshot}
/>
) : null}
{cardMintTarget ? (
<AgentCardMintDialog
agentId={cardMintTarget.id}
agentName={cardMintTarget.name}
canLock={cardMintTarget.canLock}
onExportInstead={
resolvedPersona
? () => {
// Free path: swap the mint dialog for the ordinary
// snapshot export flow (same importable agent, no spend).
onCloseCardMint();
onExportSnapshot(resolvedPersona);
}
: undefined
}
onOpenChange={(open) => {
if (!open) onCloseCardMint();
}}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,217 @@
import type { LucideIcon } from "lucide-react";
import {
MessageSquare,
Pencil,
Play,
RefreshCw,
Sparkles,
Square,
UserMinus,
UserPlus,
} from "lucide-react";
import { toast } from "sonner";
import type {
useFollowMutation,
useUnfollowMutation,
} from "@/features/profile/hooks";
import { useFeatureEnabled } from "@/shared/features";
import { cn } from "@/shared/lib/cn";
import { Spinner } from "@/shared/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
// ── Primary actions ──────────────────────────────────────────────────────────
export function ProfilePrimaryActions({
agentActionDisabled,
agentActionLabel,
agentActionLive,
canEditAgent,
followMutation,
isFollowing,
messagePending,
onAgentPrimaryAction,
onAgentRestart,
onCreateCard,
onEditAgent,
onMessage,
pubkey,
unfollowMutation,
}: {
agentActionDisabled?: boolean;
agentActionLabel?: string;
agentActionLive?: boolean;
canEditAgent: boolean;
followMutation: ReturnType<typeof useFollowMutation>;
isFollowing: boolean;
messagePending?: boolean;
onAgentPrimaryAction?: () => void;
onAgentRestart?: () => void;
onCreateCard?: () => void;
onEditAgent: () => void;
onMessage?: () => void;
pubkey: string;
unfollowMutation: ReturnType<typeof useUnfollowMutation>;
}) {
const showFollowAction = useFeatureEnabled("pulse");
const followToggleMutation = isFollowing ? unfollowMutation : followMutation;
const handleFollowClick = () => {
followToggleMutation.mutate(pubkey, {
onError: (error) =>
toast.error(
`${isFollowing ? "Unfollow" : "Follow"} failed: ${error.message}`,
),
});
};
return (
<div className="flex items-center justify-center gap-8">
{showFollowAction ? (
<ProfileQuickAction
active={isFollowing}
disabled={followToggleMutation.isPending}
icon={isFollowing ? UserMinus : UserPlus}
label={isFollowing ? "Unfollow" : "Follow"}
onClick={handleFollowClick}
/>
) : null}
{onMessage ? (
<ProfileQuickAction
disabled={messagePending}
icon={MessageSquare}
isLoading={messagePending}
label="Message"
onClick={onMessage}
testId="user-profile-message"
/>
) : null}
{canEditAgent ? (
<ProfileQuickAction
icon={Pencil}
label="Edit"
onClick={onEditAgent}
testId="user-profile-edit-agent"
/>
) : null}
{onAgentPrimaryAction && agentActionLabel ? (
<ProfileQuickAction
active={agentActionLive}
disabled={agentActionDisabled}
icon={agentActionLive ? Square : Play}
label={agentActionLabel}
onClick={onAgentPrimaryAction}
testId="user-profile-agent-primary-action"
/>
) : null}
{onAgentRestart ? (
<ProfileQuickAction
disabled={agentActionDisabled}
icon={RefreshCw}
label="Restart"
onClick={onAgentRestart}
testId="user-profile-agent-restart"
/>
) : null}
{onCreateCard ? (
<ProfileQuickAction
icon={Sparkles}
label="Create card"
onClick={onCreateCard}
testId="user-profile-create-card"
/>
) : null}
</div>
);
}
export function ProfilePersonaPrimaryActions({
canEditAgent,
disabled,
onCreateCard,
onEditAgent,
onStartAgent,
}: {
canEditAgent: boolean;
disabled: boolean;
onCreateCard?: () => void;
onEditAgent: () => void;
onStartAgent: () => void;
}) {
return (
<div className="flex items-center justify-center gap-8">
<ProfileQuickAction
disabled={disabled}
icon={Play}
label="Start agent"
onClick={onStartAgent}
testId="user-profile-start-agent"
/>
{canEditAgent ? (
<ProfileQuickAction
disabled={disabled}
icon={Pencil}
label="Edit"
onClick={onEditAgent}
testId="user-profile-edit-agent"
/>
) : null}
{onCreateCard ? (
<ProfileQuickAction
disabled={disabled}
icon={Sparkles}
label="Create card"
onClick={onCreateCard}
testId="user-profile-create-card"
/>
) : null}
</div>
);
}
function ProfileQuickAction({
active,
disabled,
icon: Icon,
isLoading,
label,
onClick,
testId,
}: {
active?: boolean;
disabled?: boolean;
icon: LucideIcon;
isLoading?: boolean;
label: string;
onClick: () => void;
testId?: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={label}
className={cn(
"flex h-12 w-12 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50",
active
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted/60 text-foreground hover:bg-muted/80",
)}
data-testid={testId}
disabled={disabled}
onClick={onClick}
type="button"
>
{isLoading ? (
<Spinner aria-hidden="true" className="h-4 w-4 border-2" />
) : (
<Icon className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent align="center" side="top">
{label}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -240,6 +240,108 @@ export async function encodeAgentSnapshotForSend(
});
}
// ── Agent trading cards ───────────────────────────────────────────────────────
/** Result of `mint_agent_card`. */
export type MintedAgentCard = {
/** Final `.agent.png` bytes (chunk-injected, verified), base64-encoded. */
cardPngBase64: string;
/** Suggested filename, e.g. `eva.agent.png`. */
fileName: string;
/** Designer commentary emitted alongside the image (may be empty). */
designerNotes: string;
/** True when the embedded manifest is encrypted to the (owner, agent) pair. */
locked: boolean;
/** How much memory is embedded in the card's snapshot. */
memoryLevel: SnapshotMemoryLevel;
};
/** Error prefix Rust returns when no OpenAI key is configured. */
export const NO_OPENAI_KEY_PREFIX = "NO_OPENAI_KEY:";
/**
* Check whether an OpenAI key would resolve for a card mint of this agent
* (same env layering as the mint itself). Never returns the key.
*/
export async function cardMintKeyStatus(id: string): Promise<boolean> {
return invokeTauri<boolean>("card_mint_key_status", { id });
}
/**
* Save an OPENAI_API_KEY into the global Agent Defaults env for card minting.
*
* Narrow seam: validated read-modify-write of the single key against the
* latest on-disk config. Unlike the general set_global_agent_config, this
* NEVER restarts running agents — the mint re-reads config per call, and a
* card setup must not disrupt unrelated agents.
*/
export async function cardMintSaveOpenaiKey(key: string): Promise<void> {
return invokeTauri<void>("card_mint_save_openai_key", { key });
}
/**
* Mint a trading card for an agent. One long API call (~2–3 minutes).
* Reroll = call again; the backend holds no session state.
*
* When `lock` is true, the embedded manifest is NIP-44-encrypted to the
* (owner, agent) pair: only those two keys can import the card. Requires a
* linked agent instance.
*
* `memoryLevel` (default `"none"`) embeds the owner's decrypted memory in
* the card's snapshot — same levels as snapshot export. Levels other than
* `"none"` require a linked agent instance; Rust derives the memory source
* from the resolved instance itself.
*/
export async function mintAgentCard(
id: string,
styleNotes?: string,
lock?: boolean,
memoryLevel?: SnapshotMemoryLevel,
): Promise<MintedAgentCard> {
return invokeTauri<MintedAgentCard>("mint_agent_card", {
id,
styleNotes: styleNotes || null,
lock: lock ?? null,
memoryLevel: memoryLevel ?? null,
});
}
/** Save minted card bytes to disk via the OS save dialog. */
export async function saveAgentCard(
cardPngBase64: string,
fileName: string,
): Promise<boolean> {
return invokeTauri<boolean>("save_agent_card", { cardPngBase64, fileName });
}
/** One archived (previously minted) card, as listed by `list_agent_cards`. */
export type ArchivedAgentCard = {
/** On-disk archive key — pass to `loadAgentCard`. */
storedFileName: string;
/** Suggested save-as name, e.g. `eva.agent.png`. */
fileName: string;
agentId: string;
agentName: string;
designerNotes: string;
locked: boolean;
/** Memory embedded in the card's snapshot ("none" for pre-field archives). */
memoryLevel: SnapshotMemoryLevel;
/** ISO-8601 mint timestamp. */
mintedAt: string;
/** Small JPEG grid preview, base64 (absent if the thumb write failed). */
thumbJpegBase64: string | null;
};
/** List all archived cards, newest first. */
export async function listAgentCards(): Promise<ArchivedAgentCard[]> {
return invokeTauri<ArchivedAgentCard[]>("list_agent_cards");
}
/** Load one archived card's full PNG bytes (base64) by its archive key. */
export async function loadAgentCard(storedFileName: string): Promise<string> {
return invokeTauri<string>("load_agent_card", { storedFileName });
}
// ── Snapshot import ───────────────────────────────────────────────────────────
/** Preview returned by `preview_agent_snapshot_import` before any write. */
@@ -258,6 +360,16 @@ export type AgentSnapshotImportPreview = {
/** True when the snapshot's respond_to_allowlist is non-empty. */
hasSourceAllowlist: boolean;
sourceAllowlistCount: number;
/** Full source pubkeys shown before import, not only their count. */
sourceAllowlist: string[];
/** Validated, pretty-printed manifest for full payload disclosure. */
manifestJson: string;
/**
* True when the snapshot came from a locked (encrypted) card this machine
* unlocked. Cards that cannot be unlocked fail with a refusal error and
* never reach a preview.
*/
locked: boolean;
};
/** Confirmation sent to `confirm_agent_snapshot_import`. */

View File

@@ -11282,6 +11282,9 @@ export function maybeInstallE2eTauriMocks() {
memoryEntryCount: 0,
hasSourceAllowlist: false,
sourceAllowlistCount: 0,
sourceAllowlist: [],
manifestJson: "{}",
locked: false,
};
}
case "confirm_agent_snapshot_import": {
@@ -11825,10 +11828,18 @@ export function maybeInstallE2eTauriMocks() {
case "download_image":
case "save_png_data_url":
case "download_file":
case "save_agent_card":
// The save dialog can't run headlessly; report a successful save so the
// FileCard / image-menu click handlers resolve. Specs assert the
// command was invoked via `__BUZZ_E2E_COMMANDS__`, not the dialog.
return true;
case "card_mint_key_status":
// Cards: pretend a key is configured so the mint form renders.
return true;
case "list_agent_cards":
// Cards archive starts empty in E2E; specs exercising the gallery
// can extend this with a seeded config knob when needed.
return [];
case "copy_image_to_clipboard":
return;
case "copy_text_to_clipboard":