diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c61e76faa1..7be20082a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 3f0ed6d531c..f1e87fc4938 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -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(() => ({})); diff --git a/src/gateway/control-ui.auto-root.http.test.ts b/src/gateway/control-ui.auto-root.http.test.ts new file mode 100644 index 00000000000..523700083d6 --- /dev/null +++ b/src/gateway/control-ui.auto-root.http.test.ts @@ -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(); + return { + ...actual, + resolveControlUiRootSync: resolveControlUiRootSyncMock, + isPackageProvenControlUiRootSync: isPackageProvenControlUiRootSyncMock, + }; +}); + +const { handleControlUiHttpRequest } = await import("./control-ui.js"); +const { makeMockHttpResponse } = await import("./test-http-response.js"); + +async function withControlUiRoot(fn: (tmp: string) => Promise) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-auto-root-")); + try { + await fs.writeFile(path.join(tmp, "index.html"), "fallback\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, "fallback-hardlink\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("fallback-hardlink\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); + }); + }); +}); diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 4810d987a5f..a63bb1590e2 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -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, "outside-hardlink\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) => { diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 99e1e4e4174..b3d65bd72b8 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -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)) { diff --git a/src/gateway/server.control-ui-root.test.ts b/src/gateway/server.control-ui-root.test.ts new file mode 100644 index 00000000000..bc2e60e3f29 --- /dev/null +++ b/src/gateway/server.control-ui-root.test.ts @@ -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(run: (rootPath: string) => Promise) { + 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, "pnpm-hardlink-ui\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 } }, + ); + }); + }); +}); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 1b2048b9396..898cdc6fe87 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -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" }; } diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index c1c79941e1a..ea834d093a8 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -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"), "\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"), "\n"); + ( + openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType + ).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"), "\n"); + ( + openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType + ).mockReturnValueOnce(pkgRoot); + + expect( + isPackageProvenControlUiRootSync(fallbackRoot, { + cwd: abs("fixtures/fallback-root"), + }), + ).toBe(false); + }); }); diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index 4091f8b7afb..90cdd7c31a2 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -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, 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;