fix: harden plugin install and uninstall transactions

This commit is contained in:
Peter Steinberger
2026-04-26 10:27:15 +01:00
parent 893f070560
commit 6bc5fe6952
15 changed files with 490 additions and 218 deletions

View File

@@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it } from "vitest";
import { installedPluginRoot } from "../../test/helpers/bundled-plugin-paths.js";
import type { OpenClawConfig } from "../config/config.js";
import {
applyPluginUninstallDirectoryRemoval,
buildPluginDiagnosticsReport,
loadConfig,
planPluginUninstall,
promptYesNo,
refreshPluginRegistry,
replaceConfigFile,
@@ -12,7 +14,6 @@ import {
runtimeErrors,
runtimeLogs,
setInstalledPluginIndexInstallRecords,
uninstallPlugin,
writeConfigFile,
writePersistedInstalledPluginIndexInstallRecords,
} from "./plugins-cli-test-helpers.js";
@@ -49,10 +50,25 @@ describe("plugins cli uninstall", () => {
plugins: [{ id: "alpha", name: "alpha" }],
diagnostics: [],
});
planPluginUninstall.mockReturnValue({
ok: true,
config: {} as OpenClawConfig,
actions: {
entry: true,
install: true,
allowlist: false,
denylist: false,
loadPath: false,
memorySlot: false,
contextEngineSlot: true,
directory: false,
},
directoryRemoval: null,
});
await runPluginsCommand(["plugins", "uninstall", "alpha", "--dry-run"]);
expect(uninstallPlugin).not.toHaveBeenCalled();
expect(planPluginUninstall).toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(refreshPluginRegistry).not.toHaveBeenCalled();
expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true);
@@ -87,25 +103,26 @@ describe("plugins cli uninstall", () => {
plugins: [{ id: "alpha", name: "alpha" }],
diagnostics: [],
});
uninstallPlugin.mockResolvedValue({
planPluginUninstall.mockReturnValue({
ok: true,
config: nextConfig,
warnings: [],
actions: {
entry: true,
install: true,
allowlist: false,
denylist: false,
loadPath: false,
memorySlot: false,
contextEngineSlot: false,
directory: false,
},
directoryRemoval: null,
});
await runPluginsCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]);
expect(promptYesNo).not.toHaveBeenCalled();
expect(uninstallPlugin).toHaveBeenCalledWith(
expect(planPluginUninstall).toHaveBeenCalledWith(
expect.objectContaining({
pluginId: "alpha",
deleteFiles: false,
@@ -157,19 +174,20 @@ describe("plugins cli uninstall", () => {
plugins: [{ id: "alpha", name: "alpha" }],
diagnostics: [],
});
uninstallPlugin.mockResolvedValue({
planPluginUninstall.mockReturnValue({
ok: true,
config: nextConfig,
warnings: [],
actions: {
entry: true,
install: true,
allowlist: false,
denylist: false,
loadPath: false,
memorySlot: false,
contextEngineSlot: false,
directory: false,
},
directoryRemoval: null,
});
replaceConfigFile.mockRejectedValueOnce(new Error("config changed"));
@@ -183,6 +201,68 @@ describe("plugins cli uninstall", () => {
installRecords,
);
expect(refreshPluginRegistry).not.toHaveBeenCalled();
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
});
it("removes plugin files only after config and index commit succeeds", async () => {
const installRecords = {
alpha: {
source: "npm",
spec: "alpha@1.0.0",
installPath: ALPHA_INSTALL_PATH,
},
} as const;
const baseConfig = {
plugins: {
entries: {
alpha: { enabled: true },
},
installs: installRecords,
},
} as OpenClawConfig;
const nextConfig = {
plugins: {
entries: {},
installs: {},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(baseConfig);
setInstalledPluginIndexInstallRecords(installRecords);
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [{ id: "alpha", name: "alpha" }],
diagnostics: [],
});
planPluginUninstall.mockReturnValue({
ok: true,
config: nextConfig,
actions: {
entry: true,
install: true,
allowlist: false,
denylist: false,
loadPath: false,
memorySlot: false,
contextEngineSlot: false,
directory: false,
},
directoryRemoval: { target: ALPHA_INSTALL_PATH },
});
applyPluginUninstallDirectoryRemoval.mockResolvedValue({
directoryRemoved: true,
warnings: [],
});
await runPluginsCommand(["plugins", "uninstall", "alpha", "--force"]);
const configWriteOrder = writeConfigFile.mock.invocationCallOrder[0] ?? 0;
const deleteOrder =
applyPluginUninstallDirectoryRemoval.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER;
expect(configWriteOrder).toBeGreaterThan(0);
expect(deleteOrder).toBeGreaterThan(configWriteOrder);
expect(applyPluginUninstallDirectoryRemoval).toHaveBeenCalledWith({
target: ALPHA_INSTALL_PATH,
});
});
it("exits when uninstall target is not managed by plugin install records", async () => {
@@ -202,6 +282,6 @@ describe("plugins cli uninstall", () => {
);
expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records");
expect(uninstallPlugin).not.toHaveBeenCalled();
expect(planPluginUninstall).not.toHaveBeenCalled();
});
});