diff --git a/CHANGELOG.md b/CHANGELOG.md index c5476015d6..aaf5594ee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI: add hardened fallback for asset resolution in global npm installs. (#4855) Thanks @anapivirtua. - Models: add forward-compat fallback for `openai-codex/gpt-5.3-codex` when model registry hasn't discovered it yet. (#9989) Thanks @w1kke. - Auto-reply/Docs: normalize `extra-high` (and spaced variants) to `xhigh` for Codex thinking levels, and align Codex 5.3 FAQ examples. (#9976) Thanks @slonce70. - Compaction: remove orphaned `tool_result` messages during history pruning to prevent session corruption from aborted tool calls. (#9868, fixes #9769, #9724, #9672) diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index a09d5d49dc..e9ca9c5106 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -145,4 +145,49 @@ describe("control UI assets helpers", () => { await fs.rm(tmp, { recursive: true, force: true }); } }); + + it("resolves via fallback when package root resolution fails but package name matches", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + // Package named "openclaw" but resolveOpenClawPackageRoot failed for other reasons + 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"), "\n"); + + expect(await resolveControlUiDistIndexPath(path.join(tmp, "openclaw.mjs"))).toBe( + path.join(tmp, "dist", "control-ui", "index.html"), + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("returns null when package name does not match openclaw", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + // Package with different name should not be resolved + await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "malicious-pkg" })); + await fs.writeFile(path.join(tmp, "index.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"), "\n"); + + expect(await resolveControlUiDistIndexPath(path.join(tmp, "index.mjs"))).toBeNull(); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("returns null when no control-ui assets exist", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + // Just a package.json, no dist/control-ui + await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "some-pkg" })); + await fs.writeFile(path.join(tmp, "index.mjs"), "export {};\n"); + + expect(await resolveControlUiDistIndexPath(path.join(tmp, "index.mjs"))).toBeNull(); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); }); diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index d749135e99..4e14be2f18 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -54,10 +54,35 @@ export async function resolveControlUiDistIndexPath( } const packageRoot = await resolveOpenClawPackageRoot({ argv1: normalized }); - if (!packageRoot) { - return null; + if (packageRoot) { + return path.join(packageRoot, "dist", "control-ui", "index.html"); } - return path.join(packageRoot, "dist", "control-ui", "index.html"); + + // 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) && fs.existsSync(indexPath)) { + try { + const raw = fs.readFileSync(pkgJsonPath, "utf-8"); + const parsed = JSON.parse(raw) as { name?: unknown }; + if (parsed.name === "openclaw") { + return indexPath; + } + } catch { + // Invalid package.json, continue searching + } + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + + return null; } export type ControlUiRootResolveOptions = {