Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added desktop/src-tauri/assets/card_template.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 6 additions & 3 deletions desktop/src-tauri/src/commands/media_download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 => {
Expand Down
801 changes: 801 additions & 0 deletions desktop/src-tauri/src/commands/personas/card.rs

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions desktop/src-tauri/src/commands/personas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -970,11 +970,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::{card_mint_key_status, card_mint_save_openai_key, mint_agent_card, save_agent_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};
165 changes: 133 additions & 32 deletions desktop/src-tauri/src/commands/personas/snapshot/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ use tauri::{AppHandle, Emitter, State};
use crate::{
app_state::AppState,
managed_agents::{
agent_snapshot::{decode_snapshot_json, decode_snapshot_png, MemoryLevel},
agent_snapshot::{extract_chunk_payload_png, MemoryLevel},
agent_snapshot_envelope::{
decrypt_envelope, parse_chunk_payload, resolve_unlock_secret, ChunkPayload,
LOCKED_CARD_REFUSAL,
},
load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition,
ManagedAgentRecord, RespondTo,
},
Expand Down Expand Up @@ -65,6 +69,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.
Expand Down Expand Up @@ -203,42 +217,91 @@ const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47];
///
/// **Size cap:** PNG inputs over 10 MiB and JSON inputs over 5 MiB are rejected
/// before allocation to avoid avoidable large-input work.
pub(crate) fn decode_snapshot_from_bytes(
file_bytes: &[u8],
) -> Result<crate::managed_agents::agent_snapshot::AgentSnapshot, String> {
if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC {
///
/// **Locked cards:** a structurally valid locked envelope parses successfully
/// as `ChunkPayload::Locked` — no decryption happens here. Callers that can
/// unlock go through [`decode_snapshot_for_import`]; callers that only need
/// transit validation (e.g. `fetch_snapshot_bytes`) accept `Locked` as-is.
pub(crate) fn parse_snapshot_payload_from_bytes(file_bytes: &[u8]) -> Result<ChunkPayload, String> {
let payload: ChunkPayload = if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC {
if file_bytes.len() > MAX_SNAPSHOT_PNG_BYTES {
return Err(format!(
"Snapshot file is too large ({} MiB). PNG snapshots must be under 10 MiB.",
file_bytes.len() / (1024 * 1024)
));
}
let snapshot = decode_snapshot_png(file_bytes)?;
if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() {
return Err(
"Snapshot is malformed: memory.level is 'none' but entries are present."
.to_string(),
);
let chunk_json = extract_chunk_payload_png(file_bytes)?;
parse_chunk_payload(&chunk_json)?
} else {
// JSON path — apply size cap before serde allocation.
if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES {
return Err(format!(
"Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.",
file_bytes.len() / (1024 * 1024)
));
}
return Ok(snapshot);
}
// JSON path — apply size cap before serde allocation.
if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES {
return Err(format!(
"Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.",
file_bytes.len() / (1024 * 1024)
));
}
let snapshot = decode_snapshot_json(file_bytes)?;
parse_chunk_payload(file_bytes)?
};
// Consistency check: none + non-empty entries is always malformed,
// regardless of format. Mirrors the PNG path above so the rule is
// enforced at decode time for both formats.
if !snapshot.memory.entries.is_empty() && snapshot.memory.level == MemoryLevel::None {
// regardless of enclosing format. Enforced at decode time for plain
// payloads here, and after decryption for locked ones (see
// `enforce_memory_consistency` callers).
if let ChunkPayload::Plain(snapshot) = &payload {
enforce_memory_consistency(snapshot)?;
}
Ok(payload)
}

/// The shared malformed-memory guard: `memory.level == none` with non-empty
/// entries is always rejected before any write.
fn enforce_memory_consistency(
snapshot: &crate::managed_agents::agent_snapshot::AgentSnapshot,
) -> Result<(), String> {
if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() {
return Err(
"Snapshot is malformed: memory.level is 'none' but entries are present.".to_string(),
);
}
Ok(snapshot)
Ok(())
}

/// Decode a plain snapshot from raw bytes, refusing locked cards.
///
/// Test-only convenience: production call sites either unlock through
/// [`decode_snapshot_for_import`] or validate structurally through
/// [`parse_snapshot_payload_from_bytes`].
#[cfg(test)]
pub(crate) fn decode_snapshot_from_bytes(
file_bytes: &[u8],
) -> Result<crate::managed_agents::agent_snapshot::AgentSnapshot, String> {
match parse_snapshot_payload_from_bytes(file_bytes)? {
ChunkPayload::Plain(snapshot) => Ok(*snapshot),
ChunkPayload::Locked(_) => Err(LOCKED_CARD_REFUSAL.to_string()),
}
}

/// Decode a snapshot for import, unlocking locked cards when — and only
/// when — this machine holds one of the envelope's two exact key endpoints
/// (the owner identity or the named local agent record).
///
/// Returns the decoded manifest and whether it came from a locked envelope.
/// When neither endpoint exists, fails closed with the locked-card refusal —
/// never partial plaintext, never crypto details.
pub(crate) fn decode_snapshot_for_import(
file_bytes: &[u8],
owner_keys: Option<&nostr::Keys>,
records: &[ManagedAgentRecord],
) -> Result<(crate::managed_agents::agent_snapshot::AgentSnapshot, bool), String> {
match parse_snapshot_payload_from_bytes(file_bytes)? {
ChunkPayload::Plain(snapshot) => Ok((*snapshot, false)),
ChunkPayload::Locked(envelope) => {
let secret = resolve_unlock_secret(&envelope, owner_keys, records)
.ok_or_else(|| LOCKED_CARD_REFUSAL.to_string())?;
let snapshot = decrypt_envelope(&envelope, &secret)?;
enforce_memory_consistency(&snapshot)?;
Ok((snapshot, true))
}
}
}

// ── `preview_agent_snapshot_import` ──────────────────────────────────────────
Expand All @@ -250,17 +313,36 @@ pub(crate) fn decode_snapshot_from_bytes(
/// `.agent.png` file. The format is sniffed from the content, not the
/// extension, so an incorrectly-named file is handled correctly.
///
/// Locked cards are unlocked here when this machine holds one of the
/// envelope's two exact key endpoints; a card that cannot be unlocked fails
/// with the locked-card refusal (shown directly to the user), never a
/// partial preview. Identity-recovery mode is tolerated: owner keys are
/// simply unavailable, so only the agent-record endpoint can unlock.
///
/// Returns an `AgentSnapshotImportPreview` or a descriptive error. Errors
/// represent irrecoverable failures (corrupt / unsupported file) and are
/// shown directly to the user.
/// represent irrecoverable failures (corrupt / unsupported / locked-to-
/// someone-else file) and are shown directly to the user.
#[tauri::command]
pub async fn preview_agent_snapshot_import(
file_bytes: Vec<u8>,
file_name: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<AgentSnapshotImportPreview, String> {
// Key material + records are gathered up front (cheap, lock-scoped) so
// the blocking decode below owns plain data.
let owner_keys = state.signing_keys().ok();
let records = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
load_managed_agents(&app)?
};
tokio::task::spawn_blocking(move || {
reject_legacy_persona_filename(&file_name)?;
let snapshot = decode_snapshot_from_bytes(&file_bytes)?;
let (snapshot, locked) =
decode_snapshot_for_import(&file_bytes, owner_keys.as_ref(), &records)?;

let memory_level = match snapshot.memory.level {
MemoryLevel::None => "none",
Expand All @@ -269,6 +351,10 @@ pub async fn preview_agent_snapshot_import(
}
.to_string();

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(),
system_prompt: snapshot.definition.system_prompt.clone(),
Expand All @@ -280,8 +366,11 @@ pub async fn preview_agent_snapshot_import(
.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,
})
})
.await
Expand Down Expand Up @@ -313,8 +402,20 @@ pub async fn confirm_agent_snapshot_import(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<AgentSnapshotImportResult, String> {
// ── Phase 1: validate (no I/O) ───────────────────────────────────────────
let snapshot = decode_snapshot_from_bytes(&input.file_bytes)?;
// ── Phase 1: validate (no writes) ────────────────────────────────────────
// Locked cards unlock only via this machine's exact key endpoints;
// anything else fails closed here, before key generation.
let snapshot = {
let owner_keys = state.signing_keys().ok();
let records = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
load_managed_agents(&app)?
};
decode_snapshot_for_import(&input.file_bytes, owner_keys.as_ref(), &records)?.0
};

let display_name = snapshot.profile.display_name.trim().to_string();
if display_name.is_empty() {
Expand Down
5 changes: 5 additions & 0 deletions desktop/src-tauri/src/commands/personas/snapshot/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -969,3 +969,8 @@ fn validate_encode_size_png_over_boundary_is_rejected() {
"error must mention size limit, got: {err}"
);
}

// ── Import: decode_snapshot_for_import (locked cards) ─────────────────────

#[path = "tests_locked.rs"]
mod locked_import;
Loading
Loading