Files
moltbot/src/cli/plugins-cli.uninstall.test.ts
the sun gif man d4b4660026 config: stop automatic writes and guard Nix mutators (#78047)
Keep startup-derived plugin enablement, gateway auth tokens, control UI origins, and owner-display secrets runtime-only instead of persisting them into openclaw.json.

Refuse config writers, mutating update/plugin lifecycle commands, and doctor repair/token generation in Nix mode with agent-first nix-openclaw guidance.

Verification:
- pnpm check
- pnpm build
- pnpm test -- src/config/io.write-config.test.ts src/config/mutate.test.ts src/config/io.owner-display-secret.test.ts src/gateway/server-startup-config.recovery.test.ts src/gateway/startup-auth.test.ts src/gateway/startup-control-ui-origins.test.ts src/cli/plugins-cli.install.test.ts src/cli/plugins-cli.policy.test.ts src/cli/plugins-cli.uninstall.test.ts src/cli/plugins-cli.update.test.ts src/cli/update-cli.test.ts src/auto-reply/reply/commands-plugins.install.test.ts src/auto-reply/reply/commands-plugins.test.ts src/commands/onboarding-plugin-install.test.ts src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts src/commands/doctor/shared/codex-route-warnings.test.ts src/commands/doctor/repair-sequencing.test.ts src/agents/auth-profile-runtime-contract.test.ts src/auto-reply/reply/agent-runner-execution.test.ts
- GitHub CI green on 05a2c71b90

Co-authored-by: Codex <noreply@openai.com>
2026-05-06 14:43:32 +02:00

546 lines
16 KiB
TypeScript

import { installedPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
applyPluginUninstallDirectoryRemoval,
buildPluginDiagnosticsReport,
buildPluginSnapshotReport,
loadConfig,
planPluginUninstall,
PromptInputClosedError,
promptYesNo,
refreshPluginRegistry,
replaceConfigFile,
resetPluginsCliTestState,
runPluginsCommand,
runtimeErrors,
runtimeLogs,
setInstalledPluginIndexInstallRecords,
writeConfigFile,
writePersistedInstalledPluginIndexInstallRecords,
} from "./plugins-cli-test-helpers.js";
const CLI_STATE_ROOT = "/tmp/openclaw-state";
const ALPHA_INSTALL_PATH = installedPluginRoot(CLI_STATE_ROOT, "alpha");
const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE;
describe("plugins cli uninstall", () => {
beforeEach(() => {
resetPluginsCliTestState();
});
afterEach(() => {
if (ORIGINAL_OPENCLAW_NIX_MODE === undefined) {
delete process.env.OPENCLAW_NIX_MODE;
} else {
process.env.OPENCLAW_NIX_MODE = ORIGINAL_OPENCLAW_NIX_MODE;
}
});
it("refuses plugin uninstalls in Nix mode before planning file removal", async () => {
const previous = process.env.OPENCLAW_NIX_MODE;
process.env.OPENCLAW_NIX_MODE = "1";
try {
await expect(runPluginsCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow(
"OPENCLAW_NIX_MODE=1",
);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_NIX_MODE;
} else {
process.env.OPENCLAW_NIX_MODE = previous;
}
}
expect(planPluginUninstall).not.toHaveBeenCalled();
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("shows uninstall dry-run preview without mutating config", async () => {
loadConfig.mockReturnValue({
plugins: {
entries: {
alpha: {
enabled: true,
},
},
installs: {
alpha: {
source: "path",
sourcePath: ALPHA_INSTALL_PATH,
installPath: ALPHA_INSTALL_PATH,
},
},
slots: {
contextEngine: "alpha",
},
},
} as OpenClawConfig);
buildPluginSnapshotReport.mockReturnValue({
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(buildPluginSnapshotReport).toHaveBeenCalled();
expect(buildPluginDiagnosticsReport).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);
expect(runtimeLogs.some((line) => line.includes("context engine slot"))).toBe(true);
});
it("uninstalls with --force and --keep-files without prompting", async () => {
const baseConfig = {
plugins: {
entries: {
alpha: { enabled: true },
},
installs: {
alpha: {
source: "path",
sourcePath: ALPHA_INSTALL_PATH,
installPath: ALPHA_INSTALL_PATH,
},
},
},
} as OpenClawConfig;
const nextConfig = {
plugins: {
entries: {},
installs: {},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(baseConfig);
setInstalledPluginIndexInstallRecords(baseConfig.plugins?.installs ?? {});
buildPluginSnapshotReport.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: null,
});
await runPluginsCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]);
expect(promptYesNo).not.toHaveBeenCalled();
expect(planPluginUninstall).toHaveBeenCalledWith(
expect.objectContaining({
pluginId: "alpha",
deleteFiles: false,
}),
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({});
expect(writeConfigFile).toHaveBeenCalledWith({
plugins: {
entries: {},
},
});
expect(refreshPluginRegistry).toHaveBeenCalledWith({
config: {
plugins: {
entries: {},
},
},
installRecords: {},
reason: "source-changed",
});
});
it("exits cleanly when confirmation input closes before an answer", async () => {
const baseConfig = {
plugins: {
entries: {
alpha: { enabled: true },
},
installs: {
alpha: {
source: "path",
sourcePath: ALPHA_INSTALL_PATH,
installPath: ALPHA_INSTALL_PATH,
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(baseConfig);
setInstalledPluginIndexInstallRecords(baseConfig.plugins?.installs ?? {});
buildPluginSnapshotReport.mockReturnValue({
plugins: [{ id: "alpha", name: "alpha" }],
diagnostics: [],
});
planPluginUninstall.mockReturnValue({
ok: true,
config: { plugins: { entries: {}, installs: {} } } as OpenClawConfig,
actions: {
entry: true,
install: true,
allowlist: false,
denylist: false,
loadPath: false,
memorySlot: false,
contextEngineSlot: false,
directory: false,
},
directoryRemoval: null,
});
promptYesNo.mockRejectedValueOnce(new PromptInputClosedError());
await expect(runPluginsCommand(["plugins", "uninstall", "alpha"])).rejects.toThrow(
"__exit__:1",
);
expect(runtimeErrors).toContain(
"Error: plugins uninstall requires confirmation input. Re-run in an interactive TTY or pass --force.",
);
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(refreshPluginRegistry).not.toHaveBeenCalled();
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
});
it("restores install records when the config write rejects during uninstall", async () => {
const installRecords = {
alpha: {
source: "path",
sourcePath: ALPHA_INSTALL_PATH,
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);
buildPluginSnapshotReport.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: null,
});
replaceConfigFile.mockRejectedValueOnce(new Error("config changed"));
await expect(
runPluginsCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]),
).rejects.toThrow("config changed");
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(1, {});
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
2,
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);
buildPluginSnapshotReport.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;
const refreshOrder =
refreshPluginRegistry.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER;
expect(configWriteOrder).toBeGreaterThan(0);
expect(deleteOrder).toBeGreaterThan(configWriteOrder);
expect(refreshOrder).toBeGreaterThan(deleteOrder);
expect(applyPluginUninstallDirectoryRemoval).toHaveBeenCalledWith({
target: ALPHA_INSTALL_PATH,
});
});
it("cleans stale policy refs even when plugin is absent from the current registry", async () => {
const baseConfig = {
plugins: {
allow: ["alpha", "beta"],
deny: ["alpha"],
},
} as OpenClawConfig;
const nextConfig = {
plugins: {
allow: ["beta"],
},
} as OpenClawConfig;
loadConfig.mockReturnValue(baseConfig);
buildPluginSnapshotReport.mockReturnValue({
plugins: [],
diagnostics: [],
});
planPluginUninstall.mockReturnValue({
ok: true,
config: nextConfig,
actions: {
entry: false,
install: false,
allowlist: true,
denylist: true,
loadPath: false,
memorySlot: false,
contextEngineSlot: false,
channelConfig: false,
directory: false,
},
directoryRemoval: null,
});
await runPluginsCommand(["plugins", "uninstall", "alpha", "--force"]);
expect(planPluginUninstall).toHaveBeenCalledWith(
expect.objectContaining({
pluginId: "alpha",
deleteFiles: true,
}),
);
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
expect(runtimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"');
});
it("uninstalls stale enabled entries when plugin is absent from the current registry", async () => {
const baseConfig = {
plugins: {
entries: {
alpha: { enabled: true },
},
},
} as OpenClawConfig;
const nextConfig = {} as OpenClawConfig;
loadConfig.mockReturnValue(baseConfig);
buildPluginSnapshotReport.mockReturnValue({
plugins: [],
diagnostics: [],
});
planPluginUninstall.mockReturnValue({
ok: true,
config: nextConfig,
actions: {
entry: true,
install: false,
allowlist: false,
denylist: false,
loadPath: false,
memorySlot: false,
contextEngineSlot: false,
channelConfig: false,
directory: false,
},
directoryRemoval: null,
});
await runPluginsCommand(["plugins", "uninstall", "alpha", "--force"]);
expect(planPluginUninstall).toHaveBeenCalledWith(
expect.objectContaining({
pluginId: "alpha",
deleteFiles: true,
}),
);
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
expect(refreshPluginRegistry).toHaveBeenCalledWith({
config: nextConfig,
installRecords: {},
reason: "source-changed",
});
expect(runtimeErrors).not.toContain("Plugin not found: alpha");
expect(runtimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"');
});
it("removes installed channel config when plugin code is absent from the current registry", 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,
},
channels: {
alpha: {
enabled: true,
},
discord: {
enabled: true,
},
},
} as OpenClawConfig;
const nextConfig = {
channels: {
discord: {
enabled: true,
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(baseConfig);
setInstalledPluginIndexInstallRecords(installRecords);
buildPluginSnapshotReport.mockReturnValue({
plugins: [],
diagnostics: [],
});
planPluginUninstall.mockReturnValue({
ok: true,
config: nextConfig,
actions: {
entry: true,
install: true,
allowlist: false,
denylist: false,
loadPath: false,
memorySlot: false,
contextEngineSlot: false,
channelConfig: true,
directory: false,
},
directoryRemoval: null,
});
await runPluginsCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]);
expect(planPluginUninstall).toHaveBeenCalledWith(
expect.objectContaining({
pluginId: "alpha",
channelIds: undefined,
deleteFiles: false,
}),
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({});
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
expect(runtimeLogs.some((line) => line.includes("channel config (channels.alpha)"))).toBe(true);
expect(runtimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"');
});
it("exits when uninstall target is not managed by plugin install records", async () => {
loadConfig.mockReturnValue({
plugins: {
entries: {},
installs: {},
},
} as OpenClawConfig);
buildPluginSnapshotReport.mockReturnValue({
plugins: [{ id: "alpha", name: "alpha" }],
diagnostics: [],
});
planPluginUninstall.mockReturnValue({
ok: false,
error: "Plugin not found: alpha",
});
await expect(runPluginsCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow(
"__exit__:1",
);
expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records");
expect(planPluginUninstall).toHaveBeenCalled();
});
});