From 5935c4d23d5e3a4d521df99e5d419fb4f8690557 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 3 Feb 2026 13:56:20 -0500 Subject: [PATCH] fix(ui): fix web UI after tsdown migration and typing changes --- CONTRIBUTING.md | 15 +++++ docs/gateway/configuration.md | 1 + docs/zh-CN/gateway/configuration.md | 1 + src/agents/skills-status.ts | 19 +++++- src/agents/skills/bundled-context.ts | 24 ++++++++ src/agents/skills/bundled-dir.test.ts | 54 +++++++++++++++++ src/agents/skills/bundled-dir.ts | 69 +++++++++++++++++++--- src/cli/skills-cli.test.ts | 1 + src/cli/skills-cli.ts | 1 + src/config/schema.ts | 4 ++ src/config/types.gateway.ts | 2 + src/config/zod-schema.ts | 1 + src/gateway/control-ui.ts | 60 ++++++++++--------- src/gateway/server-http.ts | 9 ++- src/gateway/server-runtime-config.ts | 7 +++ src/gateway/server-runtime-state.ts | 3 + src/gateway/server.impl.ts | 42 ++++++++++++++ src/infra/control-ui-assets.test.ts | 70 +++++++++++++++++++++- src/infra/control-ui-assets.ts | 83 ++++++++++++++++++++++++++- src/infra/openclaw-root.ts | 54 +++++++++++++++++ tsconfig.json | 3 +- ui/src/ui/types.ts | 1 + ui/src/ui/views/agents.ts | 5 +- ui/src/ui/views/skills.ts | 13 ++++- 24 files changed, 499 insertions(+), 43 deletions(-) create mode 100644 src/agents/skills/bundled-context.ts create mode 100644 src/agents/skills/bundled-dir.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ffd628a75d1..49ddd66bb8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,21 @@ Welcome to the lobster tank! 🦞 - Keep PRs focused (one thing per PR) - Describe what & why +## Control UI Decorators + +The Control UI uses Lit with **legacy** decorators (current Rollup parsing does not support +`accessor` fields required for standard decorators). When adding reactive fields, keep the +legacy style: + +```ts +@state() foo = "bar"; +@property({ type: Number }) count = 0; +``` + +The root `tsconfig.json` is configured for legacy decorators (`experimentalDecorators: true`) +with `useDefineForClassFields: false`. Avoid flipping these unless you are also updating the UI +build tooling to support standard decorators. + ## AI/Vibe-Coded PRs Welcome! 🤖 Built with Codex, Claude, or other AI tools? **Awesome - just mark it!** diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index faf19a98c49..e8bc990dbdf 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2952,6 +2952,7 @@ Control UI base path: - `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served. - Examples: `"/ui"`, `"/openclaw"`, `"/apps/openclaw"`. - Default: root (`/`) (unchanged). +- `gateway.controlUi.root` sets the filesystem root for Control UI assets (default: `dist/control-ui`). - `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI when device identity is omitted (typically over HTTP). Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`. diff --git a/docs/zh-CN/gateway/configuration.md b/docs/zh-CN/gateway/configuration.md index 2b771c65dfc..8dc15e44f88 100644 --- a/docs/zh-CN/gateway/configuration.md +++ b/docs/zh-CN/gateway/configuration.md @@ -2901,6 +2901,7 @@ OpenClaw 可以为 OpenClaw 启动一个**专用、隔离的** Chrome/Brave/Edge - `gateway.controlUi.basePath` 设置控制台 UI 提供服务的 URL 前缀。 - 示例:`"/ui"`、`"/openclaw"`、`"/apps/openclaw"`。 - 默认:根路径(`/`)(不变)。 +- `gateway.controlUi.root` 设置控制台 UI 资产的文件系统根目录(默认:`dist/control-ui`)。 - `gateway.controlUi.allowInsecureAuth` 允许在省略设备身份时对控制台 UI 进行仅 token 认证(通常通过 HTTP)。默认:`false`。建议使用 HTTPS(Tailscale Serve)或 `127.0.0.1`。 - `gateway.controlUi.dangerouslyDisableDeviceAuth` 禁用控制台 UI 的设备身份检查(仅 token/密码)。默认:`false`。仅用于紧急情况。 diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index 03c962044b5..abec175b84e 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -15,6 +15,7 @@ import { type SkillInstallSpec, type SkillsInstallPreferences, } from "./skills.js"; +import { resolveBundledSkillsContext } from "./skills/bundled-context.js"; export type SkillStatusConfigCheck = { path: string; @@ -33,6 +34,7 @@ export type SkillStatusEntry = { name: string; description: string; source: string; + bundled: boolean; filePath: string; baseDir: string; skillKey: string; @@ -167,6 +169,7 @@ function buildSkillStatus( config?: OpenClawConfig, prefs?: SkillsInstallPreferences, eligibility?: SkillEligibilityContext, + bundledNames?: Set, ): SkillStatusEntry { const skillKey = resolveSkillKey(entry); const skillConfig = resolveSkillConfig(config, skillKey); @@ -181,6 +184,10 @@ function buildSkillStatus( entry.frontmatter.website ?? entry.frontmatter.url; const homepage = homepageRaw?.trim() ? homepageRaw.trim() : undefined; + const bundled = + bundledNames && bundledNames.size > 0 + ? bundledNames.has(entry.skill.name) + : entry.skill.source === "openclaw-bundled"; const requiredBins = entry.metadata?.requires?.bins ?? []; const requiredAnyBins = entry.metadata?.requires?.anyBins ?? []; @@ -256,6 +263,7 @@ function buildSkillStatus( name: entry.skill.name, description: entry.skill.description, source: entry.skill.source, + bundled, filePath: entry.skill.filePath, baseDir: entry.skill.baseDir, skillKey, @@ -289,13 +297,20 @@ export function buildWorkspaceSkillStatus( }, ): SkillStatusReport { const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills"); - const skillEntries = opts?.entries ?? loadWorkspaceSkillEntries(workspaceDir, opts); + const bundledContext = resolveBundledSkillsContext(); + const skillEntries = + opts?.entries ?? + loadWorkspaceSkillEntries(workspaceDir, { + config: opts?.config, + managedSkillsDir, + bundledSkillsDir: bundledContext.dir, + }); const prefs = resolveSkillsInstallPreferences(opts?.config); return { workspaceDir, managedSkillsDir, skills: skillEntries.map((entry) => - buildSkillStatus(entry, opts?.config, prefs, opts?.eligibility), + buildSkillStatus(entry, opts?.config, prefs, opts?.eligibility, bundledContext.names), ), }; } diff --git a/src/agents/skills/bundled-context.ts b/src/agents/skills/bundled-context.ts new file mode 100644 index 00000000000..6545ace79d1 --- /dev/null +++ b/src/agents/skills/bundled-context.ts @@ -0,0 +1,24 @@ +import { loadSkillsFromDir } from "@mariozechner/pi-coding-agent"; +import { resolveBundledSkillsDir, type BundledSkillsResolveOptions } from "./bundled-dir.js"; + +export type BundledSkillsContext = { + dir?: string; + names: Set; +}; + +export function resolveBundledSkillsContext( + opts: BundledSkillsResolveOptions = {}, +): BundledSkillsContext { + const dir = resolveBundledSkillsDir(opts); + const names = new Set(); + if (!dir) { + return { dir, names }; + } + const result = loadSkillsFromDir({ dir, source: "openclaw-bundled" }); + for (const skill of result.skills) { + if (skill.name.trim()) { + names.add(skill.name); + } + } + return { dir, names }; +} diff --git a/src/agents/skills/bundled-dir.test.ts b/src/agents/skills/bundled-dir.test.ts new file mode 100644 index 00000000000..45fad1bcb97 --- /dev/null +++ b/src/agents/skills/bundled-dir.test.ts @@ -0,0 +1,54 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveBundledSkillsDir } from "./bundled-dir.js"; + +async function writeSkill(dir: string, name: string) { + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, "SKILL.md"), + `---\nname: ${name}\ndescription: ${name}\n---\n\n# ${name}\n`, + "utf-8", + ); +} + +describe("resolveBundledSkillsDir", () => { + const originalOverride = process.env.OPENCLAW_BUNDLED_SKILLS_DIR; + + afterEach(() => { + if (originalOverride === undefined) { + delete process.env.OPENCLAW_BUNDLED_SKILLS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_SKILLS_DIR = originalOverride; + } + }); + + it("resolves bundled skills under a flattened dist layout", async () => { + delete process.env.OPENCLAW_BUNDLED_SKILLS_DIR; + + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-bundled-")); + await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" })); + + await writeSkill(path.join(root, "skills", "peekaboo"), "peekaboo"); + + const distDir = path.join(root, "dist"); + await fs.mkdir(distDir, { recursive: true }); + const argv1 = path.join(distDir, "index.js"); + await fs.writeFile(argv1, "// stub", "utf-8"); + + const moduleUrl = pathToFileURL(path.join(distDir, "skills.js")).href; + const execPath = path.join(root, "bin", "node"); + await fs.mkdir(path.dirname(execPath), { recursive: true }); + + const resolved = resolveBundledSkillsDir({ + argv1, + moduleUrl, + cwd: distDir, + execPath, + }); + + expect(resolved).toBe(path.join(root, "skills")); + }); +}); diff --git a/src/agents/skills/bundled-dir.ts b/src/agents/skills/bundled-dir.ts index 826d9665bd9..22da4fa46ee 100644 --- a/src/agents/skills/bundled-dir.ts +++ b/src/agents/skills/bundled-dir.ts @@ -1,8 +1,41 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; -export function resolveBundledSkillsDir(): string | undefined { +function looksLikeSkillsDir(dir: string): boolean { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".")) { + continue; + } + const fullPath = path.join(dir, entry.name); + if (entry.isFile() && entry.name.endsWith(".md")) { + return true; + } + if (entry.isDirectory()) { + if (fs.existsSync(path.join(fullPath, "SKILL.md"))) { + return true; + } + } + } + } catch { + return false; + } + return false; +} + +export type BundledSkillsResolveOptions = { + argv1?: string; + moduleUrl?: string; + cwd?: string; + execPath?: string; +}; + +export function resolveBundledSkillsDir( + opts: BundledSkillsResolveOptions = {}, +): string | undefined { const override = process.env.OPENCLAW_BUNDLED_SKILLS_DIR?.trim(); if (override) { return override; @@ -10,7 +43,8 @@ export function resolveBundledSkillsDir(): string | undefined { // bun --compile: ship a sibling `skills/` next to the executable. try { - const execDir = path.dirname(process.execPath); + const execPath = opts.execPath ?? process.execPath; + const execDir = path.dirname(execPath); const sibling = path.join(execDir, "skills"); if (fs.existsSync(sibling)) { return sibling; @@ -21,11 +55,32 @@ export function resolveBundledSkillsDir(): string | undefined { // npm/dev: resolve `/skills` relative to this module. try { - const moduleDir = path.dirname(fileURLToPath(import.meta.url)); - const root = path.resolve(moduleDir, "..", "..", ".."); - const candidate = path.join(root, "skills"); - if (fs.existsSync(candidate)) { - return candidate; + const moduleUrl = opts.moduleUrl ?? import.meta.url; + const moduleDir = path.dirname(fileURLToPath(moduleUrl)); + const argv1 = opts.argv1 ?? process.argv[1]; + const cwd = opts.cwd ?? process.cwd(); + const packageRoot = resolveOpenClawPackageRootSync({ + argv1, + moduleUrl, + cwd, + }); + if (packageRoot) { + const candidate = path.join(packageRoot, "skills"); + if (looksLikeSkillsDir(candidate)) { + return candidate; + } + } + let current = moduleDir; + for (let depth = 0; depth < 6; depth += 1) { + const candidate = path.join(current, "skills"); + if (looksLikeSkillsDir(candidate)) { + return candidate; + } + const next = path.dirname(current); + if (next === current) { + break; + } + current = next; } } catch { // ignore diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 70e7aa06f91..fa0729ae575 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -14,6 +14,7 @@ function createMockSkill(overrides: Partial = {}): SkillStatus name: "test-skill", description: "A test skill", source: "bundled", + bundled: false, filePath: "/path/to/SKILL.md", baseDir: "/path/to", skillKey: "test-skill", diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index d4c5e4f923d..4941f38a286 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -90,6 +90,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti disabled: s.disabled, blockedByAllowlist: s.blockedByAllowlist, source: s.source, + bundled: s.bundled, primaryEnv: s.primaryEnv, homepage: s.homepage, missing: s.missing, diff --git a/src/config/schema.ts b/src/config/schema.ts index bdc57765fcc..9be9c3f44b6 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -201,6 +201,7 @@ const FIELD_LABELS: Record = { "tools.web.fetch.maxRedirects": "Web Fetch Max Redirects", "tools.web.fetch.userAgent": "Web Fetch User-Agent", "gateway.controlUi.basePath": "Control UI Base Path", + "gateway.controlUi.root": "Control UI Assets Root", "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", @@ -413,6 +414,8 @@ const FIELD_HELP: Record = { "gateway.auth.password": "Required for Tailscale funnel.", "gateway.controlUi.basePath": "Optional URL prefix where the Control UI is served (e.g. /openclaw).", + "gateway.controlUi.root": + "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", "gateway.controlUi.allowInsecureAuth": "Allow Control UI auth over insecure HTTP (token-only; not recommended).", "gateway.controlUi.dangerouslyDisableDeviceAuth": @@ -747,6 +750,7 @@ const FIELD_PLACEHOLDERS: Record = { "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", "gateway.remote.sshTarget": "user@host", "gateway.controlUi.basePath": "/openclaw", + "gateway.controlUi.root": "dist/control-ui", "channels.mattermost.baseUrl": "https://chat.example.com", "agents.list[].identity.avatar": "avatars/openclaw.png", }; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index d7943accefd..af713c7d0c2 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -66,6 +66,8 @@ export type GatewayControlUiConfig = { enabled?: boolean; /** Optional base path prefix for the Control UI (e.g. "/openclaw"). */ basePath?: string; + /** Optional filesystem root for Control UI assets (defaults to dist/control-ui). */ + root?: string; /** Allow token-only auth over insecure HTTP (default: false). */ allowInsecureAuth?: boolean; /** DANGEROUS: Disable device identity checks for the Control UI (default: false). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 7a71cc8acb6..cbab23ef684 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -377,6 +377,7 @@ export const OpenClawSchema = z .object({ enabled: z.boolean().optional(), basePath: z.string().optional(), + root: z.string().optional(), allowInsecureAuth: z.boolean().optional(), dangerouslyDisableDeviceAuth: z.boolean().optional(), }) diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index b223e9f87e5..8e739d24365 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -1,8 +1,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveControlUiRootSync } from "../infra/control-ui-assets.js"; import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js"; import { buildControlUiAvatarUrl, @@ -17,34 +17,13 @@ export type ControlUiRequestOptions = { basePath?: string; config?: OpenClawConfig; agentId?: string; + root?: ControlUiRootState; }; -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; -} +export type ControlUiRootState = + | { kind: "resolved"; path: string } + | { kind: "invalid"; path: string } + | { kind: "missing" }; function contentTypeForExt(ext: string): string { switch (ext) { @@ -288,7 +267,32 @@ export function handleControlUiHttpRequest( } } - const root = resolveControlUiRoot(); + const rootState = opts?.root; + if (rootState?.kind === "invalid") { + res.statusCode = 503; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end( + `Control UI assets not found at ${rootState.path}. Build them with \`pnpm ui:build\` (auto-installs UI deps), or update gateway.controlUi.root.`, + ); + return true; + } + if (rootState?.kind === "missing") { + 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 root = + rootState?.kind === "resolved" + ? rootState.path + : resolveControlUiRootSync({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); if (!root) { res.statusCode = 503; res.setHeader("Content-Type", "text/plain; charset=utf-8"); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index d2dcdef958a..3b7734dbfe9 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -13,7 +13,11 @@ import { resolveAgentAvatar } from "../agents/identity-avatar.js"; import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import { loadConfig } from "../config/config.js"; import { handleSlackHttpRequest } from "../slack/http/index.js"; -import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js"; +import { + handleControlUiAvatarRequest, + handleControlUiHttpRequest, + type ControlUiRootState, +} from "./control-ui.js"; import { applyHookMappings } from "./hooks-mapping.js"; import { extractHookToken, @@ -206,6 +210,7 @@ export function createGatewayHttpServer(opts: { canvasHost: CanvasHostHandler | null; controlUiEnabled: boolean; controlUiBasePath: string; + controlUiRoot?: ControlUiRootState; openAiChatCompletionsEnabled: boolean; openResponsesEnabled: boolean; openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; @@ -218,6 +223,7 @@ export function createGatewayHttpServer(opts: { canvasHost, controlUiEnabled, controlUiBasePath, + controlUiRoot, openAiChatCompletionsEnabled, openResponsesEnabled, openResponsesConfig, @@ -301,6 +307,7 @@ export function createGatewayHttpServer(opts: { handleControlUiHttpRequest(req, res, { basePath: controlUiBasePath, config: configSnapshot, + root: controlUiRoot, }) ) { return; diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index b8ae4ee1b63..6fedc290f6b 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -20,6 +20,7 @@ export type GatewayRuntimeConfig = { openResponsesEnabled: boolean; openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; controlUiBasePath: string; + controlUiRoot?: string; resolvedAuth: ResolvedGatewayAuth; authMode: ResolvedGatewayAuth["mode"]; tailscaleConfig: GatewayTailscaleConfig; @@ -51,6 +52,11 @@ export async function resolveGatewayRuntimeConfig(params: { const openResponsesConfig = params.cfg.gateway?.http?.endpoints?.responses; const openResponsesEnabled = params.openResponsesEnabled ?? openResponsesConfig?.enabled ?? false; const controlUiBasePath = normalizeControlUiBasePath(params.cfg.gateway?.controlUi?.basePath); + const controlUiRootRaw = params.cfg.gateway?.controlUi?.root; + const controlUiRoot = + typeof controlUiRootRaw === "string" && controlUiRootRaw.trim().length > 0 + ? controlUiRootRaw.trim() + : undefined; const authBase = params.cfg.gateway?.auth ?? {}; const authOverrides = params.auth ?? {}; const authConfig = { @@ -103,6 +109,7 @@ export async function resolveGatewayRuntimeConfig(params: { ? { ...openResponsesConfig, enabled: openResponsesEnabled } : undefined, controlUiBasePath, + controlUiRoot, resolvedAuth, authMode, tailscaleConfig, diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 5e5d2d78c52..2fcfc62f08a 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -6,6 +6,7 @@ import type { PluginRegistry } from "../plugins/registry.js"; import type { RuntimeEnv } from "../runtime.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import type { ChatAbortControllerEntry } from "./chat-abort.js"; +import type { ControlUiRootState } from "./control-ui.js"; import type { HooksConfigResolved } from "./hooks.js"; import type { DedupeEntry } from "./server-shared.js"; import type { GatewayTlsRuntime } from "./server/tls.js"; @@ -27,6 +28,7 @@ export async function createGatewayRuntimeState(params: { port: number; controlUiEnabled: boolean; controlUiBasePath: string; + controlUiRoot?: ControlUiRootState; openAiChatCompletionsEnabled: boolean; openResponsesEnabled: boolean; openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; @@ -112,6 +114,7 @@ export async function createGatewayRuntimeState(params: { canvasHost, controlUiEnabled: params.controlUiEnabled, controlUiBasePath: params.controlUiBasePath, + controlUiRoot: params.controlUiRoot, openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled, openResponsesEnabled: params.openResponsesEnabled, openResponsesConfig: params.openResponsesConfig, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 9e5142f1351..d76fd6dd13d 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1,6 +1,8 @@ +import path from "node:path"; import type { CanvasHostServer } from "../canvas-host/server.js"; import type { PluginServicesHandle } from "../plugins/services.js"; import type { RuntimeEnv } from "../runtime.js"; +import type { ControlUiRootState } from "./control-ui.js"; import type { startBrowserControlServerIfEnabled } from "./server-browser.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { registerSkillsChangeListener } from "../agents/skills/refresh.js"; @@ -18,6 +20,11 @@ import { } from "../config/config.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; +import { + ensureControlUiAssetsBuilt, + resolveControlUiRootOverrideSync, + resolveControlUiRootSync, +} from "../infra/control-ui-assets.js"; import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; import { logAcceptedEnvOption } from "../infra/env.js"; import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js"; @@ -87,6 +94,7 @@ const logReload = log.child("reload"); const logHooks = log.child("hooks"); const logPlugins = log.child("plugins"); const logWsControl = log.child("ws"); +const gatewayRuntime = runtimeForLogger(log); const canvasRuntime = runtimeForLogger(logCanvas); export type GatewayServer = { @@ -253,6 +261,7 @@ export async function startGatewayServer( openResponsesEnabled, openResponsesConfig, controlUiBasePath, + controlUiRoot: controlUiRootOverride, resolvedAuth, tailscaleConfig, tailscaleMode, @@ -260,6 +269,38 @@ export async function startGatewayServer( let hooksConfig = runtimeConfig.hooksConfig; const canvasHostEnabled = runtimeConfig.canvasHostEnabled; + let controlUiRootState: ControlUiRootState | undefined; + if (controlUiRootOverride) { + const resolvedOverride = resolveControlUiRootOverrideSync(controlUiRootOverride); + const resolvedOverridePath = path.resolve(controlUiRootOverride); + controlUiRootState = resolvedOverride + ? { kind: "resolved", path: resolvedOverride } + : { kind: "invalid", path: resolvedOverridePath }; + if (!resolvedOverride) { + log.warn(`gateway: controlUi.root not found at ${resolvedOverridePath}`); + } + } else if (controlUiEnabled) { + let resolvedRoot = resolveControlUiRootSync({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); + if (!resolvedRoot) { + const ensureResult = await ensureControlUiAssetsBuilt(gatewayRuntime); + if (!ensureResult.ok && ensureResult.message) { + log.warn(`gateway: ${ensureResult.message}`); + } + resolvedRoot = resolveControlUiRootSync({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); + } + controlUiRootState = resolvedRoot + ? { kind: "resolved", path: resolvedRoot } + : { kind: "missing" }; + } + const wizardRunner = opts.wizardRunner ?? runOnboardingWizard; const { wizardSessions, findRunningWizard, purgeWizardSession } = createWizardSessionTracker(); @@ -291,6 +332,7 @@ export async function startGatewayServer( port, controlUiEnabled, controlUiBasePath, + controlUiRoot: controlUiRootState, openAiChatCompletionsEnabled, openResponsesEnabled, openResponsesConfig, diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index 1936e5af680..a09d5d49dc7 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -2,7 +2,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveControlUiDistIndexPath, resolveControlUiRepoRoot } from "./control-ui-assets.js"; +import { + resolveControlUiDistIndexPath, + resolveControlUiRepoRoot, + resolveControlUiRootOverrideSync, + resolveControlUiRootSync, +} from "./control-ui-assets.js"; describe("control UI assets helpers", () => { it("resolves repo root from src argv1", async () => { @@ -43,6 +48,53 @@ describe("control UI assets helpers", () => { ); }); + it("resolves control-ui root for dist bundle argv1", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true }); + await fs.writeFile(path.join(tmp, "dist", "bundle.js"), "export {};\n"); + await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "\n"); + + expect(resolveControlUiRootSync({ argv1: path.join(tmp, "dist", "bundle.js") })).toBe( + path.join(tmp, "dist", "control-ui"), + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("resolves control-ui root for dist/gateway bundle argv1", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" })); + await fs.mkdir(path.join(tmp, "dist", "gateway"), { recursive: true }); + await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true }); + await fs.writeFile(path.join(tmp, "dist", "gateway", "control-ui.js"), "export {};\n"); + await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "\n"); + + expect( + resolveControlUiRootSync({ argv1: path.join(tmp, "dist", "gateway", "control-ui.js") }), + ).toBe(path.join(tmp, "dist", "control-ui")); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("resolves control-ui root from override directory or index.html", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + const uiDir = path.join(tmp, "dist", "control-ui"); + await fs.mkdir(uiDir, { recursive: true }); + await fs.writeFile(path.join(uiDir, "index.html"), "\n"); + + expect(resolveControlUiRootOverrideSync(uiDir)).toBe(uiDir); + expect(resolveControlUiRootOverrideSync(path.join(uiDir, "index.html"))).toBe(uiDir); + expect(resolveControlUiRootOverrideSync(path.join(uiDir, "missing.html"))).toBeNull(); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + it("resolves dist control-ui index path from package root argv1", async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); try { @@ -59,6 +111,22 @@ describe("control UI assets helpers", () => { } }); + it("resolves control-ui root for package entrypoint argv1", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" })); + await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n"); + await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true }); + await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "\n"); + + expect(resolveControlUiRootSync({ argv1: path.join(tmp, "openclaw.mjs") })).toBe( + path.join(tmp, "dist", "control-ui"), + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + it("resolves dist control-ui index path from .bin argv1", async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); try { diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index 97a8508859f..d749135e990 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -1,8 +1,9 @@ import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { resolveOpenClawPackageRoot } from "./openclaw-root.js"; +import { resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } from "./openclaw-root.js"; export function resolveControlUiRepoRoot( argv1: string | undefined = process.argv[1], @@ -59,6 +60,86 @@ export async function resolveControlUiDistIndexPath( return path.join(packageRoot, "dist", "control-ui", "index.html"); } +export type ControlUiRootResolveOptions = { + argv1?: string; + moduleUrl?: string; + cwd?: string; + execPath?: string; +}; + +function addCandidate(candidates: Set, value: string | null) { + if (!value) { + return; + } + candidates.add(path.resolve(value)); +} + +export function resolveControlUiRootOverrideSync(rootOverride: string): string | null { + const resolved = path.resolve(rootOverride); + try { + const stats = fs.statSync(resolved); + if (stats.isFile()) { + return path.basename(resolved) === "index.html" ? path.dirname(resolved) : null; + } + if (stats.isDirectory()) { + const indexPath = path.join(resolved, "index.html"); + return fs.existsSync(indexPath) ? resolved : null; + } + } catch { + return null; + } + return null; +} + +export function resolveControlUiRootSync(opts: ControlUiRootResolveOptions = {}): string | null { + const candidates = new Set(); + const argv1 = opts.argv1 ?? process.argv[1]; + const cwd = opts.cwd ?? process.cwd(); + const moduleDir = opts.moduleUrl ? path.dirname(fileURLToPath(opts.moduleUrl)) : null; + const argv1Dir = argv1 ? path.dirname(path.resolve(argv1)) : null; + const execDir = (() => { + try { + const execPath = opts.execPath ?? process.execPath; + return path.dirname(fs.realpathSync(execPath)); + } catch { + return null; + } + })(); + const packageRoot = resolveOpenClawPackageRootSync({ + argv1, + moduleUrl: opts.moduleUrl, + cwd, + }); + + // Packaged app: control-ui lives alongside the executable. + addCandidate(candidates, execDir ? path.join(execDir, "control-ui") : null); + if (moduleDir) { + // dist/.js -> dist/control-ui + addCandidate(candidates, path.join(moduleDir, "control-ui")); + // dist/gateway/control-ui.js -> dist/control-ui + addCandidate(candidates, path.join(moduleDir, "../control-ui")); + // src/gateway/control-ui.ts -> dist/control-ui + addCandidate(candidates, path.join(moduleDir, "../../dist/control-ui")); + } + if (argv1Dir) { + // openclaw.mjs or dist/.js + addCandidate(candidates, path.join(argv1Dir, "dist", "control-ui")); + addCandidate(candidates, path.join(argv1Dir, "control-ui")); + } + if (packageRoot) { + addCandidate(candidates, path.join(packageRoot, "dist", "control-ui")); + } + addCandidate(candidates, path.join(cwd, "dist", "control-ui")); + + for (const dir of candidates) { + const indexPath = path.join(dir, "index.html"); + if (fs.existsSync(indexPath)) { + return dir; + } + } + return null; +} + export type EnsureControlUiAssetsResult = { ok: boolean; built: boolean; diff --git a/src/infra/openclaw-root.ts b/src/infra/openclaw-root.ts index 157ffbc300b..a13f510053e 100644 --- a/src/infra/openclaw-root.ts +++ b/src/infra/openclaw-root.ts @@ -1,3 +1,4 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,6 +15,16 @@ async function readPackageName(dir: string): Promise { } } +function readPackageNameSync(dir: string): string | null { + try { + const raw = fsSync.readFileSync(path.join(dir, "package.json"), "utf-8"); + const parsed = JSON.parse(raw) as { name?: unknown }; + return typeof parsed.name === "string" ? parsed.name : null; + } catch { + return null; + } +} + async function findPackageRoot(startDir: string, maxDepth = 12): Promise { let current = path.resolve(startDir); for (let i = 0; i < maxDepth; i += 1) { @@ -30,6 +41,22 @@ async function findPackageRoot(startDir: string, maxDepth = 12): Promise group.id === "built-in"); const other: SkillGroup = { id: "other", label: "Other Skills", skills: [] }; for (const skill of skills) { - const match = SKILL_SOURCE_GROUPS.find((group) => group.sources.includes(skill.source)); + const match = skill.bundled + ? builtInGroup + : SKILL_SOURCE_GROUPS.find((group) => group.sources.includes(skill.source)); if (match) { groups.get(match.id)?.skills.push(skill); } else { diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index 530800bef94..40c04692c80 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -21,9 +21,12 @@ function groupSkills(skills: SkillStatusEntry[]): SkillGroup[] { for (const def of SKILL_SOURCE_GROUPS) { groups.set(def.id, { id: def.id, label: def.label, skills: [] }); } + const builtInGroup = SKILL_SOURCE_GROUPS.find((group) => group.id === "built-in"); const other: SkillGroup = { id: "other", label: "Other Skills", skills: [] }; for (const skill of skills) { - const match = SKILL_SOURCE_GROUPS.find((group) => group.sources.includes(skill.source)); + const match = skill.bundled + ? builtInGroup + : SKILL_SOURCE_GROUPS.find((group) => group.sources.includes(skill.source)); if (match) { groups.get(match.id)?.skills.push(skill); } else { @@ -128,6 +131,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { const apiKey = props.edits[skill.skillKey] ?? ""; const message = props.messages[skill.skillKey] ?? null; const canInstall = skill.install.length > 0 && skill.missing.bins.length > 0; + const showBundledBadge = Boolean(skill.bundled && skill.source !== "openclaw-bundled"); const missing = [ ...skill.missing.bins.map((b) => `bin:${b}`), ...skill.missing.env.map((e) => `env:${e}`), @@ -150,6 +154,13 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
${clampText(skill.description, 140)}
${skill.source} + ${ + showBundledBadge + ? html` + bundled + ` + : nothing + } ${skill.eligible ? "eligible" : "blocked"}