From 7e9b77f72d82e019a99f074f1c9829be30c57ae1 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 29 Jul 2026 11:36:12 -0600 Subject: [PATCH] Fix inline raster avatars in agent catalog (#3581) ## Summary - render existing shared personas whose catalog avatar is a bounded inline PNG, JPEG, GIF, or WebP data URL - keep rejecting arbitrary, malformed, unsupported, and oversized `data:` URLs - preserve hosted relay URLs as the forward format; catalog browsing remains read-only ## Root cause Paul's live shared kind:30175 head contains a 144,878-character `data:image/png;base64,...` avatar. The catalog projection accepted HTTP(S) URLs and bounded percent-encoded SVG emoji avatars only, so it projected Paul's avatar to `null` before `ProfileAvatar` rendered it. The owner still saw the local persona avatar, producing the reported owner/viewer mismatch. This patch accepts only four raster MIME types with strict base64 shape and a 256 KiB total URL cap at the existing catalog parsing boundary. It repairs already-signed heads such as Paul without viewer-side uploads or publication side effects. Hosted media remains the canonical forward path. #3578 uploads inline raster avatars during snapshot import, preventing the known source from creating future inline persona/profile values; existing signed catalog heads still need this compatibility path until their owners republish. ## Agent instruction finding The catalog publishes `AgentDefinition.system_prompt` verbatim as the user-authored **Agent instruction**, as documented by the sharing UI and NIP-AP. No Buzz base/core/runtime prompt is concatenated in the publish, catalog, or import path. This PR therefore does not remove authored instructions and accidentally strip copied agents of their behavior. ## Validation - targeted `personaCatalogRelay.test.mjs`: 24 passed - Desktop typecheck: passed - pre-push Desktop frontend suite: 3,771 passed - pre-push Desktop checks: passed - `git diff --check`: passed - independent review: no blockers, 9.4/10 Signed-off-by: Wes Co-authored-by: Carl --- .../agents/lib/personaCatalogRelay.test.mjs | 24 ++++++++++++++++++ .../agents/lib/personaCatalogRelay.ts | 25 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index 24f6959b1c..fbaf1f5274 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -205,6 +205,30 @@ test("test_non_svg_data_avatar_is_rejected", () => { assert.equal(catalogAvatarUrl("data:image/png,%89PNG"), null); }); +test("test_legacy_inline_raster_avatar_survives_the_catalog", () => { + for (const mime of ["png", "jpeg", "gif", "webp"]) { + const avatar = `data:image/${mime};base64,iVBORw0KGgo=`; + assert.equal(catalogAvatarUrl(avatar), avatar); + } +}); + +test("test_inline_raster_avatar_rejects_unbounded_or_malformed_payloads", () => { + const prefix = "data:image/png;base64,"; + const payloadLength = 256 * 1_024 - prefix.length; + const validPayloadLength = payloadLength - (payloadLength % 4); + const withinCap = `${prefix}${"a".repeat(validPayloadLength - 2)}==`; + assert.ok(withinCap.length <= 256 * 1_024); + assert.equal(catalogAvatarUrl(withinCap), withinCap); + assert.equal( + catalogAvatarUrl( + `${withinCap}${"a".repeat(256 * 1_024 - withinCap.length + 1)}`, + ), + null, + ); + assert.equal(catalogAvatarUrl("data:image/png;base64,not base64"), null); + assert.equal(catalogAvatarUrl("data:image/bmp;base64,aA=="), null); +}); + test("test_oversized_inline_svg_avatar_is_rejected", () => { const withinCap = `data:image/svg+xml,${"a".repeat(8_192 - "data:image/svg+xml,".length)}`; assert.equal(withinCap.length, 8_192); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index c85a976ba6..02c3f8e202 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -93,6 +93,16 @@ function isSafeHttpUrl(value: unknown): value is string { const INLINE_SVG_AVATAR_PREFIX = "data:image/svg+xml,"; const MAX_INLINE_SVG_AVATAR_LENGTH = 8_192; +/** + * Shared persona heads can carry an uploaded avatar as an inline raster. Keep + * those self-contained images renderable without accepting arbitrary `data:` + * URLs: only the raster MIME types browsers decode in ``, strict base64 + * shape, and a bound no larger than the relay's event-content ceiling. + */ +const MAX_INLINE_RASTER_AVATAR_LENGTH = 256 * 1_024; +const INLINE_RASTER_AVATAR_RE = + /^data:image\/(?:png|jpeg|gif|webp);base64,([A-Za-z0-9+/]+={0,2})$/u; + function isInlineSvgAvatar(value: unknown): value is string { return ( typeof value === "string" && @@ -101,6 +111,17 @@ function isInlineSvgAvatar(value: unknown): value is string { ); } +function isInlineRasterAvatar(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length > MAX_INLINE_RASTER_AVATAR_LENGTH + ) { + return false; + } + const match = INLINE_RASTER_AVATAR_RE.exec(value); + return match !== null && (match[1]?.length ?? 0) % 4 === 0; +} + function optionalString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } @@ -121,7 +142,9 @@ function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null { } const avatarUrl = - isSafeHttpUrl(parsed.avatar_url) || isInlineSvgAvatar(parsed.avatar_url) + isSafeHttpUrl(parsed.avatar_url) || + isInlineSvgAvatar(parsed.avatar_url) || + isInlineRasterAvatar(parsed.avatar_url) ? parsed.avatar_url : null; const namePool = Array.isArray(parsed.name_pool)