From 324cddee4c187be3c552f83d2eab9467f8df4ae9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 27 Mar 2026 12:15:35 +0000 Subject: [PATCH] fix: resolve bundled plugins from running CLI --- src/plugins/bundled-dir.test.ts | 50 +++++++++++++++++++++++++ src/plugins/bundled-dir.ts | 66 ++++++++++++++++++++++++--------- 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index 15c76fb9e9a..774effd4d68 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -7,6 +7,7 @@ import { resolveBundledPluginsDir } from "./bundled-dir.js"; const tempDirs: string[] = []; const originalBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; const originalVitest = process.env.VITEST; +const originalArgv1 = process.argv[1]; function makeRepoRoot(prefix: string): string { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); @@ -26,6 +27,7 @@ afterEach(() => { } else { process.env.VITEST = originalVitest; } + process.argv[1] = originalArgv1; for (const dir of tempDirs.splice(0, tempDirs.length)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -43,6 +45,7 @@ describe("resolveBundledPluginsDir", () => { ); vi.spyOn(process, "cwd").mockReturnValue(repoRoot); + process.argv[1] = "/usr/bin/env"; expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe( fs.realpathSync(path.join(repoRoot, "dist-runtime", "extensions")), @@ -59,6 +62,7 @@ describe("resolveBundledPluginsDir", () => { ); vi.spyOn(process, "cwd").mockReturnValue(repoRoot); + process.argv[1] = "/usr/bin/env"; expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe( fs.realpathSync(path.join(repoRoot, "dist", "extensions")), @@ -78,6 +82,7 @@ describe("resolveBundledPluginsDir", () => { vi.spyOn(process, "cwd").mockReturnValue(repoRoot); process.env.VITEST = "true"; + process.argv[1] = "/usr/bin/env"; expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe( fs.realpathSync(path.join(repoRoot, "extensions")), @@ -99,9 +104,54 @@ describe("resolveBundledPluginsDir", () => { vi.spyOn(process, "cwd").mockReturnValue(repoRoot); delete process.env.VITEST; + process.argv[1] = "/usr/bin/env"; expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe( fs.realpathSync(path.join(repoRoot, "extensions")), ); }); + + it("prefers the running CLI package root over an unrelated cwd checkout", () => { + const installedRoot = makeRepoRoot("openclaw-bundled-dir-installed-"); + fs.mkdirSync(path.join(installedRoot, "dist", "extensions"), { recursive: true }); + fs.writeFileSync( + path.join(installedRoot, "package.json"), + `${JSON.stringify({ name: "openclaw" }, null, 2)}\n`, + "utf8", + ); + + const cwdRepoRoot = makeRepoRoot("openclaw-bundled-dir-cwd-"); + fs.mkdirSync(path.join(cwdRepoRoot, "extensions"), { recursive: true }); + fs.mkdirSync(path.join(cwdRepoRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(cwdRepoRoot, ".git"), "gitdir: /tmp/fake.git\n", "utf8"); + fs.writeFileSync( + path.join(cwdRepoRoot, "package.json"), + `${JSON.stringify({ name: "openclaw" }, null, 2)}\n`, + "utf8", + ); + + vi.spyOn(process, "cwd").mockReturnValue(cwdRepoRoot); + process.argv[1] = path.join(installedRoot, "openclaw.mjs"); + + expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe( + fs.realpathSync(path.join(installedRoot, "dist", "extensions")), + ); + }); + + it("falls back to the running installed package when the override path is stale", () => { + const installedRoot = makeRepoRoot("openclaw-bundled-dir-override-"); + fs.mkdirSync(path.join(installedRoot, "dist", "extensions"), { recursive: true }); + fs.writeFileSync( + path.join(installedRoot, "package.json"), + `${JSON.stringify({ name: "openclaw" }, null, 2)}\n`, + "utf8", + ); + + process.argv[1] = path.join(installedRoot, "openclaw.mjs"); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(installedRoot, "missing-extensions"); + + expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe( + fs.realpathSync(path.join(installedRoot, "dist", "extensions")), + ); + }); }); diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 930ab6c9da4..ef0758209af 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -12,39 +12,69 @@ function isSourceCheckoutRoot(packageRoot: string): boolean { ); } +function resolveBundledDirFromPackageRoot( + packageRoot: string, + preferSourceCheckout: boolean, +): string | undefined { + const sourceExtensionsDir = path.join(packageRoot, "extensions"); + const builtExtensionsDir = path.join(packageRoot, "dist", "extensions"); + if ( + (preferSourceCheckout || isSourceCheckoutRoot(packageRoot)) && + fs.existsSync(sourceExtensionsDir) + ) { + return sourceExtensionsDir; + } + // Local source checkouts stage a runtime-complete bundled plugin tree under + // dist-runtime/. Prefer that over source extensions only when the paired + // dist/ tree exists; otherwise wrappers can drift ahead of the last build. + const runtimeExtensionsDir = path.join(packageRoot, "dist-runtime", "extensions"); + if (fs.existsSync(runtimeExtensionsDir) && fs.existsSync(builtExtensionsDir)) { + return runtimeExtensionsDir; + } + if (fs.existsSync(builtExtensionsDir)) { + return builtExtensionsDir; + } + return undefined; +} + export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined { const override = env.OPENCLAW_BUNDLED_PLUGINS_DIR?.trim(); if (override) { - return resolveUserPath(override, env); + const resolvedOverride = resolveUserPath(override, env); + if (fs.existsSync(resolvedOverride)) { + return resolvedOverride; + } + // Installed CLIs can inherit stale bundled-dir overrides from older shells + // or debug sessions. Prefer the package that owns argv[1] over a broken + // override so bundled providers keep working in packaged installs. + try { + const argvPackageRoot = resolveOpenClawPackageRootSync({ argv1: process.argv[1] }); + if (argvPackageRoot && !isSourceCheckoutRoot(argvPackageRoot)) { + const argvFallback = resolveBundledDirFromPackageRoot(argvPackageRoot, false); + if (argvFallback) { + return argvFallback; + } + } + } catch { + // ignore + } + return resolvedOverride; } const preferSourceCheckout = Boolean(env.VITEST); try { const packageRoots = [ + resolveOpenClawPackageRootSync({ argv1: process.argv[1] }), resolveOpenClawPackageRootSync({ cwd: process.cwd() }), resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url }), ].filter( (entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index, ); for (const packageRoot of packageRoots) { - const sourceExtensionsDir = path.join(packageRoot, "extensions"); - const builtExtensionsDir = path.join(packageRoot, "dist", "extensions"); - if ( - (preferSourceCheckout || isSourceCheckoutRoot(packageRoot)) && - fs.existsSync(sourceExtensionsDir) - ) { - return sourceExtensionsDir; - } - // Local source checkouts stage a runtime-complete bundled plugin tree under - // dist-runtime/. Prefer that over source extensions only when the paired - // dist/ tree exists; otherwise wrappers can drift ahead of the last build. - const runtimeExtensionsDir = path.join(packageRoot, "dist-runtime", "extensions"); - if (fs.existsSync(runtimeExtensionsDir) && fs.existsSync(builtExtensionsDir)) { - return runtimeExtensionsDir; - } - if (fs.existsSync(builtExtensionsDir)) { - return builtExtensionsDir; + const bundledDir = resolveBundledDirFromPackageRoot(packageRoot, preferSourceCheckout); + if (bundledDir) { + return bundledDir; } } } catch {