Files
openclaw/src/gateway/control-ui.ts
Sid 3a6b412f00 fix(gateway): pass actual version to Control UI client instead of dev (#35230)
* fix(gateway): pass actual version to Control UI client instead of "dev"

The GatewayClient, CLI WS client, and browser Control UI all sent
"dev" as their clientVersion during handshake, making it impossible
to distinguish builds in gateway logs and health snapshots.

- GatewayClient and CLI WS client now use the resolved VERSION constant
- Control UI reads serverVersion from the bootstrap endpoint and
  forwards it when connecting
- Bootstrap contract extended with serverVersion field

Closes #35209

* Gateway: fix control-ui version version-reporting consistency

* Control UI: guard deferred bootstrap connect after disconnect

* fix(ui): accept same-origin http and relative gateway URLs for client version

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-05 00:01:34 -06:00

467 lines
13 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 { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { resolveControlUiRootSync } from "../infra/control-ui-assets.js";
import { isWithinDir } from "../infra/path-safety.js";
import { openVerifiedFileSync } from "../infra/safe-open-sync.js";
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
import { resolveRuntimeServiceVersion } from "../version.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 {
isReadHttpMethod,
respondNotFound as respondControlUiNotFound,
respondPlainText,
} from "./control-ui-http-utils.js";
import { classifyControlUiRequest } from "./control-ui-routing.js";
import {
buildControlUiAvatarUrl,
CONTROL_UI_AVATAR_PREFIX,
normalizeControlUiBasePath,
resolveAssistantAvatarUrl,
} from "./control-ui-shared.js";
const ROOT_PREFIX = "/";
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.";
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 respondControlUiAssetsUnavailable(
res: ServerResponse,
options?: { configuredRootPath?: string },
) {
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: IncomingMessage, res: ServerResponse, filePath: string): boolean {
if (req.method !== "HEAD") {
return false;
}
res.statusCode = 200;
setStaticFileHeaders(res, filePath);
res.end();
return true;
}
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 (!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)) {
respondControlUiNotFound(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") {
respondControlUiNotFound(res);
return true;
}
const safeAvatar = resolveSafeAvatarFile(resolved.filePath);
if (!safeAvatar) {
respondControlUiNotFound(res);
return true;
}
try {
if (respondHeadForFile(req, res, safeAvatar.path)) {
return true;
}
serveResolvedFile(res, safeAvatar.path, fs.readFileSync(safeAvatar.fd));
return true;
} finally {
fs.closeSync(safeAvatar.fd);
}
}
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 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 resolveSafeAvatarFile(filePath: string): { path: string; fd: number } | null {
const opened = openVerifiedFileSync({
filePath,
rejectPathSymlink: true,
maxBytes: AVATAR_MAX_BYTES,
});
if (!opened.ok) {
return null;
}
return { path: opened.path, fd: opened.fd };
}
function resolveSafeControlUiFile(
rootReal: string,
filePath: string,
): { path: string; fd: number } | null {
const opened = openBoundaryFileSync({
absolutePath: filePath,
rootPath: rootReal,
rootRealPath: rootReal,
boundaryLabel: "control ui root",
skipLexicalRootCheck: true,
});
if (!opened.ok) {
if (opened.reason === "io") {
throw opened.error;
}
return null;
}
return { path: opened.path, fd: opened.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;
}
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);
respondControlUiNotFound(res);
return true;
}
if (route.kind === "redirect") {
applyControlUiSecurityHeaders(res);
res.statusCode = 302;
res.setHeader("Location", route.location);
res.end();
return true;
}
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,
serverVersion: resolveRuntimeServiceVersion(process.env),
} satisfies ControlUiBootstrapConfig);
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.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 "";
}
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)) {
respondControlUiNotFound(res);
return true;
}
const filePath = path.resolve(root, fileRel);
if (!isWithinDir(root, filePath)) {
respondControlUiNotFound(res);
return true;
}
const safeFile = resolveSafeControlUiFile(rootReal, filePath);
if (safeFile) {
try {
if (respondHeadForFile(req, res, safeFile.path)) {
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())) {
respondControlUiNotFound(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 (respondHeadForFile(req, res, safeIndex.path)) {
return true;
}
serveResolvedIndexHtml(res, fs.readFileSync(safeIndex.fd, "utf8"));
return true;
} finally {
fs.closeSync(safeIndex.fd);
}
}
respondControlUiNotFound(res);
return true;
}