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 <img src="/ui/avatar"> 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 @@ <!-- Navbar --> <nav class="phantom-nav" aria-label="Primary"> <a href="/ui/" class="phantom-nav-brand"> - <span style="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:var(--font-family-serif);font-size:14px;font-weight:500;">{{AGENT_NAME_INITIAL}}</span> + <span style="display:inline-flex;align-items:center;">{{AGENT_AVATAR_IMG}}<span style="display:{{AGENT_FALLBACK_DISPLAY}};width:22px;height:22px;border-radius:6px;background:var(--color-primary);align-items:center;justify-content:center;color:var(--color-primary-content);font-family:var(--font-family-serif);font-size:14px;font-weight:500;">{{AGENT_NAME_INITIAL}}</span></span> <span>{{AGENT_NAME_CAPITALIZED}}</span> </a> <span class="phantom-breadcrumb-sep">/</span> 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 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title data-agent-name-title>Dashboard + @@ -16,7 +17,9 @@