mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
fix: isolate bundled plugin runtime deps
This commit is contained in:
125
src/plugins/bundled-runtime-deps.test.ts
Normal file
125
src/plugins/bundled-runtime-deps.test.ts
Normal 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"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user