v0.20 PR 7: Agent avatar upload across all 14 identity surfaces#77
Merged
v0.20 PR 7: Agent avatar upload across all 14 identity surfaces#77
Conversation
POST validates PNG/JPEG/WebP at MIME allowlist AND magic-byte sniff; SVG is rejected twice. Extension is derived from MIME, never from filename. 2MB cap enforced at content-length AND at read. Atomic tmp+rename for both image and meta files. Zero server-side image decoding; Bun writes bytes verbatim. GET /ui/avatar is public (the landing page surfaces before login), 5min cache, sha256 ETag with 304 revalidation. /health and the core server mount /chat/icon as a PWA-scope-friendly mirror of the same bytes.
…atar The _agent-name.js IIFE now reads avatar_url from /health, fills every [data-agent-avatar] slot with an img tag (with an error-triggered fallback to the letter badge), and caches the URL in localStorage so warm loads paint without a flash. _base.html gets AGENT_AVATAR_IMG + AGENT_FALLBACK_DISPLAY placeholders so phantom_create_page output picks up the current avatar. Landing, dashboard, and login pages all swap the single-letter badge for avatar slots and point their favicon links at /ui/avatar (falling back to data:, on 404). The login page server-renders conditionally using a cheap existsSync on avatar.meta.json so cookie-less visitors see the correct identity.
Custom-rendered preamble above the phantom.yaml form sections. Client-side canvas resize to 256x256 (cover-fit, PNG encoding so transparency survives), size + MIME guard before POST. Preview img with cache-busting ?v=<counter> and a letter-badge fallback. Drag-drop zone is keyboard-accessible with Enter and Space. Reset uses the dashboard modal for confirmation. Dispatches phantom:avatar-updated so the surrounding dashboard nav IIFE repaints instantly without waiting on the 5-minute cache.
BootstrapData gains avatar_url. use-bootstrap bumps its localStorage key to v2 so stale caches don't break the shape. AppShell renders a 20x20 rounded img before the agent name and posts SET_AVATAR_URL to the Service Worker each time the URL changes. SidebarFooter renders a 32x32 variant next to the agent name and Gen pill. The Service Worker caches the URL in a module-level variable and uses it for push notification icon and badge (falling back to /chat/icon, which falls back to /chat/favicon.svg). The static manifest fallback in chat-ui/public ships both /chat/icon and the SVG so dev PWAs match prod.
Covers zero server-side decoding, the MIME + magic-byte double check, MIME- derived extensions, the two-point 2MB cap, atomic tmp+rename, and the auth split (write/delete authed, read public).
Folded three per-format byte factories into a single withHeader() helper shared across the 28 test cases. Same coverage, ~40 lines lighter.
P2 (reviewer): em dash at _agent-name.js:66 in a JS comment. Cardinal Rule violation. Replaced with a comma clause. P2 (reviewer): no tests backed the /health avatar_url, /chat/bootstrap avatar_url, or the dynamic manifest icon shape. Added five direct-call tests for avatarUrlIfPresent() and readAvatarMetaForManifest(), which are the helpers those three surfaces consume. Testing the helpers rather than the full HTTP round-trip keeps the coverage proportional because the wire endpoints live in core/server.ts and chat/http.ts, outside the identity handler under test. Deferred follow-ups documented in review file: - Atomic-write rollback test (needs a renameSync seam; success path is already verified and magic-byte/allowlist/auth remain the real security surface) - Landing favicon 404 on fresh installs (IIFE updates on success, does not reset to data: fallback on null) - Dead chat-ui/public/manifest.webmanifest (dynamic handler intercepts in prod) - Partial-write orphan when meta write fails after image write (next upload self-heals via prune block)
mcheemaa
added a commit
that referenced
this pull request
Apr 17, 2026
Bumps the version to 0.20.0 in every place it's referenced: - package.json (1) - src/core/server.ts VERSION constant - src/mcp/server.ts MCP server identity - src/cli/index.ts phantom --version output - README.md version + tests badges - CLAUDE.md tagline + bun test count - CONTRIBUTING.md test count Tests: 1,799 pass / 10 skip / 0 fail. Typecheck and lint clean. No 0.19.1 or 1,584-tests references remain in source, docs, or badges. v0.20 shipped eight PRs on top of v0.19.1: #71 entrypoint dashboard sync + / redirect + /health HTML #72 Sessions dashboard tab #73 Cost dashboard tab #74 Scheduler tab + create-job + Sonnet describe-assist #75 Evolution Phase A + Memory explorer tabs #76 Settings page restructure (phantom.yaml, 6 sections) #77 Agent avatar upload across 14 identity surfaces #79 Landing page redesign (hero, starter tiles, live pages list)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Operators can now upload a PNG, JPEG, or WebP image in Settings > Identity. The image replaces the first-letter brand badge everywhere the agent identity renders. Single on-disk file under the
phantom_dataDocker volume, one atomic write path, two public reads, full cookie-auth on write/delete.14 surfaces touched
/ui/navbarpublic/index.htmlswaps the letter badge for adata-agent-avatarslot;_agent-name.jsIIFE fills it from/health.avatar_url.public/index.htmladds<link rel="icon" type="image/png" href="/ui/avatar">(with adata:,fallback).src/ui/login-page.tsprobes the avatar file synchronously and emits an<img>with letter-fallback when present./ui/avatarwhen present,data:,otherwise./ui/dashboard/navbarpublic/dashboard/index.htmlswaps to the same slot markup; IIFE fills it.phantom_create_page)public/_base.htmlgains{{AGENT_AVATAR_IMG}}+{{AGENT_FALLBACK_DISPLAY}}placeholders;src/ui/tools.ts:wrapInBaseTemplatesubstitutes them with an<img>block when an avatar exists.chat-ui/src/components/app-shell.tsxrenders a 20x20 rounded<img>before the agent name.chat-ui/src/components/sidebar-footer.tsxrenders a 32x32 rounded<img>next to the agent name.src/chat/http.tsdynamic manifest emits/chat/icon(256x256) primary + SVG fallback when an avatar exists.chat-ui/public/sw.jscaches the URL viaSET_AVATAR_URLposted fromAppShelland uses it for pushicon+badge.<link rel="icon">at/ui/avatar.Slack, email, and other platform-controlled avatars remain out of scope; Settings > Identity surfaces a one-line hint pointing operators at the Slack app settings.
New endpoints
POST /ui/api/identity/avatar- auth, multipart file upload.DELETE /ui/api/identity/avatar- auth, removes bothavatar.<ext>andavatar.meta.json.GET /ui/avatar- public, 5 minute cache, sha256 ETag, 304 revalidation.GET /chat/icon- public PWA-scope mirror of the same bytes./health.avatar_urland/chat/bootstrap.avatar_urlexpose the URL when present,nullotherwise.Security posture
Content-Lengthheader before parsing, and re-check the actual bytes after read so a lying header cannot bypass.*.tmpthen renamed, so mid-write failure leaves the prior avatar intact.Test plan
Automated (all green, 1770 pass / 0 fail):
Visual verification (recommended on first post-merge deploy):
/ui/navbar and favicon pick up the image./ui/dashboard/navbar and favicon pick up the image./chatheader and sidebar footer show the image./loginin an incognito window; the login page shows the logo.phantom_create_pagerenders the image in the agent-generated page navbar..png->.jpgcleanly.Known constraints
Reference
local/2026-04-16-v0.20-next-level/research/04-avatar-and-landing.mdSection 1.docs/security.mdhas an "Avatar Upload" section covering the layered security stance.Stats
21 files, 1216 insertions, 41 deletions. Under the 1370 LOC ceiling. identity.ts at 237/260. settings.js Identity section at 298/300.