fix(ui): fix web UI after tsdown migration and typing changes

main
Gustavo Madeira Santana 2026-02-03 13:56:20 -05:00
parent 1c4db91593
commit 5935c4d23d
24 changed files with 499 additions and 43 deletions

View File

@ -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!**

View File

@ -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`.

View File

@ -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`。建议使用 HTTPSTailscale Serve`127.0.0.1`
- `gateway.controlUi.dangerouslyDisableDeviceAuth` 禁用控制台 UI 的设备身份检查(仅 token/密码)。默认:`false`。仅用于紧急情况。

View File

@ -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<string>,
): 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),
),
};
}

View File

@ -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<string>;
};
export function resolveBundledSkillsContext(
opts: BundledSkillsResolveOptions = {},
): BundledSkillsContext {
const dir = resolveBundledSkillsDir(opts);
const names = new Set<string>();
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 };
}

View File

@ -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"));
});
});

View File

@ -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 `<packageRoot>/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

View File

@ -14,6 +14,7 @@ function createMockSkill(overrides: Partial<SkillStatusEntry> = {}): SkillStatus
name: "test-skill",
description: "A test skill",
source: "bundled",
bundled: false,
filePath: "/path/to/SKILL.md",
baseDir: "/path/to",
skillKey: "test-skill",

View File

@ -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,

View File

@ -201,6 +201,7 @@ const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
"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<string, string> = {
"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",
};

View File

@ -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). */

View File

@ -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(),
})

View File

@ -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");

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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"), "<html></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"), "<html></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"), "<html></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"), "<html></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 {

View File

@ -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<string>, 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<string>();
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/<bundle>.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/<bundle>.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;

View File

@ -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<string | null> {
}
}
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<string | null> {
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<string
return null;
}
function findPackageRootSync(startDir: string, maxDepth = 12): string | null {
let current = path.resolve(startDir);
for (let i = 0; i < maxDepth; i += 1) {
const name = readPackageNameSync(current);
if (name && CORE_PACKAGE_NAMES.has(name)) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return null;
}
function candidateDirsFromArgv1(argv1: string): string[] {
const normalized = path.resolve(argv1);
const candidates = [path.dirname(normalized)];
@ -69,3 +96,30 @@ export async function resolveOpenClawPackageRoot(opts: {
return null;
}
export function resolveOpenClawPackageRootSync(opts: {
cwd?: string;
argv1?: string;
moduleUrl?: string;
}): string | null {
const candidates: string[] = [];
if (opts.moduleUrl) {
candidates.push(path.dirname(fileURLToPath(opts.moduleUrl)));
}
if (opts.argv1) {
candidates.push(...candidateDirsFromArgv1(opts.argv1));
}
if (opts.cwd) {
candidates.push(opts.cwd);
}
for (const candidate of candidates) {
const found = findPackageRootSync(candidate);
if (found) {
return found;
}
}
return null;
}

View File

@ -16,7 +16,8 @@
"rootDir": "src",
"skipLibCheck": true,
"strict": true,
"target": "es2023"
"target": "es2023",
"useDefineForClassFields": false
},
"include": ["src/**/*", "ui/**/*"],
"exclude": [

View File

@ -515,6 +515,7 @@ export type SkillStatusEntry = {
name: string;
description: string;
source: string;
bundled?: boolean;
filePath: string;
baseDir: string;
skillKey: string;

View File

@ -1683,9 +1683,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 {

View File

@ -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) {
<div class="list-sub">${clampText(skill.description, 140)}</div>
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${skill.source}</span>
${
showBundledBadge
? html`
<span class="chip">bundled</span>
`
: nothing
}
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">
${skill.eligible ? "eligible" : "blocked"}
</span>