mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
committed by
GitHub
parent
13bd3db307
commit
4f42c03a49
@@ -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
|
||||
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
|
||||
101
src/gateway/control-ui.auto-root.http.test.ts
Normal file
101
src/gateway/control-ui.auto-root.http.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
46
src/gateway/server.control-ui-root.test.ts
Normal file
46
src/gateway/server.control-ui-root.test.ts
Normal 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 } },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user