mirror of https://github.com/openclaw/openclaw.git
523 lines
15 KiB
TypeScript
523 lines
15 KiB
TypeScript
import fs from "node:fs";
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import path from "node:path";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { resolveControlUiRootSync } from "../infra/control-ui-assets.js";
|
|
import { isWithinDir } from "../infra/path-safety.js";
|
|
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
|
|
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
|
|
import {
|
|
CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
|
|
type ControlUiBootstrapConfig,
|
|
} from "./control-ui-contract.js";
|
|
import { buildControlUiCspHeader } from "./control-ui-csp.js";
|
|
import {
|
|
buildControlUiAvatarUrl,
|
|
CONTROL_UI_AVATAR_PREFIX,
|
|
normalizeControlUiBasePath,
|
|
resolveAssistantAvatarUrl,
|
|
} from "./control-ui-shared.js";
|
|
|
|
const ROOT_PREFIX = "/";
|
|
|
|
export type ControlUiRequestOptions = {
|
|
basePath?: string;
|
|
config?: OpenClawConfig;
|
|
agentId?: string;
|
|
root?: ControlUiRootState;
|
|
};
|
|
|
|
export type ControlUiRootState =
|
|
| { kind: "resolved"; path: string }
|
|
| { kind: "invalid"; path: string }
|
|
| { kind: "missing" };
|
|
|
|
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";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extensions recognised as static assets. Missing files with these extensions
|
|
* return 404 instead of the SPA index.html fallback. `.html` is intentionally
|
|
* excluded — actual HTML files on disk are served earlier, and missing `.html`
|
|
* paths should fall through to the SPA router (client-side routers may use
|
|
* `.html`-suffixed routes).
|
|
*/
|
|
const STATIC_ASSET_EXTENSIONS = new Set([
|
|
".js",
|
|
".css",
|
|
".json",
|
|
".map",
|
|
".svg",
|
|
".png",
|
|
".jpg",
|
|
".jpeg",
|
|
".gif",
|
|
".webp",
|
|
".ico",
|
|
".txt",
|
|
]);
|
|
|
|
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 applyControlUiSecurityHeaders(res: ServerResponse) {
|
|
res.setHeader("X-Frame-Options", "DENY");
|
|
res.setHeader("Content-Security-Policy", buildControlUiCspHeader());
|
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
res.setHeader("Referrer-Policy", "no-referrer");
|
|
}
|
|
|
|
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 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}${CONTROL_UI_AVATAR_PREFIX}/`
|
|
: `${CONTROL_UI_AVATAR_PREFIX}/`;
|
|
if (!pathname.startsWith(pathWithBase)) {
|
|
return false;
|
|
}
|
|
|
|
applyControlUiSecurityHeaders(res);
|
|
|
|
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"
|
|
? buildControlUiAvatarUrl(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;
|
|
}
|
|
|
|
const safeAvatar = resolveSafeAvatarFile(resolved.filePath);
|
|
if (!safeAvatar) {
|
|
respondNotFound(res);
|
|
return true;
|
|
}
|
|
try {
|
|
if (req.method === "HEAD") {
|
|
res.statusCode = 200;
|
|
res.setHeader("Content-Type", contentTypeForExt(path.extname(safeAvatar.path).toLowerCase()));
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.end();
|
|
return true;
|
|
}
|
|
|
|
serveResolvedFile(res, safeAvatar.path, fs.readFileSync(safeAvatar.fd));
|
|
return true;
|
|
} finally {
|
|
fs.closeSync(safeAvatar.fd);
|
|
}
|
|
}
|
|
|
|
function respondNotFound(res: ServerResponse) {
|
|
res.statusCode = 404;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end("Not Found");
|
|
}
|
|
|
|
function setStaticFileHeaders(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");
|
|
}
|
|
|
|
function serveResolvedFile(res: ServerResponse, filePath: string, body: Buffer) {
|
|
setStaticFileHeaders(res, filePath);
|
|
res.end(body);
|
|
}
|
|
|
|
function serveResolvedIndexHtml(res: ServerResponse, body: string) {
|
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.end(body);
|
|
}
|
|
|
|
function isContainedPath(baseDir: string, targetPath: string): boolean {
|
|
const relative = path.relative(baseDir, targetPath);
|
|
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
}
|
|
|
|
function isExpectedSafePathError(error: unknown): boolean {
|
|
const code =
|
|
typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
|
|
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
|
|
}
|
|
|
|
function areSameFileIdentity(preOpen: fs.Stats, opened: fs.Stats): boolean {
|
|
return preOpen.dev === opened.dev && preOpen.ino === opened.ino;
|
|
}
|
|
|
|
function resolveSafeAvatarFile(filePath: string): { path: string; fd: number } | null {
|
|
let fd: number | null = null;
|
|
try {
|
|
const candidateStat = fs.lstatSync(filePath);
|
|
if (candidateStat.isSymbolicLink()) {
|
|
return null;
|
|
}
|
|
const fileReal = fs.realpathSync(filePath);
|
|
const preOpenStat = fs.lstatSync(fileReal);
|
|
if (!preOpenStat.isFile() || preOpenStat.size > AVATAR_MAX_BYTES) {
|
|
return null;
|
|
}
|
|
const openFlags =
|
|
fs.constants.O_RDONLY |
|
|
(typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
|
|
fd = fs.openSync(fileReal, openFlags);
|
|
const openedStat = fs.fstatSync(fd);
|
|
if (
|
|
!openedStat.isFile() ||
|
|
openedStat.size > AVATAR_MAX_BYTES ||
|
|
!areSameFileIdentity(preOpenStat, openedStat)
|
|
) {
|
|
return null;
|
|
}
|
|
const safeFile = { path: fileReal, fd };
|
|
fd = null;
|
|
return safeFile;
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
if (fd !== null) {
|
|
fs.closeSync(fd);
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveSafeControlUiFile(
|
|
rootReal: string,
|
|
filePath: string,
|
|
): { path: string; fd: number } | null {
|
|
let fd: number | null = null;
|
|
try {
|
|
const fileReal = fs.realpathSync(filePath);
|
|
if (!isContainedPath(rootReal, fileReal)) {
|
|
return null;
|
|
}
|
|
|
|
const preOpenStat = fs.lstatSync(fileReal);
|
|
if (!preOpenStat.isFile()) {
|
|
return null;
|
|
}
|
|
|
|
const openFlags =
|
|
fs.constants.O_RDONLY |
|
|
(typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
|
|
fd = fs.openSync(fileReal, openFlags);
|
|
const openedStat = fs.fstatSync(fd);
|
|
// Compare inode identity so swaps between validation and open are rejected.
|
|
if (!openedStat.isFile() || !areSameFileIdentity(preOpenStat, openedStat)) {
|
|
return null;
|
|
}
|
|
|
|
const resolved = { path: fileReal, fd };
|
|
fd = null;
|
|
return resolved;
|
|
} catch (error) {
|
|
if (isExpectedSafePathError(error)) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (fd !== null) {
|
|
fs.closeSync(fd);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isSafeRelativePath(relPath: string) {
|
|
if (!relPath) {
|
|
return false;
|
|
}
|
|
const normalized = path.posix.normalize(relPath);
|
|
if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
|
|
return false;
|
|
}
|
|
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/")) {
|
|
applyControlUiSecurityHeaders(res);
|
|
respondNotFound(res);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (basePath) {
|
|
if (pathname === basePath) {
|
|
applyControlUiSecurityHeaders(res);
|
|
res.statusCode = 302;
|
|
res.setHeader("Location", `${basePath}/${url.search}`);
|
|
res.end();
|
|
return true;
|
|
}
|
|
if (!pathname.startsWith(`${basePath}/`)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
applyControlUiSecurityHeaders(res);
|
|
|
|
const bootstrapConfigPath = basePath
|
|
? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`
|
|
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH;
|
|
if (pathname === bootstrapConfigPath) {
|
|
const config = opts?.config;
|
|
const identity = config
|
|
? resolveAssistantIdentity({ cfg: config, agentId: opts?.agentId })
|
|
: DEFAULT_ASSISTANT_IDENTITY;
|
|
const avatarValue = resolveAssistantAvatarUrl({
|
|
avatar: identity.avatar,
|
|
agentId: identity.agentId,
|
|
basePath,
|
|
});
|
|
if (req.method === "HEAD") {
|
|
res.statusCode = 200;
|
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.end();
|
|
return true;
|
|
}
|
|
sendJson(res, 200, {
|
|
basePath,
|
|
assistantName: identity.name,
|
|
assistantAvatar: avatarValue ?? identity.avatar,
|
|
assistantAgentId: identity.agentId,
|
|
} satisfies ControlUiBootstrapConfig);
|
|
return true;
|
|
}
|
|
|
|
const rootState = opts?.root;
|
|
if (rootState?.kind === "invalid") {
|
|
res.statusCode = 503;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end(
|
|
`Control UI assets not found at ${rootState.path}. Build them with \`pnpm ui:build\` (auto-installs UI deps), or update gateway.controlUi.root.`,
|
|
);
|
|
return true;
|
|
}
|
|
if (rootState?.kind === "missing") {
|
|
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 root =
|
|
rootState?.kind === "resolved"
|
|
? rootState.path
|
|
: resolveControlUiRootSync({
|
|
moduleUrl: import.meta.url,
|
|
argv1: process.argv[1],
|
|
cwd: process.cwd(),
|
|
});
|
|
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 rootReal = (() => {
|
|
try {
|
|
return fs.realpathSync(root);
|
|
} catch (error) {
|
|
if (isExpectedSafePathError(error)) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
})();
|
|
if (!rootReal) {
|
|
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.resolve(root, fileRel);
|
|
if (!isWithinDir(root, filePath)) {
|
|
respondNotFound(res);
|
|
return true;
|
|
}
|
|
|
|
const safeFile = resolveSafeControlUiFile(rootReal, filePath);
|
|
if (safeFile) {
|
|
try {
|
|
if (req.method === "HEAD") {
|
|
res.statusCode = 200;
|
|
setStaticFileHeaders(res, safeFile.path);
|
|
res.end();
|
|
return true;
|
|
}
|
|
if (path.basename(safeFile.path) === "index.html") {
|
|
serveResolvedIndexHtml(res, fs.readFileSync(safeFile.fd, "utf8"));
|
|
return true;
|
|
}
|
|
serveResolvedFile(res, safeFile.path, fs.readFileSync(safeFile.fd));
|
|
return true;
|
|
} finally {
|
|
fs.closeSync(safeFile.fd);
|
|
}
|
|
}
|
|
|
|
// If the requested path looks like a static asset (known extension), return
|
|
// 404 rather than falling through to the SPA index.html fallback. We check
|
|
// against the same set of extensions that contentTypeForExt() recognises so
|
|
// that dotted SPA routes (e.g. /user/jane.doe, /v2.0) still get the
|
|
// client-side router fallback.
|
|
if (STATIC_ASSET_EXTENSIONS.has(path.extname(fileRel).toLowerCase())) {
|
|
respondNotFound(res);
|
|
return true;
|
|
}
|
|
|
|
// SPA fallback (client-side router): serve index.html for unknown paths.
|
|
const indexPath = path.join(root, "index.html");
|
|
const safeIndex = resolveSafeControlUiFile(rootReal, indexPath);
|
|
if (safeIndex) {
|
|
try {
|
|
if (req.method === "HEAD") {
|
|
res.statusCode = 200;
|
|
setStaticFileHeaders(res, safeIndex.path);
|
|
res.end();
|
|
return true;
|
|
}
|
|
serveResolvedIndexHtml(res, fs.readFileSync(safeIndex.fd, "utf8"));
|
|
return true;
|
|
} finally {
|
|
fs.closeSync(safeIndex.fd);
|
|
}
|
|
}
|
|
|
|
respondNotFound(res);
|
|
return true;
|
|
}
|