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 <specs>` 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 `<pluginRoot>/.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 <simone@sharly.ai>
This commit is contained in:
simonemacario
2026-04-23 17:51:23 -07:00
committed by Peter Steinberger
parent 64ed439ad0
commit 02a9dd0ddc
2 changed files with 67 additions and 2 deletions

View File

@@ -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"],
},

View File

@@ -42,6 +42,14 @@ export type BundledRuntimeDepsInstallRoot = {
type JsonObject = Record<string, unknown>;
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 <specs>` 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,