mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
BIN
desktop/src-tauri/assets/card_template.png
Normal file
BIN
desktop/src-tauri/assets/card_template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
@@ -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 => {
|
||||
|
||||
972
desktop/src-tauri/src/commands/personas/card.rs
Normal file
972
desktop/src-tauri/src/commands/personas/card.rs
Normal 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;
|
||||
324
desktop/src-tauri/src/commands/personas/card/tests.rs
Normal file
324
desktop/src-tauri/src/commands/personas/card/tests.rs
Normal 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");
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
129
desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs
Normal file
129
desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs
Normal 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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
638
desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs
Normal file
638
desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs
Normal 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(¬_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());
|
||||
}
|
||||
}
|
||||
599
desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
Normal file
599
desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
Normal 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"));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
170
desktop/src/features/agents/cardMintStore.test.mjs
Normal file
170
desktop/src/features/agents/cardMintStore.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
227
desktop/src/features/agents/cardMintStore.ts
Normal file
227
desktop/src/features/agents/cardMintStore.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
35
desktop/src/features/agents/lib/agentCardGalleryState.ts
Normal file
35
desktop/src/features/agents/lib/agentCardGalleryState.ts
Normal 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" };
|
||||
}
|
||||
364
desktop/src/features/agents/ui/AgentCardMintDialog.tsx
Normal file
364
desktop/src/features/agents/ui/AgentCardMintDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
369
desktop/src/features/agents/ui/AgentCardViewerDialog.tsx
Normal file
369
desktop/src/features/agents/ui/AgentCardViewerDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
78
desktop/src/features/agents/ui/CardMintComposerChip.tsx
Normal file
78
desktop/src/features/agents/ui/CardMintComposerChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
217
desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx
Normal file
217
desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user