diff --git a/chat-ui/public/manifest.webmanifest b/chat-ui/public/manifest.webmanifest
index d6cebd1..8ab95d9 100644
--- a/chat-ui/public/manifest.webmanifest
+++ b/chat-ui/public/manifest.webmanifest
@@ -9,6 +9,12 @@
"background_color": "#faf9f5",
"theme_color": "#4850c4",
"icons": [
+ {
+ "src": "/chat/icon",
+ "sizes": "256x256",
+ "type": "image/png",
+ "purpose": "any"
+ },
{
"src": "/chat/favicon.svg",
"sizes": "any",
diff --git a/chat-ui/public/sw.js b/chat-ui/public/sw.js
index bd30cfd..b16f243 100644
--- a/chat-ui/public/sw.js
+++ b/chat-ui/public/sw.js
@@ -13,6 +13,11 @@ const SHELL_CACHE = "phantom-chat-shell-" + VERSION;
// which would render title and body as the same string.
var agentName = "";
+// Avatar URL posted by the client. Null or empty means "use favicon.svg".
+// Falls through /chat/icon which is the PWA-scope-friendly mirror of
+// /ui/avatar; the fetch still works if the scope is installed as a PWA.
+var avatarUrl = "";
+
self.addEventListener("install", function (event) {
self.skipWaiting();
});
@@ -109,10 +114,11 @@ self.addEventListener("push", function (event) {
// Using data.body here caused title=body duplication when the client
// had not yet posted the agent name (push landed before first mount).
var title = data.title || agentName || "Message";
+ var icon = avatarUrl || "/chat/icon";
var options = {
body: data.body || "",
- icon: "/chat/favicon.svg",
- badge: "/chat/favicon.svg",
+ icon: icon,
+ badge: icon,
tag: data.tag,
data: data.data || {},
requireInteraction: data.requireInteraction || false,
@@ -155,4 +161,9 @@ self.addEventListener("message", function (event) {
if (event.data && event.data.type === "SET_AGENT_NAME" && typeof event.data.agentName === "string") {
agentName = event.data.agentName;
}
+ if (event.data && event.data.type === "SET_AVATAR_URL") {
+ // Null / empty string means "no avatar; fall back to /chat/icon (which
+ // 404s if no avatar is uploaded) and then the SVG default".
+ avatarUrl = typeof event.data.url === "string" ? event.data.url : "";
+ }
});
diff --git a/chat-ui/src/components/app-shell.tsx b/chat-ui/src/components/app-shell.tsx
index 895715c..c9f75b0 100644
--- a/chat-ui/src/components/app-shell.tsx
+++ b/chat-ui/src/components/app-shell.tsx
@@ -19,9 +19,11 @@ export function AppShell({ children }: { children: React.ReactNode }) {
useSessions();
const { toggleTheme } = useTheme();
const isMobile = useIsMobile();
- const { data: bootstrap, cachedName } = useBootstrap();
+ const { data: bootstrap, cachedName, cachedAvatarUrl } = useBootstrap();
const agentName = bootstrap?.agent_name ?? cachedName ?? "Agent";
+ const avatarUrl = bootstrap?.avatar_url ?? cachedAvatarUrl ?? null;
+ const [avatarBroken, setAvatarBroken] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(!isMobile);
const [deleteTarget, setDeleteTarget] = useState<{
@@ -50,6 +52,22 @@ export function AppShell({ children }: { children: React.ReactNode }) {
.catch(() => {});
}, [agentName]);
+ // Mirror the avatar URL into the Service Worker so push notifications
+ // render the operator's logo. Null unsets any cached icon.
+ useEffect(() => {
+ if (typeof navigator === "undefined" || !navigator.serviceWorker) return;
+ navigator.serviceWorker.ready
+ .then((reg) => {
+ reg.active?.postMessage({ type: "SET_AVATAR_URL", url: avatarUrl });
+ })
+ .catch(() => {});
+ }, [avatarUrl]);
+
+ // Reset the broken flag when the URL changes (new upload -> retry display).
+ useEffect(() => {
+ setAvatarBroken(false);
+ }, [avatarUrl]);
+
const handleNewSession = useCallback(async () => {
const id = await createSession();
navigate(`/s/${id}`);
@@ -152,6 +170,14 @@ export function AppShell({ children }: { children: React.ReactNode }) {
>
+ {avatarUrl && !avatarBroken ? (
+ setAvatarBroken(true)}
+ />
+ ) : null}
{agentName}
diff --git a/chat-ui/src/components/sidebar-footer.tsx b/chat-ui/src/components/sidebar-footer.tsx
index dcbda54..db03398 100644
--- a/chat-ui/src/components/sidebar-footer.tsx
+++ b/chat-ui/src/components/sidebar-footer.tsx
@@ -1,21 +1,39 @@
+import { useEffect, useState } from "react";
import { useBootstrap } from "@/hooks/use-bootstrap";
import { ThemeToggle } from "./theme-toggle";
export function SidebarFooter() {
- const { data, cachedName, cachedGen } = useBootstrap();
+ const { data, cachedName, cachedGen, cachedAvatarUrl } = useBootstrap();
const agentName = data?.agent_name ?? cachedName ?? "Agent";
const gen = data?.evolution_gen ?? cachedGen;
+ const avatarUrl = data?.avatar_url ?? cachedAvatarUrl ?? null;
+ const [avatarBroken, setAvatarBroken] = useState(false);
+
+ // Reset the broken flag when a fresh avatar URL arrives (post-upload).
+ useEffect(() => {
+ setAvatarBroken(false);
+ }, [avatarUrl]);
return (
-
-
-
- {agentName}
-
-
- {gen != null && gen > 0 &&
Gen {gen} }
+
+
+ {avatarUrl && !avatarBroken ? (
+
setAvatarBroken(true)}
+ />
+ ) : null}
+
+
+ {agentName}
+
+
+ {gen != null && gen > 0 && Gen {gen} }
+
diff --git a/chat-ui/src/hooks/use-bootstrap.ts b/chat-ui/src/hooks/use-bootstrap.ts
index eac2581..7e0c1f8 100644
--- a/chat-ui/src/hooks/use-bootstrap.ts
+++ b/chat-ui/src/hooks/use-bootstrap.ts
@@ -11,11 +11,14 @@ import { getBootstrap, type BootstrapData } from "@/lib/client";
// Exported so pre-mount bootstrap code (main.tsx) can read the same key
// without duplicating the literal. Renaming the key now requires one edit.
-export const STORAGE_KEY = "phantom-chat-bootstrap-v1";
+// v2 bump: adds avatar_url to the cached shape so warm loads paint the
+// brand immediately instead of flashing the letter badge.
+export const STORAGE_KEY = "phantom-chat-bootstrap-v2";
type CachedBootstrap = {
agent_name: string;
evolution_gen: number;
+ avatar_url: string | null;
};
let inFlightPromise: Promise
| null = null;
@@ -45,6 +48,7 @@ function writeCache(data: BootstrapData): void {
const minimal: CachedBootstrap = {
agent_name: data.agent_name,
evolution_gen: data.evolution_gen,
+ avatar_url: data.avatar_url ?? null,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(minimal));
} catch {
@@ -71,6 +75,7 @@ export function useBootstrap(): {
data: BootstrapData | null;
cachedName: string | null;
cachedGen: number | null;
+ cachedAvatarUrl: string | null;
} {
const [data, setData] = useState(cachedData);
// Lazy initializers: readCache() only runs once per consumer mount
@@ -88,6 +93,10 @@ export function useBootstrap(): {
if (typeof window === "undefined") return null;
return readCache()?.evolution_gen ?? null;
});
+ const [cachedAvatarUrl, setCachedAvatarUrl] = useState(() => {
+ if (typeof window === "undefined") return null;
+ return readCache()?.avatar_url ?? null;
+ });
useEffect(() => {
let cancelled = false;
@@ -97,6 +106,7 @@ export function useBootstrap(): {
setData(next);
setCachedName(next.agent_name);
setCachedGen(next.evolution_gen);
+ setCachedAvatarUrl(next.avatar_url ?? null);
})
.catch(() => {});
return () => {
@@ -104,7 +114,7 @@ export function useBootstrap(): {
};
}, []);
- return { data, cachedName, cachedGen };
+ return { data, cachedName, cachedGen, cachedAvatarUrl };
}
// Non-hook accessor for consumers that already hold data and want the
diff --git a/chat-ui/src/lib/client.ts b/chat-ui/src/lib/client.ts
index 8a0d493..c6936eb 100644
--- a/chat-ui/src/lib/client.ts
+++ b/chat-ui/src/lib/client.ts
@@ -4,6 +4,7 @@
export type BootstrapData = {
agent_name: string;
evolution_gen: number;
+ avatar_url: string | null;
memory_count: number;
slack_status: string;
scheduled_jobs_count: number;
diff --git a/docs/security.md b/docs/security.md
index 6cfc37c..f96c700 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -182,6 +182,35 @@ Dynamic tools (registered at runtime by the agent) execute code in isolated subp
- Bun script handlers use `--env-file=` to prevent automatic loading of `.env` files
- Tool input is passed via the TOOL_INPUT environment variable (JSON string)
+## Avatar Upload
+
+The Settings > Identity card accepts PNG, JPEG, and WebP images up to 2 MB,
+stored at `data/identity/avatar.` with a companion `avatar.meta.json`.
+The upload path is locked down across several layers:
+
+- **Zero server-side image decoding.** Bun writes bytes verbatim; the image
+ is only ever decoded inside the browser's sandboxed renderer. This
+ eliminates the "malformed JPEG crashes Bun" class of attack.
+- **MIME allowlist plus magic-byte sniff.** `image/png`, `image/jpeg`,
+ `image/webp` only. SVG is rejected at MIME AND by inspecting the first
+ bytes, which catches SVG (or any other format) renamed to `.png` and
+ submitted with a forged MIME.
+- **Extension derived from the validated MIME.** The operator's filename is
+ never used in any filesystem path, so path traversal via a crafted
+ filename is impossible.
+- **2 MB cap enforced at content-length AND at read.** The client-side
+ Content-Length header is treated as a hint; the server re-checks after
+ reading so a lying or missing header cannot bypass the cap.
+- **Atomic tmp + rename.** Both the image file and its meta JSON are
+ written to `*.tmp` first and then renamed, so a mid-write failure leaves
+ the previous avatar intact.
+- **Auth posture.** POST + DELETE require the cookie session (owner).
+ `GET /ui/avatar` and the scope-friendly mirror `GET /chat/icon` are
+ public because the landing page renders before login.
+
+The avatar is operator-visual state, not configuration; it is not subject to
+the phantom.yaml audit log.
+
## Webhook Callback URL Validation
Webhook callback URLs are validated before use to prevent SSRF attacks:
diff --git a/public/_agent-name.js b/public/_agent-name.js
index 3fc6c16..88526b3 100644
--- a/public/_agent-name.js
+++ b/public/_agent-name.js
@@ -1,16 +1,22 @@
-// Canonical agent-name customization IIFE for Phantom static pages.
+// Canonical agent-name and avatar customization IIFE for Phantom static pages.
//
// Loaded once per page with .
// Replaces [data-agent-name], [data-agent-name-initial], [data-agent-name-lower]
// nodes with the deployed agent name and substitutes {{AGENT_NAME_CAPITALIZED}}
// in any template.
//
+// Avatar: if the operator has uploaded one, any [data-agent-avatar] element
+// gets an inserted. A sibling marked
+// [data-agent-avatar-fallback] is hidden on successful load and un-hidden if
+// the image errors (so the initial-letter badge still reads).
+//
// Mirrors the server-side capitalizeAgentName contract: empty/whitespace name
// falls back to "Phantom" so the brand never reads as blank. Paints an
-// optimistic value from localStorage (or "Phantom") on load, then swaps when
-// /health resolves so warm loads have no flash and cold loads see "Phantom"
-// instead of a stray until the fetch resolves.
+// optimistic value from localStorage (agent name AND avatar URL) on load, then
+// swaps when /health resolves so warm loads have no flash.
(function () {
+ var AVATAR_KEY = "phantom-agent-avatar";
+
function cap(name) {
if (!name) return "Phantom";
var trimmed = String(name).trim();
@@ -35,7 +41,7 @@
}
}
- function apply(name) {
+ function applyName(name) {
var display = cap(name);
var initial = display.charAt(0).toUpperCase();
var lower = display.toLowerCase();
@@ -52,24 +58,82 @@
titleEl.textContent = titleTemplate.split("{{AGENT_NAME_CAPITALIZED}}").join(display);
}
try {
- if (name) {
- localStorage.setItem("phantom-agent-name", name);
+ if (name) localStorage.setItem("phantom-agent-name", name);
+ } catch (e) {}
+ }
+
+ function applyAvatar(url) {
+ // null means "no avatar uploaded", so make sure any previously-inserted
+ // img is removed and fallbacks are visible.
+ document.querySelectorAll("[data-agent-avatar]").forEach(function (slot) {
+ var existing = slot.querySelector("img[data-agent-avatar-img]");
+ var fallback = slot.querySelector("[data-agent-avatar-fallback]");
+ if (!url) {
+ if (existing) existing.remove();
+ if (fallback) fallback.style.display = "";
+ return;
+ }
+ if (existing) {
+ if (existing.getAttribute("src") !== url) existing.setAttribute("src", url);
+ return;
}
+ var img = document.createElement("img");
+ img.setAttribute("data-agent-avatar-img", "");
+ img.setAttribute("alt", "");
+ img.className = "phantom-nav-logo-img";
+ img.addEventListener("error", function () {
+ img.remove();
+ if (fallback) fallback.style.display = "";
+ });
+ img.addEventListener("load", function () {
+ if (fallback) fallback.style.display = "none";
+ });
+ img.setAttribute("src", url);
+ // Hide the fallback letter the moment we commit to inserting the
+ // img. If it errors the listener above brings it back.
+ if (fallback) fallback.style.display = "none";
+ slot.insertBefore(img, fallback || null);
+ });
+ document.querySelectorAll("[data-agent-avatar-url]").forEach(function (el) {
+ if (url) {
+ el.setAttribute("content", url);
+ el.setAttribute("href", url);
+ }
+ });
+ try {
+ if (url) localStorage.setItem(AVATAR_KEY, url);
+ else localStorage.removeItem(AVATAR_KEY);
} catch (e) {}
}
- var cached = "";
+ var cachedName = "";
+ var cachedAvatar = null;
try {
- cached = localStorage.getItem("phantom-agent-name") || "";
+ cachedName = localStorage.getItem("phantom-agent-name") || "";
+ cachedAvatar = localStorage.getItem(AVATAR_KEY);
} catch (e) {}
- apply(cached || "Phantom");
+ applyName(cachedName || "Phantom");
+ if (cachedAvatar) applyAvatar(cachedAvatar);
- fetch("/health", { credentials: "same-origin" })
+ fetch("/health", { credentials: "same-origin", headers: { Accept: "application/json" } })
.then(function (r) {
return r.ok ? r.json() : null;
})
.then(function (d) {
- if (d && d.agent) apply(d.agent);
+ if (!d) return;
+ if (d.agent) applyName(d.agent);
+ // avatar_url is null when no upload, "/ui/avatar" otherwise.
+ if (Object.prototype.hasOwnProperty.call(d, "avatar_url")) {
+ applyAvatar(d.avatar_url || null);
+ }
})
.catch(function () {});
+
+ // Exposed so the dashboard Settings > Identity section can force the
+ // surrounding navbar to repaint immediately after a successful upload,
+ // without waiting for the 5-minute cache to expire.
+ window.addEventListener("phantom:avatar-updated", function (ev) {
+ var url = ev && ev.detail && ev.detail.url;
+ applyAvatar(url === undefined ? "/ui/avatar" : url);
+ });
})();
diff --git a/public/_base.html b/public/_base.html
index 8e38cc1..dfbc987 100644
--- a/public/_base.html
+++ b/public/_base.html
@@ -1010,7 +1010,7 @@
- {{AGENT_NAME_INITIAL}}
+ {{AGENT_AVATAR_IMG}}{{AGENT_NAME_INITIAL}}
{{AGENT_NAME_CAPITALIZED}}
/
diff --git a/public/dashboard/dashboard.css b/public/dashboard/dashboard.css
index 241a09f..96aab69 100644
--- a/public/dashboard/dashboard.css
+++ b/public/dashboard/dashboard.css
@@ -3026,3 +3026,87 @@ body {
@media (prefers-reduced-motion: reduce) {
.dash-sched-inline-spinner { animation: none; }
}
+
+/* ==== Identity section (Settings > Identity): avatar upload ==== */
+.phantom-nav-logo-img { width: 22px; height: 22px; border-radius: 6px; object-fit: cover; display: inline-block; }
+.phantom-nav-avatar-slot { display: inline-flex; align-items: center; }
+
+.dash-identity-card {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: var(--space-6);
+ align-items: center;
+ padding: var(--space-5);
+ border: 1px solid var(--color-base-300);
+ border-radius: var(--radius-lg);
+ background: var(--color-base-200);
+}
+@media (max-width: 640px) {
+ .dash-identity-card { grid-template-columns: 1fr; justify-items: center; text-align: center; }
+}
+
+.dash-avatar-preview-wrap {
+ width: 96px;
+ height: 96px;
+ position: relative;
+}
+.dash-avatar-preview {
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 1px solid var(--color-base-300);
+ display: block;
+ background: var(--color-base-100);
+}
+.dash-avatar-preview-letter {
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ background: var(--color-primary);
+ color: var(--color-primary-content);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-family: 'Instrument Serif', Georgia, serif;
+ font-size: 56px;
+ font-weight: 400;
+ border: 1px solid var(--color-base-300);
+ user-select: none;
+}
+
+.dash-avatar-drop {
+ border: 1.5px dashed var(--color-base-300);
+ border-radius: var(--radius-md);
+ padding: var(--space-4);
+ font-size: 13px;
+ color: color-mix(in oklab, var(--color-base-content) 62%, transparent);
+ transition: border-color var(--motion-fast) var(--ease-out), background-color var(--motion-fast) var(--ease-out);
+ cursor: pointer;
+ outline: none;
+}
+.dash-avatar-drop[data-drag="true"],
+.dash-avatar-drop:focus-visible {
+ border-color: var(--color-primary);
+ background: color-mix(in oklab, var(--color-primary) 5%, transparent);
+ color: var(--color-base-content);
+}
+.dash-avatar-drop strong { color: var(--color-base-content); }
+
+.dash-identity-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-top: var(--space-3);
+}
+.dash-identity-guidance {
+ font-size: 12px;
+ color: color-mix(in oklab, var(--color-base-content) 58%, transparent);
+ line-height: 1.5;
+}
+.dash-identity-guidance p { margin: 0 0 var(--space-2); }
+.dash-identity-guidance p:last-child { margin-bottom: 0; }
+.dash-identity-slack {
+ font-style: italic;
+}
+
diff --git a/public/dashboard/index.html b/public/dashboard/index.html
index db3dca7..2ef39e8 100644
--- a/public/dashboard/index.html
+++ b/public/dashboard/index.html
@@ -4,6 +4,7 @@
Dashboard
+
@@ -16,7 +17,9 @@
-
+
+
+
/
diff --git a/public/dashboard/settings.js b/public/dashboard/settings.js
index b524ace..432109e 100644
--- a/public/dashboard/settings.js
+++ b/public/dashboard/settings.js
@@ -77,6 +77,21 @@
audit: { entries: null, loading: false, error: null, expanded: false, loaded: false },
lastModifiedAt: null,
lastModifiedBy: null,
+ avatar: {
+ // URL used by the . Bumped with ?v= after upload so
+ // the browser cache does not show the prior image. Starts as null
+ // until the first probe resolves.
+ url: null,
+ probing: false,
+ uploading: false,
+ resetting: false,
+ cacheBust: 0,
+ lastError: null,
+ // Tracks whether the HEAD probe has ever succeeded this session.
+ // Drives the visibility of the Reset button.
+ exists: null,
+ displayName: "",
+ },
};
var ctx = null;
var root = null;
@@ -549,6 +564,280 @@
return "";
}
+ // ----- Identity > Avatar custom card --------------------------------
+ // The avatar is operator-visual state (not a phantom.yaml key) so it has
+ // its own upload/delete endpoint at /ui/api/identity/avatar. This card
+ // renders as a preamble to the six-section form. It owns its cacheBust
+ // counter so the preview img swaps instantly after a successful POST.
+
+ function avatarInitialLetter() {
+ var d = state.avatar.displayName || (state.sections.identity.draft && state.sections.identity.draft.name) || "P";
+ var s = String(d).trim();
+ if (!s) return "P";
+ return s.charAt(0).toUpperCase();
+ }
+
+ function avatarPreviewHtml() {
+ var a = state.avatar;
+ var letter = avatarInitialLetter();
+ var url = a.url ? a.url + (a.cacheBust ? "?v=" + a.cacheBust : "") : null;
+ var hidden = a.exists === false;
+ var imgDisplay = hidden ? "none" : "block";
+ var letterDisplay = hidden ? "inline-flex" : "none";
+ var img = ' ';
+ var fallback = '' + esc(letter) + ' ';
+ return '' + img + fallback + '
';
+ }
+
+ function avatarActionsHtml() {
+ var a = state.avatar;
+ var uploadLabel = a.uploading ? "Uploading..." : "Choose image";
+ var resetLabel = a.resetting ? "Removing..." : "Reset to letter";
+ var resetBtn = a.exists
+ ? '" + esc(resetLabel) + " "
+ : "";
+ return (
+ '' +
+ '" + esc(uploadLabel) + " " +
+ ' ' +
+ resetBtn +
+ "
"
+ );
+ }
+
+ function avatarCardHtml() {
+ var a = state.avatar;
+ var errorBlock = a.lastError
+ ? '' + esc(a.lastError) + "
"
+ : "";
+ return (
+ '' +
+ "" +
+ 'Identity ' +
+ 'Upload a logo and it replaces the first-letter badge on the landing, dashboard, chat, login, and every page your agent generates. Stored as a single file on disk; served with a 5-minute cache.
' +
+ " " +
+ '' +
+ avatarPreviewHtml() +
+ '
' +
+ '
Drop an image here, or choose one. PNG, JPEG, or WebP, up to 2 MB. We scale it to 256x256 before upload.
' +
+ avatarActionsHtml() +
+ errorBlock +
+ '
' +
+ '
Your image appears in the navbar, chat header, sidebar, push notifications, the PWA home-screen icon, and the browser favicon.
' +
+ '
To change the Slack avatar, edit the icon in your Slack app settings at api.slack.com/apps .
' +
+ '
If you installed the chat as a PWA, the home-screen icon may cache. Re-install or wait for the OS to refresh.
' +
+ "
" +
+ "
" +
+ "
" +
+ " "
+ );
+ }
+
+ function probeAvatar() {
+ if (state.avatar.probing) return;
+ state.avatar.probing = true;
+ fetch("/ui/avatar", { method: "GET", credentials: "same-origin", cache: "no-store" })
+ .then(function (r) {
+ state.avatar.probing = false;
+ state.avatar.exists = r.ok;
+ state.avatar.url = r.ok ? "/ui/avatar" : null;
+ render();
+ })
+ .catch(function () {
+ state.avatar.probing = false;
+ state.avatar.exists = false;
+ render();
+ });
+ }
+
+ function resizeToBlob(file) {
+ // cover-fit to 256x256, encode as PNG (preserves transparency).
+ return createImageBitmap(file).then(function (bitmap) {
+ var canvas = document.createElement("canvas");
+ canvas.width = 256; canvas.height = 256;
+ var ctx2d = canvas.getContext("2d");
+ if (!ctx2d) throw new Error("Canvas 2d context unavailable");
+ var ratio = Math.max(256 / bitmap.width, 256 / bitmap.height);
+ var w = bitmap.width * ratio;
+ var h = bitmap.height * ratio;
+ ctx2d.clearRect(0, 0, 256, 256);
+ ctx2d.drawImage(bitmap, (256 - w) / 2, (256 - h) / 2, w, h);
+ return new Promise(function (resolve, reject) {
+ canvas.toBlob(function (blob) { blob ? resolve(blob) : reject(new Error("Canvas encode failed")); }, "image/png", 0.92);
+ });
+ });
+ }
+
+ function postAvatarBlob(blob) {
+ var form = new FormData();
+ form.append("file", blob, "avatar.png");
+ return fetch("/ui/api/identity/avatar", {
+ method: "POST",
+ credentials: "same-origin",
+ body: form,
+ }).then(function (res) {
+ if (!res.ok) {
+ return res.json().then(function (body) {
+ throw new Error((body && body.error) || ("Upload failed (" + res.status + ")"));
+ }, function () {
+ throw new Error("Upload failed (" + res.status + ")");
+ });
+ }
+ return res.json();
+ });
+ }
+
+ function beginUpload(file) {
+ if (!file) return;
+ if (state.avatar.uploading) return;
+ state.avatar.lastError = null;
+ // Client-side size guard matches the server cap (2MB at content-length).
+ if (file.size > 2 * 1024 * 1024) {
+ state.avatar.lastError = "Image is larger than 2 MB. Please choose a smaller file.";
+ render();
+ return;
+ }
+ var allowed = ["image/png", "image/jpeg", "image/webp"];
+ if (allowed.indexOf(file.type) < 0) {
+ state.avatar.lastError = "Unsupported image type. Use PNG, JPEG, or WebP.";
+ render();
+ return;
+ }
+ state.avatar.uploading = true;
+ render();
+ resizeToBlob(file)
+ .then(postAvatarBlob)
+ .then(function () {
+ state.avatar.uploading = false;
+ state.avatar.exists = true;
+ state.avatar.url = "/ui/avatar";
+ state.avatar.cacheBust = state.avatar.cacheBust + 1;
+ state.avatar.lastError = null;
+ ctx.toast("success", "Avatar updated", "Your logo is live across every surface.");
+ // Repaint surrounding navbar in-place by notifying the IIFE.
+ try {
+ window.dispatchEvent(new CustomEvent("phantom:avatar-updated", { detail: { url: "/ui/avatar" } }));
+ } catch (e) {}
+ render();
+ })
+ .catch(function (err) {
+ state.avatar.uploading = false;
+ state.avatar.lastError = (err && err.message) || String(err);
+ render();
+ });
+ }
+
+ function askResetAvatar() {
+ var body = document.createElement("div");
+ var p = document.createElement("p");
+ p.style.margin = "0 0 var(--space-2)";
+ p.textContent = "Remove the uploaded avatar and fall back to the letter badge?";
+ var info = document.createElement("p");
+ info.className = "phantom-muted";
+ info.style.margin = "0";
+ info.style.fontSize = "12px";
+ info.textContent = "You can upload a new image any time.";
+ body.appendChild(p);
+ body.appendChild(info);
+ ctx.openModal({
+ title: "Reset avatar?",
+ body: body,
+ actions: [
+ { label: "Keep avatar", className: "dash-btn-ghost" },
+ {
+ label: "Reset",
+ className: "dash-btn-danger",
+ onClick: function () { confirmResetAvatar(); return true; },
+ },
+ ],
+ });
+ }
+
+ function confirmResetAvatar() {
+ if (state.avatar.resetting) return;
+ state.avatar.resetting = true;
+ render();
+ fetch("/ui/api/identity/avatar", { method: "DELETE", credentials: "same-origin" })
+ .then(function (res) {
+ state.avatar.resetting = false;
+ if (!res.ok && res.status !== 204) {
+ throw new Error("Reset failed (" + res.status + ")");
+ }
+ state.avatar.exists = false;
+ state.avatar.url = null;
+ state.avatar.cacheBust = state.avatar.cacheBust + 1;
+ ctx.toast("success", "Avatar removed", "The letter badge is showing again everywhere.");
+ try {
+ window.dispatchEvent(new CustomEvent("phantom:avatar-updated", { detail: { url: null } }));
+ } catch (e) {}
+ render();
+ })
+ .catch(function (err) {
+ state.avatar.resetting = false;
+ state.avatar.lastError = (err && err.message) || String(err);
+ render();
+ });
+ }
+
+ function wireAvatarCard() {
+ var chooseBtn = document.getElementById("dash-avatar-choose");
+ var fileInput = document.getElementById("dash-avatar-file");
+ var resetBtn = document.getElementById("dash-avatar-reset");
+ var drop = document.getElementById("dash-avatar-drop");
+ var img = document.getElementById("dash-avatar-img");
+ var letter = document.getElementById("dash-avatar-letter");
+
+ if (chooseBtn && fileInput) {
+ chooseBtn.addEventListener("click", function () { fileInput.click(); });
+ fileInput.addEventListener("change", function () {
+ var f = fileInput.files && fileInput.files[0];
+ if (f) beginUpload(f);
+ fileInput.value = "";
+ });
+ }
+ if (resetBtn) {
+ resetBtn.addEventListener("click", function () { askResetAvatar(); });
+ }
+ if (drop) {
+ drop.addEventListener("dragenter", function (e) { e.preventDefault(); drop.setAttribute("data-drag", "true"); });
+ drop.addEventListener("dragover", function (e) { e.preventDefault(); drop.setAttribute("data-drag", "true"); });
+ drop.addEventListener("dragleave", function () { drop.removeAttribute("data-drag"); });
+ drop.addEventListener("drop", function (e) {
+ e.preventDefault();
+ drop.removeAttribute("data-drag");
+ var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
+ if (f) beginUpload(f);
+ });
+ drop.addEventListener("keydown", function (e) {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ if (fileInput) fileInput.click();
+ }
+ });
+ drop.addEventListener("click", function () { if (fileInput) fileInput.click(); });
+ }
+ if (img) {
+ img.addEventListener("load", function () {
+ state.avatar.exists = true;
+ if (letter) letter.style.display = "none";
+ img.style.display = "block";
+ });
+ img.addEventListener("error", function () {
+ state.avatar.exists = false;
+ img.style.display = "none";
+ if (letter) letter.style.display = "inline-flex";
+ });
+ }
+ }
+
function renderSection(meta) {
var sec = state.sections[meta.key];
var dirty = isSectionDirty(meta.key);
@@ -629,10 +918,15 @@
);
return;
}
- root.innerHTML = renderHeader() + SECTIONS.map(renderSection).join("") + renderAuditDrawer();
+ root.innerHTML =
+ renderHeader() + avatarCardHtml() + SECTIONS.map(renderSection).join("") + renderAuditDrawer();
wireInputs();
wireButtons();
wireAuditDrawer();
+ wireAvatarCard();
+ // Keep the preview's displayName in sync with the "Agent name" field so
+ // the letter fallback reflects typed-but-unsaved edits.
+ state.avatar.displayName = (state.sections.identity.draft && state.sections.identity.draft.name) || state.avatar.displayName || "";
ctx.setBreadcrumb("Settings");
}
@@ -898,8 +1192,10 @@
.then(function (res) {
if (!res || !res.config) throw new Error("Missing config in response");
hydrate(res.config, res.audit || {});
+ state.avatar.displayName = res.config.name || "";
state.loading = false;
render();
+ probeAvatar();
})
.catch(function (err) {
state.loading = false;
diff --git a/public/index.html b/public/index.html
index 7d15563..c4d3306 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,6 +4,7 @@
{{AGENT_NAME_CAPITALIZED}}
+
@@ -35,6 +36,8 @@
.phantom-nav { display:flex; align-items:center; gap:var(--space-4); padding:var(--space-3) var(--space-8); border-bottom:1px solid var(--color-base-300); position:sticky; top:0; background:color-mix(in oklab, var(--color-base-100) 85%, transparent); backdrop-filter:blur(8px); z-index:10; }
.phantom-nav-brand { display:inline-flex; align-items:center; gap:var(--space-2); font-family:'Instrument Serif',Georgia,serif; font-size:18px; color:var(--color-base-content); text-decoration:none; }
.phantom-nav-logo { display:inline-flex; width:22px; height:22px; border-radius:6px; background:var(--color-primary); align-items:center; justify-content:center; color:var(--color-primary-content); font-family:'Instrument Serif',serif; font-size:14px; }
+.phantom-nav-logo-img { width:22px; height:22px; border-radius:6px; object-fit:cover; display:inline-block; }
+.phantom-nav-avatar-slot { display:inline-flex; align-items:center; }
.phantom-mono { font-family:'JetBrains Mono',ui-monospace,monospace; font-size:12px; font-variant-numeric:tabular-nums; }
.phantom-display { font-family:'Instrument Serif',Georgia,serif; font-size:clamp(44px,5vw,60px); font-weight:400; line-height:1.05; letter-spacing:-0.01em; color:var(--color-base-content); margin:0 0 var(--space-4); }
@@ -84,7 +87,9 @@
-
+
+
+
/
diff --git a/src/chat/http.ts b/src/chat/http.ts
index 1d11500..8c21d9c 100644
--- a/src/chat/http.ts
+++ b/src/chat/http.ts
@@ -1,5 +1,6 @@
import type { Database } from "bun:sqlite";
import type { AgentRuntime } from "../agent/runtime.ts";
+import { avatarUrlIfPresent, readAvatarMetaForManifest } from "../ui/api/identity.ts";
import { isAuthenticated } from "../ui/serve.ts";
import type { ChatAttachmentStore } from "./attachment-store.ts";
import type { ChatEventLog } from "./event-log.ts";
@@ -94,7 +95,8 @@ function isApiPath(path: string): boolean {
async function routeApi(req: Request, url: URL, path: string, deps: ChatHandlerDeps): Promise {
if (path === "/chat/bootstrap" && req.method === "GET") {
- return Response.json(deps.getBootstrapData?.() ?? {});
+ const base = deps.getBootstrapData?.() ?? {};
+ return Response.json({ ...base, avatar_url: avatarUrlIfPresent() });
}
if (path === "/chat/sessions" && req.method === "POST") {
@@ -263,6 +265,12 @@ async function handlePushTest(deps: ChatHandlerDeps): Promise {
function serveManifest(agentName?: string): Response {
const name = agentName && agentName.length > 0 ? agentName : "Phantom";
+ const avatar = readAvatarMetaForManifest();
+ const icons: Array<{ src: string; sizes: string; type: string; purpose: string }> = [];
+ if (avatar) {
+ icons.push({ src: "/chat/icon", sizes: "256x256", type: avatar.mime, purpose: "any" });
+ }
+ icons.push({ src: "/chat/favicon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" });
const manifest = {
name,
short_name: name,
@@ -273,14 +281,7 @@ function serveManifest(agentName?: string): Response {
display: "standalone",
background_color: "#faf9f5",
theme_color: "#4850c4",
- icons: [
- {
- src: "/chat/favicon.svg",
- sizes: "any",
- type: "image/svg+xml",
- purpose: "any",
- },
- ],
+ icons,
};
return new Response(JSON.stringify(manifest), {
headers: {
diff --git a/src/core/health-page.ts b/src/core/health-page.ts
index abad477..50cc400 100644
--- a/src/core/health-page.ts
+++ b/src/core/health-page.ts
@@ -6,6 +6,7 @@ export type HealthPayload = {
uptime: number;
version: string;
agent: string;
+ avatar_url: string | null;
public_url?: string;
role: { id: string; name: string };
channels: Record;
diff --git a/src/core/server.ts b/src/core/server.ts
index be06a95..58b1b2a 100644
--- a/src/core/server.ts
+++ b/src/core/server.ts
@@ -7,6 +7,7 @@ import { loadMcpConfig } from "../mcp/config.ts";
import type { PhantomMcpServer } from "../mcp/server.ts";
import type { MemoryHealth } from "../memory/types.ts";
import type { SchedulerHealthSummary } from "../scheduler/health.ts";
+import { avatarUrlIfPresent, handleAvatarGet } from "../ui/api/identity.ts";
import { handleUiRequest } from "../ui/serve.ts";
import { type HealthPayload, renderHealthHtml } from "./health-page.ts";
@@ -128,6 +129,7 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp
uptime: Math.floor((Date.now() - startedAt) / 1000),
version: VERSION,
agent: config.name,
+ avatar_url: avatarUrlIfPresent(),
...(config.public_url ? { public_url: config.public_url } : {}),
role: roleInfo ?? { id: config.role, name: config.role },
channels,
@@ -177,6 +179,17 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp
return handleEmailLogin(req, publicUrl, config.name);
}
+ // Public PWA/SW-scoped mirror of the operator avatar. Service
+ // workers cannot reliably reach /ui/* across the /chat/ scope, so
+ // we expose the same bytes under /chat/icon. Same headers as
+ // /ui/avatar.
+ if (url.pathname === "/chat/icon" && req.method === "GET") {
+ return handleAvatarGet(req);
+ }
+ if (url.pathname === "/chat/icon") {
+ return new Response("Method not allowed", { status: 405, headers: { Allow: "GET" } });
+ }
+
if (url.pathname.startsWith("/chat") && chatHandler) {
const response = await chatHandler(req);
if (response) return response;
diff --git a/src/ui/api/__tests__/identity.test.ts b/src/ui/api/__tests__/identity.test.ts
new file mode 100644
index 0000000..b41e425
--- /dev/null
+++ b/src/ui/api/__tests__/identity.test.ts
@@ -0,0 +1,358 @@
+// Tests for POST/DELETE /ui/api/identity/avatar and GET /ui/avatar.
+//
+// We point the handler at a tmp dir via setIdentityDirForTests so each case
+// exercises real disk I/O (atomic rename, extension swap, ETag parity) with
+// no cross-test bleed.
+
+import { Database } from "bun:sqlite";
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { createHash } from "node:crypto";
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { MIGRATIONS } from "../../../db/schema.ts";
+import { handleUiRequest, setDashboardDb, setPublicDir } from "../../serve.ts";
+import { createSession, revokeAllSessions } from "../../session.ts";
+import { avatarUrlIfPresent, readAvatarMetaForManifest, setIdentityDirForTests } from "../identity.ts";
+
+setPublicDir(resolve(import.meta.dir, "../../../../public"));
+
+function runMigrations(target: Database): void {
+ for (const migration of MIGRATIONS) {
+ try {
+ target.run(migration);
+ } catch {
+ /* idempotent */
+ }
+ }
+}
+
+// Bytes shaped to pass the handler's magic-byte sniff. The handler does not
+// decode, so valid headers + filler are all that's required.
+const PNG_HEADER = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
+const JPEG_HEADER = [0xff, 0xd8, 0xff, 0xe0];
+const WEBP_HEADER = [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50];
+
+function withHeader(header: number[], length = 128): Uint8Array {
+ const out = new Uint8Array(Math.max(length, header.length));
+ for (let i = 0; i < header.length; i++) out[i] = header[i];
+ for (let i = header.length; i < out.length; i++) out[i] = (i * 7) & 0xff;
+ return out;
+}
+const pngBytes = (length = 128) => withHeader(PNG_HEADER, length);
+const jpegBytes = (length = 128) => withHeader(JPEG_HEADER, length);
+const webpBytes = (length = 128) => withHeader(WEBP_HEADER, length);
+const svgBytes = () =>
+ new TextEncoder().encode(' ');
+
+let db: Database;
+let sessionToken: string;
+let tmpDir: string;
+
+beforeEach(() => {
+ db = new Database(":memory:");
+ runMigrations(db);
+ setDashboardDb(db);
+ sessionToken = createSession().sessionToken;
+ tmpDir = mkdtempSync(join(tmpdir(), "phantom-identity-test-"));
+ setIdentityDirForTests(tmpDir);
+});
+
+afterEach(() => {
+ setIdentityDirForTests(null);
+ db.close();
+ revokeAllSessions();
+ rmSync(tmpDir, { recursive: true, force: true });
+});
+
+function authHeaders(extra: Record = {}): Record {
+ return { Cookie: `phantom_session=${encodeURIComponent(sessionToken)}`, ...extra };
+}
+function publicHeaders(extra: Record = {}): Record {
+ return { ...extra };
+}
+
+async function postAvatar(
+ mime: string,
+ bytes: Uint8Array,
+ filename = "logo.bin",
+ opts: { cookie?: boolean; contentLength?: number | null } = {},
+): Promise {
+ const form = new FormData();
+ // Create a fresh ArrayBuffer-backed view so Blob's BlobPart typing accepts it.
+ const buf = new ArrayBuffer(bytes.byteLength);
+ new Uint8Array(buf).set(bytes);
+ const blob = new Blob([buf], { type: mime });
+ form.append("file", blob, filename);
+ const headers: Record = opts.cookie === false ? {} : authHeaders();
+ if (opts.contentLength != null) {
+ headers["content-length"] = String(opts.contentLength);
+ }
+ return handleUiRequest(
+ new Request("http://localhost/ui/api/identity/avatar", {
+ method: "POST",
+ body: form,
+ headers,
+ }),
+ );
+}
+
+const deleteAvatar = (opts: { cookie?: boolean } = {}) =>
+ handleUiRequest(
+ new Request("http://localhost/ui/api/identity/avatar", {
+ method: "DELETE",
+ headers: opts.cookie === false ? {} : authHeaders(),
+ }),
+ );
+const getAvatar = (extra: Record = {}) =>
+ handleUiRequest(new Request("http://localhost/ui/avatar", { method: "GET", headers: publicHeaders(extra) }));
+
+describe("identity avatar API", () => {
+ test("401 on POST without cookie", async () => {
+ const res = await postAvatar("image/png", pngBytes(), "logo.png", { cookie: false });
+ expect(res.status).toBe(401);
+ });
+
+ test("401 on DELETE without cookie", async () => {
+ const res = await deleteAvatar({ cookie: false });
+ expect(res.status).toBe(401);
+ });
+
+ test("GET /ui/avatar is public (no cookie required) and 404s with no upload", async () => {
+ const res = await getAvatar();
+ expect(res.status).toBe(404);
+ });
+
+ test("POST with PNG writes file + meta and returns 200", async () => {
+ const bytes = pngBytes(200);
+ const res = await postAvatar("image/png", bytes, "logo.png");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { ok: boolean; url: string; size: number; mime: string };
+ expect(body.ok).toBe(true);
+ expect(body.url).toBe("/ui/avatar");
+ expect(body.size).toBe(bytes.byteLength);
+ expect(body.mime).toBe("image/png");
+ expect(existsSync(join(tmpDir, "avatar.png"))).toBe(true);
+ const meta = JSON.parse(readFileSync(join(tmpDir, "avatar.meta.json"), "utf-8")) as {
+ ext: string;
+ mime: string;
+ size: number;
+ sha256: string;
+ };
+ expect(meta.ext).toBe("png");
+ expect(meta.mime).toBe("image/png");
+ expect(meta.size).toBe(bytes.byteLength);
+ expect(meta.sha256).toBe(createHash("sha256").update(bytes).digest("hex"));
+ });
+
+ test("POST with JPEG writes .jpg on disk", async () => {
+ const res = await postAvatar("image/jpeg", jpegBytes(), "photo.jpeg");
+ expect(res.status).toBe(200);
+ expect(existsSync(join(tmpDir, "avatar.jpg"))).toBe(true);
+ const meta = JSON.parse(readFileSync(join(tmpDir, "avatar.meta.json"), "utf-8")) as { mime: string };
+ expect(meta.mime).toBe("image/jpeg");
+ });
+
+ test("POST with WebP writes .webp on disk", async () => {
+ const res = await postAvatar("image/webp", webpBytes(), "logo.webp");
+ expect(res.status).toBe(200);
+ expect(existsSync(join(tmpDir, "avatar.webp"))).toBe(true);
+ });
+
+ test("POST with SVG MIME rejected 400, no file written", async () => {
+ const res = await postAvatar("image/svg+xml", svgBytes(), "logo.svg");
+ expect(res.status).toBe(400);
+ expect(existsSync(join(tmpDir, "avatar.svg"))).toBe(false);
+ expect(existsSync(join(tmpDir, "avatar.meta.json"))).toBe(false);
+ });
+
+ test("POST with SVG bytes but MIME=image/png rejected by magic-byte sniff", async () => {
+ const res = await postAvatar("image/png", svgBytes(), "logo.png");
+ expect(res.status).toBe(400);
+ expect(existsSync(join(tmpDir, "avatar.png"))).toBe(false);
+ });
+
+ test("POST with PNG magic bytes but MIME=image/jpeg rejected by magic-byte sniff", async () => {
+ const res = await postAvatar("image/jpeg", pngBytes(), "logo.jpg");
+ expect(res.status).toBe(400);
+ expect(existsSync(join(tmpDir, "avatar.jpg"))).toBe(false);
+ });
+
+ test("POST with HEIC rejected 400", async () => {
+ const res = await postAvatar("image/heic", pngBytes(), "photo.heic");
+ expect(res.status).toBe(400);
+ });
+
+ test("POST with GIF rejected 400", async () => {
+ const res = await postAvatar("image/gif", pngBytes(), "logo.gif");
+ expect(res.status).toBe(400);
+ });
+
+ test("POST over 2MB via content-length header returns 413", async () => {
+ const bytes = pngBytes(16);
+ const res = await postAvatar("image/png", bytes, "logo.png", { contentLength: 3 * 1024 * 1024 });
+ expect(res.status).toBe(413);
+ expect(existsSync(join(tmpDir, "avatar.png"))).toBe(false);
+ });
+
+ test("POST over 2MB at read-time returns 413 even if Content-Length is absent", async () => {
+ // Simulate a >2MB PNG payload. Content-Length is not set manually so Bun
+ // computes it from the form, but the handler re-checks after reading.
+ const bytes = pngBytes(2 * 1024 * 1024 + 100);
+ const res = await postAvatar("image/png", bytes, "logo.png");
+ expect(res.status).toBe(413);
+ });
+
+ test("POST with traversal filename has extension derived from MIME, path is hardcoded", async () => {
+ const bytes = pngBytes();
+ const res = await postAvatar("image/png", bytes, "../../etc/passwd.png");
+ expect(res.status).toBe(200);
+ expect(existsSync(join(tmpDir, "avatar.png"))).toBe(true);
+ // Nothing else got written outside tmpDir.
+ expect(existsSync(join(tmpDir, "..", "etc"))).toBe(false);
+ });
+
+ test("POST replaces previous avatar with different extension (PNG -> WebP)", async () => {
+ await postAvatar("image/png", pngBytes(), "logo.png");
+ expect(existsSync(join(tmpDir, "avatar.png"))).toBe(true);
+
+ const res = await postAvatar("image/webp", webpBytes(), "logo.webp");
+ expect(res.status).toBe(200);
+ expect(existsSync(join(tmpDir, "avatar.webp"))).toBe(true);
+ expect(existsSync(join(tmpDir, "avatar.png"))).toBe(false);
+ });
+
+ test("DELETE removes avatar + meta and returns 204", async () => {
+ await postAvatar("image/png", pngBytes(), "logo.png");
+ expect(existsSync(join(tmpDir, "avatar.png"))).toBe(true);
+ const res = await deleteAvatar();
+ expect(res.status).toBe(204);
+ expect(existsSync(join(tmpDir, "avatar.png"))).toBe(false);
+ expect(existsSync(join(tmpDir, "avatar.meta.json"))).toBe(false);
+ });
+
+ test("DELETE is idempotent: returns 204 even when no avatar exists", async () => {
+ const res = await deleteAvatar();
+ expect(res.status).toBe(204);
+ });
+
+ test("GET returns bytes with correct Content-Type", async () => {
+ const bytes = jpegBytes(50);
+ await postAvatar("image/jpeg", bytes, "logo.jpg");
+ const res = await getAvatar();
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("image/jpeg");
+ expect(res.headers.get("Cache-Control")).toContain("max-age=300");
+ const got = new Uint8Array(await res.arrayBuffer());
+ expect(got.byteLength).toBe(bytes.byteLength);
+ });
+
+ test("GET with If-None-Match matching ETag returns 304", async () => {
+ await postAvatar("image/png", pngBytes(), "logo.png");
+ const first = await getAvatar();
+ const etag = first.headers.get("ETag");
+ expect(etag).toBeTruthy();
+ const second = await getAvatar({ "If-None-Match": etag ?? "" });
+ expect(second.status).toBe(304);
+ expect(second.headers.get("ETag")).toBe(etag);
+ });
+
+ test("GET with non-matching ETag returns 200 and new ETag", async () => {
+ await postAvatar("image/png", pngBytes(), "logo.png");
+ const res = await getAvatar({ "If-None-Match": '"stale"' });
+ expect(res.status).toBe(200);
+ });
+
+ test("GET 404 when meta exists but file is missing", async () => {
+ await postAvatar("image/png", pngBytes(), "logo.png");
+ // Clobber the image bytes but leave the meta in place.
+ const { unlinkSync } = await import("node:fs");
+ unlinkSync(join(tmpDir, "avatar.png"));
+ const res = await getAvatar();
+ expect(res.status).toBe(404);
+ });
+
+ test("POST rejects empty file with 400", async () => {
+ const res = await postAvatar("image/png", new Uint8Array(0), "empty.png");
+ expect(res.status).toBe(400);
+ });
+
+ test("POST rejects missing file with 400", async () => {
+ const form = new FormData();
+ form.append("other", "nofile");
+ const res = await handleUiRequest(
+ new Request("http://localhost/ui/api/identity/avatar", {
+ method: "POST",
+ body: form,
+ headers: authHeaders(),
+ }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ test("POST unknown MIME rejected", async () => {
+ const res = await postAvatar("application/pdf", pngBytes(), "logo.pdf");
+ expect(res.status).toBe(400);
+ });
+
+ test("atomic rename: tmp files are not left behind on success", async () => {
+ await postAvatar("image/png", pngBytes(), "logo.png");
+ expect(existsSync(join(tmpDir, "avatar.png.tmp"))).toBe(false);
+ expect(existsSync(join(tmpDir, "avatar.meta.json.tmp"))).toBe(false);
+ });
+
+ test("pre-existing stale meta + stale file: new upload cleans prior extension", async () => {
+ // Seed the directory as if a prior deployment wrote a .gif (old allowlist).
+ // The handler must remove it on the next successful upload.
+ writeFileSync(join(tmpDir, "avatar.gif"), new Uint8Array([0x47, 0x49, 0x46, 0x38]));
+ await postAvatar("image/png", pngBytes(), "logo.png");
+ expect(existsSync(join(tmpDir, "avatar.gif"))).toBe(false);
+ expect(existsSync(join(tmpDir, "avatar.png"))).toBe(true);
+ });
+
+ test("GET /ui/avatar other methods return 405", async () => {
+ const res = await handleUiRequest(
+ new Request("http://localhost/ui/avatar", { method: "POST", headers: publicHeaders() }),
+ );
+ expect(res.status).toBe(405);
+ });
+
+ test("/ui/api/identity/avatar unsupported method returns 405", async () => {
+ const res = await handleUiRequest(
+ new Request("http://localhost/ui/api/identity/avatar", { method: "GET", headers: authHeaders() }),
+ );
+ expect(res.status).toBe(405);
+ });
+
+ // Wire-contract helpers. These back /health avatar_url, /chat/bootstrap
+ // avatar_url, and the dynamic manifest's icon[] section. Test the helpers
+ // directly rather than the full HTTP round-trip because /health and the
+ // manifest live in core/server.ts and chat/http.ts, which have their own
+ // handlers outside handleUiRequest's scope.
+
+ test("avatarUrlIfPresent returns null when no avatar exists", () => {
+ expect(avatarUrlIfPresent()).toBeNull();
+ });
+
+ test("avatarUrlIfPresent returns /ui/avatar once an avatar is on disk", async () => {
+ await postAvatar("image/png", pngBytes(), "logo.png");
+ expect(avatarUrlIfPresent()).toBe("/ui/avatar");
+ });
+
+ test("avatarUrlIfPresent returns null again after DELETE", async () => {
+ await postAvatar("image/png", pngBytes(), "logo.png");
+ await handleUiRequest(
+ new Request("http://localhost/ui/api/identity/avatar", { method: "DELETE", headers: authHeaders() }),
+ );
+ expect(avatarUrlIfPresent()).toBeNull();
+ });
+
+ test("readAvatarMetaForManifest returns the mime of the uploaded avatar", async () => {
+ await postAvatar("image/jpeg", jpegBytes(30), "logo.jpg");
+ expect(readAvatarMetaForManifest()).toEqual({ mime: "image/jpeg" });
+ });
+
+ test("readAvatarMetaForManifest returns null when no avatar exists", () => {
+ expect(readAvatarMetaForManifest()).toBeNull();
+ });
+});
diff --git a/src/ui/api/identity.ts b/src/ui/api/identity.ts
new file mode 100644
index 0000000..c865bb3
--- /dev/null
+++ b/src/ui/api/identity.ts
@@ -0,0 +1,237 @@
+// Avatar upload endpoints. Single operator-visible identity asset on disk at
+// data/identity/avatar. + avatar.meta.json. All three serve paths
+// (/ui/avatar, /chat/icon, /health avatar_url) share one reader so the bytes
+// only live in one place.
+//
+// Security posture:
+// - Server never decodes the image. Bun writes bytes verbatim; the browser
+// decodes in its sandbox.
+// - MIME allowlist: PNG, JPEG, WebP. SVG rejected at MIME AND via magic-byte
+// sniff because some form parse libs derive MIME from the filename.
+// - Extension is derived from the validated MIME, never from the uploaded
+// filename. Path is hardcoded so traversal is impossible.
+// - 2MB cap at content-length AND at read. Both checks required (the
+// Content-Length header can lie or be absent).
+
+import { createHash } from "node:crypto";
+import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+const MAX_BYTES = 2 * 1024 * 1024;
+const ALLOWED_MIMES = new Set(["image/png", "image/jpeg", "image/webp"]);
+
+let identityDirOverride: string | null = null;
+
+export function setIdentityDirForTests(dir: string | null): void {
+ identityDirOverride = dir;
+}
+
+export function getIdentityDir(): string {
+ return identityDirOverride ?? resolve(process.cwd(), "data", "identity");
+}
+
+type AvatarMeta = {
+ ext: "png" | "jpg" | "webp";
+ mime: string;
+ size: number;
+ uploaded_at: string;
+ sha256: string;
+};
+
+function metaPath(): string {
+ return resolve(getIdentityDir(), "avatar.meta.json");
+}
+
+function avatarPath(ext: string): string {
+ return resolve(getIdentityDir(), `avatar.${ext}`);
+}
+
+function readMetaSync(): AvatarMeta | null {
+ const p = metaPath();
+ if (!existsSync(p)) return null;
+ try {
+ const text = readFileSync(p, "utf-8");
+ const parsed = JSON.parse(text) as AvatarMeta;
+ if (!parsed || typeof parsed.ext !== "string" || typeof parsed.mime !== "string") return null;
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+export function hasAvatar(): boolean {
+ const meta = readMetaSync();
+ if (!meta) return false;
+ return existsSync(avatarPath(meta.ext));
+}
+
+export function avatarUrlIfPresent(): string | null {
+ return hasAvatar() ? "/ui/avatar" : null;
+}
+
+// Manifest consumer needs the MIME to set the icons[].type correctly so
+// Android/iOS pick the right entry. Returns null when no avatar is uploaded.
+export function readAvatarMetaForManifest(): { mime: string } | null {
+ const meta = readMetaSync();
+ if (!meta) return null;
+ if (!existsSync(avatarPath(meta.ext))) return null;
+ return { mime: meta.mime };
+}
+
+function extFromMime(mime: string): "png" | "jpg" | "webp" | null {
+ if (mime === "image/png") return "png";
+ if (mime === "image/jpeg") return "jpg";
+ if (mime === "image/webp") return "webp";
+ return null;
+}
+
+// Magic-byte sniff. Even if the MIME check is bypassed, this catches SVG
+// masquerading as PNG (opening `3C 3F 78 6D 6C` or `3C 73 76 67`) and other
+// format swaps. Defense in depth.
+function sniffMatches(bytes: Uint8Array, mime: string): boolean {
+ if (bytes.length < 12) return false;
+ if (mime === "image/png") {
+ return bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47;
+ }
+ if (mime === "image/jpeg") {
+ return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
+ }
+ if (mime === "image/webp") {
+ const riff = bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46;
+ const webp = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
+ return riff && webp;
+ }
+ return false;
+}
+
+function errJson(message: string, status: number): Response {
+ return new Response(JSON.stringify({ error: message }), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+export async function handleAvatarPost(req: Request): Promise {
+ const contentLengthHeader = req.headers.get("content-length");
+ if (contentLengthHeader !== null) {
+ const cl = Number(contentLengthHeader);
+ if (Number.isFinite(cl) && cl > MAX_BYTES) {
+ return errJson("Avatar too large. Max 2 MB.", 413);
+ }
+ }
+
+ let formData: FormData;
+ try {
+ formData = await req.formData();
+ } catch {
+ return errJson("Could not parse multipart form data.", 400);
+ }
+
+ const files = formData.getAll("file").filter((v): v is File => v instanceof File);
+ if (files.length === 0) return errJson("No file attached.", 400);
+ if (files.length > 1) return errJson("Exactly one file is required.", 400);
+
+ const file = files[0];
+ const mime = file.type;
+ if (!ALLOWED_MIMES.has(mime)) {
+ return errJson("Unsupported image type. Use PNG, JPEG, or WebP.", 400);
+ }
+
+ if (file.size === 0) return errJson("File is empty.", 400);
+ if (file.size > MAX_BYTES) return errJson("Avatar too large. Max 2 MB.", 413);
+
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ if (bytes.byteLength > MAX_BYTES) return errJson("Avatar too large. Max 2 MB.", 413);
+
+ if (!sniffMatches(bytes, mime)) {
+ return errJson("File bytes do not match declared type.", 400);
+ }
+
+ const ext = extFromMime(mime);
+ if (!ext) return errJson("Unsupported image type. Use PNG, JPEG, or WebP.", 400);
+
+ const dir = getIdentityDir();
+ mkdirSync(dir, { recursive: true });
+
+ const targetFile = avatarPath(ext);
+ const tmpFile = `${targetFile}.tmp`;
+ const targetMeta = metaPath();
+ const tmpMeta = `${targetMeta}.tmp`;
+
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
+ const meta: AvatarMeta = {
+ ext,
+ mime,
+ size: bytes.byteLength,
+ uploaded_at: new Date().toISOString(),
+ sha256,
+ };
+
+ try {
+ writeFileSync(tmpFile, bytes);
+ renameSync(tmpFile, targetFile);
+ } catch (err: unknown) {
+ try {
+ if (existsSync(tmpFile)) unlinkSync(tmpFile);
+ } catch {}
+ const msg = err instanceof Error ? err.message : String(err);
+ return errJson(`Avatar write failed: ${msg}`, 500);
+ }
+
+ try {
+ writeFileSync(tmpMeta, JSON.stringify(meta, null, 2));
+ renameSync(tmpMeta, targetMeta);
+ } catch (err: unknown) {
+ try {
+ if (existsSync(tmpMeta)) unlinkSync(tmpMeta);
+ } catch {}
+ const msg = err instanceof Error ? err.message : String(err);
+ return errJson(`Avatar meta write failed: ${msg}`, 500);
+ }
+
+ // Prune any previous avatar with a different extension (PNG -> WebP etc).
+ for (const entry of readdirSync(dir)) {
+ if (!entry.startsWith("avatar.")) continue;
+ if (entry === `avatar.${ext}` || entry === "avatar.meta.json") continue;
+ if (entry.endsWith(".tmp")) continue;
+ try {
+ unlinkSync(resolve(dir, entry));
+ } catch {}
+ }
+
+ return Response.json({ ok: true, url: "/ui/avatar", size: bytes.byteLength, mime });
+}
+
+export function handleAvatarDelete(): Response {
+ const dir = getIdentityDir();
+ if (!existsSync(dir)) return new Response(null, { status: 204 });
+ for (const entry of readdirSync(dir)) {
+ if (!entry.startsWith("avatar.")) continue;
+ try {
+ unlinkSync(resolve(dir, entry));
+ } catch {}
+ }
+ return new Response(null, { status: 204 });
+}
+
+export async function handleAvatarGet(req: Request): Promise {
+ const meta = readMetaSync();
+ if (!meta) return new Response("Not found", { status: 404 });
+ const file = Bun.file(avatarPath(meta.ext));
+ if (!(await file.exists())) {
+ console.warn("[identity] avatar meta exists but file is missing; returning 404");
+ return new Response("Not found", { status: 404 });
+ }
+ const etag = `"${meta.sha256}"`;
+ const ifNoneMatch = req.headers.get("if-none-match");
+ if (ifNoneMatch && ifNoneMatch === etag) {
+ return new Response(null, { status: 304, headers: { ETag: etag } });
+ }
+ return new Response(file, {
+ headers: {
+ "Content-Type": meta.mime,
+ "Cache-Control": "private, max-age=300, must-revalidate",
+ ETag: etag,
+ },
+ });
+}
diff --git a/src/ui/login-page.ts b/src/ui/login-page.ts
index f4489c1..d0aa001 100644
--- a/src/ui/login-page.ts
+++ b/src/ui/login-page.ts
@@ -5,6 +5,7 @@
// module-level setter wired from src/index.ts at startup so this file stays
// callable from src/ui/serve.ts with no signature change.
+import { avatarUrlIfPresent } from "./api/identity.ts";
import { escapeHtml } from "./html.ts";
import { agentNameInitial, capitalizeAgentName } from "./name.ts";
@@ -18,13 +19,21 @@ export function loginPageHtml(): string {
const displayName = capitalizeAgentName(configuredAgentName);
const safeName = escapeHtml(displayName);
const safeInitial = escapeHtml(agentNameInitial(displayName));
+ // Server-side avatar probe is a cheap synchronous existsSync in
+ // avatarUrlIfPresent. Don't cache; the page renders once per auth attempt.
+ const avatarUrl = avatarUrlIfPresent();
+ const brandLogo = avatarUrl
+ ? `${safeInitial} `
+ : `${safeInitial} `;
+ const faviconHref = avatarUrl ? "/ui/avatar" : "data:,";
+ const faviconType = avatarUrl ? "image/png" : "";
return `
Sign in - ${safeName}
-
+