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 | null; allowedGoogleEmails: Set | null; allowedGithubLogins: Set | 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 | 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(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 { if (!header) { return {}; } const out: Record = {}; 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(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(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 ? `Continue with Google` : `
Google login not configured
`, opts.hasGithub ? `Continue with GitHub` : `
GitHub login not configured
`, ].join(""); return ` OpenClaw Control UI Login

Sign in to OpenClaw Control UI

Only accounts allowed by environment variables can access the console.

${buttons}
This page is served from ${loginPath}.
`; } 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 { 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 = ``; // Check if already injected if (html.includes("__OPENCLAW_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?: 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; }