openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
799 lines (798 loc) • 30.7 kB
JavaScript
import { a as normalizeLowercaseStringOrEmpty } from "./string-coerce-mnp54Vah.js";
import { M as resolveTimestampMsToIsoString, o as asDateTimestampMs } from "./number-coercion-CJQ8TR--.js";
import { c as isWithinDir, m as FsSafeError } from "./path-BlG8lhgR.js";
import { S as safeFileURLToPath, s as readSecureFile } from "./fs-safe-aqmM_n6V.js";
import { r as openLocalFileSafely } from "./secure-temp-dir-XAWcZnE2.js";
import { i as openRootFileSync, n as matchRootFileOpenFailure } from "./root-file-jRMCpJW4.js";
import { p as resolveUserPath } from "./utils-CCC-BEJH.js";
import { s as resolveRuntimeServiceVersion } from "./version-Crcn9X9T.js";
import "./boundary-file-read-CBe_wA_B.js";
import "./path-safety-CBe_wA_B.js";
import { _ as resolveRequestClientIp } from "./net-DTe7AQiu.js";
import { o as AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, r as AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN } from "./auth-rate-limit-Bwr29t2Y.js";
import { r as authorizeHttpGatewayConnect } from "./auth-DXSWhsnk.js";
import { t as AVATAR_MAX_BYTES } from "./avatar-policy-CLA5a0j8.js";
import { n as authorizeOperatorScopesForMethod } from "./method-scopes-BtdN3y7s.js";
import { i as resolveAssistantAvatarUrl, n as buildControlUiAvatarUrl, r as normalizeControlUiBasePath, t as CONTROL_UI_AVATAR_PREFIX } from "./control-ui-shared-Clry3kRy.js";
import { n as detectMime } from "./mime-C8mVE2Bw.js";
import "./local-file-access-CBe_wA_B.js";
import { a as resolveMediaReferenceLocalPath } from "./media-reference-C05KIYAZ.js";
import { r as getAgentScopedMediaLocalRoots } from "./local-roots-BqdtViGS.js";
import { n as assertLocalMediaAllowed, r as getDefaultLocalRoots } from "./local-media-access-Bw3v5URj.js";
import { c as resolveControlUiRootSync, n as isPackageProvenControlUiRootSync } from "./control-ui-assets-OWiFO3R_.js";
import { n as verifyPairingToken } from "./pairing-token-BEUQW6ez.js";
import { n as resolvePublicAgentAvatarSource, t as resolveAgentAvatar } from "./identity-avatar-DDczSDZA.js";
import { l as listDevicePairing, v as verifyDeviceToken } from "./device-pairing-Bcmp5CuV.js";
import { r as sendGatewayAuthFailure, t as buildMissingScopeForbiddenBody } from "./http-common-DunL6val.js";
import { f as resolveTrustedHttpOperatorScopes, i as getBearerToken, s as resolveHttpBrowserOriginPolicy } from "./http-auth-utils-Du8mRZIw.js";
import "./http-utils-leZWdpma.js";
import { n as resolveAssistantIdentity, t as DEFAULT_ASSISTANT_IDENTITY } from "./assistant-identity-BYpxelqc.js";
import { t as resolveSharedGatewaySessionGeneration } from "./ws-shared-generation-Bp5l7wzu.js";
import fs from "node:fs";
import path from "node:path";
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
//#region src/gateway/control-ui-contract.ts
/** HTTP path for the Control UI bootstrap config payload. */
const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/control-ui-config.json";
//#endregion
//#region src/gateway/control-ui-csp.ts
const SCRIPT_ATTRIBUTE_NAME_RE = /\s([^\s=/>]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/g;
/**
* Compute SHA-256 CSP hashes for inline `<script>` blocks in an HTML string.
* Only scripts without a `src` attribute are considered inline.
*/
function computeInlineScriptHashes(html) {
const hashes = [];
const re = /<script(?:\s[^>]*)?>([^]*?)<\/script>/gi;
let match;
while ((match = re.exec(html)) !== null) {
if (hasScriptSrcAttribute(match[0].slice(0, match[0].indexOf(">") + 1))) continue;
const content = match[1];
if (!content) continue;
const hash = createHash("sha256").update(content, "utf8").digest("base64");
hashes.push(`sha256-${hash}`);
}
return hashes;
}
function hasScriptSrcAttribute(openTag) {
return Array.from(openTag.matchAll(SCRIPT_ATTRIBUTE_NAME_RE)).some((match) => normalizeLowercaseStringOrEmpty(match[1]) === "src");
}
/** Build the CSP header applied to Gateway-served Control UI HTML. */
function buildControlUiCspHeader(opts) {
const hashes = opts?.inlineScriptHashes;
return [
"default-src 'self'",
"base-uri 'none'",
"object-src 'none'",
"frame-ancestors 'none'",
hashes?.length ? `script-src 'self' ${hashes.map((h) => `'${h}'`).join(" ")}` : "script-src 'self'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: blob:",
"media-src 'self' data: blob:",
"font-src 'self' https://fonts.gstatic.com",
"worker-src 'self'",
"connect-src 'self' ws: wss: https://api.openai.com https://tweakcn.com"
].join("; ");
}
//#endregion
//#region src/gateway/control-ui-http-utils.ts
/** Returns true for idempotent HTTP methods that can read Control UI assets. */
function isReadHttpMethod(method) {
return method === "GET" || method === "HEAD";
}
/** Sends a plain-text response with the standard UTF-8 content type. */
function respondPlainText(res, statusCode, body) {
res.statusCode = statusCode;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(body);
}
/** Sends the shared plain-text 404 response for Control UI routes. */
function respondNotFound(res) {
respondPlainText(res, 404, "Not Found");
}
//#endregion
//#region src/gateway/control-ui-routing.ts
const ROOT_MOUNTED_GATEWAY_PROBE_PATHS = new Set([
"/health",
"/healthz",
"/ready",
"/readyz"
]);
/** Classify an HTTP request as Control UI serving, redirect, 404, or non-Control-UI. */
function classifyControlUiRequest(params) {
const { basePath, pathname, search, method } = params;
if (!basePath) {
if (pathname === "/ui" || pathname.startsWith("/ui/")) return { kind: "not-found" };
if (ROOT_MOUNTED_GATEWAY_PROBE_PATHS.has(pathname)) return { kind: "not-control-ui" };
if (pathname === "/plugins" || pathname.startsWith("/plugins/")) return { kind: "not-control-ui" };
if (pathname === "/api" || pathname.startsWith("/api/")) return { kind: "not-control-ui" };
if (!isReadHttpMethod(method)) return { kind: "not-control-ui" };
return { kind: "serve" };
}
if (!pathname.startsWith(`${basePath}/`) && pathname !== basePath) return { kind: "not-control-ui" };
if (!isReadHttpMethod(method)) return { kind: "not-control-ui" };
if (pathname === basePath) return {
kind: "redirect",
location: `${basePath}/${search}`
};
return { kind: "serve" };
}
//#endregion
//#region src/gateway/control-ui.ts
const ROOT_PREFIX = "/";
const CONTROL_UI_ASSISTANT_MEDIA_PREFIX = "/__openclaw__/assistant-media";
const CONTROL_UI_ASSISTANT_MEDIA_TICKET_SCOPE = "assistant-media";
const CONTROL_UI_ASSISTANT_MEDIA_TICKET_TTL_MS = 300 * 1e3;
const CONTROL_UI_ASSETS_MISSING_MESSAGE = "Control UI assets not found. Build them with `pnpm ui:build` (auto-installs UI deps), or run `pnpm ui:dev` during development.";
const CONTROL_UI_OPERATOR_READ_SCOPE = "operator.read";
const CONTROL_UI_OPERATOR_ROLE = "operator";
const controlUiAssistantMediaTicketSecret = randomBytes(32);
function contentTypeForExt(ext) {
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";
case ".webmanifest": return "application/manifest+json; 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",
".webmanifest"
]);
const CONTROL_UI_NAMESPACE_PREFIX = "/__openclaw__/";
const CONTROL_UI_ROOT_PUBLIC_ASSETS = new Set([
"apple-touch-icon.png",
"favicon-32.png",
"favicon.ico",
"favicon.svg",
"manifest.webmanifest",
"sw.js"
]);
function controlUiAvatarResolutionMeta(resolved) {
if (!resolved) return {
avatarSource: null,
avatarStatus: null,
avatarReason: null
};
return {
avatarSource: resolvePublicAgentAvatarSource(resolved) ?? null,
avatarStatus: resolved.kind,
avatarReason: resolved.kind === "none" ? resolved.reason : null
};
}
function applyControlUiSecurityHeaders(res) {
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, status, body) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.end(JSON.stringify(body));
}
function respondControlUiAssetsUnavailable(res, options) {
if (options?.configuredRootPath) {
respondPlainText(res, 503, `Control UI assets not found at ${options.configuredRootPath}. Build them with \`pnpm ui:build\` (auto-installs UI deps), or update gateway.controlUi.root.`);
return;
}
respondPlainText(res, 503, CONTROL_UI_ASSETS_MISSING_MESSAGE);
}
function respondHeadForFile(req, res, filePath) {
if (req.method !== "HEAD") return false;
res.statusCode = 200;
setStaticFileHeaders(res, filePath);
res.end();
return true;
}
function isValidAgentId(agentId) {
return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(agentId);
}
function normalizeAssistantMediaSource(source) {
const trimmed = source.trim();
if (!trimmed) return null;
if (trimmed.startsWith("file://")) try {
return safeFileURLToPath(trimmed);
} catch {
return null;
}
if (trimmed.startsWith("~")) return resolveUserPath(trimmed);
return trimmed;
}
function resolveAssistantMediaRoutePath(basePath) {
return `${basePath && basePath !== "/" ? basePath.endsWith("/") ? basePath.slice(0, -1) : basePath : ""}${CONTROL_UI_ASSISTANT_MEDIA_PREFIX}`;
}
function resolveAssistantMediaAuthToken(req) {
const bearer = getBearerToken(req);
if (bearer) return bearer;
const urlRaw = req.url;
if (!urlRaw) return;
try {
return new URL(urlRaw, "http://localhost").searchParams.get("token")?.trim() || void 0;
} catch {
return;
}
}
function resolveControlUiReadAuthToken(req, opts) {
const bearer = getBearerToken(req);
if (bearer) return bearer;
if (!opts?.allowQueryToken) return;
return resolveAssistantMediaAuthToken(req);
}
async function authorizeControlUiReadRequest(req, res, opts) {
if (!opts?.auth) return true;
const token = resolveControlUiReadAuthToken(req, { allowQueryToken: opts.allowQueryToken });
const clientIp = resolveRequestClientIp(req, opts.trustedProxies, opts.allowRealIpFallback === true) ?? req.socket?.remoteAddress;
let resolvedAuthResult = await authorizeHttpGatewayConnect({
auth: opts.auth,
connectAuth: token ? {
token,
password: token
} : null,
req,
browserOriginPolicy: resolveHttpBrowserOriginPolicy(req),
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,
rateLimiter: token ? opts.rateLimiter : void 0,
clientIp,
rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET
});
if (!resolvedAuthResult.ok && token && opts.auth.mode !== "trusted-proxy" && opts.auth.mode !== "none") {
const deviceRateCheck = opts.rateLimiter?.check(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
if (deviceRateCheck && !deviceRateCheck.allowed) resolvedAuthResult = {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: deviceRateCheck.retryAfterMs
};
else if (await authorizeControlUiDeviceReadToken(token, { requiredSharedGatewaySessionGeneration: resolveSharedGatewaySessionGeneration(opts.auth, opts.trustedProxies) })) {
opts.rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
opts.rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET);
resolvedAuthResult = {
ok: true,
method: "device-token"
};
} else opts.rateLimiter?.recordFailure(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
}
if (!resolvedAuthResult.ok) {
sendGatewayAuthFailure(res, resolvedAuthResult);
return false;
}
const trustDeclaredOperatorScopes = resolvedAuthResult.method === "trusted-proxy";
if (!trustDeclaredOperatorScopes) return true;
const requestedScopes = resolveTrustedHttpOperatorScopes(req, { trustDeclaredOperatorScopes });
const scopeAuth = authorizeOperatorScopesForMethod(opts.requiredOperatorMethod ?? "assistant.media.get", requestedScopes);
if (!scopeAuth.allowed) {
sendJson(res, 403, buildMissingScopeForbiddenBody(scopeAuth.missingScope));
return false;
}
return true;
}
async function authorizeControlUiDeviceReadToken(token, opts) {
const pairing = await listDevicePairing();
for (const device of pairing.paired) {
const operatorToken = device.tokens?.[CONTROL_UI_OPERATOR_ROLE];
if (!operatorToken || operatorToken.revokedAtMs) continue;
if (!verifyPairingToken(token, operatorToken.token)) continue;
if ((await verifyDeviceToken({
deviceId: device.deviceId,
token,
role: CONTROL_UI_OPERATOR_ROLE,
scopes: [CONTROL_UI_OPERATOR_READ_SCOPE],
requiredSharedGatewaySessionGeneration: opts.requiredSharedGatewaySessionGeneration
})).ok) return true;
}
return false;
}
function signAssistantMediaTicketPayload(encodedPayload) {
return createHmac("sha256", controlUiAssistantMediaTicketSecret).update(encodedPayload).digest("base64url");
}
function createAssistantMediaTicket(source, nowMs = Date.now()) {
const now = asDateTimestampMs(nowMs);
if (now === void 0) return {};
const exp = asDateTimestampMs(now + CONTROL_UI_ASSISTANT_MEDIA_TICKET_TTL_MS);
if (exp === void 0) return {};
const payload = {
scope: CONTROL_UI_ASSISTANT_MEDIA_TICKET_SCOPE,
source,
exp
};
const encodedPayload = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
return {
mediaTicket: `v1.${encodedPayload}.${signAssistantMediaTicketPayload(encodedPayload)}`,
mediaTicketExpiresAt: resolveTimestampMsToIsoString(exp)
};
}
function verifyAssistantMediaTicket(ticket, source, nowMs = Date.now()) {
const now = asDateTimestampMs(nowMs);
if (now === void 0) return false;
const parts = ticket?.split(".");
if (!parts || parts.length !== 3 || parts[0] !== "v1") return false;
const [, encodedPayload, sig] = parts;
if (!encodedPayload || !sig) return false;
const expectedSig = signAssistantMediaTicketPayload(encodedPayload);
const sigBuffer = Buffer.from(sig, "base64url");
const expectedBuffer = Buffer.from(expectedSig, "base64url");
if (sigBuffer.length !== expectedBuffer.length || !timingSafeEqual(sigBuffer, expectedBuffer)) return false;
try {
const payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8"));
return payload.scope === CONTROL_UI_ASSISTANT_MEDIA_TICKET_SCOPE && payload.source === source && typeof payload.exp === "number" && Number.isFinite(payload.exp) && payload.exp >= now;
} catch {
return false;
}
}
function classifyAssistantMediaError(err) {
if (err instanceof FsSafeError) switch (err.code) {
case "not-found": return {
available: false,
code: "file-not-found",
reason: "File not found"
};
case "not-file": return {
available: false,
code: "not-a-file",
reason: "Not a file"
};
case "invalid-path":
case "path-mismatch":
case "symlink": return {
available: false,
code: "invalid-file",
reason: "Invalid file"
};
default: return {
available: false,
code: "attachment-unavailable",
reason: "Attachment unavailable"
};
}
if (err instanceof Error && "code" in err) {
const errorCode = err.code;
switch (typeof errorCode === "string" ? errorCode : "") {
case "path-not-allowed": return {
available: false,
code: "outside-allowed-folders",
reason: "Outside allowed folders"
};
case "invalid-file-url":
case "invalid-path":
case "unsafe-bypass":
case "network-path-not-allowed":
case "invalid-root": return {
available: false,
code: "blocked-local-file",
reason: "Blocked local file"
};
case "not-found": return {
available: false,
code: "file-not-found",
reason: "File not found"
};
case "not-file": return {
available: false,
code: "not-a-file",
reason: "Not a file"
};
default: break;
}
}
return {
available: false,
code: "attachment-unavailable",
reason: "Attachment unavailable"
};
}
async function resolveAssistantMediaAvailability(source, localRoots) {
try {
const localPath = await resolveMediaReferenceLocalPath(source);
await assertLocalMediaAllowed(localPath, localRoots);
await (await openLocalFileSafely({ filePath: localPath })).handle.close();
return { available: true };
} catch (err) {
return classifyAssistantMediaError(err);
}
}
async function handleControlUiAssistantMediaRequest(req, res, opts) {
const urlRaw = req.url;
if (!urlRaw || !isReadHttpMethod(req.method)) return false;
const url = new URL(urlRaw, "http://localhost");
if (url.pathname !== resolveAssistantMediaRoutePath(opts?.basePath)) return false;
applyControlUiSecurityHeaders(res);
const source = normalizeAssistantMediaSource(url.searchParams.get("source") ?? "");
if (!source) {
respondNotFound(res);
return true;
}
const isMetaRequest = url.searchParams.get("meta") === "1";
if (!(!isMetaRequest && verifyAssistantMediaTicket(url.searchParams.get("mediaTicket"), source)) && !await authorizeControlUiReadRequest(req, res, {
auth: opts?.auth,
trustedProxies: opts?.trustedProxies,
allowRealIpFallback: opts?.allowRealIpFallback,
rateLimiter: opts?.rateLimiter,
allowQueryToken: true
})) return true;
const localRoots = opts?.config ? getAgentScopedMediaLocalRoots(opts.config, opts.agentId) : getDefaultLocalRoots();
if (isMetaRequest) {
const availability = await resolveAssistantMediaAvailability(source, localRoots);
sendJson(res, 200, availability.available ? {
...availability,
...createAssistantMediaTicket(source)
} : availability);
return true;
}
let opened = null;
let localPath;
let handleClosed = false;
const closeOpenedHandle = async () => {
if (!opened || handleClosed) return;
handleClosed = true;
await opened.handle.close().catch(() => {});
};
try {
localPath = await resolveMediaReferenceLocalPath(source);
await assertLocalMediaAllowed(localPath, localRoots);
opened = await openLocalFileSafely({ filePath: localPath });
const sniffLength = Math.min(opened.stat.size, 8192);
const sniffBuffer = sniffLength > 0 ? Buffer.allocUnsafe(sniffLength) : void 0;
const bytesRead = sniffBuffer && sniffLength > 0 ? (await opened.handle.read(sniffBuffer, 0, sniffLength, 0)).bytesRead : 0;
const mime = await detectMime({
buffer: sniffBuffer?.subarray(0, bytesRead),
filePath: localPath
});
if (mime) res.setHeader("Content-Type", mime);
else res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Content-Length", String(opened.stat.size));
const stream = opened.handle.createReadStream({
start: 0,
autoClose: false
});
const finishClose = () => {
closeOpenedHandle();
};
stream.once("end", finishClose);
stream.once("close", finishClose);
stream.once("error", () => {
closeOpenedHandle();
if (!res.headersSent) respondNotFound(res);
else res.destroy();
});
res.once("close", finishClose);
stream.pipe(res);
return true;
} catch {
await closeOpenedHandle();
respondNotFound(res);
return true;
}
}
async function handleControlUiAvatarRequest(req, res, opts) {
const urlRaw = req.url;
if (!urlRaw) return false;
if (!isReadHttpMethod(req.method)) 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 (!await authorizeControlUiReadRequest(req, res, {
auth: opts.auth,
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,
rateLimiter: opts.rateLimiter
})) return true;
if (url.searchParams.get("meta") === "1") {
const resolved = opts.resolveAvatar(agentId);
const meta = controlUiAvatarResolutionMeta(resolved);
sendJson(res, 200, {
avatarUrl: resolved.kind === "local" ? buildControlUiAvatarUrl(basePath, agentId) : resolved.kind === "remote" || resolved.kind === "data" ? resolved.url : null,
avatarSource: meta.avatarSource,
avatarStatus: meta.avatarStatus ?? resolved.kind,
avatarReason: meta.avatarReason
});
return true;
}
const resolved = opts.resolveAvatar(agentId);
if (resolved.kind !== "local") {
respondNotFound(res);
return true;
}
const safeAvatar = await resolveSafeAvatarFile(resolved.filePath);
if (!safeAvatar) {
respondNotFound(res);
return true;
}
if (respondHeadForFile(req, res, safeAvatar.path)) return true;
serveResolvedFile(res, safeAvatar.path, safeAvatar.buffer);
return true;
}
function setStaticFileHeaders(res, filePath) {
const ext = path.extname(filePath).toLowerCase();
res.setHeader("Content-Type", contentTypeForExt(ext));
res.setHeader("Cache-Control", "no-cache");
}
function serveResolvedFile(res, filePath, body) {
setStaticFileHeaders(res, filePath);
res.end(body);
}
function serveResolvedIndexHtml(res, body) {
const hashes = computeInlineScriptHashes(body);
if (hashes.length > 0) res.setHeader("Content-Security-Policy", buildControlUiCspHeader({ inlineScriptHashes: hashes }));
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.end(body);
}
function readOpenedFile(fd) {
return new Promise((resolve, reject) => {
fs.readFile(fd, (error, data) => {
if (error) {
reject(error);
return;
}
resolve(data);
});
});
}
async function readOpenedFileText(fd) {
return (await readOpenedFile(fd)).toString("utf8");
}
function isExpectedSafePathError(error) {
const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
}
async function resolveSafeAvatarFile(filePath) {
try {
const read = await readSecureFile({
filePath,
label: "Control UI avatar",
permissions: {
allowInsecure: true,
allowReadableByOthers: true
},
io: { maxBytes: AVATAR_MAX_BYTES }
});
return {
path: read.realPath,
buffer: read.buffer
};
} catch {
return null;
}
}
function resolveSafeControlUiFile(rootReal, filePath, rejectHardlinks) {
const opened = openRootFileSync({
absolutePath: filePath,
rootPath: rootReal,
rootRealPath: rootReal,
boundaryLabel: "control ui root",
skipLexicalRootCheck: true,
rejectHardlinks
});
if (!opened.ok) return matchRootFileOpenFailure(opened, {
io: (failure) => {
throw failure.error;
},
fallback: () => null
});
return {
path: opened.path,
fd: opened.fd
};
}
function isSafeRelativePath(relPath) {
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;
}
const CONTROL_UI_DEFAULT_NAMESPACE_BOOTSTRAP_CONFIG_PATH = `${CONTROL_UI_NAMESPACE_PREFIX.replace(/\/$/, "")}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`;
const LEGACY_BOOTSTRAP_CONFIG_PATH = `/__openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`;
/**
* Whether `pathname` should be served the Control UI bootstrap config payload.
*
* The canonical endpoint is the configured base path joined with the shared
* bootstrap constant (or the bare constant when no base path is configured).
* For every base path (configured or empty) we additionally accept the legacy
* single-underscore suffix `${basePath}/__openclaw/control-ui-config.json` that
* current main and v2026.6.1 serve and document, so older bundles and clients
* that still request the pre-#66946 endpoint keep receiving config after an
* upgrade instead of 404ing. When no base path is configured we further accept
* the default-namespace alias `/__openclaw__/control-ui-config.json`, which is
* what the default `/__openclaw__/` entry requests after inferring its base path
* from the URL. All compatibility endpoints are preserved; no path is removed.
*/
function matchesControlUiBootstrapConfigPath(pathname, basePath) {
if (pathname === `${basePath}/control-ui-config.json` || pathname === `${basePath}${LEGACY_BOOTSTRAP_CONFIG_PATH}`) return true;
return basePath === "" && pathname === CONTROL_UI_DEFAULT_NAMESPACE_BOOTSTRAP_CONFIG_PATH;
}
async function handleControlUiHttpRequest(req, res, opts) {
const urlRaw = req.url;
if (!urlRaw) return false;
const url = new URL(urlRaw, "http://localhost");
const basePath = normalizeControlUiBasePath(opts?.basePath);
const pathname = url.pathname;
const route = classifyControlUiRequest({
basePath,
pathname,
search: url.search,
method: req.method
});
if (route.kind === "not-control-ui") return false;
if (route.kind === "not-found") {
applyControlUiSecurityHeaders(res);
respondNotFound(res);
return true;
}
if (route.kind === "redirect") {
applyControlUiSecurityHeaders(res);
res.statusCode = 302;
res.setHeader("Location", route.location);
res.end();
return true;
}
applyControlUiSecurityHeaders(res);
if (matchesControlUiBootstrapConfigPath(pathname, basePath)) {
if (!await authorizeControlUiReadRequest(req, res, {
auth: opts?.auth,
trustedProxies: opts?.trustedProxies,
allowRealIpFallback: opts?.allowRealIpFallback,
rateLimiter: opts?.rateLimiter
})) return true;
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
});
const avatarMeta = config ? controlUiAvatarResolutionMeta(resolveAgentAvatar(config, identity.agentId, { includeUiOverride: true })) : controlUiAvatarResolutionMeta(null);
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,
assistantAvatarSource: avatarMeta.avatarSource,
assistantAvatarStatus: avatarMeta.avatarStatus,
assistantAvatarReason: avatarMeta.avatarReason,
assistantAgentId: identity.agentId,
serverVersion: resolveRuntimeServiceVersion(process.env),
localMediaPreviewRoots: [...getAgentScopedMediaLocalRoots(config ?? {}, identity.agentId)],
embedSandbox: config?.gateway?.controlUi?.embedSandbox === "trusted" ? "trusted" : config?.gateway?.controlUi?.embedSandbox === "strict" ? "strict" : "scripts",
allowExternalEmbedUrls: config?.gateway?.controlUi?.allowExternalEmbedUrls === true,
chatMessageMaxWidth: config?.gateway?.controlUi?.chatMessageMaxWidth
});
return true;
}
const rootState = opts?.root;
if (rootState?.kind === "invalid") {
respondControlUiAssetsUnavailable(res, { configuredRootPath: rootState.path });
return true;
}
if (rootState?.kind === "missing") {
respondControlUiAssetsUnavailable(res);
return true;
}
const root = rootState?.kind === "resolved" || rootState?.kind === "bundled" ? rootState.path : resolveControlUiRootSync({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd()
});
if (!root) {
respondControlUiAssetsUnavailable(res);
return true;
}
const rootReal = (() => {
try {
return fs.realpathSync(root);
} catch (error) {
if (isExpectedSafePathError(error)) return null;
throw error;
}
})();
if (!rootReal) {
respondControlUiAssetsUnavailable(res);
return true;
}
const uiPath = basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname;
const rel = (() => {
if (uiPath === ROOT_PREFIX) return "";
if (uiPath.startsWith(CONTROL_UI_NAMESPACE_PREFIX)) {
const namespacedRel = uiPath.slice(14);
if (CONTROL_UI_ROOT_PUBLIC_ASSETS.has(namespacedRel)) return namespacedRel;
}
const assetsIndex = uiPath.indexOf("/assets/");
if (assetsIndex >= 0) return uiPath.slice(assetsIndex + 1);
return uiPath.slice(1);
})();
const fileRel = (rel && !rel.endsWith("/") ? rel : `${rel}index.html`) || "index.html";
if (!isSafeRelativePath(fileRel)) {
respondNotFound(res);
return true;
}
const filePath = path.resolve(root, fileRel);
if (!isWithinDir(root, filePath)) {
respondNotFound(res);
return true;
}
const rejectHardlinks = !(rootState?.kind === "bundled" || rootState === void 0 && isPackageProvenControlUiRootSync(root, {
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd()
}));
const safeFile = resolveSafeControlUiFile(rootReal, filePath, rejectHardlinks);
if (safeFile) try {
if (respondHeadForFile(req, res, safeFile.path)) return true;
if (path.basename(safeFile.path) === "index.html") {
serveResolvedIndexHtml(res, await readOpenedFileText(safeFile.fd));
return true;
}
serveResolvedFile(res, safeFile.path, await readOpenedFile(safeFile.fd));
return true;
} finally {
fs.closeSync(safeFile.fd);
}
if (STATIC_ASSET_EXTENSIONS.has(path.extname(fileRel).toLowerCase())) {
respondNotFound(res);
return true;
}
const safeIndex = resolveSafeControlUiFile(rootReal, path.join(root, "index.html"), rejectHardlinks);
if (safeIndex) try {
if (respondHeadForFile(req, res, safeIndex.path)) return true;
serveResolvedIndexHtml(res, await readOpenedFileText(safeIndex.fd));
return true;
} finally {
fs.closeSync(safeIndex.fd);
}
respondNotFound(res);
return true;
}
//#endregion
export { handleControlUiAssistantMediaRequest, handleControlUiAvatarRequest, handleControlUiHttpRequest };