Skip to content

v0.20 PR 7: Agent avatar upload across all 14 identity surfaces#77

Merged
mcheemaa merged 7 commits intomainfrom
v0.20-pr-07-avatar-upload
Apr 17, 2026
Merged

v0.20 PR 7: Agent avatar upload across all 14 identity surfaces#77
mcheemaa merged 7 commits intomainfrom
v0.20-pr-07-avatar-upload

Conversation

@mcheemaa
Copy link
Copy Markdown
Member

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_data Docker volume, one atomic write path, two public reads, full cookie-auth on write/delete.

14 surfaces touched

# Surface Touch
1 Landing /ui/ navbar public/index.html swaps the letter badge for a data-agent-avatar slot; _agent-name.js IIFE fills it from /health.avatar_url.
2 Landing favicon public/index.html adds <link rel="icon" type="image/png" href="/ui/avatar"> (with a data:, fallback).
3 Login top-bar logo src/ui/login-page.ts probes the avatar file synchronously and emits an <img> with letter-fallback when present.
4 Login favicon Same page uses /ui/avatar when present, data:, otherwise.
5 Dashboard /ui/dashboard/ navbar public/dashboard/index.html swaps to the same slot markup; IIFE fills it.
6 Dashboard page title Already dynamic via IIFE; no change needed.
7 Agent-generated pages (phantom_create_page) public/_base.html gains {{AGENT_AVATAR_IMG}} + {{AGENT_FALLBACK_DISPLAY}} placeholders; src/ui/tools.ts:wrapInBaseTemplate substitutes them with an <img> block when an avatar exists.
8 Chat SPA header chat-ui/src/components/app-shell.tsx renders a 20x20 rounded <img> before the agent name.
9 Chat SPA sidebar footer chat-ui/src/components/sidebar-footer.tsx renders a 32x32 rounded <img> next to the agent name.
10 PWA installed icon src/chat/http.ts dynamic manifest emits /chat/icon (256x256) primary + SVG fallback when an avatar exists.
11 PWA manifest name Already dynamic; no change.
12 Service Worker push icon chat-ui/public/sw.js caches the URL via SET_AVATAR_URL posted from AppShell and uses it for push icon + badge.
13 Chat SPA document.title Already dynamic; no change.
14 Browser favicon on every static page Landing + dashboard + login all point <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 both avatar.<ext> and avatar.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_url and /chat/bootstrap.avatar_url expose the URL when present, null otherwise.

Security posture

  • Zero server-side image decoding. Bun writes bytes verbatim; the browser decodes in its sandboxed renderer. No malformed-JPEG-crashes-Bun attack surface.
  • MIME allowlist plus magic-byte sniff. PNG, JPEG, WebP only. SVG is rejected twice: once at MIME and once by inspecting the first bytes. Rejects HEIC, GIF, AVIF by default.
  • Extension derived from validated MIME, never from filename. The operator's filename never flows into a path; traversal is impossible.
  • 2 MB cap at two points. Check the Content-Length header before parsing, and re-check the actual bytes after read so a lying header cannot bypass.
  • Atomic tmp + rename. Both image and meta are written as *.tmp then renamed, so mid-write failure leaves the prior avatar intact.
  • Auth. POST and DELETE require the cookie session. GET is public so the landing and login pages can render for unauthenticated visitors.

Test plan

Automated (all green, 1770 pass / 0 fail):

  • 28 identity-API cases exercise the MIME allowlist, magic-byte sniff, SVG rejection at two layers, HEIC/GIF rejection, both 2 MB checkpoints, filename traversal defence, atomic rename, ETag 304 flow, PNG-to-WebP extension swap, stale-file recovery, idempotent DELETE, 405 on wrong methods.
  • Pre-existing manifest + bootstrap tests still pass.

Visual verification (recommended on first post-merge deploy):

  • Upload a 512x512 PNG via Settings > Identity; preview updates within 500ms.
  • /ui/ navbar and favicon pick up the image.
  • /ui/dashboard/ navbar and favicon pick up the image.
  • /chat header and sidebar footer show the image.
  • Log out, visit /login in an incognito window; the login page shows the logo.
  • Install the PWA; home-screen icon is the uploaded image.
  • Trigger a push test; notification icon is the uploaded image.
  • phantom_create_page renders the image in the agent-generated page navbar.
  • Re-upload a JPEG; disk transitions .png -> .jpg cleanly.
  • Reset to letter; every surface falls back to the first-letter badge.
  • Dark and light themes both render the image with proper border color.

Known constraints

  • Browsers cache the dynamic manifest; operators who edit the avatar after installing the PWA may need to re-install or wait for the OS to refresh.
  • Slack channel avatar remains Slack-owned; operators change it via the Slack app settings at api.slack.com/apps.
  • Email recipient avatars are Gravatar / MUA-controlled; we cannot influence them.

Reference

  • Canonical spec: local/2026-04-16-v0.20-next-level/research/04-avatar-and-landing.md Section 1.
  • New documentation: docs/security.md has 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.

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 mcheemaa merged commit e53770d into main Apr 17, 2026
1 check passed
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant