mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
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:
committed by
Peter Steinberger
parent
64ed439ad0
commit
02a9dd0ddc
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user