OpenClawBot / src /gateway /control-ui.ts
darkfire514's picture
Upload 2526 files
34b00eb verified
import type { IncomingMessage, ServerResponse } from "node:http";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { OpenClawConfig } from "../config/config.js";
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.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;
};
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 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;
}
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;
}
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));
}
type ControlUiOauthProvider = "google" | "github";
type ControlUiOauthConfig = {
sessionSecret: string;
sessionTtlSeconds: number;
allowedEmails: Set<string> | null;
allowedGoogleEmails: Set<string> | null;
allowedGithubLogins: Set<string> | null;
google: { clientId: string; clientSecret: string } | null;
github: { clientId: string; clientSecret: string } | null;
};
type ControlUiSessionPayload = {
v: 1;
exp: number;
provider: ControlUiOauthProvider;
sub: string;
email?: string;
login?: string;
name?: string;
picture?: string;
};
const CONTROL_UI_SESSION_COOKIE = "openclaw_ui_session";
const CONTROL_UI_OAUTH_STATE_COOKIE = "openclaw_ui_oauth_state";
const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24;
const fallbackSessionSecret = crypto.randomBytes(32).toString("hex");
function normalizeIdentifier(value: string) {
return value.trim().toLowerCase();
}
function parseCsvSet(value: string | undefined): Set<string> | null {
const raw = value?.trim();
if (!raw) {
return null;
}
const items = raw
.split(/[,\n]/g)
.map((item) => normalizeIdentifier(item))
.filter(Boolean);
return items.length ? new Set(items) : null;
}
function resolveControlUiOauthConfig(): ControlUiOauthConfig {
const googleClientId = process.env.OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_ID?.trim() ?? "";
const googleClientSecret = process.env.OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_SECRET?.trim() ?? "";
const githubClientId = process.env.OPENCLAW_CONTROL_UI_GITHUB_CLIENT_ID?.trim() ?? "";
const githubClientSecret = process.env.OPENCLAW_CONTROL_UI_GITHUB_CLIENT_SECRET?.trim() ?? "";
const sessionSecret =
process.env.OPENCLAW_CONTROL_UI_SESSION_SECRET?.trim() ??
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ??
fallbackSessionSecret;
const ttlRaw = process.env.OPENCLAW_CONTROL_UI_SESSION_TTL_SECONDS?.trim();
const sessionTtlSeconds =
ttlRaw && Number.isFinite(Number(ttlRaw)) && Number(ttlRaw) > 0
? Math.floor(Number(ttlRaw))
: DEFAULT_SESSION_TTL_SECONDS;
return {
sessionSecret,
sessionTtlSeconds,
allowedEmails: parseCsvSet(process.env.OPENCLAW_CONTROL_UI_ALLOWED_EMAILS),
allowedGoogleEmails: parseCsvSet(process.env.OPENCLAW_CONTROL_UI_ALLOWED_GOOGLE_EMAILS),
allowedGithubLogins: parseCsvSet(process.env.OPENCLAW_CONTROL_UI_ALLOWED_GITHUB_LOGINS),
google: googleClientId && googleClientSecret ? { clientId: googleClientId, clientSecret: googleClientSecret } : null,
github: githubClientId && githubClientSecret ? { clientId: githubClientId, clientSecret: githubClientSecret } : null,
};
}
function isControlUiOauthEnabled(cfg: ControlUiOauthConfig) {
return Boolean(cfg.google || cfg.github);
}
function base64UrlEncode(value: string) {
return Buffer.from(value, "utf8").toString("base64url");
}
function base64UrlDecode(value: string) {
return Buffer.from(value, "base64url").toString("utf8");
}
function signToken(secret: string, payload: unknown) {
const body = base64UrlEncode(JSON.stringify(payload));
const sig = crypto.createHmac("sha256", secret).update(body).digest("base64url");
return `${body}.${sig}`;
}
function verifyToken<T>(secret: string, token: string): { ok: true; value: T } | { ok: false } {
const parts = token.split(".");
if (parts.length !== 2) {
return { ok: false };
}
const [body, sig] = parts;
const expected = crypto.createHmac("sha256", secret).update(body).digest("base64url");
try {
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length) {
return { ok: false };
}
if (!crypto.timingSafeEqual(a, b)) {
return { ok: false };
}
} catch {
return { ok: false };
}
try {
const parsed = JSON.parse(base64UrlDecode(body)) as T;
return { ok: true, value: parsed };
} catch {
return { ok: false };
}
}
function parseCookies(header: string | undefined): Record<string, string> {
if (!header) {
return {};
}
const out: Record<string, string> = {};
for (const part of header.split(";")) {
const idx = part.indexOf("=");
if (idx === -1) {
continue;
}
const k = part.slice(0, idx).trim();
const v = part.slice(idx + 1).trim();
if (!k) {
continue;
}
out[k] = decodeURIComponent(v);
}
return out;
}
function appendSetCookie(res: ServerResponse, value: string) {
const prev = res.getHeader("Set-Cookie");
if (!prev) {
res.setHeader("Set-Cookie", value);
return;
}
if (Array.isArray(prev)) {
res.setHeader("Set-Cookie", [...prev, value]);
return;
}
res.setHeader("Set-Cookie", [String(prev), value]);
}
function isSecureRequest(req: IncomingMessage) {
const xfProto = req.headers["x-forwarded-proto"];
const proto = Array.isArray(xfProto) ? xfProto[0] : xfProto;
if (proto && proto.toLowerCase().includes("https")) {
return true;
}
return Boolean((req.socket as { encrypted?: boolean }).encrypted);
}
function getRequestOrigin(req: IncomingMessage) {
const proto = isSecureRequest(req) ? "https" : "http";
const xfHost = req.headers["x-forwarded-host"];
const hostRaw = (Array.isArray(xfHost) ? xfHost[0] : xfHost) ?? req.headers.host ?? "localhost";
const host = String(hostRaw).split(",")[0]?.trim() || "localhost";
return `${proto}://${host}`;
}
function controlUiCookiePath(_basePath: string) {
return "/";
}
function buildBaseUrlPath(basePath: string, suffix: string) {
return basePath ? `${basePath}${suffix}` : suffix;
}
function setCookie(
res: ServerResponse,
opts: { name: string; value: string; basePath: string; maxAgeSeconds?: number; secure: boolean },
) {
const parts = [
`${opts.name}=${encodeURIComponent(opts.value)}`,
`Path=${controlUiCookiePath(opts.basePath)}`,
"HttpOnly",
"SameSite=Lax",
];
if (typeof opts.maxAgeSeconds === "number") {
parts.push(`Max-Age=${Math.max(0, Math.floor(opts.maxAgeSeconds))}`);
}
if (opts.secure) {
parts.push("Secure");
}
appendSetCookie(res, parts.join("; "));
}
function clearCookie(res: ServerResponse, opts: { name: string; basePath: string; secure: boolean }) {
setCookie(res, { name: opts.name, value: "", basePath: opts.basePath, maxAgeSeconds: 0, secure: opts.secure });
}
function sendHtml(res: ServerResponse, status: number, html: string) {
res.statusCode = status;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.end(html);
}
function sendRedirect(res: ServerResponse, location: string) {
res.statusCode = 302;
res.setHeader("Location", location);
res.end();
}
function isAllowedAccount(cfg: ControlUiOauthConfig, payload: ControlUiSessionPayload) {
const email = payload.email ? normalizeIdentifier(payload.email) : null;
const login = payload.login ? normalizeIdentifier(payload.login) : null;
const hasAnyAllowList = Boolean(
cfg.allowedEmails || cfg.allowedGoogleEmails || cfg.allowedGithubLogins,
);
if (!hasAnyAllowList) {
return false;
}
if (cfg.allowedEmails) {
if (!email || !cfg.allowedEmails.has(email)) {
return false;
}
}
if (payload.provider === "google" && cfg.allowedGoogleEmails) {
if (!email || !cfg.allowedGoogleEmails.has(email)) {
return false;
}
}
if (payload.provider === "github" && cfg.allowedGithubLogins) {
if (!login || !cfg.allowedGithubLogins.has(login)) {
return false;
}
}
return true;
}
function readSessionFromRequest(req: IncomingMessage, cfg: ControlUiOauthConfig): ControlUiSessionPayload | null {
const cookies = parseCookies(req.headers.cookie);
const raw = cookies[CONTROL_UI_SESSION_COOKIE];
if (!raw) {
return null;
}
const verified = verifyToken<ControlUiSessionPayload>(cfg.sessionSecret, raw);
if (!verified.ok) {
return null;
}
const value = verified.value;
if (!value || value.v !== 1) {
return null;
}
if (typeof value.exp !== "number" || value.exp <= Math.floor(Date.now() / 1000)) {
return null;
}
if (value.provider !== "google" && value.provider !== "github") {
return null;
}
if (typeof value.sub !== "string" || !value.sub) {
return null;
}
if (!isAllowedAccount(cfg, value)) {
return null;
}
return value;
}
export type ControlUiOauthSessionIdentity = {
provider: "google" | "github";
sub: string;
email?: string;
login?: string;
name?: string;
};
export function readControlUiOauthSessionIdentityFromRequest(
req: IncomingMessage,
): ControlUiOauthSessionIdentity | null {
const cfg = resolveControlUiOauthConfig();
if (!isControlUiOauthEnabled(cfg)) {
return null;
}
const session = readSessionFromRequest(req, cfg);
if (!session) {
return null;
}
return {
provider: session.provider,
sub: session.sub,
email: session.email,
login: session.login,
name: session.name,
};
}
type OAuthStatePayload = { v: 1; exp: number; nonce: string; provider: ControlUiOauthProvider };
function issueOAuthState(res: ServerResponse, cfg: ControlUiOauthConfig, basePath: string, secure: boolean, provider: ControlUiOauthProvider) {
const exp = Math.floor(Date.now() / 1000) + 60 * 10;
const statePayload: OAuthStatePayload = { v: 1, exp, nonce: crypto.randomBytes(16).toString("hex"), provider };
const token = signToken(cfg.sessionSecret, statePayload);
setCookie(res, { name: CONTROL_UI_OAUTH_STATE_COOKIE, value: token, basePath, maxAgeSeconds: 60 * 10, secure });
return token;
}
function consumeOAuthState(req: IncomingMessage, res: ServerResponse, cfg: ControlUiOauthConfig, basePath: string, secure: boolean) {
const cookies = parseCookies(req.headers.cookie);
const raw = cookies[CONTROL_UI_OAUTH_STATE_COOKIE];
if (!raw) {
return null;
}
const verified = verifyToken<OAuthStatePayload>(cfg.sessionSecret, raw);
clearCookie(res, { name: CONTROL_UI_OAUTH_STATE_COOKIE, basePath, secure });
if (!verified.ok) {
return null;
}
const value = verified.value;
if (!value || value.v !== 1) {
return null;
}
if (typeof value.exp !== "number" || value.exp <= Math.floor(Date.now() / 1000)) {
return null;
}
if (value.provider !== "google" && value.provider !== "github") {
return null;
}
if (typeof value.nonce !== "string" || !value.nonce) {
return null;
}
return { token: raw, payload: value };
}
function renderLoginPage(opts: { basePath: string; hasGoogle: boolean; hasGithub: boolean }) {
const loginPath = buildBaseUrlPath(opts.basePath, "/auth/login");
const googlePath = buildBaseUrlPath(opts.basePath, "/auth/google");
const githubPath = buildBaseUrlPath(opts.basePath, "/auth/github");
const buttons = [
opts.hasGoogle
? `<a class="btn google" href="${googlePath}">Continue with Google</a>`
: `<div class="btn disabled">Google login not configured</div>`,
opts.hasGithub
? `<a class="btn github" href="${githubPath}">Continue with GitHub</a>`
: `<div class="btn disabled">GitHub login not configured</div>`,
].join("");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenClaw Control UI Login</title>
<style>
:root { color-scheme: light dark; }
body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; }
.wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.card { width: 100%; max-width: 420px; border: 1px solid rgba(127,127,127,.35); border-radius: 14px; padding: 18px; }
h1 { font-size: 18px; margin: 0 0 10px; }
p { margin: 0 0 16px; opacity: .85; font-size: 13px; line-height: 1.45; }
.btn { display: block; text-decoration: none; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(127,127,127,.35); margin: 10px 0; text-align: center; font-weight: 600; }
.btn.google { background: rgba(66,133,244,.12); }
.btn.github { background: rgba(36,41,46,.12); }
.btn.disabled { opacity: .55; cursor: not-allowed; }
.small { font-size: 12px; opacity: .75; margin-top: 12px; }
.small a { color: inherit; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Sign in to OpenClaw Control UI</h1>
<p>Only accounts allowed by environment variables can access the console.</p>
${buttons}
<div class="small">This page is served from <a href="${loginPath}">${loginPath}</a>.</div>
</div>
</div>
</body>
</html>`;
}
async function exchangeGoogleUser(opts: {
code: string;
redirectUri: string;
clientId: string;
clientSecret: string;
}) {
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code: opts.code,
client_id: opts.clientId,
client_secret: opts.clientSecret,
redirect_uri: opts.redirectUri,
grant_type: "authorization_code",
}),
});
if (!tokenRes.ok) {
throw new Error(`google token exchange failed: ${tokenRes.status}`);
}
const tokenJson = (await tokenRes.json()) as { access_token?: unknown };
const accessToken = typeof tokenJson.access_token === "string" ? tokenJson.access_token : null;
if (!accessToken) {
throw new Error("google access token missing");
}
const userRes = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!userRes.ok) {
throw new Error(`google userinfo failed: ${userRes.status}`);
}
const userJson = (await userRes.json()) as {
sub?: unknown;
email?: unknown;
email_verified?: unknown;
name?: unknown;
picture?: unknown;
};
const sub = typeof userJson.sub === "string" ? userJson.sub : null;
const email = typeof userJson.email === "string" ? userJson.email : null;
const emailVerified = userJson.email_verified === true;
const name = typeof userJson.name === "string" ? userJson.name : null;
const picture = typeof userJson.picture === "string" ? userJson.picture : null;
if (!sub) {
throw new Error("google subject missing");
}
if (email && !emailVerified) {
throw new Error("google email not verified");
}
return { sub, email, name, picture };
}
async function exchangeGithubUser(opts: { code: string; redirectUri: string; clientId: string; clientSecret: string }) {
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code: opts.code,
client_id: opts.clientId,
client_secret: opts.clientSecret,
redirect_uri: opts.redirectUri,
}),
});
if (!tokenRes.ok) {
throw new Error(`github token exchange failed: ${tokenRes.status}`);
}
const tokenJson = (await tokenRes.json()) as { access_token?: unknown; token_type?: unknown };
const accessToken = typeof tokenJson.access_token === "string" ? tokenJson.access_token : null;
if (!accessToken) {
throw new Error("github access token missing");
}
const userRes = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
"User-Agent": "openclaw-control-ui",
},
});
if (!userRes.ok) {
throw new Error(`github user fetch failed: ${userRes.status}`);
}
const userJson = (await userRes.json()) as { id?: unknown; login?: unknown; name?: unknown; avatar_url?: unknown };
const sub = typeof userJson.id === "number" ? String(userJson.id) : typeof userJson.id === "string" ? userJson.id : null;
const login = typeof userJson.login === "string" ? userJson.login : null;
const name = typeof userJson.name === "string" ? userJson.name : null;
const picture = typeof userJson.avatar_url === "string" ? userJson.avatar_url : null;
if (!sub || !login) {
throw new Error("github user identity missing");
}
const emailsRes = await fetch("https://api.github.com/user/emails", {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
"User-Agent": "openclaw-control-ui",
},
});
let email: string | null = null;
if (emailsRes.ok) {
const emailsJson = (await emailsRes.json()) as Array<{
email?: unknown;
verified?: unknown;
primary?: unknown;
}>;
const verified = emailsJson
.map((item) => ({
email: typeof item.email === "string" ? item.email : null,
verified: item.verified === true,
primary: item.primary === true,
}))
.filter((item) => item.email && item.verified);
const primary = verified.find((item) => item.primary);
email = primary?.email ?? verified[0]?.email ?? null;
}
return { sub, login, name, picture, email };
}
export async function handleControlUiAuthRequest(
req: IncomingMessage,
res: ServerResponse,
opts?: ControlUiRequestOptions,
): Promise<boolean> {
const urlRaw = req.url;
if (!urlRaw) {
return false;
}
if (req.method !== "GET" && req.method !== "HEAD") {
return false;
}
const cfg = resolveControlUiOauthConfig();
if (!isControlUiOauthEnabled(cfg)) {
return false;
}
const url = new URL(urlRaw, "http://localhost");
const basePath = normalizeControlUiBasePath(opts?.basePath);
const pathname = url.pathname;
if (basePath) {
if (pathname === basePath) {
const loginUrl = buildBaseUrlPath(basePath, "/auth/login");
sendRedirect(res, `${loginUrl}${url.search}`);
return true;
}
if (!pathname.startsWith(`${basePath}/`)) {
return false;
}
}
const uiPath = basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname;
const secure = isSecureRequest(req);
const session = readSessionFromRequest(req, cfg);
if (uiPath === "/auth/login") {
sendHtml(
res,
200,
renderLoginPage({ basePath, hasGoogle: Boolean(cfg.google), hasGithub: Boolean(cfg.github) }),
);
return true;
}
if (uiPath === "/auth/logout") {
clearCookie(res, { name: CONTROL_UI_SESSION_COOKIE, basePath, secure });
sendRedirect(res, buildBaseUrlPath(basePath, "/auth/login"));
return true;
}
if (uiPath === "/auth/google") {
if (!cfg.google) {
respondNotFound(res);
return true;
}
const state = issueOAuthState(res, cfg, basePath, secure, "google");
const origin = getRequestOrigin(req);
const redirectUri = `${origin}${buildBaseUrlPath(basePath, "/auth/google/callback")}`;
const authorize = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authorize.searchParams.set("client_id", cfg.google.clientId);
authorize.searchParams.set("redirect_uri", redirectUri);
authorize.searchParams.set("response_type", "code");
authorize.searchParams.set("scope", "openid email profile");
authorize.searchParams.set("state", state);
authorize.searchParams.set("access_type", "online");
authorize.searchParams.set("prompt", "select_account");
sendRedirect(res, authorize.toString());
return true;
}
if (uiPath === "/auth/google/callback") {
if (!cfg.google) {
respondNotFound(res);
return true;
}
const code = url.searchParams.get("code")?.trim() ?? "";
const state = url.searchParams.get("state")?.trim() ?? "";
const consumed = consumeOAuthState(req, res, cfg, basePath, secure);
if (!code || !state || !consumed || state !== consumed.token || consumed.payload.provider !== "google") {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Invalid OAuth callback");
return true;
}
try {
const origin = getRequestOrigin(req);
const redirectUri = `${origin}${buildBaseUrlPath(basePath, "/auth/google/callback")}`;
const user = await exchangeGoogleUser({
code,
redirectUri,
clientId: cfg.google.clientId,
clientSecret: cfg.google.clientSecret,
});
const exp = Math.floor(Date.now() / 1000) + cfg.sessionTtlSeconds;
const payload: ControlUiSessionPayload = {
v: 1,
exp,
provider: "google",
sub: user.sub,
email: user.email ?? undefined,
name: user.name ?? undefined,
picture: user.picture ?? undefined,
};
if (!isAllowedAccount(cfg, payload)) {
res.statusCode = 403;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Forbidden");
return true;
}
setCookie(res, {
name: CONTROL_UI_SESSION_COOKIE,
value: signToken(cfg.sessionSecret, payload),
basePath,
maxAgeSeconds: cfg.sessionTtlSeconds,
secure,
});
sendRedirect(res, basePath ? `${basePath}/` : "/");
return true;
} catch {
res.statusCode = 502;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("OAuth provider error");
return true;
}
}
if (uiPath === "/auth/github") {
if (!cfg.github) {
respondNotFound(res);
return true;
}
const state = issueOAuthState(res, cfg, basePath, secure, "github");
const origin = getRequestOrigin(req);
const redirectUri = `${origin}${buildBaseUrlPath(basePath, "/auth/github/callback")}`;
const authorize = new URL("https://github.com/login/oauth/authorize");
authorize.searchParams.set("client_id", cfg.github.clientId);
authorize.searchParams.set("redirect_uri", redirectUri);
authorize.searchParams.set("scope", "read:user user:email");
authorize.searchParams.set("state", state);
sendRedirect(res, authorize.toString());
return true;
}
if (uiPath === "/auth/github/callback") {
if (!cfg.github) {
respondNotFound(res);
return true;
}
const code = url.searchParams.get("code")?.trim() ?? "";
const state = url.searchParams.get("state")?.trim() ?? "";
const consumed = consumeOAuthState(req, res, cfg, basePath, secure);
if (!code || !state || !consumed || state !== consumed.token || consumed.payload.provider !== "github") {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Invalid OAuth callback");
return true;
}
try {
const origin = getRequestOrigin(req);
const redirectUri = `${origin}${buildBaseUrlPath(basePath, "/auth/github/callback")}`;
const user = await exchangeGithubUser({
code,
redirectUri,
clientId: cfg.github.clientId,
clientSecret: cfg.github.clientSecret,
});
const exp = Math.floor(Date.now() / 1000) + cfg.sessionTtlSeconds;
const payload: ControlUiSessionPayload = {
v: 1,
exp,
provider: "github",
sub: user.sub,
email: user.email ?? undefined,
login: user.login ?? undefined,
name: user.name ?? undefined,
picture: user.picture ?? undefined,
};
if (!isAllowedAccount(cfg, payload)) {
res.statusCode = 403;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Forbidden");
return true;
}
setCookie(res, {
name: CONTROL_UI_SESSION_COOKIE,
value: signToken(cfg.sessionSecret, payload),
basePath,
maxAgeSeconds: cfg.sessionTtlSeconds,
secure,
});
sendRedirect(res, basePath ? `${basePath}/` : "/");
return true;
} catch {
res.statusCode = 502;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("OAuth provider error");
return true;
}
}
if (session) {
return false;
}
if (uiPath.startsWith("/auth/")) {
respondNotFound(res);
return true;
}
sendRedirect(res, buildBaseUrlPath(basePath, "/auth/login"));
return true;
}
interface ControlUiInjectionOpts {
basePath: string;
assistantName?: string;
assistantAvatar?: string;
}
function injectControlUiConfig(html: string, opts: ControlUiInjectionOpts): string {
const { basePath, assistantName, assistantAvatar } = opts;
const script =
`<script>` +
`window.__OPENCLAW_CONTROL_UI_BASE_PATH__=${JSON.stringify(basePath)};` +
`window.__OPENCLAW_ASSISTANT_NAME__=${JSON.stringify(
assistantName ?? DEFAULT_ASSISTANT_IDENTITY.name,
)};` +
`window.__OPENCLAW_ASSISTANT_AVATAR__=${JSON.stringify(
assistantAvatar ?? DEFAULT_ASSISTANT_IDENTITY.avatar,
)};` +
`</script>`;
// Check if already injected
if (html.includes("__OPENCLAW_ASSISTANT_NAME__")) {
return html;
}
const headClose = html.indexOf("</head>");
if (headClose !== -1) {
return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`;
}
return `${script}${html}`;
}
interface ServeIndexHtmlOpts {
basePath: string;
config?: OpenClawConfig;
agentId?: string;
}
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
const { basePath, config, agentId } = opts;
const identity = config
? resolveAssistantIdentity({ cfg: config, agentId })
: DEFAULT_ASSISTANT_IDENTITY;
const resolvedAgentId =
typeof (identity as { agentId?: string }).agentId === "string"
? (identity as { agentId?: string }).agentId
: agentId;
const avatarValue =
resolveAssistantAvatarUrl({
avatar: identity.avatar,
agentId: resolvedAgentId,
basePath,
}) ?? identity.avatar;
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;
}