From b0244f613edab0c596f8a8b193d99e624d7ca3c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 02:36:27 +0100 Subject: [PATCH] fix(plugins): clean bundled runtime install stage --- src/plugins/bundled-runtime-deps.test.ts | 45 +++++++++++- src/plugins/bundled-runtime-deps.ts | 87 +++++++++++++++--------- 2 files changed, 99 insertions(+), 33 deletions(-) diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 1d9a72974af..e283b34370e 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -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", () => { diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 0f5a4ca4cbf..f9d3a45b241 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -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); } }