import fs from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { ClawdbotConfig } from "../config/config.js"; import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js"; const ROOT_PREFIX = "/"; const AVATAR_PREFIX = "/avatar"; export type ControlUiRequestOptions = { basePath?: string; config?: ClawdbotConfig; agentId?: string; }; export function normalizeControlUiBasePath(basePath?: string): string { if (!basePath) return ""; let normalized = basePath.trim(); if (!normalized) return ""; if (!normalized.startsWith("/")) normalized = `/${normalized}`; if (normalized === "/") return ""; if (normalized.endsWith("/")) normalized = normalized.slice(0, -1); return normalized; } function resolveControlUiRoot(): string | null { const here = path.dirname(fileURLToPath(import.meta.url)); const execDir = (() => { try { return path.dirname(fs.realpathSync(process.execPath)); } catch { return null; } })(); const candidates = [ // Packaged app: control-ui lives alongside the executable. execDir ? path.resolve(execDir, "control-ui") : null, // Running from dist: dist/gateway/control-ui.js -> dist/control-ui path.resolve(here, "../control-ui"), // Running from source: src/gateway/control-ui.ts -> dist/control-ui path.resolve(here, "../../dist/control-ui"), // Fallback to cwd (dev) path.resolve(process.cwd(), "dist", "control-ui"), ].filter((dir): dir is string => Boolean(dir)); for (const dir of candidates) { if (fs.existsSync(path.join(dir, "index.html"))) return dir; } return null; } function contentTypeForExt(ext: string): string { switch (ext) { case ".html": return "text/html; charset=utf-8"; case ".js": return "application/javascript; charset=utf-8"; case ".css": return "text/css; charset=utf-8"; case ".json": case ".map": return "application/json; charset=utf-8"; case ".svg": return "image/svg+xml"; case ".png": return "image/png"; case ".jpg": case ".jpeg": return "image/jpeg"; case ".gif": return "image/gif"; case ".webp": return "image/webp"; case ".ico": return "image/x-icon"; case ".txt": return "text/plain; charset=utf-8"; default: return "application/octet-stream"; } } export type ControlUiAvatarResolution = | { kind: "none"; reason: string } | { kind: "local"; filePath: string } | { kind: "remote"; url: string } | { kind: "data"; url: string }; type ControlUiAvatarMeta = { avatarUrl: string | null; }; function sendJson(res: ServerResponse, status: number, body: unknown) { res.statusCode = status; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); res.end(JSON.stringify(body)); } function buildAvatarUrl(basePath: string, agentId: string): string { return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`; } function isValidAgentId(agentId: string): boolean { return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(agentId); } export function handleControlUiAvatarRequest( req: IncomingMessage, res: ServerResponse, opts: { basePath?: string; resolveAvatar: (agentId: string) => ControlUiAvatarResolution }, ): boolean { const urlRaw = req.url; if (!urlRaw) return false; if (req.method !== "GET" && req.method !== "HEAD") return false; const url = new URL(urlRaw, "http://localhost"); const basePath = normalizeControlUiBasePath(opts.basePath); const pathname = url.pathname; const pathWithBase = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`; if (!pathname.startsWith(pathWithBase)) return false; const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean); const agentId = agentIdParts[0] ?? ""; if (agentIdParts.length !== 1 || !agentId || !isValidAgentId(agentId)) { respondNotFound(res); return true; } if (url.searchParams.get("meta") === "1") { const resolved = opts.resolveAvatar(agentId); const avatarUrl = resolved.kind === "local" ? buildAvatarUrl(basePath, agentId) : resolved.kind === "remote" || resolved.kind === "data" ? resolved.url : null; sendJson(res, 200, { avatarUrl } satisfies ControlUiAvatarMeta); return true; } const resolved = opts.resolveAvatar(agentId); if (resolved.kind !== "local") { respondNotFound(res); return true; } if (req.method === "HEAD") { res.statusCode = 200; res.setHeader("Content-Type", contentTypeForExt(path.extname(resolved.filePath).toLowerCase())); res.setHeader("Cache-Control", "no-cache"); res.end(); return true; } serveFile(res, resolved.filePath); return true; } function respondNotFound(res: ServerResponse) { res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Not Found"); } function serveFile(res: ServerResponse, filePath: string) { const ext = path.extname(filePath).toLowerCase(); res.setHeader("Content-Type", contentTypeForExt(ext)); // Static UI should never be cached aggressively while iterating; allow the // browser to revalidate. res.setHeader("Cache-Control", "no-cache"); res.end(fs.readFileSync(filePath)); } interface ControlUiInjectionOpts { basePath: string; assistantName?: string; assistantAvatar?: string; } function injectControlUiConfig(html: string, opts: ControlUiInjectionOpts): string { const { basePath, assistantName, assistantAvatar } = opts; const script = ``; // Check if already injected if (html.includes("__CLAWDBOT_ASSISTANT_NAME__")) return html; const headClose = html.indexOf(""); if (headClose !== -1) { return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`; } return `${script}${html}`; } interface ServeIndexHtmlOpts { basePath: string; config?: ClawdbotConfig; agentId?: string; } function looksLikeLocalAvatarPath(value: string | undefined): boolean { if (!value) return false; if (/^https?:\/\//i.test(value) || /^data:image\//i.test(value)) return false; return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value); } function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) { const { basePath, config, agentId } = opts; const identity = config ? resolveAssistantIdentity({ cfg: config, agentId }) : DEFAULT_ASSISTANT_IDENTITY; // Resolve local file avatars to /avatar/{agentId} URL let avatarValue = identity.avatar; if (looksLikeLocalAvatarPath(avatarValue) && identity.agentId) { avatarValue = buildAvatarUrl(basePath, identity.agentId); } res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); const raw = fs.readFileSync(indexPath, "utf8"); res.end( injectControlUiConfig(raw, { basePath, assistantName: identity.name, assistantAvatar: avatarValue, }), ); } function isSafeRelativePath(relPath: string) { if (!relPath) return false; const normalized = path.posix.normalize(relPath); if (normalized.startsWith("../") || normalized === "..") return false; if (normalized.includes("\0")) return false; return true; } export function handleControlUiHttpRequest( req: IncomingMessage, res: ServerResponse, opts?: ControlUiRequestOptions, ): boolean { const urlRaw = req.url; if (!urlRaw) return false; if (req.method !== "GET" && req.method !== "HEAD") { res.statusCode = 405; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Method Not Allowed"); return true; } const url = new URL(urlRaw, "http://localhost"); const basePath = normalizeControlUiBasePath(opts?.basePath); const pathname = url.pathname; if (!basePath) { if (pathname === "/ui" || pathname.startsWith("/ui/")) { respondNotFound(res); return true; } } if (basePath) { if (pathname === basePath) { res.statusCode = 302; res.setHeader("Location", `${basePath}/${url.search}`); res.end(); return true; } if (!pathname.startsWith(`${basePath}/`)) return false; } const root = resolveControlUiRoot(); if (!root) { res.statusCode = 503; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end( "Control UI assets not found. Build them with `pnpm ui:build` (auto-installs UI deps), or run `pnpm ui:dev` during development.", ); return true; } const uiPath = basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname; const rel = (() => { if (uiPath === ROOT_PREFIX) return ""; const assetsIndex = uiPath.indexOf("/assets/"); if (assetsIndex >= 0) return uiPath.slice(assetsIndex + 1); return uiPath.slice(1); })(); const requested = rel && !rel.endsWith("/") ? rel : `${rel}index.html`; const fileRel = requested || "index.html"; if (!isSafeRelativePath(fileRel)) { respondNotFound(res); return true; } const filePath = path.join(root, fileRel); if (!filePath.startsWith(root)) { respondNotFound(res); return true; } if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { if (path.basename(filePath) === "index.html") { serveIndexHtml(res, filePath, { basePath, config: opts?.config, agentId: opts?.agentId, }); return true; } serveFile(res, filePath); return true; } // SPA fallback (client-side router): serve index.html for unknown paths. const indexPath = path.join(root, "index.html"); if (fs.existsSync(indexPath)) { serveIndexHtml(res, indexPath, { basePath, config: opts?.config, agentId: opts?.agentId, }); return true; } respondNotFound(res); return true; }