diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index c334e63a62b..1d9a72974af 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -370,6 +370,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { const calls: Array<{ installRoot: string; + installExecutionRoot?: string; missingSpecs: string[]; installSpecs?: string[]; }> = []; @@ -391,6 +392,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { expect(calls).toEqual([ { installRoot: pluginRoot, + installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), missingSpecs: ["missing@2.0.0"], installSpecs: ["already-present@1.0.0", "missing@2.0.0", "previous@3.0.0"], }, @@ -430,12 +432,60 @@ describe("ensureBundledPluginRuntimeDeps", () => { expect(calls).toEqual([ { installRoot: pluginRoot, + installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), missingSpecs: ["external-runtime@^1.2.3"], installSpecs: ["external-runtime@^1.2.3"], }, ]); }); + it("stages plugin-root install when the plugin's own package.json declares workspace:* deps", () => { + // Regression guard for packaged/Docker bundled plugins whose `package.json` + // still lists `"@openclaw/plugin-sdk": "workspace:*"` (and similar) alongside + // concrete runtime deps. Without a distinct execution root, `npm install` + // would resolve the plugin's own cwd manifest and fail with + // EUNSUPPORTEDPROTOCOL on the `workspace:` protocol. + const packageRoot = makeTempDir(); + const extensionsRoot = path.join(packageRoot, "dist", "extensions"); + const pluginRoot = path.join(extensionsRoot, "anthropic"); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify({ + dependencies: { + "@openclaw/plugin-sdk": "workspace:*", + "@anthropic-ai/sdk": "^0.50.0", + }, + }), + ); + + const calls: BundledRuntimeDepsInstallParams[] = []; + const result = ensureBundledPluginRuntimeDeps({ + env: {}, + installDeps: (params) => { + calls.push(params); + }, + pluginId: "anthropic", + pluginRoot, + }); + + expect(result).toEqual({ + installedSpecs: ["@anthropic-ai/sdk@^0.50.0"], + retainSpecs: ["@anthropic-ai/sdk@^0.50.0"], + }); + expect(calls).toEqual([ + { + installRoot: pluginRoot, + installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), + missingSpecs: ["@anthropic-ai/sdk@^0.50.0"], + installSpecs: ["@anthropic-ai/sdk@^0.50.0"], + }, + ]); + // The stage dir must be distinct from the plugin root so npm does not read + // the plugin's cwd manifest during install. + expect(path.resolve(calls[0]!.installExecutionRoot!)).not.toEqual(path.resolve(pluginRoot)); + }); + it("installs runtime deps into an external stage dir and exposes loader aliases", () => { const packageRoot = makeTempDir(); const stageDir = makeTempDir(); @@ -932,6 +982,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { expect(calls).toEqual([ { installRoot: pluginRoot, + installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), missingSpecs: ["@mariozechner/pi-ai@0.68.1"], installSpecs: ["@mariozechner/pi-ai@0.68.1"], }, @@ -974,6 +1025,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { expect(calls).toEqual([ { installRoot: pluginRoot, + installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), missingSpecs: ["ws@^8.20.0", "zod@^4.3.6"], installSpecs: ["ws@^8.20.0", "zod@^4.3.6"], }, @@ -1018,6 +1070,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { expect(calls).toEqual([ { installRoot: pluginRoot, + installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), missingSpecs: ["zod@^4.3.6"], installSpecs: ["zod@^4.3.6"], }, diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 5dca7aa081b..0f5a4ca4cbf 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -42,6 +42,14 @@ export type BundledRuntimeDepsInstallRoot = { type JsonObject = Record; const RETAINED_RUNTIME_DEPS_MANIFEST = ".openclaw-runtime-deps.json"; +// Packaged bundled plugins (Docker image, npm global install) keep their +// `package.json` next to their entry point; running `npm install ` with +// `cwd: pluginRoot` would make npm resolve the plugin's own `workspace:*` +// dependencies and fail with `EUNSUPPORTEDPROTOCOL`. To avoid that, stage the +// install inside this sub-directory and move the produced `node_modules/` back +// to the plugin root. Source-checkout installs already have their own cache +// path and keep using it. +const PLUGIN_ROOT_INSTALL_STAGE_DIR = ".openclaw-install-stage"; export type BundledRuntimeDepsNpmRunner = { command: string; @@ -920,12 +928,16 @@ export function ensureBundledPluginRuntimeDeps(params: { pluginRoot: params.pluginRoot, installSpecs, }); - const installExecutionRoot = + const isPluginRootInstall = path.resolve(installRoot) === path.resolve(params.pluginRoot); + const sourceCheckoutCacheStage = cacheDir && - path.resolve(installRoot) === path.resolve(params.pluginRoot) && + isPluginRootInstall && resolveSourceCheckoutBundledPluginPackageRoot(params.pluginRoot) ? cacheDir : undefined; + const installExecutionRoot = + sourceCheckoutCacheStage ?? + (isPluginRootInstall ? path.join(installRoot, PLUGIN_ROOT_INSTALL_STAGE_DIR) : undefined); if ( restoreSourceCheckoutRuntimeDepsFromCache({ cacheDir,