2026-02-18 01:29:02 +00:00
import fs from "node:fs" ;
2026-02-18 01:34:35 +00:00
import type { IncomingMessage , ServerResponse } from "node:http" ;
2025-12-18 22:40:46 +00:00
import path from "node:path" ;
2026-01-30 03:15:10 +01:00
import type { OpenClawConfig } from "../config/config.js" ;
2026-02-26 13:32:02 +01:00
import { openBoundaryFileSync } from "../infra/boundary-file-read.js" ;
2026-02-03 13:56:20 -05:00
import { resolveControlUiRootSync } from "../infra/control-ui-assets.js" ;
2026-02-21 15:47:51 -07:00
import { isWithinDir } from "../infra/path-safety.js" ;
2026-02-22 23:12:31 +01:00
import { openVerifiedFileSync } from "../infra/safe-open-sync.js" ;
2026-02-22 22:30:33 +01:00
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js" ;
2026-03-05 14:01:34 +08:00
import { resolveRuntimeServiceVersion } from "../version.js" ;
2026-01-22 06:47:37 +00:00
import { DEFAULT_ASSISTANT_IDENTITY , resolveAssistantIdentity } from "./assistant-identity.js" ;
2026-02-16 03:35:11 +01:00
import {
CONTROL_UI_BOOTSTRAP_CONFIG_PATH ,
type ControlUiBootstrapConfig ,
} from "./control-ui-contract.js" ;
import { buildControlUiCspHeader } from "./control-ui-csp.js" ;
2026-03-02 16:17:31 +00:00
import {
isReadHttpMethod ,
respondNotFound as respondControlUiNotFound ,
respondPlainText ,
} from "./control-ui-http-utils.js" ;
import { classifyControlUiRequest } from "./control-ui-routing.js" ;
2026-01-22 23:41:28 +00:00
import {
buildControlUiAvatarUrl ,
CONTROL_UI_AVATAR_PREFIX ,
normalizeControlUiBasePath ,
resolveAssistantAvatarUrl ,
} from "./control-ui-shared.js" ;
2026-01-22 06:47:37 +00:00
2025-12-19 05:11:08 +00:00
const ROOT_PREFIX = "/" ;
2026-03-03 00:54:28 +00:00
const CONTROL_UI_ASSETS_MISSING_MESSAGE =
"Control UI assets not found. Build them with `pnpm ui:build` (auto-installs UI deps), or run `pnpm ui:dev` during development." ;
2025-12-18 22:40:46 +00:00
2026-01-03 17:54:52 +01:00
export type ControlUiRequestOptions = {
basePath? : string ;
2026-01-30 03:15:10 +01:00
config? : OpenClawConfig ;
2026-01-22 06:47:37 +00:00
agentId? : string ;
2026-02-03 13:56:20 -05:00
root? : ControlUiRootState ;
2026-01-03 17:54:52 +01:00
} ;
2026-02-03 13:56:20 -05:00
export type ControlUiRootState =
| { kind : "resolved" ; path : string }
| { kind : "invalid" ; path : string }
| { kind : "missing" } ;
2025-12-18 22:40:46 +00:00
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" ;
2026-01-22 03:54:31 +00:00
case ".gif" :
return "image/gif" ;
case ".webp" :
return "image/webp" ;
2025-12-18 22:40:46 +00:00
case ".ico" :
return "image/x-icon" ;
case ".txt" :
return "text/plain; charset=utf-8" ;
default :
return "application/octet-stream" ;
}
}
2026-02-20 14:41:57 -03:00
/ * *
* Extensions recognised as static assets . Missing files with these extensions
* return 404 instead of the SPA index . html fallback . ` .html ` is intentionally
* excluded — actual HTML files on disk are served earlier , and missing ` .html `
* paths should fall through to the SPA router ( client - side routers may use
* ` .html ` - suffixed routes ) .
* /
const STATIC_ASSET_EXTENSIONS = new Set ( [
".js" ,
".css" ,
".json" ,
".map" ,
".svg" ,
".png" ,
".jpg" ,
".jpeg" ,
".gif" ,
".webp" ,
".ico" ,
".txt" ,
] ) ;
2026-01-22 03:54:31 +00:00
export type ControlUiAvatarResolution =
| { kind : "none" ; reason : string }
| { kind : "local" ; filePath : string }
| { kind : "remote" ; url : string }
| { kind : "data" ; url : string } ;
type ControlUiAvatarMeta = {
avatarUrl : string | null ;
} ;
2026-02-03 16:00:57 -08:00
function applyControlUiSecurityHeaders ( res : ServerResponse ) {
res . setHeader ( "X-Frame-Options" , "DENY" ) ;
2026-02-16 03:35:11 +01:00
res . setHeader ( "Content-Security-Policy" , buildControlUiCspHeader ( ) ) ;
2026-02-03 16:00:57 -08:00
res . setHeader ( "X-Content-Type-Options" , "nosniff" ) ;
2026-02-16 03:04:47 +01:00
res . setHeader ( "Referrer-Policy" , "no-referrer" ) ;
2026-02-03 16:00:57 -08:00
}
2026-01-22 03:54:31 +00:00
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 ) ) ;
}
2026-03-03 00:54:28 +00:00
function respondControlUiAssetsUnavailable (
res : ServerResponse ,
options ? : { configuredRootPath? : string } ,
) {
if ( options ? . configuredRootPath ) {
respondPlainText (
res ,
503 ,
` Control UI assets not found at ${ options . configuredRootPath } . Build them with \` pnpm ui:build \` (auto-installs UI deps), or update gateway.controlUi.root. ` ,
) ;
return ;
}
respondPlainText ( res , 503 , CONTROL_UI_ASSETS_MISSING_MESSAGE ) ;
}
function respondHeadForFile ( req : IncomingMessage , res : ServerResponse , filePath : string ) : boolean {
if ( req . method !== "HEAD" ) {
return false ;
}
res . statusCode = 200 ;
setStaticFileHeaders ( res , filePath ) ;
res . end ( ) ;
return true ;
}
2026-01-22 03:54:31 +00:00
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 ;
2026-01-31 16:19:20 +09:00
if ( ! urlRaw ) {
return false ;
}
2026-03-02 16:17:31 +00:00
if ( ! isReadHttpMethod ( req . method ) ) {
2026-01-31 16:19:20 +09:00
return false ;
}
2026-01-22 03:54:31 +00:00
const url = new URL ( urlRaw , "http://localhost" ) ;
const basePath = normalizeControlUiBasePath ( opts . basePath ) ;
const pathname = url . pathname ;
2026-01-22 23:41:28 +00:00
const pathWithBase = basePath
? ` ${ basePath } ${ CONTROL_UI_AVATAR_PREFIX } / `
: ` ${ CONTROL_UI_AVATAR_PREFIX } / ` ;
2026-01-31 16:19:20 +09:00
if ( ! pathname . startsWith ( pathWithBase ) ) {
return false ;
}
2026-01-22 03:54:31 +00:00
2026-02-03 16:00:57 -08:00
applyControlUiSecurityHeaders ( res ) ;
2026-01-22 03:54:31 +00:00
const agentIdParts = pathname . slice ( pathWithBase . length ) . split ( "/" ) . filter ( Boolean ) ;
const agentId = agentIdParts [ 0 ] ? ? "" ;
if ( agentIdParts . length !== 1 || ! agentId || ! isValidAgentId ( agentId ) ) {
2026-03-02 16:17:31 +00:00
respondControlUiNotFound ( res ) ;
2026-01-22 03:54:31 +00:00
return true ;
}
if ( url . searchParams . get ( "meta" ) === "1" ) {
const resolved = opts . resolveAvatar ( agentId ) ;
const avatarUrl =
resolved . kind === "local"
2026-01-22 23:41:28 +00:00
? buildControlUiAvatarUrl ( basePath , agentId )
2026-01-22 03:54:31 +00:00
: 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" ) {
2026-03-02 16:17:31 +00:00
respondControlUiNotFound ( res ) ;
2026-01-22 03:54:31 +00:00
return true ;
}
2026-02-22 22:30:33 +01:00
const safeAvatar = resolveSafeAvatarFile ( resolved . filePath ) ;
if ( ! safeAvatar ) {
2026-03-02 16:17:31 +00:00
respondControlUiNotFound ( res ) ;
2026-01-22 03:54:31 +00:00
return true ;
}
2026-02-22 22:30:33 +01:00
try {
2026-03-03 00:54:28 +00:00
if ( respondHeadForFile ( req , res , safeAvatar . path ) ) {
2026-02-22 22:30:33 +01:00
return true ;
}
2026-01-22 03:54:31 +00:00
2026-02-22 22:30:33 +01:00
serveResolvedFile ( res , safeAvatar . path , fs . readFileSync ( safeAvatar . fd ) ) ;
return true ;
} finally {
fs . closeSync ( safeAvatar . fd ) ;
}
2026-01-22 03:54:31 +00:00
}
2026-02-21 23:36:47 +01:00
function setStaticFileHeaders ( res : ServerResponse , filePath : string ) {
2025-12-18 22:40:46 +00:00
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" ) ;
2026-02-21 23:36:47 +01:00
}
2026-02-21 23:10:47 +01:00
function serveResolvedFile ( res : ServerResponse , filePath : string , body : Buffer ) {
2026-02-21 23:36:47 +01:00
setStaticFileHeaders ( res , filePath ) ;
2026-02-21 23:10:47 +01:00
res . end ( body ) ;
}
function serveResolvedIndexHtml ( res : ServerResponse , body : string ) {
2026-01-03 17:54:52 +01:00
res . setHeader ( "Content-Type" , "text/html; charset=utf-8" ) ;
res . setHeader ( "Cache-Control" , "no-cache" ) ;
2026-02-21 23:10:47 +01:00
res . end ( body ) ;
}
function isExpectedSafePathError ( error : unknown ) : boolean {
const code =
typeof error === "object" && error !== null && "code" in error ? String ( error . code ) : "" ;
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP" ;
}
2026-02-22 22:30:33 +01:00
function resolveSafeAvatarFile ( filePath : string ) : { path : string ; fd : number } | null {
2026-02-22 23:12:31 +01:00
const opened = openVerifiedFileSync ( {
filePath ,
rejectPathSymlink : true ,
maxBytes : AVATAR_MAX_BYTES ,
} ) ;
if ( ! opened . ok ) {
2026-02-22 22:30:33 +01:00
return null ;
}
2026-02-22 23:12:31 +01:00
return { path : opened.path , fd : opened.fd } ;
2026-02-22 22:30:33 +01:00
}
2026-02-21 23:10:47 +01:00
function resolveSafeControlUiFile (
2026-02-21 23:36:47 +01:00
rootReal : string ,
2026-02-21 23:10:47 +01:00
filePath : string ,
2026-02-21 23:36:47 +01:00
) : { path : string ; fd : number } | null {
2026-02-26 13:32:02 +01:00
const opened = openBoundaryFileSync ( {
absolutePath : filePath ,
rootPath : rootReal ,
rootRealPath : rootReal ,
boundaryLabel : "control ui root" ,
skipLexicalRootCheck : true ,
} ) ;
if ( ! opened . ok ) {
if ( opened . reason === "io" ) {
throw opened . error ;
2026-02-21 23:10:47 +01:00
}
2026-02-26 13:32:02 +01:00
return null ;
2026-02-21 23:10:47 +01:00
}
2026-02-26 13:32:02 +01:00
return { path : opened.path , fd : opened.fd } ;
2026-01-03 17:54:52 +01:00
}
2025-12-18 22:40:46 +00:00
function isSafeRelativePath ( relPath : string ) {
2026-01-31 16:19:20 +09:00
if ( ! relPath ) {
return false ;
}
2025-12-18 22:40:46 +00:00
const normalized = path . posix . normalize ( relPath ) ;
2026-02-21 15:47:51 -07:00
if ( path . posix . isAbsolute ( normalized ) || path . win32 . isAbsolute ( normalized ) ) {
return false ;
}
2026-01-31 16:19:20 +09:00
if ( normalized . startsWith ( "../" ) || normalized === ".." ) {
return false ;
}
if ( normalized . includes ( "\0" ) ) {
return false ;
}
2025-12-18 22:40:46 +00:00
return true ;
}
export function handleControlUiHttpRequest (
req : IncomingMessage ,
res : ServerResponse ,
2026-01-03 17:54:52 +01:00
opts? : ControlUiRequestOptions ,
2025-12-18 22:40:46 +00:00
) : boolean {
const urlRaw = req . url ;
2026-01-31 16:19:20 +09:00
if ( ! urlRaw ) {
return false ;
}
2025-12-18 22:40:46 +00:00
const url = new URL ( urlRaw , "http://localhost" ) ;
2026-01-03 17:54:52 +01:00
const basePath = normalizeControlUiBasePath ( opts ? . basePath ) ;
const pathname = url . pathname ;
2026-03-02 16:17:31 +00:00
const route = classifyControlUiRequest ( {
basePath ,
pathname ,
search : url.search ,
method : req.method ,
} ) ;
if ( route . kind === "not-control-ui" ) {
return false ;
2026-01-03 17:54:52 +01:00
}
2026-03-02 16:17:31 +00:00
if ( route . kind === "not-found" ) {
applyControlUiSecurityHeaders ( res ) ;
respondControlUiNotFound ( res ) ;
return true ;
}
if ( route . kind === "redirect" ) {
applyControlUiSecurityHeaders ( res ) ;
res . statusCode = 302 ;
res . setHeader ( "Location" , route . location ) ;
res . end ( ) ;
return true ;
2026-03-01 22:52:11 -08:00
}
2026-02-03 16:00:57 -08:00
applyControlUiSecurityHeaders ( res ) ;
2026-02-16 03:04:47 +01:00
const bootstrapConfigPath = basePath
? ` ${ basePath } ${ CONTROL_UI_BOOTSTRAP_CONFIG_PATH } `
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH ;
if ( pathname === bootstrapConfigPath ) {
const config = opts ? . config ;
const identity = config
? resolveAssistantIdentity ( { cfg : config , agentId : opts?.agentId } )
: DEFAULT_ASSISTANT_IDENTITY ;
const avatarValue = resolveAssistantAvatarUrl ( {
avatar : identity.avatar ,
agentId : identity.agentId ,
basePath ,
} ) ;
if ( req . method === "HEAD" ) {
res . statusCode = 200 ;
res . setHeader ( "Content-Type" , "application/json; charset=utf-8" ) ;
res . setHeader ( "Cache-Control" , "no-cache" ) ;
res . end ( ) ;
return true ;
}
sendJson ( res , 200 , {
basePath ,
assistantName : identity.name ,
assistantAvatar : avatarValue ? ? identity . avatar ,
assistantAgentId : identity.agentId ,
2026-03-05 14:01:34 +08:00
serverVersion : resolveRuntimeServiceVersion ( process . env ) ,
2026-02-16 03:35:11 +01:00
} satisfies ControlUiBootstrapConfig ) ;
2026-02-16 03:04:47 +01:00
return true ;
}
2026-02-03 13:56:20 -05:00
const rootState = opts ? . root ;
if ( rootState ? . kind === "invalid" ) {
2026-03-03 00:54:28 +00:00
respondControlUiAssetsUnavailable ( res , { configuredRootPath : rootState.path } ) ;
2026-02-03 13:56:20 -05:00
return true ;
}
if ( rootState ? . kind === "missing" ) {
2026-03-03 00:54:28 +00:00
respondControlUiAssetsUnavailable ( res ) ;
2026-02-03 13:56:20 -05:00
return true ;
}
const root =
rootState ? . kind === "resolved"
? rootState . path
: resolveControlUiRootSync ( {
moduleUrl : import.meta.url ,
argv1 : process.argv [ 1 ] ,
cwd : process.cwd ( ) ,
} ) ;
2025-12-18 22:40:46 +00:00
if ( ! root ) {
2026-03-03 00:54:28 +00:00
respondControlUiAssetsUnavailable ( res ) ;
2025-12-18 22:40:46 +00:00
return true ;
}
2026-02-21 23:36:47 +01:00
const rootReal = ( ( ) = > {
try {
return fs . realpathSync ( root ) ;
} catch ( error ) {
if ( isExpectedSafePathError ( error ) ) {
return null ;
}
throw error ;
}
} ) ( ) ;
if ( ! rootReal ) {
2026-03-03 00:54:28 +00:00
respondControlUiAssetsUnavailable ( res ) ;
2026-02-21 23:36:47 +01:00
return true ;
}
2026-01-03 17:54:52 +01:00
const uiPath =
2026-01-14 14:31:43 +00:00
basePath && pathname . startsWith ( ` ${ basePath } / ` ) ? pathname . slice ( basePath . length ) : pathname ;
2025-12-19 05:11:08 +00:00
const rel = ( ( ) = > {
2026-01-31 16:19:20 +09:00
if ( uiPath === ROOT_PREFIX ) {
return "" ;
}
2026-01-03 17:54:52 +01:00
const assetsIndex = uiPath . indexOf ( "/assets/" ) ;
2026-01-31 16:19:20 +09:00
if ( assetsIndex >= 0 ) {
return uiPath . slice ( assetsIndex + 1 ) ;
}
2026-01-03 17:54:52 +01:00
return uiPath . slice ( 1 ) ;
2025-12-19 05:11:08 +00:00
} ) ( ) ;
2025-12-18 22:40:46 +00:00
const requested = rel && ! rel . endsWith ( "/" ) ? rel : ` ${ rel } index.html ` ;
const fileRel = requested || "index.html" ;
if ( ! isSafeRelativePath ( fileRel ) ) {
2026-03-02 16:17:31 +00:00
respondControlUiNotFound ( res ) ;
2025-12-18 22:40:46 +00:00
return true ;
}
2026-02-21 15:47:51 -07:00
const filePath = path . resolve ( root , fileRel ) ;
if ( ! isWithinDir ( root , filePath ) ) {
2026-03-02 16:17:31 +00:00
respondControlUiNotFound ( res ) ;
2025-12-18 22:40:46 +00:00
return true ;
}
2026-02-21 23:36:47 +01:00
const safeFile = resolveSafeControlUiFile ( rootReal , filePath ) ;
2026-02-21 23:10:47 +01:00
if ( safeFile ) {
2026-02-21 23:36:47 +01:00
try {
2026-03-03 00:54:28 +00:00
if ( respondHeadForFile ( req , res , safeFile . path ) ) {
2026-02-21 23:36:47 +01:00
return true ;
}
if ( path . basename ( safeFile . path ) === "index.html" ) {
serveResolvedIndexHtml ( res , fs . readFileSync ( safeFile . fd , "utf8" ) ) ;
return true ;
}
serveResolvedFile ( res , safeFile . path , fs . readFileSync ( safeFile . fd ) ) ;
2026-01-03 17:54:52 +01:00
return true ;
2026-02-21 23:36:47 +01:00
} finally {
fs . closeSync ( safeFile . fd ) ;
2026-01-03 17:54:52 +01:00
}
2025-12-18 22:40:46 +00:00
}
2026-02-20 14:41:57 -03:00
// If the requested path looks like a static asset (known extension), return
// 404 rather than falling through to the SPA index.html fallback. We check
// against the same set of extensions that contentTypeForExt() recognises so
// that dotted SPA routes (e.g. /user/jane.doe, /v2.0) still get the
// client-side router fallback.
if ( STATIC_ASSET_EXTENSIONS . has ( path . extname ( fileRel ) . toLowerCase ( ) ) ) {
2026-03-02 16:17:31 +00:00
respondControlUiNotFound ( res ) ;
2026-02-20 14:41:57 -03:00
return true ;
}
2025-12-18 22:40:46 +00:00
// SPA fallback (client-side router): serve index.html for unknown paths.
const indexPath = path . join ( root , "index.html" ) ;
2026-02-21 23:36:47 +01:00
const safeIndex = resolveSafeControlUiFile ( rootReal , indexPath ) ;
2026-02-21 23:10:47 +01:00
if ( safeIndex ) {
2026-02-21 23:36:47 +01:00
try {
2026-03-03 00:54:28 +00:00
if ( respondHeadForFile ( req , res , safeIndex . path ) ) {
2026-02-21 23:36:47 +01:00
return true ;
}
serveResolvedIndexHtml ( res , fs . readFileSync ( safeIndex . fd , "utf8" ) ) ;
return true ;
} finally {
fs . closeSync ( safeIndex . fd ) ;
}
2025-12-18 22:40:46 +00:00
}
2026-03-02 16:17:31 +00:00
respondControlUiNotFound ( res ) ;
2025-12-18 22:40:46 +00:00
return true ;
}