From 8d5094e1f41964049608e1adafb21d780d50f743 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 12 Feb 2026 14:45:31 -0500 Subject: [PATCH] fix: resolve symlinked argv1 for Control UI asset detection (#14919) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 07b85041dc70b5839247dc661f123ff37b745c1c Co-authored-by: gumadeiras <116837+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- src/infra/control-ui-assets.test.ts | 104 ++++++++++++++++++++++++++++ src/infra/control-ui-assets.ts | 13 +++- src/infra/openclaw-root.ts | 12 ++++ 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index 7b5acbe5455..6376d408ce8 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -2,6 +2,24 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; + +/** Try to create a symlink; returns false if the OS denies it (Windows CI without Developer Mode). */ +async function trySymlink(target: string, linkPath: string): Promise { + try { + await fs.symlink(target, linkPath); + return true; + } catch { + return false; + } +} + +async function canonicalPath(p: string): Promise { + try { + return await fs.realpath(p); + } catch { + return path.resolve(p); + } +} import { resolveControlUiDistIndexHealth, resolveControlUiDistIndexPath, @@ -10,6 +28,7 @@ import { resolveControlUiRootOverrideSync, resolveControlUiRootSync, } from "./control-ui-assets.js"; +import { resolveOpenClawPackageRoot } from "./openclaw-root.js"; describe("control UI assets helpers", () => { it("resolves repo root from src argv1", async () => { @@ -221,4 +240,89 @@ describe("control UI assets helpers", () => { await fs.rm(tmp, { recursive: true, force: true }); } }); + + it("resolves control-ui root when argv1 is a symlink (nvm scenario)", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + const realPkg = path.join(tmp, "real-pkg"); + const bin = path.join(tmp, "bin"); + await fs.mkdir(realPkg, { recursive: true }); + await fs.mkdir(bin, { recursive: true }); + await fs.writeFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" })); + await fs.writeFile(path.join(realPkg, "openclaw.mjs"), "export {};\n"); + await fs.mkdir(path.join(realPkg, "dist", "control-ui"), { recursive: true }); + await fs.writeFile(path.join(realPkg, "dist", "control-ui", "index.html"), "\n"); + const ok = await trySymlink( + path.join("..", "real-pkg", "openclaw.mjs"), + path.join(bin, "openclaw"), + ); + if (!ok) { + return; // symlinks not supported (Windows CI) + } + + const resolvedRoot = resolveControlUiRootSync({ argv1: path.join(bin, "openclaw") }); + expect(resolvedRoot).not.toBeNull(); + expect(await canonicalPath(resolvedRoot ?? "")).toBe( + await canonicalPath(path.join(realPkg, "dist", "control-ui")), + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("resolves package root via symlinked argv1", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + const realPkg = path.join(tmp, "real-pkg"); + const bin = path.join(tmp, "bin"); + await fs.mkdir(realPkg, { recursive: true }); + await fs.mkdir(bin, { recursive: true }); + await fs.writeFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" })); + await fs.writeFile(path.join(realPkg, "openclaw.mjs"), "export {};\n"); + await fs.mkdir(path.join(realPkg, "dist", "control-ui"), { recursive: true }); + await fs.writeFile(path.join(realPkg, "dist", "control-ui", "index.html"), "\n"); + const ok = await trySymlink( + path.join("..", "real-pkg", "openclaw.mjs"), + path.join(bin, "openclaw"), + ); + if (!ok) { + return; // symlinks not supported (Windows CI) + } + + const packageRoot = await resolveOpenClawPackageRoot({ argv1: path.join(bin, "openclaw") }); + expect(packageRoot).not.toBeNull(); + expect(await canonicalPath(packageRoot ?? "")).toBe(await canonicalPath(realPkg)); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("resolves dist index path via symlinked argv1 (async)", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + const realPkg = path.join(tmp, "real-pkg"); + const bin = path.join(tmp, "bin"); + await fs.mkdir(realPkg, { recursive: true }); + await fs.mkdir(bin, { recursive: true }); + await fs.writeFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" })); + await fs.writeFile(path.join(realPkg, "openclaw.mjs"), "export {};\n"); + await fs.mkdir(path.join(realPkg, "dist", "control-ui"), { recursive: true }); + await fs.writeFile(path.join(realPkg, "dist", "control-ui", "index.html"), "\n"); + const ok = await trySymlink( + path.join("..", "real-pkg", "openclaw.mjs"), + path.join(bin, "openclaw"), + ); + if (!ok) { + return; // symlinks not supported (Windows CI) + } + + const indexPath = await resolveControlUiDistIndexPath(path.join(bin, "openclaw")); + expect(indexPath).not.toBeNull(); + expect(await canonicalPath(indexPath ?? "")).toBe( + await canonicalPath(path.join(realPkg, "dist", "control-ui", "index.html")), + ); + } 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 08e0312c8fa..953fb30941b 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -20,11 +20,15 @@ export async function resolveControlUiDistIndexHealth( opts: { root?: string; argv1?: string; + moduleUrl?: string; } = {}, ): Promise { const indexPath = opts.root ? resolveControlUiDistIndexPathForRoot(opts.root) - : await resolveControlUiDistIndexPath(opts.argv1 ?? process.argv[1]); + : await resolveControlUiDistIndexPath({ + argv1: opts.argv1 ?? process.argv[1], + moduleUrl: opts.moduleUrl, + }); return { indexPath, exists: Boolean(indexPath && fs.existsSync(indexPath)), @@ -66,8 +70,11 @@ export function resolveControlUiRepoRoot( } export async function resolveControlUiDistIndexPath( - argv1: string | undefined = process.argv[1], + argv1OrOpts?: string | { argv1?: string; moduleUrl?: string }, ): Promise { + const argv1 = + typeof argv1OrOpts === "string" ? argv1OrOpts : (argv1OrOpts?.argv1 ?? process.argv[1]); + const moduleUrl = typeof argv1OrOpts === "object" ? argv1OrOpts?.moduleUrl : undefined; if (!argv1) { return null; } @@ -79,7 +86,7 @@ export async function resolveControlUiDistIndexPath( return path.join(distDir, "control-ui", "index.html"); } - const packageRoot = await resolveOpenClawPackageRoot({ argv1: normalized }); + const packageRoot = await resolveOpenClawPackageRoot({ argv1: normalized, moduleUrl }); if (packageRoot) { return path.join(packageRoot, "dist", "control-ui", "index.html"); } diff --git a/src/infra/openclaw-root.ts b/src/infra/openclaw-root.ts index a13f510053e..2beb3e8f0c4 100644 --- a/src/infra/openclaw-root.ts +++ b/src/infra/openclaw-root.ts @@ -60,6 +60,18 @@ function findPackageRootSync(startDir: string, maxDepth = 12): string | null { function candidateDirsFromArgv1(argv1: string): string[] { const normalized = path.resolve(argv1); const candidates = [path.dirname(normalized)]; + + // Resolve symlinks for version managers (nvm, fnm, n, Homebrew/Linuxbrew) + // that create symlinks in bin/ pointing to the real package location. + try { + const resolved = fsSync.realpathSync(normalized); + if (resolved !== normalized) { + candidates.push(path.dirname(resolved)); + } + } catch { + // realpathSync throws if path doesn't exist; keep original candidates + } + const parts = normalized.split(path.sep); const binIndex = parts.lastIndexOf(".bin"); if (binIndex > 0 && parts[binIndex - 1] === "node_modules") {