Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions chat-ui/public/manifest.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 13 additions & 2 deletions chat-ui/public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 : "";
}
});
28 changes: 27 additions & 1 deletion chat-ui/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -152,6 +170,14 @@ export function AppShell({ children }: { children: React.ReactNode }) {
>
<PanelLeft className="h-4 w-4" />
</button>
{avatarUrl && !avatarBroken ? (
<img
src={avatarUrl}
alt=""
className="mr-2 h-5 w-5 rounded-md object-cover"
onError={() => setAvatarBroken(true)}
/>
) : null}
<span className="text-sm font-medium text-foreground">
{agentName}
</span>
Expand Down
34 changes: 26 additions & 8 deletions chat-ui/src/components/sidebar-footer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="border-t border-sidebar-border px-3 py-3">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-sidebar-foreground">
{agentName}
</div>
<div className="flex gap-2 text-xs text-sidebar-muted-foreground">
{gen != null && gen > 0 && <span>Gen {gen}</span>}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{avatarUrl && !avatarBroken ? (
<img
src={avatarUrl}
alt=""
className="h-8 w-8 shrink-0 rounded-md object-cover"
onError={() => setAvatarBroken(true)}
/>
) : null}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-sidebar-foreground">
{agentName}
</div>
<div className="flex gap-2 text-xs text-sidebar-muted-foreground">
{gen != null && gen > 0 && <span>Gen {gen}</span>}
</div>
</div>
</div>
<ThemeToggle />
Expand Down
14 changes: 12 additions & 2 deletions chat-ui/src/hooks/use-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BootstrapData> | null = null;
Expand Down Expand Up @@ -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 {
Expand All @@ -71,6 +75,7 @@ export function useBootstrap(): {
data: BootstrapData | null;
cachedName: string | null;
cachedGen: number | null;
cachedAvatarUrl: string | null;
} {
const [data, setData] = useState<BootstrapData | null>(cachedData);
// Lazy initializers: readCache() only runs once per consumer mount
Expand All @@ -88,6 +93,10 @@ export function useBootstrap(): {
if (typeof window === "undefined") return null;
return readCache()?.evolution_gen ?? null;
});
const [cachedAvatarUrl, setCachedAvatarUrl] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
return readCache()?.avatar_url ?? null;
});

useEffect(() => {
let cancelled = false;
Expand All @@ -97,14 +106,15 @@ export function useBootstrap(): {
setData(next);
setCachedName(next.agent_name);
setCachedGen(next.evolution_gen);
setCachedAvatarUrl(next.avatar_url ?? null);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);

return { data, cachedName, cachedGen };
return { data, cachedName, cachedGen, cachedAvatarUrl };
}

// Non-hook accessor for consumers that already hold data and want the
Expand Down
1 change: 1 addition & 0 deletions chat-ui/src/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<ext>` 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:
Expand Down
88 changes: 76 additions & 12 deletions public/_agent-name.js
Original file line number Diff line number Diff line change
@@ -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 <script src="/ui/_agent-name.js"></script>.
// 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 <title data-agent-name-title> 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 &nbsp; 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();
Expand All @@ -35,7 +41,7 @@
}
}

function apply(name) {
function applyName(name) {
var display = cap(name);
var initial = display.charAt(0).toUpperCase();
var lower = display.toLowerCase();
Expand All @@ -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);
});
})();
2 changes: 1 addition & 1 deletion public/_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Loading
Loading