gateway: fix global Control UI 404s for symlinked wrappers and bundled package roots (#40385)

Merged via squash.

Prepared head SHA: 567b3ed684
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
This commit is contained in:
Radek Sienkiewicz
2026-03-09 01:50:42 +01:00
committed by GitHub
parent 13bd3db307
commit 4f42c03a49
9 changed files with 404 additions and 41 deletions

View File

@@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.
- Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @velvet-shark.
## 2026.3.7

View File

@@ -36,17 +36,16 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
const resolveGatewayPort = vi.fn(() => 18789);
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
const probeGateway =
vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };
timeoutMs: number;
}) => Promise<{
ok: boolean;
configSnapshot: unknown;
}>
>();
const probeGateway = vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };
timeoutMs: number;
}) => Promise<{
ok: boolean;
configSnapshot: unknown;
}>
>();
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
const loadConfig = vi.fn(() => ({}));

View File

@@ -0,0 +1,101 @@
import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const { resolveControlUiRootSyncMock, isPackageProvenControlUiRootSyncMock } = vi.hoisted(() => ({
resolveControlUiRootSyncMock: vi.fn(),
isPackageProvenControlUiRootSyncMock: vi.fn().mockReturnValue(true),
}));
vi.mock("../infra/control-ui-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../infra/control-ui-assets.js")>();
return {
...actual,
resolveControlUiRootSync: resolveControlUiRootSyncMock,
isPackageProvenControlUiRootSync: isPackageProvenControlUiRootSyncMock,
};
});
const { handleControlUiHttpRequest } = await import("./control-ui.js");
const { makeMockHttpResponse } = await import("./test-http-response.js");
async function withControlUiRoot<T>(fn: (tmp: string) => Promise<T>) {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-auto-root-"));
try {
await fs.writeFile(path.join(tmp, "index.html"), "<html>fallback</html>\n");
return await fn(tmp);
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
}
afterEach(() => {
resolveControlUiRootSyncMock.mockReset();
isPackageProvenControlUiRootSyncMock.mockReset();
isPackageProvenControlUiRootSyncMock.mockReturnValue(true);
});
describe("handleControlUiHttpRequest auto-detected root", () => {
it("serves hardlinked asset files for bundled auto-detected roots", async () => {
await withControlUiRoot(async (tmp) => {
const assetsDir = path.join(tmp, "assets");
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');");
await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js"));
resolveControlUiRootSyncMock.mockReturnValue(tmp);
const { res, end } = makeMockHttpResponse();
const handled = handleControlUiHttpRequest(
{ url: "/assets/app.hl.js", method: "GET" } as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("console.log('hi');");
});
});
it("serves hardlinked SPA fallback index.html for bundled auto-detected roots", async () => {
await withControlUiRoot(async (tmp) => {
const sourceIndex = path.join(tmp, "index.source.html");
const indexPath = path.join(tmp, "index.html");
await fs.writeFile(sourceIndex, "<html>fallback-hardlink</html>\n");
await fs.rm(indexPath);
await fs.link(sourceIndex, indexPath);
resolveControlUiRootSyncMock.mockReturnValue(tmp);
const { res, end } = makeMockHttpResponse();
const handled = handleControlUiHttpRequest(
{ url: "/dashboard", method: "GET" } as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("<html>fallback-hardlink</html>\n");
});
});
it("rejects hardlinked assets for non-package-proven auto-detected roots", async () => {
isPackageProvenControlUiRootSyncMock.mockReturnValue(false);
await withControlUiRoot(async (tmp) => {
const assetsDir = path.join(tmp, "assets");
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');");
await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js"));
resolveControlUiRootSyncMock.mockReturnValue(tmp);
const { res } = makeMockHttpResponse();
const handled = handleControlUiHttpRequest(
{ url: "/assets/app.hl.js", method: "GET" } as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
});
});

View File

@@ -45,6 +45,7 @@ describe("handleControlUiHttpRequest", () => {
method: "GET" | "HEAD" | "POST";
rootPath: string;
basePath?: string;
rootKind?: "resolved" | "bundled";
}) {
const { res, end } = makeMockHttpResponse();
const handled = handleControlUiHttpRequest(
@@ -52,7 +53,7 @@ describe("handleControlUiHttpRequest", () => {
res,
{
...(params.basePath ? { basePath: params.basePath } : {}),
root: { kind: "resolved", path: params.rootPath },
root: { kind: params.rootKind ?? "resolved", path: params.rootPath },
},
);
return { res, end, handled };
@@ -326,6 +327,72 @@ describe("handleControlUiHttpRequest", () => {
});
});
it("rejects hardlinked index.html for non-package control-ui roots", async () => {
await withControlUiRoot({
fn: async (tmp) => {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-index-hardlink-"));
try {
const outsideIndex = path.join(outsideDir, "index.html");
await fs.writeFile(outsideIndex, "<html>outside-hardlink</html>\n");
await fs.rm(path.join(tmp, "index.html"));
await fs.link(outsideIndex, path.join(tmp, "index.html"));
const { res, end, handled } = runControlUiRequest({
url: "/",
method: "GET",
rootPath: tmp,
});
expectNotFoundResponse({ handled, res, end });
} finally {
await fs.rm(outsideDir, { recursive: true, force: true });
}
},
});
});
it("rejects hardlinked asset files for custom/resolved roots (security boundary)", async () => {
await withControlUiRoot({
fn: async (tmp) => {
const assetsDir = path.join(tmp, "assets");
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');");
await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js"));
const { res, end, handled } = runControlUiRequest({
url: "/assets/app.hl.js",
method: "GET",
rootPath: tmp,
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
expect(end).toHaveBeenCalledWith("Not Found");
},
});
});
it("serves hardlinked asset files for bundled roots (pnpm global install)", async () => {
await withControlUiRoot({
fn: async (tmp) => {
const assetsDir = path.join(tmp, "assets");
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');");
await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js"));
const { res, end, handled } = runControlUiRequest({
url: "/assets/app.hl.js",
method: "GET",
rootPath: tmp,
rootKind: "bundled",
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("console.log('hi');");
},
});
});
it("does not handle POST to root-mounted paths (plugin webhook passthrough)", async () => {
await withControlUiRoot({
fn: async (tmp) => {

View File

@@ -3,7 +3,10 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { resolveControlUiRootSync } from "../infra/control-ui-assets.js";
import {
isPackageProvenControlUiRootSync,
resolveControlUiRootSync,
} from "../infra/control-ui-assets.js";
import { isWithinDir } from "../infra/path-safety.js";
import { openVerifiedFileSync } from "../infra/safe-open-sync.js";
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
@@ -39,6 +42,7 @@ export type ControlUiRequestOptions = {
};
export type ControlUiRootState =
| { kind: "bundled"; path: string }
| { kind: "resolved"; path: string }
| { kind: "invalid"; path: string }
| { kind: "missing" };
@@ -256,6 +260,7 @@ function resolveSafeAvatarFile(filePath: string): { path: string; fd: number } |
function resolveSafeControlUiFile(
rootReal: string,
filePath: string,
rejectHardlinks: boolean,
): { path: string; fd: number } | null {
const opened = openBoundaryFileSync({
absolutePath: filePath,
@@ -263,6 +268,7 @@ function resolveSafeControlUiFile(
rootRealPath: rootReal,
boundaryLabel: "control ui root",
skipLexicalRootCheck: true,
rejectHardlinks,
});
if (!opened.ok) {
if (opened.reason === "io") {
@@ -367,7 +373,7 @@ export function handleControlUiHttpRequest(
}
const root =
rootState?.kind === "resolved"
rootState?.kind === "resolved" || rootState?.kind === "bundled"
? rootState.path
: resolveControlUiRootSync({
moduleUrl: import.meta.url,
@@ -419,7 +425,16 @@ export function handleControlUiHttpRequest(
return true;
}
const safeFile = resolveSafeControlUiFile(rootReal, filePath);
const isBundledRoot =
rootState?.kind === "bundled" ||
(rootState === undefined &&
isPackageProvenControlUiRootSync(root, {
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
}));
const rejectHardlinks = !isBundledRoot;
const safeFile = resolveSafeControlUiFile(rootReal, filePath, rejectHardlinks);
if (safeFile) {
try {
if (respondHeadForFile(req, res, safeFile.path)) {
@@ -448,7 +463,7 @@ export function handleControlUiHttpRequest(
// SPA fallback (client-side router): serve index.html for unknown paths.
const indexPath = path.join(root, "index.html");
const safeIndex = resolveSafeControlUiFile(rootReal, indexPath);
const safeIndex = resolveSafeControlUiFile(rootReal, indexPath, rejectHardlinks);
if (safeIndex) {
try {
if (respondHeadForFile(req, res, safeIndex.path)) {

View File

@@ -0,0 +1,46 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { installGatewayTestHooks, testState, withGatewayServer } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
async function withGlobalControlUiHardlinkFixture<T>(run: (rootPath: string) => Promise<T>) {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-ui-hardlink-"));
try {
const packageRoot = path.join(tmp, "pnpm-global", "5", "node_modules", "openclaw");
const controlUiRoot = path.join(packageRoot, "dist", "control-ui");
await fs.mkdir(controlUiRoot, { recursive: true });
await fs.writeFile(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw" }),
);
const storeDir = path.join(tmp, "pnpm-store", "files");
await fs.mkdir(storeDir, { recursive: true });
const storeIndex = path.join(storeDir, "index.html");
await fs.writeFile(storeIndex, "<html><body>pnpm-hardlink-ui</body></html>\n");
await fs.link(storeIndex, path.join(controlUiRoot, "index.html"));
return await run(controlUiRoot);
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
}
describe("gateway.controlUi.root", () => {
test("rejects hardlinked index.html when configured root points at global OpenClaw package control-ui", async () => {
await withGlobalControlUiHardlinkFixture(async (rootPath) => {
testState.gatewayControlUi = { root: rootPath };
await withGatewayServer(
async ({ port }) => {
const res = await fetch(`http://127.0.0.1:${port}/`);
expect(res.status).toBe(404);
expect(await res.text()).toBe("Not Found");
},
{ serverOptions: { controlUiEnabled: true } },
);
});
});
});

View File

@@ -24,6 +24,7 @@ import { resolveMainSessionKey } from "../config/sessions.js";
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
import {
ensureControlUiAssetsBuilt,
isPackageProvenControlUiRootSync,
resolveControlUiRootOverrideSync,
resolveControlUiRootSync,
} from "../infra/control-ui-assets.js";
@@ -545,7 +546,16 @@ export async function startGatewayServer(
});
}
controlUiRootState = resolvedRoot
? { kind: "resolved", path: resolvedRoot }
? {
kind: isPackageProvenControlUiRootSync(resolvedRoot, {
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
})
? "bundled"
: "resolved",
path: resolvedRoot,
}
: { kind: "missing" };
}

View File

@@ -76,6 +76,7 @@ vi.mock("./openclaw-root.js", () => ({
let resolveControlUiRepoRoot: typeof import("./control-ui-assets.js").resolveControlUiRepoRoot;
let resolveControlUiDistIndexPath: typeof import("./control-ui-assets.js").resolveControlUiDistIndexPath;
let resolveControlUiDistIndexHealth: typeof import("./control-ui-assets.js").resolveControlUiDistIndexHealth;
let isPackageProvenControlUiRootSync: typeof import("./control-ui-assets.js").isPackageProvenControlUiRootSync;
let resolveControlUiRootOverrideSync: typeof import("./control-ui-assets.js").resolveControlUiRootOverrideSync;
let resolveControlUiRootSync: typeof import("./control-ui-assets.js").resolveControlUiRootSync;
let openclawRoot: typeof import("./openclaw-root.js");
@@ -86,6 +87,7 @@ describe("control UI assets helpers (fs-mocked)", () => {
resolveControlUiRepoRoot,
resolveControlUiDistIndexPath,
resolveControlUiDistIndexHealth,
isPackageProvenControlUiRootSync,
resolveControlUiRootOverrideSync,
resolveControlUiRootSync,
} = await import("./control-ui-assets.js"));
@@ -123,6 +125,18 @@ describe("control UI assets helpers (fs-mocked)", () => {
);
});
it("resolves dist control-ui index path for symlinked argv1 via realpath", async () => {
const pkgRoot = abs("fixtures/bun-global/openclaw");
const wrapperArgv1 = abs("fixtures/bin/openclaw");
const realEntrypoint = path.join(pkgRoot, "dist", "index.js");
state.realpaths.set(wrapperArgv1, realEntrypoint);
await expect(resolveControlUiDistIndexPath(wrapperArgv1)).resolves.toBe(
path.join(pkgRoot, "dist", "control-ui", "index.html"),
);
});
it("uses resolveOpenClawPackageRoot when available", async () => {
const pkgRoot = abs("fixtures/openclaw");
(
@@ -199,4 +213,48 @@ describe("control UI assets helpers (fs-mocked)", () => {
const moduleUrl = pathToFileURL(path.join(pkgRoot, "dist", "bundle.js")).toString();
expect(resolveControlUiRootSync({ moduleUrl })).toBe(uiDir);
});
it("resolves control-ui root for symlinked argv1 via realpath", () => {
const pkgRoot = abs("fixtures/bun-global/openclaw");
const wrapperArgv1 = abs("fixtures/bin/openclaw");
const realEntrypoint = path.join(pkgRoot, "dist", "index.js");
const uiDir = path.join(pkgRoot, "dist", "control-ui");
state.realpaths.set(wrapperArgv1, realEntrypoint);
setFile(path.join(uiDir, "index.html"), "<html></html>\n");
expect(resolveControlUiRootSync({ argv1: wrapperArgv1 })).toBe(uiDir);
});
it("detects package-proven control-ui roots", () => {
const pkgRoot = abs("fixtures/openclaw-package-root");
const uiDir = path.join(pkgRoot, "dist", "control-ui");
setDir(uiDir);
setFile(path.join(uiDir, "index.html"), "<html></html>\n");
(
openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType<typeof vi.fn>
).mockReturnValueOnce(pkgRoot);
expect(
isPackageProvenControlUiRootSync(uiDir, {
cwd: abs("fixtures/cwd"),
}),
).toBe(true);
});
it("does not treat fallback roots as package-proven", () => {
const pkgRoot = abs("fixtures/openclaw-package-root");
const fallbackRoot = abs("fixtures/fallback-root/dist/control-ui");
setDir(fallbackRoot);
setFile(path.join(fallbackRoot, "index.html"), "<html></html>\n");
(
openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType<typeof vi.fn>
).mockReturnValueOnce(pkgRoot);
expect(
isPackageProvenControlUiRootSync(fallbackRoot, {
cwd: abs("fixtures/fallback-root"),
}),
).toBe(false);
});
});

View File

@@ -79,11 +79,23 @@ export async function resolveControlUiDistIndexPath(
return null;
}
const normalized = path.resolve(argv1);
const entrypointCandidates = [normalized];
try {
const realpathEntrypoint = fs.realpathSync(normalized);
if (realpathEntrypoint !== normalized) {
entrypointCandidates.push(realpathEntrypoint);
}
} catch {
// Ignore missing/non-realpath argv1 and keep path-based candidates.
}
// Case 1: entrypoint is directly inside dist/ (e.g., dist/entry.js)
const distDir = path.dirname(normalized);
if (path.basename(distDir) === "dist") {
return path.join(distDir, "control-ui", "index.html");
// Case 1: entrypoint is directly inside dist/ (e.g., dist/entry.js).
// Include symlink-resolved argv1 so global wrappers (e.g. Bun) still map to dist/control-ui.
for (const entrypoint of entrypointCandidates) {
const distDir = path.dirname(entrypoint);
if (path.basename(distDir) === "dist") {
return path.join(distDir, "control-ui", "index.html");
}
}
const packageRoot = await resolveOpenClawPackageRoot({ argv1: normalized, moduleUrl });
@@ -93,29 +105,34 @@ export async function resolveControlUiDistIndexPath(
// Fallback: traverse up and find package.json with name "openclaw" + dist/control-ui/index.html
// This handles global installs where path-based resolution might fail.
let dir = path.dirname(normalized);
for (let i = 0; i < 8; i++) {
const pkgJsonPath = path.join(dir, "package.json");
const indexPath = path.join(dir, "dist", "control-ui", "index.html");
if (fs.existsSync(pkgJsonPath)) {
try {
const raw = fs.readFileSync(pkgJsonPath, "utf-8");
const parsed = JSON.parse(raw) as { name?: unknown };
if (parsed.name === "openclaw") {
return fs.existsSync(indexPath) ? indexPath : null;
const fallbackStartDirs = new Set(
entrypointCandidates.map((candidate) => path.dirname(candidate)),
);
for (const startDir of fallbackStartDirs) {
let dir = startDir;
for (let i = 0; i < 8; i++) {
const pkgJsonPath = path.join(dir, "package.json");
const indexPath = path.join(dir, "dist", "control-ui", "index.html");
if (fs.existsSync(pkgJsonPath)) {
try {
const raw = fs.readFileSync(pkgJsonPath, "utf-8");
const parsed = JSON.parse(raw) as { name?: unknown };
if (parsed.name === "openclaw") {
return fs.existsSync(indexPath) ? indexPath : null;
}
// Stop at the first package boundary to avoid resolving through unrelated ancestors.
break;
} catch {
// Invalid package.json at package boundary; abort this candidate chain.
break;
}
// Stop at the first package boundary to avoid resolving through unrelated ancestors.
return null;
} catch {
// Invalid package.json at package boundary; abort fallback resolution.
return null;
}
const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return null;
@@ -128,6 +145,22 @@ export type ControlUiRootResolveOptions = {
execPath?: string;
};
function pathsMatchByRealpathOrResolve(left: string, right: string): boolean {
let realLeft: string;
let realRight: string;
try {
realLeft = fs.realpathSync(left);
} catch {
realLeft = path.resolve(left);
}
try {
realRight = fs.realpathSync(right);
} catch {
realRight = path.resolve(right);
}
return realLeft === realRight;
}
function addCandidate(candidates: Set<string>, value: string | null) {
if (!value) {
return;
@@ -158,6 +191,16 @@ export function resolveControlUiRootSync(opts: ControlUiRootResolveOptions = {})
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 argv1RealpathDir = (() => {
if (!argv1) {
return null;
}
try {
return path.dirname(fs.realpathSync(path.resolve(argv1)));
} catch {
return null;
}
})();
const execDir = (() => {
try {
const execPath = opts.execPath ?? process.execPath;
@@ -187,6 +230,11 @@ export function resolveControlUiRootSync(opts: ControlUiRootResolveOptions = {})
addCandidate(candidates, path.join(argv1Dir, "dist", "control-ui"));
addCandidate(candidates, path.join(argv1Dir, "control-ui"));
}
if (argv1RealpathDir && argv1RealpathDir !== argv1Dir) {
// Symlinked wrappers (e.g. ~/.bun/bin/openclaw -> .../dist/index.js)
addCandidate(candidates, path.join(argv1RealpathDir, "dist", "control-ui"));
addCandidate(candidates, path.join(argv1RealpathDir, "control-ui"));
}
if (packageRoot) {
addCandidate(candidates, path.join(packageRoot, "dist", "control-ui"));
}
@@ -201,6 +249,24 @@ export function resolveControlUiRootSync(opts: ControlUiRootResolveOptions = {})
return null;
}
export function isPackageProvenControlUiRootSync(
root: string,
opts: ControlUiRootResolveOptions = {},
): boolean {
const argv1 = opts.argv1 ?? process.argv[1];
const cwd = opts.cwd ?? process.cwd();
const packageRoot = resolveOpenClawPackageRootSync({
argv1,
moduleUrl: opts.moduleUrl,
cwd,
});
if (!packageRoot) {
return false;
}
const packageDistRoot = path.join(packageRoot, "dist", "control-ui");
return pathsMatchByRealpathOrResolve(root, packageDistRoot);
}
export type EnsureControlUiAssetsResult = {
ok: boolean;
built: boolean;