fix(snapshot): send Buzz shares as PNG avatar cards (#1811)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-13 13:58:24 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent d41b4c3905
commit a6cfd65511
19 changed files with 558 additions and 247 deletions
@@ -281,7 +281,7 @@ async fn fetch_blob_bytes_with_cap(
pub(crate) enum SnapshotFileKind {
/// `.agent.json` — plaintext JSON; accepts memory; 5 MiB cap.
AgentJson,
/// `.agent.png` — PNG with embedded metadata; no memory; 10 MiB cap.
/// `.agent.png` — PNG with embedded metadata; 10 MiB cap.
AgentPng,
/// `.team.json` — team template; 25 MiB cap.
TeamJson,
@@ -443,9 +443,8 @@ pub async fn fetch_snapshot_bytes(
return Err("hash mismatch: fetched bytes do not match the declared SHA-256".to_string());
}
// 3. Byte magic must match the expected kind (filename → format).
// This prevents .agent.png delivering JSON bytes (including memory-bearing
// JSON) and .agent.json delivering PNG bytes.
// 3. Byte magic must match the expected kind (filename → format), so a
// filename cannot select a JSON or PNG parser for the other format.
ensure_bytes_match_kind(&bytes, kind)?;
// 4. Bytes must parse as the snapshot type selected by the filename.
@@ -173,7 +173,6 @@ fn parse_format_is_png(s: &str) -> Result<bool, String> {
///
/// **Invariants preserved (identical for both callers):**
/// - JSON/PNG format selection from the parsed `is_png` flag (magic-byte sniffing is import-only)
/// - PNG + memory hard rejection
/// - Memory-source pubkey validation
/// - Secret exclusion (env_vars never enter the manifest via `build_snapshot`)
/// - Output filename derived from the agent display name
@@ -182,18 +181,10 @@ pub(crate) async fn materialize_snapshot_bytes(
memory_source_pubkey: Option<String>,
memory_level: MemoryLevel,
is_png: bool,
avatar_png_data_url: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<SnapshotPayload, String> {
// Eagerly reject PNG + memory — avoid an unnecessary relay round-trip.
if is_png && memory_level != MemoryLevel::None {
return Err(
"Cannot export memory to .agent.png — use JSON format for memory-bearing \
snapshots."
.to_string(),
);
}
// ── Load definition record and memory-source instance under lock ─────────
let (record, memory_pubkey) = {
let _store_guard = state
@@ -274,7 +265,9 @@ pub(crate) async fn materialize_snapshot_bytes(
let slug = crate::util::slugify(&display_name, "agent", 50);
if is_png {
let png_bytes = encode_snapshot_png(&snapshot, avatar_bytes.as_deref())
let png_body_avatar_bytes =
resolve_png_body_avatar_bytes(avatar_png_data_url.as_deref(), avatar_bytes);
let png_bytes = encode_snapshot_png(&snapshot, png_body_avatar_bytes.as_deref())
.map_err(|e| format!("Failed to encode .agent.png: {e}"))?;
validate_snapshot_encode_size(png_bytes.len(), true)?;
Ok(SnapshotPayload {
@@ -292,6 +285,17 @@ pub(crate) async fn materialize_snapshot_bytes(
}
}
/// Choose bytes for the PNG image body without changing the source avatar the
/// manifest preserves for import.
fn resolve_png_body_avatar_bytes(
avatar_png_data_url: Option<&str>,
store_avatar_bytes: Option<Vec<u8>>,
) -> Option<Vec<u8>> {
avatar_png_data_url
.and_then(crate::managed_agents::agent_snapshot::decode_avatar_data_url)
.or(store_avatar_bytes)
}
/// Export an agent definition as a `buzz-agent-snapshot v1` file.
///
/// `id` is a definition slug or a keyed-instance pubkey.
@@ -309,6 +313,7 @@ pub async fn export_agent_snapshot(
memory_source_pubkey: Option<String>,
memory_level: String,
format: String,
avatar_png_data_url: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<bool, String> {
@@ -320,6 +325,7 @@ pub async fn export_agent_snapshot(
memory_source_pubkey,
memory_level,
is_png,
avatar_png_data_url,
app.clone(),
state,
)
@@ -370,15 +376,23 @@ pub async fn encode_agent_snapshot_for_send(
memory_source_pubkey: Option<String>,
memory_level: String,
format: String,
avatar_png_data_url: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<EncodedSnapshotPayload, String> {
let memory_level = parse_memory_level(&memory_level)?;
let is_png = parse_format_is_png(&format)?;
let payload =
materialize_snapshot_bytes(id, memory_source_pubkey, memory_level, is_png, app, state)
.await?;
let payload = materialize_snapshot_bytes(
id,
memory_source_pubkey,
memory_level,
is_png,
avatar_png_data_url,
app,
state,
)
.await?;
Ok(EncodedSnapshotPayload {
file_bytes: payload.bytes,
@@ -388,3 +402,66 @@ pub async fn encode_agent_snapshot_for_send(
#[cfg(test)]
mod tests;
#[cfg(test)]
mod png_body_tests {
use super::*;
use base64::Engine as _;
use png::Decoder;
#[test]
fn frontend_raster_becomes_png_body_without_replacing_manifest_source() {
let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
3,
2,
image::Rgb([0x12, 0x34, 0x56]),
));
let mut source_png = Vec::new();
avatar
.write_to(
&mut std::io::Cursor::new(&mut source_png),
image::ImageFormat::Png,
)
.unwrap();
let avatar_data_url = format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(&source_png)
);
let snapshot = crate::managed_agents::agent_snapshot::AgentSnapshot {
format: crate::managed_agents::agent_snapshot::FORMAT_DISCRIMINATOR.to_string(),
version: crate::managed_agents::agent_snapshot::FORMAT_VERSION,
definition: crate::managed_agents::agent_snapshot::AgentSnapshotDefinition {
name: "Agent".to_string(),
system_prompt: None,
runtime: None,
model: None,
provider: None,
parallelism: None,
respond_to: None,
respond_to_allowlist: vec![],
name_pool: vec![],
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
},
profile: crate::managed_agents::agent_snapshot::AgentSnapshotProfile {
display_name: "Agent".to_string(),
about: None,
avatar_data_url: None,
avatar_url: Some("https://relay.example/media/avatar.png".to_string()),
},
memory: crate::managed_agents::agent_snapshot::AgentSnapshotMemory {
level: MemoryLevel::None,
entries: vec![],
},
};
let body_bytes = resolve_png_body_avatar_bytes(Some(&avatar_data_url), None);
let png_bytes = encode_snapshot_png(&snapshot, body_bytes.as_deref()).unwrap();
let reader = Decoder::new(std::io::Cursor::new(&png_bytes))
.read_info()
.unwrap();
let decoded = import::decode_snapshot_from_bytes(&png_bytes).unwrap();
assert_eq!((reader.info().width, reader.info().height), (3, 2));
assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url);
}
}
@@ -197,9 +197,9 @@ const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47];
/// Fails closed on malformed content, wrong format, or unsupported version.
/// Never trusts the file extension — only the bytes.
///
/// **PNG memory policy:** any PNG manifest whose `memory.level` is not `None`,
/// or whose `memory.entries` is non-empty despite `level == None`, is rejected
/// before any write. Plaintext memory is accepted only from `.agent.json`.
/// **Memory consistency:** any manifest whose `memory.entries` is non-empty
/// despite `memory.level == None` is rejected before any write, regardless of
/// the enclosing format.
///
/// **Size cap:** PNG inputs over 10 MiB and JSON inputs over 5 MiB are rejected
/// before allocation to avoid avoidable large-input work.
@@ -214,18 +214,9 @@ pub(crate) fn decode_snapshot_from_bytes(
));
}
let snapshot = decode_snapshot_png(file_bytes)?;
// Hard reject: PNG must never carry memory.
if snapshot.memory.level != crate::managed_agents::agent_snapshot::MemoryLevel::None {
if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() {
return Err(
"Cannot import a memory-bearing .agent.png — use .agent.json for \
snapshots that include memory."
.to_string(),
);
}
if !snapshot.memory.entries.is_empty() {
return Err(
"Snapshot is malformed: .agent.png carries memory entries despite \
memory.level being 'none'."
"Snapshot is malformed: memory.level is 'none' but entries are present."
.to_string(),
);
}
@@ -368,80 +368,71 @@ fn import_avatar_url_fallback_is_used_when_no_data_url() {
);
}
// ── Import: PNG memory policy ─────────────────────────────────────────────
// ── Import: PNG memory parity ─────────────────────────────────────────────
/// PNG with memory level `core` is rejected before any write.
/// PNG with core memory round-trips through the production import decoder.
#[test]
fn import_png_with_core_memory_is_rejected() {
// encode_snapshot_png refuses to encode memory-bearing snapshots —
// that guard on the EXPORT side is correct. On the IMPORT side,
// decode_snapshot_from_bytes adds a matching guard so that a
// foreign-built PNG carrying a core/everything memory level is also
// rejected. We verify the guard condition here.
let level = MemoryLevel::Core;
let entries = vec![AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "Secret.".to_string(),
}];
// Simulate what decode_snapshot_from_bytes checks after PNG decode:
let would_reject = level != MemoryLevel::None;
assert!(
would_reject,
"PNG with core memory level must be rejected by the import guard"
);
// Also verify that the encoder itself refuses this on the export side.
fn import_png_with_core_memory_preserves_entries() {
use crate::managed_agents::agent_snapshot::encode_snapshot_png;
let snapshot = make_snapshot(MemoryLevel::Core, entries);
assert!(
encode_snapshot_png(&snapshot, None).is_err(),
"encode_snapshot_png must also refuse memory-bearing snapshots"
let snapshot = make_snapshot(
MemoryLevel::Core,
vec![AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "Remember this.".to_string(),
}],
);
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap();
assert_eq!(decoded.memory, snapshot.memory);
}
/// PNG with memory level `everything` is rejected before any write.
/// PNG with all memory round-trips through the production import decoder.
#[test]
fn import_png_with_everything_memory_is_rejected() {
// Same as core: both the encoder guard and the decoder guard fire.
let level = MemoryLevel::Everything;
let would_reject = level != MemoryLevel::None;
assert!(
would_reject,
"PNG with everything memory level must be rejected by the import guard"
);
fn import_png_with_everything_memory_preserves_entries() {
use crate::managed_agents::agent_snapshot::encode_snapshot_png;
let snapshot = make_snapshot(
MemoryLevel::Everything,
vec![AgentSnapshotMemoryEntry {
slug: "mem/research".to_string(),
body: "Notes.".to_string(),
}],
);
assert!(
encode_snapshot_png(&snapshot, None).is_err(),
"encode_snapshot_png must also refuse memory-bearing snapshots"
vec![
AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "Remember this.".to_string(),
},
AgentSnapshotMemoryEntry {
slug: "mem/research".to_string(),
body: "Notes.".to_string(),
},
],
);
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap();
assert_eq!(decoded.memory, snapshot.memory);
}
/// PNG with `none` level but non-empty entries is rejected (malformed).
/// We test this by verifying the guard condition directly, since
/// encode_snapshot_png correctly refuses to produce such a PNG.
/// PNG with `none` level but non-empty entries is rejected as malformed.
#[test]
fn import_png_with_none_level_and_entries_is_rejected() {
// This guard is enforced in decode_snapshot_from_bytes after the PNG
// path resolves. A PNG with none + entries cannot be produced by
// encode_snapshot_png (it already guards that), but could arrive from
// a foreign tool. We verify the guard condition matches the spec:
let level = MemoryLevel::None;
let entries = [AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "Leak.".to_string(),
}];
// The guard in decode_snapshot_from_bytes:
let would_reject = !entries.is_empty() && level == MemoryLevel::None;
assert!(
would_reject,
"none level + non-empty entries must trigger the guard"
use crate::managed_agents::agent_snapshot::{
encode_snapshot_json, encode_snapshot_png, make_png_with_text, PNG_CHUNK_KEYWORD,
};
use base64::{engine::general_purpose::STANDARD, Engine as _};
let snapshot = make_snapshot(
MemoryLevel::None,
vec![AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "Leak.".to_string(),
}],
);
let json = encode_snapshot_json(&snapshot).unwrap();
let png_bytes = make_png_with_text(PNG_CHUNK_KEYWORD, &STANDARD.encode(json)).unwrap();
assert!(encode_snapshot_png(&snapshot, None).is_err());
let error = decode_snapshot_from_bytes(&png_bytes).unwrap_err();
assert!(error.contains("'none' but entries are present"));
}
/// PNG with `none` level and no entries imports normally.
@@ -7,10 +7,12 @@
//! - **memory** — optional, owner-decrypted engrams at one of three levels
//!
//! Two encodings are supported:
//! - `.agent.json` — canonical, may carry memory at any level
//! - `.agent.json` — canonical snapshot manifest
//! - `.agent.png` — avatar image with manifest in a `buzz_agent_snapshot`
//! tEXt chunk; memory MUST be `None` (PNG files are casually shared and
//! would leak plaintext memory into chat uploads)
//! tEXt chunk
//!
//! Both formats may carry memory at any level. Memory entries are plaintext,
//! so callers must require an explicit opt-in before exporting them.
//!
//! **Zip is NOT in v1** — deferred to v2 for skills bundling.
//!
@@ -284,17 +286,13 @@ pub fn decode_snapshot_json(bytes: &[u8]) -> Result<AgentSnapshot, String> {
/// Encode a snapshot into a `.agent.png` — avatar as the image body, manifest
/// in the `buzz_agent_snapshot` tEXt chunk.
///
/// **Rejects** any snapshot whose `memory.level != None` — memory in a PNG
/// file is a security hazard (images are casually shared, pasted into chats).
pub fn encode_snapshot_png(
snapshot: &AgentSnapshot,
avatar_bytes: Option<&[u8]>,
) -> Result<Vec<u8>, String> {
if snapshot.memory.level != MemoryLevel::None || !snapshot.memory.entries.is_empty() {
if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() {
return Err(
"Cannot write memory to a .agent.png file — use .agent.json for memory-bearing \
snapshots. PNG images are casually shared and would expose memory as plaintext."
"Cannot write a snapshot with memory.level 'none' and non-empty memory entries."
.to_string(),
);
}
@@ -303,18 +301,23 @@ pub fn encode_snapshot_png(
let json_bytes = encode_snapshot_json(snapshot)?;
let chunk_text = STANDARD.encode(&json_bytes);
// Use the avatar bytes as the PNG image when available; otherwise produce
// a minimal 1×1 transparent placeholder.
let png_bytes = match avatar_bytes.filter(|b| !b.is_empty()) {
Some(bytes) if bytes.starts_with(b"\x89PNG") => {
// Already a PNG — inject the tEXt chunk.
inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text)?
}
Some(_bytes) => {
// Non-PNG avatar (JPEG, etc.) — re-encode as 1×1 placeholder and
// carry the avatar via the manifest's `profile.avatar_data_url`.
// This keeps the PNG valid while preserving the avatar in the JSON.
make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?
// Use the avatar as the PNG image body, transcoding decodable non-PNG
// avatars. Fall back to a minimal 1×1 transparent placeholder only when
// there is no avatar or it cannot be decoded.
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)?,
};
@@ -403,6 +406,21 @@ pub(crate) fn make_png_with_text(keyword: &str, text: &str) -> Result<Vec<u8>, S
Ok(buf)
}
/// Transcode a decodable avatar to PNG and add the snapshot manifest chunk.
fn transcode_avatar_to_png_with_text(
avatar_bytes: &[u8],
keyword: &str,
text: &str,
) -> Result<Vec<u8>, String> {
let image = image::load_from_memory(avatar_bytes)
.map_err(|e| format!("Failed to decode avatar image: {e}"))?;
let mut png_bytes = Vec::new();
image
.write_to(&mut Cursor::new(&mut png_bytes), image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode avatar as PNG: {e}"))?;
inject_text_chunk(&png_bytes, keyword, text)
}
/// Inject a tEXt chunk into an existing PNG by re-encoding it.
///
/// Re-decodes the image data via the `png` crate and writes a fresh PNG with
@@ -576,35 +594,67 @@ mod tests {
assert_eq!(parsed.definition.name, snapshot.definition.name);
}
// ── PNG memory guard ──────────────────────────────────────────────────────
#[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_export_with_core_memory_is_rejected() {
fn png_round_trip_with_core_memory() {
let record = minimal_record();
let entries = vec![AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "secret memory".to_string(),
body: "remember this".to_string(),
}];
let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None);
let result = encode_snapshot_png(&snapshot, None);
assert!(result.is_err(), "Expected error for PNG with memory");
let err = result.unwrap_err();
assert!(
err.contains("Cannot write memory to a .agent.png"),
"Error message should explain the PNG memory restriction, got: {err}"
);
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_everything_memory_is_rejected() {
fn png_round_trip_with_everything_memory() {
let record = minimal_record();
let entries = vec![AgentSnapshotMemoryEntry {
slug: "mem/notes".to_string(),
body: "private notes".to_string(),
}];
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 result = encode_snapshot_png(&snapshot, None);
assert!(result.is_err());
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
let parsed = decode_snapshot_png(&png_bytes).unwrap();
assert_eq!(parsed.memory, snapshot.memory);
}
#[test]
@@ -632,8 +682,10 @@ mod tests {
"PNG encoder must reject level=None with non-empty entries"
);
assert!(
result.unwrap_err().contains("Cannot write memory"),
"Error must explain the PNG memory restriction"
result
.unwrap_err()
.contains("memory.level 'none' and non-empty memory entries"),
"Error must explain the malformed memory state"
);
}
+26 -3
View File
@@ -7,6 +7,7 @@ import {
ensureChannelAgentPresetInChannel,
} from "@/features/agents/channelAgents";
import { channelsQueryKey } from "@/features/channels/hooks";
import { resolveSnapshotAvatarPng } from "@/features/agents/ui/snapshotAvatarPng";
import { evictUsersBatchEntries } from "@/features/profile/hooks";
import {
createManagedAgent,
@@ -597,17 +598,31 @@ export function useCreateChannelManagedAgentsMutation(
export function useExportAgentSnapshotMutation() {
return useMutation({
mutationFn: ({
mutationFn: async ({
id,
memoryLevel,
format,
memorySourcePubkey,
avatarUrl,
}: {
id: string;
memoryLevel: SnapshotMemoryLevel;
format: SnapshotFormat;
memorySourcePubkey?: string | null;
}) => exportAgentSnapshot(id, memoryLevel, format, memorySourcePubkey),
avatarUrl?: string | null;
}) => {
const avatarPngDataUrl =
format === "png"
? await resolveSnapshotAvatarPng(avatarUrl)
: undefined;
return exportAgentSnapshot(
id,
memoryLevel,
format,
memorySourcePubkey,
avatarPngDataUrl,
);
},
});
}
@@ -618,13 +633,21 @@ export function useEncodeAgentSnapshotForSendMutation() {
memoryLevel,
format,
memorySourcePubkey,
avatarPngDataUrl,
}: {
id: string;
memoryLevel: SnapshotMemoryLevel;
format: SnapshotFormat;
memorySourcePubkey?: string | null;
avatarPngDataUrl?: string;
}) =>
encodeAgentSnapshotForSend(id, memoryLevel, format, memorySourcePubkey),
encodeAgentSnapshotForSend(
id,
memoryLevel,
format,
memorySourcePubkey,
avatarPngDataUrl,
),
});
}
@@ -69,17 +69,6 @@ export function AgentSnapshotExportDialog({
const hasLinkedAgent = linkedAgentPubkey !== null;
const showMemoryWarning = memoryLevel !== "none";
// PNG format is disabled when memory is selected (backend guards this too,
// but we disable the option proactively to avoid a confusing error).
const pngDisabled = memoryLevel !== "none";
React.useEffect(() => {
// If the user switches to a memory level, force JSON format since PNG
// cannot carry memory.
if (pngDisabled && format === "png") {
setFormat("json");
}
}, [pngDisabled, format]);
// Reset state when the dialog opens for a fresh export.
React.useEffect(() => {
@@ -226,23 +215,21 @@ export function AgentSnapshotExportDialog({
/>
<span className="text-sm">.agent.json</span>
</label>
<label
className={`flex items-center gap-2 ${pngDisabled ? "cursor-not-allowed opacity-40" : "cursor-pointer"}`}
>
<label className="flex cursor-pointer items-center gap-2">
<input
checked={format === "png"}
disabled={pngDisabled}
name="snapshot-format"
onChange={() => !pngDisabled && setFormat("png")}
onChange={() => setFormat("png")}
type="radio"
value="png"
/>
<span className="text-sm">
.agent.png
{pngDisabled ? " (unavailable with memory)" : ""}
</span>
<span className="text-sm">.agent.png</span>
</label>
</div>
<p className="text-xs text-muted-foreground">
Applies to saved files; snapshots shared in Buzz always use
.agent.png. PNG exports include memory when selected.
</p>
</div>
</div>
</DialogContent>
@@ -251,7 +238,6 @@ export function AgentSnapshotExportDialog({
{/* Send-in-Buzz destination picker — opened as a secondary dialog */}
{sendOpen ? (
<AgentSnapshotSendDialog
format={format}
linkedAgentPubkey={linkedAgentPubkey}
memoryLevel={memoryLevel}
open={sendOpen}
@@ -2,10 +2,7 @@ import * as React from "react";
import { AlertCircle, Check, Search, Send } from "lucide-react";
import type { AgentPersona } from "@/shared/api/types";
import type {
SnapshotFormat,
SnapshotMemoryLevel,
} from "@/shared/api/tauriPersonas";
import type { SnapshotMemoryLevel } from "@/shared/api/tauriPersonas";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -17,6 +14,7 @@ import {
import { Separator } from "@/shared/ui/separator";
import { useEncodeAgentSnapshotForSendMutation } from "@/features/agents/hooks";
import { useTimeoutState } from "@/features/moderation/lib/timeoutStore";
import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng";
import {
useSnapshotSendController,
type SendPhase,
@@ -30,7 +28,6 @@ type AgentSnapshotSendDialogProps = {
persona: AgentPersona;
linkedAgentPubkey: string | null;
memoryLevel: SnapshotMemoryLevel;
format: SnapshotFormat;
onOpenChange: (open: boolean) => void;
/** Called when the snapshot was successfully sent. */
onSent: () => void;
@@ -53,7 +50,6 @@ export function AgentSnapshotSendDialog({
persona,
linkedAgentPubkey,
memoryLevel,
format,
onOpenChange,
onSent,
}: AgentSnapshotSendDialogProps) {
@@ -163,12 +159,15 @@ export function AgentSnapshotSendDialog({
// This closes the race between this pre-flight check and the moment encode
// or upload actually starts.
await controller.beginSend(
() =>
async () =>
encodeMutation.mutateAsync({
id: persona.id,
memoryLevel,
format,
// PNG is the avatar card image and retains the snapshot contents.
// JSON has no relay-valid thumbnail.
format: "png",
memorySourcePubkey: linkedAgentPubkey,
avatarPngDataUrl: await resolveSnapshotAvatarPng(persona.avatarUrl),
}),
destination.id,
);
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng.ts";
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
test("resolveSnapshotAvatarPng: relay media URL becomes PNG data URL", async () => {
const result = await resolveSnapshotAvatarPng(
"https://relay.example/media/avatar.png",
{
fetchBytes: async (url) => {
assert.equal(url, "https://relay.example/media/avatar.png");
return PNG_BYTES;
},
},
);
assert.equal(result, "data:image/png;base64,iVBORw==");
});
test("resolveSnapshotAvatarPng: emoji SVG is rasterized onto a canvas", async () => {
const draws = [];
const image = {
src: "",
decode: async () => {},
};
const canvas = {
width: 0,
height: 0,
getContext: () => ({
drawImage: (...args) => draws.push(args),
}),
toDataURL: (type) => {
assert.equal(type, "image/png");
return "data:image/png;base64,cmFzdGVyaXplZA==";
},
};
const result = await resolveSnapshotAvatarPng(
"data:image/svg+xml,%3Csvg%2F%3E",
{
createCanvas: () => canvas,
createImage: () => image,
},
);
assert.equal(result, "data:image/png;base64,cmFzdGVyaXplZA==");
assert.equal(image.src, "data:image/svg+xml,%3Csvg%2F%3E");
assert.equal(canvas.width, 512);
assert.equal(canvas.height, 512);
assert.deepEqual(draws, [[image, 0, 0, 512, 512]]);
});
test("resolveSnapshotAvatarPng: failed media fetches and malformed URLs return undefined", async () => {
let fetchCalled = false;
const dependencies = {
fetchBytes: async () => {
fetchCalled = true;
throw new Error("external URL rejected by Rust validation");
},
};
assert.equal(
await resolveSnapshotAvatarPng(
"https://external.example/avatar.png",
dependencies,
),
undefined,
);
assert.equal(
await resolveSnapshotAvatarPng("not a URL", dependencies),
undefined,
);
assert.equal(fetchCalled, true);
});
@@ -0,0 +1,83 @@
import { fetchMediaBytes } from "@/shared/api/tauriMedia";
type SnapshotAvatarPngDependencies = {
fetchBytes?: (url: string) => Promise<Uint8Array>;
createCanvas?: () => HTMLCanvasElement;
createImage?: () => HTMLImageElement;
};
/**
* Resolve an avatar to PNG data for the image body of a snapshot PNG.
*
* The original avatar URL remains in the manifest so imports preserve the
* editable source; this only supplies a renderable card thumbnail. Relay
* media fetches are validated by Rust, which rejects external origins.
*/
export async function resolveSnapshotAvatarPng(
avatarUrl: string | null | undefined,
dependencies: SnapshotAvatarPngDependencies = {},
): Promise<string | undefined> {
const url = avatarUrl?.trim();
if (!url) return undefined;
if (isSvgDataUrl(url)) {
return rasterizeSvg(url, dependencies);
}
if (!isHttpsUrl(url)) return undefined;
try {
// Rust validates same-relay `/media/` URLs before fetching; other origins
// fail there rather than being fetched by the webview.
const bytes = await (dependencies.fetchBytes ?? fetchMediaBytes)(url);
return `data:image/png;base64,${bytesToBase64(bytes)}`;
} catch {
return undefined;
}
}
function isSvgDataUrl(url: string) {
return /^data:image\/svg\+xml(?:;[^,]*)?,/i.test(url);
}
function isHttpsUrl(url: string) {
try {
return new URL(url).protocol === "https:";
} catch {
return false;
}
}
async function rasterizeSvg(
svgDataUrl: string,
dependencies: SnapshotAvatarPngDependencies,
): Promise<string | undefined> {
try {
const image = (dependencies.createImage ?? (() => new Image()))();
image.src = svgDataUrl;
await image.decode();
const canvas = (
dependencies.createCanvas ?? (() => document.createElement("canvas"))
)();
canvas.width = 512;
canvas.height = 512;
const context = canvas.getContext("2d");
if (!context) return undefined;
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
} catch {
return undefined;
}
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(
...bytes.subarray(offset, offset + chunkSize),
);
}
return btoa(binary);
}
@@ -423,6 +423,7 @@ export function usePersonaActions() {
memoryLevel,
format,
memorySourcePubkey: linkedAgentPubkey,
avatarUrl: persona.avatarUrl,
},
{
onSuccess: (saved) => {
@@ -117,6 +117,17 @@ test("formatImetaMediaLine: spoilered image mime → wrapped ![image] line", ()
);
});
test("formatImetaMediaLine: agent snapshot PNG → filename link", () => {
assert.equal(
formatImetaMediaLine({
url: "https://b/analyst.png",
type: "image/png",
filename: "analyst.agent.png",
}),
"\n[analyst.agent.png](https://b/analyst.png)",
);
});
test("buildImetaTags keeps media filenames in imeta", () => {
// Filenames are included for every MIME type — the video review dialog
// and file cards use them as display titles.
@@ -404,6 +415,20 @@ test("buildOutgoingMessage: appends media markdown line per attachment, in order
);
});
test("buildOutgoingMessage: agent snapshot PNG uses the snapshot-card link path", () => {
const out = buildOutgoingMessage("", [
{
url: "https://b/analyst.png",
type: "image/png",
sha256: "x",
size: 1,
uploaded: 0,
filename: "analyst.agent.png",
},
]);
assert.equal(out.content, "\n[analyst.agent.png](https://b/analyst.png)");
});
test("buildOutgoingMessage: wraps spoilered image and video attachments", () => {
const out = buildOutgoingMessage(
"hi",
@@ -235,17 +235,21 @@ export function findSpoileredImetaMediaUrls(
* Images and video use `![image|video](url)` so the renderer draws them inline.
* Generic files use a plain `[filename](url)` link the renderer recognises the
* href as a local media blob with a non-media MIME and upgrades it to a file
* card. Mime-driven so the form is correct regardless of URL suffix.
* card. Agent snapshot PNGs deliberately use the file-link form despite their
* image MIME so the renderer can upgrade them to the import/download card.
*/
export function formatImetaMediaLine(
{ url, type, filename }: ImetaMedia,
options: { spoiler?: boolean } = {},
): string {
// A PNG snapshot is image/png on the wire, but it is an importable file, not
// inline media. Keep it on the anchor renderer's snapshot-card path.
const isAgentSnapshot = filename?.toLowerCase().endsWith(".agent.png");
if (type.startsWith("video/")) {
const line = `![video](${url})`;
return options.spoiler ? `\n||${line}||` : `\n${line}`;
}
if (type.startsWith("image/")) {
if (type.startsWith("image/") && !isAgentSnapshot) {
const line = `![image](${url})`;
return options.spoiler ? `\n||${line}||` : `\n${line}`;
}
@@ -28,6 +28,7 @@ export function UserProfileSnapshotExportDialog({
memoryLevel,
format,
memorySourcePubkey: linkedAgentPubkey,
avatarUrl: persona.avatarUrl,
},
{
onSuccess: (saved) => {
+4
View File
@@ -124,12 +124,14 @@ export async function exportAgentSnapshot(
memoryLevel: SnapshotMemoryLevel,
format: SnapshotFormat,
memorySourcePubkey?: string | null,
avatarPngDataUrl?: string,
): Promise<boolean> {
return invokeTauri<boolean>("export_agent_snapshot", {
id,
memorySourcePubkey: memorySourcePubkey ?? null,
memoryLevel,
format,
avatarPngDataUrl: avatarPngDataUrl ?? null,
});
}
@@ -154,12 +156,14 @@ export async function encodeAgentSnapshotForSend(
memoryLevel: SnapshotMemoryLevel,
format: SnapshotFormat,
memorySourcePubkey?: string | null,
avatarPngDataUrl?: string,
): Promise<EncodedSnapshotPayload> {
return invokeTauri<EncodedSnapshotPayload>("encode_agent_snapshot_for_send", {
id,
memorySourcePubkey: memorySourcePubkey ?? null,
memoryLevel,
format,
avatarPngDataUrl: avatarPngDataUrl ?? null,
});
}
+3 -4
View File
@@ -33,10 +33,9 @@ export type ResolvedSnapshotCard = {
/** Discriminant for the snapshot kind — currently only "agent". */
snapshotKind: "agent";
/**
* Optional thumbnail URL for the card icon. For `.agent.json` attachments
* this comes from the imeta `thumb` field set by the sender. For
* `.agent.png` attachments it falls back to the attachment URL itself
* (the PNG is the avatar card image).
* Optional thumbnail URL for the card icon. PNG snapshots use the
* attachment URL because the PNG body is the avatar card image. JSON
* snapshots have no thumbnail and use the generic icon.
*/
thumb?: string;
};
+4 -15
View File
@@ -8902,8 +8902,8 @@ export function maybeInstallE2eTauriMocks() {
// Specs assert invocation via __BUZZ_E2E_COMMANDS__.
return true;
case "encode_agent_snapshot_for_send": {
// Return a minimal valid `.agent.json` payload so the send flow can
// proceed through upload_media_bytes without a real Rust encode step.
// Return a minimal PNG-shaped payload so the send flow can proceed
// through upload_media_bytes without a real Rust encode step.
// Optional encodeDelayMs lets specs observe the "preparing" phase before
// the upload begins.
const encodeDelayMs = activeConfig?.mock?.encodeDelayMs ?? 0;
@@ -8912,20 +8912,9 @@ export function maybeInstallE2eTauriMocks() {
window.setTimeout(resolve, encodeDelayMs),
);
}
const jsonBytes = Array.from(
new TextEncoder().encode(
JSON.stringify({
format: "buzz-agent-snapshot",
version: 1,
definition: { system_prompt: null },
profile: { display_name: "E2E Agent" },
memory: { level: "none", entries: [] },
}),
),
);
return {
fileBytes: jsonBytes,
fileName: "e2e-agent.agent.json",
fileBytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
fileName: "e2e-agent.agent.png",
};
}
case "preview_agent_snapshot_import": {
@@ -21,6 +21,39 @@ async function readCommandLog(page: import("@playwright/test").Page) {
);
}
async function invokeMockCommand(
page: import("@playwright/test").Page,
command: string,
payload: Record<string, unknown>,
) {
// The app installs its dynamically imported bridge during bootstrap. Wait for
// that installation after navigation instead of racing the first invoke.
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
null,
{ timeout: 5_000 },
);
return page.evaluate(
async ({ command: cmd, payload: request }) => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__: (
command: string,
payload: Record<string, unknown>,
) => Promise<unknown>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
return invoke(cmd, request);
},
{ command, payload },
);
}
const ANALYST_PERSONA_ID = "test-analyst";
const ANALYST_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
@@ -63,23 +96,24 @@ async function seedAndSendSnapshot(
: {}),
});
await page.goto("/");
await page.getByTestId("open-agents-view").click();
// Open the send dialog and send to #general.
await page.getByLabel("Open actions for Analyst").click();
await page.getByRole("menuitem", { name: "Export snapshot" }).click();
const sendBtn = page.getByRole("button", { name: "Send in Buzz" });
if (await sendBtn.isVisible()) await sendBtn.click();
await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible();
await page
.getByTestId("agent-snapshot-send-channel-list")
.getByText("general")
.click();
await page.getByTestId("agent-snapshot-send-confirm").click();
await expect(page.getByTestId("agent-snapshot-send-done")).toBeVisible({
timeout: 8000,
// JSON snapshots can still arrive from saved files or older clients, even
// though new in-app shares always encode PNG. Seed one directly so this
// recipient suite retains JSON-card coverage independent of send behavior.
await invokeMockCommand(page, "send_channel_message", {
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
content: `[${SNAPSHOT_UPLOAD_DESCRIPTOR.filename}](${SNAPSHOT_UPLOAD_DESCRIPTOR.url})`,
mediaTags: [
[
"imeta",
`url ${SNAPSHOT_UPLOAD_DESCRIPTOR.url}`,
`m ${SNAPSHOT_UPLOAD_DESCRIPTOR.type}`,
`x ${SNAPSHOT_UPLOAD_DESCRIPTOR.sha256}`,
`size ${SNAPSHOT_UPLOAD_DESCRIPTOR.size}`,
`filename ${SNAPSHOT_UPLOAD_DESCRIPTOR.filename}`,
],
],
});
await page.getByRole("button", { name: "Close" }).click();
// Navigate to #general.
await page.getByTestId("channel-general").click();
@@ -194,48 +228,9 @@ test("recipient_import_confirm_calls_confirm_once_and_shows_result", async ({
test("recipient_fetch_error_shows_error_and_download_remains", async ({
page,
}) => {
await installMockBridge(page, {
personas: [
{
id: ANALYST_PERSONA_ID,
displayName: "Analyst",
systemPrompt: "You are an analyst.",
},
],
managedAgents: [
{
pubkey: ANALYST_PUBKEY,
name: "Analyst",
personaId: ANALYST_PERSONA_ID,
},
],
uploadDescriptors: [
{
...SNAPSHOT_UPLOAD_DESCRIPTOR,
filename: "bad.agent.json",
},
],
await seedAndSendSnapshot(page, {
snapshotFetchError: "hash mismatch: fetched bytes do not match",
});
await page.goto("/");
await page.getByTestId("open-agents-view").click();
// Send to #general.
await page.getByLabel("Open actions for Analyst").click();
await page.getByRole("menuitem", { name: "Export snapshot" }).click();
const sendBtn = page.getByRole("button", { name: "Send in Buzz" });
if (await sendBtn.isVisible()) await sendBtn.click();
await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible();
await page
.getByTestId("agent-snapshot-send-channel-list")
.getByText("general")
.click();
await page.getByTestId("agent-snapshot-send-confirm").click();
await expect(page.getByTestId("agent-snapshot-send-done")).toBeVisible({
timeout: 8000,
});
await page.getByRole("button", { name: "Close" }).click();
await page.getByTestId("channel-general").click();
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
+25 -9
View File
@@ -32,12 +32,12 @@ const ANALYST_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
const MOCK_UPLOAD_DESCRIPTOR = {
url: `https://mock.relay/media/${"a".repeat(64)}.json`,
url: `https://mock.relay/media/${"a".repeat(64)}.png`,
sha256: "a".repeat(64),
size: 1234,
type: "application/json",
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
filename: "analyst.agent.json",
filename: "analyst.agent.png",
};
// ── Destination picker: channel/DM visibility ─────────────────────────────────
@@ -210,6 +210,7 @@ test("snapshot_send_config_only_calls_encode_upload_send_in_order", async ({
{
id: ANALYST_PERSONA_ID,
displayName: "Analyst",
avatarUrl: "https://mock.relay/media/avatar.png",
systemPrompt: "You are an analyst.",
},
],
@@ -222,6 +223,13 @@ test("snapshot_send_config_only_calls_encode_upload_send_in_order", async ({
],
uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR],
});
await page.route("https://mock.relay/media/avatar.png", (route) =>
route.fulfill({
body: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
contentType: "image/png",
headers: { "access-control-allow-origin": "*" },
}),
);
await gotoAgentsPage(page);
await page.getByLabel("Open actions for Analyst").click();
@@ -279,22 +287,30 @@ test("snapshot_send_config_only_calls_encode_upload_send_in_order", async ({
const imeta = sendPayload?.mediaTags?.[0];
expect(imeta).toBeDefined();
const sha = "a".repeat(64);
const expectedUrl = `https://mock.relay/media/${sha}.json`;
const expectedUrl = `https://mock.relay/media/${sha}.png`;
expect(imeta).toContain(`url ${expectedUrl}`);
expect(imeta).toContain("m application/json");
expect(imeta).toContain("m image/png");
expect(imeta).toContain(`x ${sha}`);
expect(imeta).toContain("size 1234");
// The filename in the imeta comes from the encode payload's fileName — the
// controller sets descriptorWithFilename.filename = fileName (the file produced
// by encode_agent_snapshot_for_send), which the bridge hardcodes as
// "e2e-agent.agent.json".
expect(imeta).toContain("filename e2e-agent.agent.json");
// "e2e-agent.agent.png".
expect(imeta).toContain("filename e2e-agent.agent.png");
// The encode command itself produces "e2e-agent.agent.json" (bridge fixture).
// Send-in-Buzz always encodes PNG so the attachment itself provides the
// avatar thumbnail.
const encodeEntry = log.find(
(e) => e.command === "encode_agent_snapshot_for_send",
);
expect(encodeEntry).toBeTruthy();
const encodePayload = encodeEntry?.payload as
| { format?: string; avatarPngDataUrl?: string }
| undefined;
expect(encodePayload?.format).toBe("png");
expect(encodePayload?.avatarPngDataUrl).toEqual(
expect.stringMatching(/^data:image\/png;base64,/),
);
// Close the dialog and navigate to #general to verify the AgentSnapshotCard renders.
await page.getByRole("button", { name: "Close" }).click();
@@ -304,7 +320,7 @@ test("snapshot_send_config_only_calls_encode_upload_send_in_order", async ({
// FileCard) with the exact filename that the encode step produced.
const snapshotCard = page.getByTestId("agent-snapshot-card").last();
await expect(snapshotCard).toBeVisible({ timeout: 5000 });
await expect(snapshotCard).toContainText("e2e-agent.agent.json");
await expect(snapshotCard).toContainText("e2e-agent.agent.png");
});
// ── Memory-bearing flow: gate stops before encode/upload/send ─────────────────