fix: isolate bundled plugin runtime deps

This commit is contained in:
Peter Steinberger
2026-04-21 04:30:35 +01:00
parent 201bf85ce9
commit 817f861167
2 changed files with 222 additions and 15 deletions

View File

@@ -0,0 +1,125 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
ensureBundledPluginRuntimeDeps,
resolveBundledRuntimeDepsNpmRunner,
} from "./bundled-runtime-deps.js";
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-deps-test-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("resolveBundledRuntimeDepsNpmRunner", () => {
it("uses the Node-adjacent npm CLI on Windows", () => {
const execPath = "C:\\Program Files\\nodejs\\node.exe";
const npmCliPath = path.win32.resolve(
path.win32.dirname(execPath),
"node_modules/npm/bin/npm-cli.js",
);
const runner = resolveBundledRuntimeDepsNpmRunner({
env: {},
execPath,
existsSync: (candidate) => candidate === npmCliPath,
npmArgs: ["install", "acpx@0.5.3"],
platform: "win32",
});
expect(runner).toEqual({
command: execPath,
args: [npmCliPath, "install", "acpx@0.5.3"],
});
});
it("does not fall back to bare npm on Windows", () => {
expect(() =>
resolveBundledRuntimeDepsNpmRunner({
env: {},
execPath: "C:\\Program Files\\nodejs\\node.exe",
existsSync: () => false,
npmArgs: ["install"],
platform: "win32",
}),
).toThrow("failed to resolve a toolchain-local npm");
});
it("prefixes PATH with the active Node directory on POSIX", () => {
const runner = resolveBundledRuntimeDepsNpmRunner({
env: {
PATH: "/usr/bin:/bin",
},
execPath: "/opt/node/bin/node",
existsSync: () => false,
npmArgs: ["install", "acpx@0.5.3"],
platform: "linux",
});
expect(runner).toEqual({
command: "npm",
args: ["install", "acpx@0.5.3"],
env: {
PATH: `/opt/node/bin${path.delimiter}/usr/bin:/bin`,
},
});
});
it("installs all direct plugin runtime deps when one is missing", () => {
const packageRoot = makeTempDir();
const extensionsRoot = path.join(packageRoot, "dist", "extensions");
const pluginRoot = path.join(extensionsRoot, "bedrock");
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({
dependencies: {
"already-present": "1.0.0",
missing: "2.0.0",
},
}),
);
fs.mkdirSync(path.join(extensionsRoot, "node_modules", "already-present"), {
recursive: true,
});
fs.writeFileSync(
path.join(extensionsRoot, "node_modules", "already-present", "package.json"),
JSON.stringify({ name: "already-present", version: "1.0.0" }),
);
const calls: Array<{
installRoot: string;
missingSpecs: string[];
installSpecs?: string[];
}> = [];
const retainedSpecs = ensureBundledPluginRuntimeDeps({
env: {},
installDeps: (params) => {
calls.push(params);
},
pluginId: "bedrock",
pluginRoot,
retainSpecs: ["previous@3.0.0"],
});
expect(retainedSpecs).toEqual(["already-present@1.0.0", "missing@2.0.0", "previous@3.0.0"]);
expect(calls).toEqual([
{
installRoot: extensionsRoot,
missingSpecs: ["missing@2.0.0"],
installSpecs: ["already-present@1.0.0", "missing@2.0.0", "previous@3.0.0"],
},
]);
});
});

View File

@@ -26,6 +26,12 @@ export type BundledRuntimeDepsInstallParams = {
type JsonObject = Record<string, unknown>;
export type BundledRuntimeDepsNpmRunner = {
command: string;
args: string[];
env?: NodeJS.ProcessEnv;
};
function dependencySentinelPath(depName: string): string {
return path.join("node_modules", ...depName.split("/"), "package.json");
}
@@ -73,6 +79,68 @@ function createNestedNpmInstallEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return nextEnv;
}
function resolvePathEnvKey(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string {
if (platform !== "win32") {
return "PATH";
}
return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "Path";
}
export function resolveBundledRuntimeDepsNpmRunner(params: {
npmArgs: string[];
env?: NodeJS.ProcessEnv;
execPath?: string;
existsSync?: typeof fs.existsSync;
platform?: NodeJS.Platform;
}): BundledRuntimeDepsNpmRunner {
const env = params.env ?? process.env;
const execPath = params.execPath ?? process.execPath;
const existsSync = params.existsSync ?? fs.existsSync;
const platform = params.platform ?? process.platform;
const pathImpl = platform === "win32" ? path.win32 : path.posix;
const nodeDir = pathImpl.dirname(execPath);
const npmCliCandidates = [
pathImpl.resolve(nodeDir, "../lib/node_modules/npm/bin/npm-cli.js"),
pathImpl.resolve(nodeDir, "node_modules/npm/bin/npm-cli.js"),
];
const npmCliPath = npmCliCandidates.find((candidate) => existsSync(candidate));
if (npmCliPath) {
return {
command: execPath,
args: [npmCliPath, ...params.npmArgs],
};
}
if (platform === "win32") {
const npmExePath = pathImpl.resolve(nodeDir, "npm.exe");
if (existsSync(npmExePath)) {
return {
command: npmExePath,
args: params.npmArgs,
};
}
throw new Error(
`failed to resolve a toolchain-local npm next to ${execPath}. ` +
`Checked: ${[...npmCliCandidates, npmExePath].join(", ")}.`,
);
}
const pathKey = resolvePathEnvKey(env, platform);
const currentPath = env[pathKey];
return {
command: "npm",
args: params.npmArgs,
env: {
...env,
[pathKey]:
typeof currentPath === "string" && currentPath.length > 0
? `${nodeDir}${path.delimiter}${currentPath}`
: nodeDir,
},
};
}
function readBundledPluginChannels(pluginDir: string): string[] {
const manifest = readJsonObject(path.join(pluginDir, "openclaw.plugin.json"));
const channels = manifest?.channels;
@@ -255,7 +323,13 @@ export function scanBundledPluginRuntimeDeps(params: {
pluginIds: normalizePluginIdSet(params.pluginIds),
});
const missing = deps.filter(
(dep) => !fs.existsSync(path.join(params.packageRoot, dependencySentinelPath(dep.name))),
(dep) =>
!fs.existsSync(path.join(params.packageRoot, dependencySentinelPath(dep.name))) &&
!fs.existsSync(path.join(extensionsDir, dependencySentinelPath(dep.name))) &&
dep.pluginIds.every(
(pluginId) =>
!fs.existsSync(path.join(extensionsDir, pluginId, dependencySentinelPath(dep.name))),
),
);
return { missing, conflicts };
}
@@ -267,7 +341,7 @@ export function resolveBundledRuntimeDependencyInstallRoot(pluginRoot: string):
path.basename(extensionsDir) === "extensions" &&
(path.basename(buildDir) === "dist" || path.basename(buildDir) === "dist-runtime")
) {
return path.dirname(buildDir);
return extensionsDir;
}
return extensionsDir;
}
@@ -277,10 +351,12 @@ export function installBundledRuntimeDeps(params: {
missingSpecs: string[];
env: NodeJS.ProcessEnv;
}): void {
const result = spawnSync(
"npm",
[
const npmRunner = resolveBundledRuntimeDepsNpmRunner({
env: params.env,
npmArgs: [
"install",
"--prefix",
params.installRoot,
"--omit=dev",
"--no-save",
"--package-lock=false",
@@ -288,14 +364,17 @@ export function installBundledRuntimeDeps(params: {
"--legacy-peer-deps",
...params.missingSpecs,
],
{
cwd: params.installRoot,
encoding: "utf8",
env: createNestedNpmInstallEnv(params.env),
stdio: "pipe",
shell: false,
},
);
});
const result = spawnSync(npmRunner.command, npmRunner.args, {
cwd: params.installRoot,
encoding: "utf8",
env: createNestedNpmInstallEnv(npmRunner.env ?? params.env),
stdio: "pipe",
shell: false,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
throw new Error(output || "npm install failed");
@@ -339,6 +418,9 @@ export function ensureBundledPluginRuntimeDeps(params: {
}
const installRoot = resolveBundledRuntimeDependencyInstallRoot(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))))
.map((dep) => `${dep.name}@${dep.version}`)
@@ -346,7 +428,7 @@ export function ensureBundledPluginRuntimeDeps(params: {
if (missingSpecs.length === 0) {
return [];
}
const installSpecs = [...new Set([...(params.retainSpecs ?? []), ...missingSpecs])].toSorted(
const installSpecs = [...new Set([...(params.retainSpecs ?? []), ...dependencySpecs])].toSorted(
(left, right) => left.localeCompare(right),
);
@@ -359,5 +441,5 @@ export function ensureBundledPluginRuntimeDeps(params: {
env: params.env,
}));
install({ installRoot, missingSpecs, installSpecs });
return missingSpecs;
return installSpecs;
}