Spaces:
Paused
Paused
| 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; | |
| } | |