From 02a9dd0ddc59ec2efc4f00803ee6fe839015b15f Mon Sep 17 00:00:00 2001 From: simonemacario Date: Thu, 23 Apr 2026 17:51:23 -0700 Subject: [PATCH] fix(plugins): stage bundled-plugin runtime-dep install outside the plugin root When a packaged bundled plugin's `pluginRoot` is used directly as the npm execution cwd, `npm install ` resolves the plugin's own `package.json` as the project manifest and fails with `EUNSUPPORTEDPROTOCOL: Unsupported URL Type "workspace:": workspace:*` whenever that manifest declares a `workspace:` runtime dep (e.g. `"@openclaw/plugin-sdk": "workspace:*"`). This takes out every plugin with any runtime deps at gateway startup. `ensureBundledPluginRuntimeDeps` already filters `workspace:` specs from the CLI arguments, but npm's own resolver reads the cwd manifest regardless, so the filter alone is not enough. The existing isolated execution-root + `replaceNodeModulesDir` machinery handles this exact problem for source-checkout + cache-hit installs. This change activates the same staging path for the packaged case: when `installRoot === pluginRoot` and we are not in the source-checkout cache path, stage the install inside `/.openclaw-install-stage` (which has a minimal generated `package.json`) and move the produced `node_modules/` back to the plugin root as before. - Add regression test `stages plugin-root install when the plugin's own package.json declares workspace:* deps` covering the Docker scenario (mixed `workspace:*` + concrete runtime dep, e.g. anthropic-style `@openclaw/plugin-sdk` + `@anthropic-ai/sdk`). - Update existing plugin-root-install expectations (`installs plugin-local runtime deps when one is missing`, `skips workspace-only runtime deps before npm install`, `installs deps that are only present in the package root`, `does not trust runtime deps that only resolve from the package root`, `does not treat sibling extension runtime deps as satisfying a plugin`) to assert the new `installExecutionRoot`. Reported in #70844; same root cause as #70701, #70756, #70773, #70818, #70839 which see the downstream "Cannot find package 'openclaw' from plugin-runtime-deps" symptom because their `resolveBundledRuntimeDependencyInstallRoot` resolves to an external stage dir (clean manifest) so the install succeeds but the resulting node_modules tree cannot satisfy the filtered-out workspace packages at ESM import time. ## AI assistance This PR was AI-assisted with Claude Code. Testing degree: fully tested for the touched `bundled-runtime-deps` install staging surface. - `pnpm exec vitest run --config test/vitest/vitest.plugins.config.ts src/plugins/bundled-runtime-deps.test.ts` (31/31) - `pnpm exec vitest run --config test/vitest/vitest.plugins.config.ts src/plugins/` (43/43 across 8 files) - `pnpm exec tsgo --noEmit -p tsconfig.core.json`, `pnpm exec tsgo --noEmit -p tsconfig.core.test.json` (clean) - `pnpm exec oxlint src/plugins/bundled-runtime-deps.ts src/plugins/bundled-runtime-deps.test.ts` (0 warnings, 0 errors) - `node scripts/check-src-extension-import-boundary.mjs --json` and `node scripts/check-sdk-package-extension-import-boundary.mjs --json` (both `[]`) I understand the code path changed here: packaged bundled plugins now stage their runtime-dep install one directory below `pluginRoot` so npm never reads the plugin's `workspace:*`-containing manifest during install; after install completes, the produced `node_modules/` is moved back to `pluginRoot` via the existing `replaceNodeModulesDir` helper. Signed-off-by: Simone Macario --- src/plugins/bundled-runtime-deps.test.ts | 53 ++++++++++++++++++++++++ src/plugins/bundled-runtime-deps.ts | 16 ++++++- 2 files changed, 67 insertions(+), 2 deletions(-) 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,