fix(plugins): clean bundled runtime install stage

This commit is contained in:
Peter Steinberger
2026-04-24 02:36:27 +01:00
parent 02a9dd0ddc
commit b0244f613e
2 changed files with 99 additions and 33 deletions

View File

@@ -261,6 +261,47 @@ describe("installBundledRuntimeDeps", () => {
);
});
it("cleans an owned isolated execution root after copying node_modules back", () => {
const installRoot = makeTempDir();
const installExecutionRoot = path.join(installRoot, ".openclaw-install-stage");
spawnSyncMock.mockImplementation((_command, _args, options) => {
const cwd = String(options?.cwd ?? "");
fs.mkdirSync(path.join(cwd, "node_modules", "tokenjuice"), { recursive: true });
fs.writeFileSync(
path.join(cwd, "node_modules", "tokenjuice", "package.json"),
JSON.stringify({ name: "tokenjuice", version: "0.6.1" }),
);
return {
pid: 123,
output: [],
stdout: "",
stderr: "",
signal: null,
status: 0,
};
});
installBundledRuntimeDeps({
installRoot,
installExecutionRoot,
missingSpecs: ["tokenjuice@0.6.1"],
env: {},
});
expect(fs.existsSync(installExecutionRoot)).toBe(false);
expect(
JSON.parse(
fs.readFileSync(
path.join(installRoot, "node_modules", "tokenjuice", "package.json"),
"utf8",
),
),
).toEqual({
name: "tokenjuice",
version: "0.6.1",
});
});
it("does not fail an isolated runtime deps install when temp cleanup races", () => {
const installRoot = makeTempDir();
const installExecutionRoot = makeTempDir();
@@ -483,7 +524,9 @@ describe("ensureBundledPluginRuntimeDeps", () => {
]);
// 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));
const installExecutionRoot = calls[0]?.installExecutionRoot;
expect(installExecutionRoot).toBeDefined();
expect(path.resolve(installExecutionRoot ?? "")).not.toEqual(path.resolve(pluginRoot));
});
it("installs runtime deps into an external stage dir and exposes loader aliases", () => {

View File

@@ -825,6 +825,15 @@ export function createBundledRuntimeDependencyAliasMap(params: {
return aliases;
}
function shouldCleanBundledRuntimeDepsInstallExecutionRoot(params: {
installRoot: string;
installExecutionRoot: string;
}): boolean {
const installRoot = path.resolve(params.installRoot);
const installExecutionRoot = path.resolve(params.installExecutionRoot);
return installExecutionRoot.startsWith(`${installRoot}${path.sep}`);
}
export function installBundledRuntimeDeps(params: {
installRoot: string;
installExecutionRoot?: string;
@@ -832,39 +841,53 @@ export function installBundledRuntimeDeps(params: {
env: NodeJS.ProcessEnv;
}): void {
const installExecutionRoot = params.installExecutionRoot ?? params.installRoot;
fs.mkdirSync(params.installRoot, { recursive: true });
fs.mkdirSync(installExecutionRoot, { recursive: true });
if (path.resolve(installExecutionRoot) !== path.resolve(params.installRoot)) {
fs.writeFileSync(
path.join(installExecutionRoot, "package.json"),
`${JSON.stringify({ name: "openclaw-runtime-deps-install", private: true }, null, 2)}\n`,
"utf8",
);
}
const installEnv = createBundledRuntimeDepsInstallEnv(params.env);
const npmRunner = resolveBundledRuntimeDepsNpmRunner({
env: installEnv,
npmArgs: createBundledRuntimeDepsInstallArgs(params.missingSpecs),
});
const result = spawnSync(npmRunner.command, npmRunner.args, {
cwd: installExecutionRoot,
encoding: "utf8",
env: npmRunner.env ?? installEnv,
stdio: "pipe",
});
if (result.status !== 0 || result.error) {
const output = [result.error?.message, result.stderr, result.stdout]
.filter(Boolean)
.join("\n")
.trim();
throw new Error(output || "npm install failed");
}
if (path.resolve(installExecutionRoot) !== path.resolve(params.installRoot)) {
const stagedNodeModulesDir = path.join(installExecutionRoot, "node_modules");
if (!fs.existsSync(stagedNodeModulesDir)) {
throw new Error("npm install did not produce node_modules");
const isolatedExecutionRoot =
path.resolve(installExecutionRoot) !== path.resolve(params.installRoot);
const cleanInstallExecutionRoot =
isolatedExecutionRoot &&
shouldCleanBundledRuntimeDepsInstallExecutionRoot({
installRoot: params.installRoot,
installExecutionRoot,
});
try {
fs.mkdirSync(params.installRoot, { recursive: true });
fs.mkdirSync(installExecutionRoot, { recursive: true });
if (isolatedExecutionRoot) {
fs.writeFileSync(
path.join(installExecutionRoot, "package.json"),
`${JSON.stringify({ name: "openclaw-runtime-deps-install", private: true }, null, 2)}\n`,
"utf8",
);
}
const installEnv = createBundledRuntimeDepsInstallEnv(params.env);
const npmRunner = resolveBundledRuntimeDepsNpmRunner({
env: installEnv,
npmArgs: createBundledRuntimeDepsInstallArgs(params.missingSpecs),
});
const result = spawnSync(npmRunner.command, npmRunner.args, {
cwd: installExecutionRoot,
encoding: "utf8",
env: npmRunner.env ?? installEnv,
stdio: "pipe",
});
if (result.status !== 0 || result.error) {
const output = [result.error?.message, result.stderr, result.stdout]
.filter(Boolean)
.join("\n")
.trim();
throw new Error(output || "npm install failed");
}
if (isolatedExecutionRoot) {
const stagedNodeModulesDir = path.join(installExecutionRoot, "node_modules");
if (!fs.existsSync(stagedNodeModulesDir)) {
throw new Error("npm install did not produce node_modules");
}
replaceNodeModulesDir(path.join(params.installRoot, "node_modules"), stagedNodeModulesDir);
}
} finally {
if (cleanInstallExecutionRoot) {
fs.rmSync(installExecutionRoot, { recursive: true, force: true });
}
replaceNodeModulesDir(path.join(params.installRoot, "node_modules"), stagedNodeModulesDir);
}
}