fix(ui): fix web UI after tsdown migration and typing changes
parent
1c4db91593
commit
5935c4d23d
|
|
@ -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!**
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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`。仅用于紧急情况。
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
"rootDir": "src",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "es2023"
|
||||
"target": "es2023",
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"include": ["src/**/*", "ui/**/*"],
|
||||
"exclude": [
|
||||
|
|
|
|||
|
|
@ -515,6 +515,7 @@ export type SkillStatusEntry = {
|
|||
name: string;
|
||||
description: string;
|
||||
source: string;
|
||||
bundled?: boolean;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
skillKey: string;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue