diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 4a984ee1db1..5bac1100fe8 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -275,6 +275,80 @@ describe("ensureBundledPluginRuntimeDeps", () => { expect(result).toEqual({ installedSpecs: [], retainSpecs: [] }); }); + it("skips install when runtime deps resolve from the package root", () => { + const packageRoot = makeTempDir(); + const pluginRoot = path.join(packageRoot, "dist", "extensions", "openai"); + fs.mkdirSync(path.join(packageRoot, "node_modules", "@mariozechner", "pi-ai"), { + recursive: true, + }); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify({ + dependencies: { + "@mariozechner/pi-ai": "0.67.68", + }, + }), + ); + fs.writeFileSync( + path.join(packageRoot, "node_modules", "@mariozechner", "pi-ai", "package.json"), + JSON.stringify({ name: "@mariozechner/pi-ai", version: "0.67.68" }), + ); + + const result = ensureBundledPluginRuntimeDeps({ + env: {}, + installDeps: () => { + throw new Error("package-root runtime deps should not reinstall"); + }, + pluginId: "openai", + pluginRoot, + }); + + expect(result).toEqual({ installedSpecs: [], retainSpecs: [] }); + }); + + it("installs only deps missing from plugin and package-root resolution", () => { + const packageRoot = makeTempDir(); + const pluginRoot = path.join(packageRoot, "dist", "extensions", "codex"); + fs.mkdirSync(path.join(packageRoot, "node_modules", "ws"), { recursive: true }); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify({ + dependencies: { + ws: "^8.20.0", + zod: "^4.3.6", + }, + }), + ); + fs.writeFileSync( + path.join(packageRoot, "node_modules", "ws", "package.json"), + JSON.stringify({ name: "ws", version: "8.20.0" }), + ); + const calls: BundledRuntimeDepsInstallParams[] = []; + + const result = ensureBundledPluginRuntimeDeps({ + env: {}, + installDeps: (params) => { + calls.push(params); + }, + pluginId: "codex", + pluginRoot, + }); + + expect(result).toEqual({ + installedSpecs: ["zod@^4.3.6"], + retainSpecs: ["ws@^8.20.0", "zod@^4.3.6"], + }); + expect(calls).toEqual([ + { + installRoot: pluginRoot, + missingSpecs: ["zod@^4.3.6"], + installSpecs: ["ws@^8.20.0", "zod@^4.3.6"], + }, + ]); + }); + it("does not treat sibling extension runtime deps as satisfying a plugin", () => { const packageRoot = makeTempDir(); const extensionsRoot = path.join(packageRoot, "dist", "extensions"); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 8c1d1d0a7b2..fb7f68b9bbd 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -91,6 +91,26 @@ function resolveSourceCheckoutDistPackageRoot(pluginRoot: string): string | null return isSourceCheckoutRoot(packageRoot) ? packageRoot : null; } +function resolveBundledRuntimeDependencySearchRoots(params: { + installRoot: string; + pluginRoot: string; +}): string[] { + const roots = new Set([params.installRoot]); + const pluginRoot = path.resolve(params.pluginRoot); + const extensionsDir = path.dirname(pluginRoot); + const buildDir = path.dirname(extensionsDir); + if ( + path.basename(extensionsDir) !== "extensions" || + (path.basename(buildDir) !== "dist" && path.basename(buildDir) !== "dist-runtime") + ) { + return [...roots]; + } + roots.add(extensionsDir); + roots.add(buildDir); + roots.add(path.dirname(buildDir)); + return [...roots]; +} + function createRuntimeDepsCacheKey(pluginId: string, specs: readonly string[]): string { return createHash("sha256") .update(pluginId) @@ -121,6 +141,12 @@ function hasAllDependencySentinels(rootDir: string, deps: readonly { name: strin return deps.every((dep) => fs.existsSync(path.join(rootDir, dependencySentinelPath(dep.name)))); } +function hasDependencySentinel(searchRoots: readonly string[], dep: { name: string }): boolean { + return searchRoots.some((rootDir) => + fs.existsSync(path.join(rootDir, dependencySentinelPath(dep.name))), + ); +} + function replaceNodeModulesDir(targetDir: string, sourceDir: string): void { const parentDir = path.dirname(targetDir); const tempDir = fs.mkdtempSync(path.join(parentDir, ".openclaw-runtime-deps-copy-")); @@ -528,11 +554,15 @@ export function ensureBundledPluginRuntimeDeps(params: { } const installRoot = resolveBundledRuntimeDependencyInstallRoot(params.pluginRoot); + const dependencySearchRoots = resolveBundledRuntimeDependencySearchRoots({ + installRoot, + pluginRoot: params.pluginRoot, + }); const dependencySpecs = deps .map((dep) => `${dep.name}@${dep.version}`) .toSorted((left, right) => left.localeCompare(right)); const missingSpecs = deps - .filter((dep) => !fs.existsSync(path.join(installRoot, dependencySentinelPath(dep.name)))) + .filter((dep) => !hasDependencySentinel(dependencySearchRoots, dep)) .map((dep) => `${dep.name}@${dep.version}`) .toSorted((left, right) => left.localeCompare(right)); if (missingSpecs.length === 0) {