import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { resolvePluginInstallDir } from "./install.js"; import { removePluginFromConfig, resolveUninstallChannelConfigKeys, resolveUninstallDirectoryTarget, uninstallPlugin, } from "./uninstall.js"; type PluginConfig = NonNullable; type PluginInstallRecord = NonNullable[string]; async function createInstalledNpmPluginFixture(params: { baseDir: string; pluginId?: string; }): Promise<{ pluginId: string; extensionsDir: string; pluginDir: string; config: OpenClawConfig; }> { const pluginId = params.pluginId ?? "my-plugin"; const extensionsDir = path.join(params.baseDir, "extensions"); const pluginDir = resolvePluginInstallDir(pluginId, extensionsDir); await fs.mkdir(pluginDir, { recursive: true }); await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin"); return { pluginId, extensionsDir, pluginDir, config: { plugins: { entries: { [pluginId]: { enabled: true }, }, installs: { [pluginId]: { source: "npm", spec: `${pluginId}@1.0.0`, installPath: pluginDir, }, }, }, }, }; } type UninstallResult = Awaited>; async function runDeleteInstalledNpmPluginFixture(baseDir: string): Promise<{ pluginDir: string; result: UninstallResult; }> { const { pluginId, extensionsDir, pluginDir, config } = await createInstalledNpmPluginFixture({ baseDir, }); const result = await uninstallPlugin({ config, pluginId, deleteFiles: true, extensionsDir, }); return { pluginDir, result }; } function expectSuccessfulUninstall(result: UninstallResult) { expect(result.ok).toBe(true); if (!result.ok) { throw new Error(`expected uninstall success, got: ${result.error}`); } return result; } function expectSuccessfulUninstallActions( result: UninstallResult, params: { directory: boolean; loadPath?: boolean; warnings?: string[]; }, ) { const successfulResult = expectSuccessfulUninstall(result); expect(successfulResult.actions.directory).toBe(params.directory); if (params.loadPath !== undefined) { expect(successfulResult.actions.loadPath).toBe(params.loadPath); } if (params.warnings) { expect(successfulResult.warnings).toEqual(params.warnings); } return successfulResult; } function createSinglePluginEntries(pluginId = "my-plugin") { return { [pluginId]: { enabled: true }, }; } function createNpmInstallRecord(pluginId = "my-plugin", installPath?: string): PluginInstallRecord { return { source: "npm", spec: `${pluginId}@1.0.0`, ...(installPath ? { installPath } : {}), }; } function createPathInstallRecord( installPath = "/path/to/plugin", sourcePath = installPath, ): PluginInstallRecord { return { source: "path", sourcePath, installPath, }; } function createPluginConfig(params: { entries?: Record; installs?: Record; allow?: string[]; deny?: string[]; enabled?: boolean; slots?: PluginConfig["slots"]; loadPaths?: string[]; channels?: OpenClawConfig["channels"]; }): OpenClawConfig { const plugins: PluginConfig = {}; if (params.entries) { plugins.entries = params.entries; } if (params.installs) { plugins.installs = params.installs; } if (params.allow) { plugins.allow = params.allow; } if (params.deny) { plugins.deny = params.deny; } if (params.enabled !== undefined) { plugins.enabled = params.enabled; } if (params.slots) { plugins.slots = params.slots; } if (params.loadPaths) { plugins.load = { paths: params.loadPaths }; } return { ...(Object.keys(plugins).length > 0 ? { plugins } : {}), ...(params.channels ? { channels: params.channels } : {}), }; } function expectRemainingChannels( channels: OpenClawConfig["channels"], expected: Record | undefined, ) { expect(channels as Record | undefined).toEqual(expected); } function expectChannelCleanupResult(params: { config: OpenClawConfig; pluginId: string; expectedChannels: Record | undefined; expectedChanged: boolean; options?: { channelIds?: readonly string[] }; }) { const { config: result, actions } = removePluginFromConfig( params.config, params.pluginId, params.options ? params.options.channelIds ? { channelIds: [...params.options.channelIds] } : {} : undefined, ); expectRemainingChannels(result.channels, params.expectedChannels); expect(actions.channelConfig).toBe(params.expectedChanged); } function createSinglePluginWithEmptySlotsConfig(): OpenClawConfig { return createPluginConfig({ entries: createSinglePluginEntries(), slots: {}, }); } function createSingleNpmInstallConfig(installPath: string): OpenClawConfig { return createPluginConfig({ entries: createSinglePluginEntries(), installs: { "my-plugin": createNpmInstallRecord("my-plugin", installPath), }, }); } async function createPluginDirFixture(baseDir: string, pluginId = "my-plugin") { const pluginDir = path.join(baseDir, pluginId); await fs.mkdir(pluginDir, { recursive: true }); await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin"); return pluginDir; } async function expectPathAccessState(pathToCheck: string, expected: "exists" | "missing") { const accessExpectation = fs.access(pathToCheck); if (expected === "exists") { await expect(accessExpectation).resolves.toBeUndefined(); return; } await expect(accessExpectation).rejects.toThrow(); } describe("resolveUninstallChannelConfigKeys", () => { it("falls back to pluginId when channelIds are unknown", () => { expect(resolveUninstallChannelConfigKeys("timbot")).toEqual(["timbot"]); }); it("keeps explicit empty channelIds as remove-nothing", () => { expect(resolveUninstallChannelConfigKeys("telegram", { channelIds: [] })).toEqual([]); }); it("filters shared keys and duplicate channel ids", () => { expect( resolveUninstallChannelConfigKeys("bad-plugin", { channelIds: ["defaults", "discord", "discord", "modelByChannel", "slack"], }), ).toEqual(["discord", "slack"]); }); }); describe("removePluginFromConfig", () => { it("removes plugin from entries", () => { const config = createPluginConfig({ entries: { ...createSinglePluginEntries(), "other-plugin": { enabled: true }, }, }); const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); expect(result.plugins?.entries).toEqual({ "other-plugin": { enabled: true } }); expect(actions.entry).toBe(true); }); it("removes plugin from installs", () => { const config = createPluginConfig({ installs: { "my-plugin": createNpmInstallRecord(), "other-plugin": createNpmInstallRecord("other-plugin"), }, }); const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); expect(result.plugins?.installs).toEqual({ "other-plugin": createNpmInstallRecord("other-plugin"), }); expect(actions.install).toBe(true); }); it("removes plugin from allowlist", () => { const config = createPluginConfig({ allow: ["my-plugin", "other-plugin"], }); const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); expect(result.plugins?.allow).toEqual(["other-plugin"]); expect(actions.allowlist).toBe(true); }); it.each([ { name: "removes linked path from load.paths", loadPaths: ["/path/to/plugin", "/other/path"], expectedPaths: ["/other/path"], }, { name: "cleans up load when removing the only linked path", loadPaths: ["/path/to/plugin"], expectedPaths: undefined, }, ])("$name", ({ loadPaths, expectedPaths }) => { const config = createPluginConfig({ installs: { "my-plugin": createPathInstallRecord(), }, loadPaths, }); const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); expect(result.plugins?.load?.paths).toEqual(expectedPaths); expect(actions.loadPath).toBe(true); }); it.each([ { name: "clears memory slot when uninstalling active memory plugin", config: createPluginConfig({ entries: { "memory-plugin": { enabled: true }, }, slots: { memory: "memory-plugin", }, }), pluginId: "memory-plugin", expectedMemory: "memory-core", expectedChanged: true, }, { name: "does not modify memory slot when uninstalling non-memory plugin", config: createPluginConfig({ entries: createSinglePluginEntries(), slots: { memory: "memory-core", }, }), pluginId: "my-plugin", expectedMemory: "memory-core", expectedChanged: false, }, ] as const)("$name", ({ config, pluginId, expectedMemory, expectedChanged }) => { const { config: result, actions } = removePluginFromConfig(config, pluginId); expect(result.plugins?.slots?.memory).toBe(expectedMemory); expect(actions.memorySlot).toBe(expectedChanged); }); it("removes plugins object when uninstall leaves only empty slots", () => { const config = createSinglePluginWithEmptySlotsConfig(); const { config: result } = removePluginFromConfig(config, "my-plugin"); expect(result.plugins?.slots).toBeUndefined(); }); it("cleans up empty slots object", () => { const config = createSinglePluginWithEmptySlotsConfig(); const { config: result } = removePluginFromConfig(config, "my-plugin"); expect(result.plugins).toBeUndefined(); }); it.each([ { name: "handles plugin that only exists in entries", config: createPluginConfig({ entries: createSinglePluginEntries(), }), expectedEntries: undefined, expectedInstalls: undefined, entryChanged: true, installChanged: false, }, { name: "handles plugin that only exists in installs", config: createPluginConfig({ installs: { "my-plugin": createNpmInstallRecord(), }, }), expectedEntries: undefined, expectedInstalls: undefined, entryChanged: false, installChanged: true, }, ])("$name", ({ config, expectedEntries, expectedInstalls, entryChanged, installChanged }) => { const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); expect(result.plugins?.entries).toEqual(expectedEntries); expect(result.plugins?.installs).toEqual(expectedInstalls); expect(actions.entry).toBe(entryChanged); expect(actions.install).toBe(installChanged); }); it("cleans up empty plugins object", () => { const config = createPluginConfig({ entries: createSinglePluginEntries(), }); const { config: result } = removePluginFromConfig(config, "my-plugin"); expect(result.plugins?.entries).toBeUndefined(); }); it("preserves other config values", () => { const config = createPluginConfig({ enabled: true, deny: ["denied-plugin"], entries: createSinglePluginEntries(), }); const { config: result } = removePluginFromConfig(config, "my-plugin"); expect(result.plugins?.enabled).toBe(true); expect(result.plugins?.deny).toEqual(["denied-plugin"]); }); it.each([ { name: "removes channel config for installed extension plugin", config: createPluginConfig({ entries: { timbot: { enabled: true }, }, installs: { timbot: createNpmInstallRecord("timbot"), }, channels: { timbot: { sdkAppId: "123", secretKey: "abc" }, telegram: { enabled: true }, }, }), pluginId: "timbot", expectedChannels: { telegram: { enabled: true }, }, expectedChanged: true, }, { name: "does not remove channel config for built-in channel without install record", config: createPluginConfig({ entries: { telegram: { enabled: true }, }, channels: { telegram: { enabled: true }, discord: { enabled: true }, }, }), pluginId: "telegram", expectedChannels: { telegram: { enabled: true }, discord: { enabled: true }, }, expectedChanged: false, }, { name: "cleans up channels object when removing the only channel config", config: createPluginConfig({ entries: { timbot: { enabled: true }, }, installs: { timbot: createNpmInstallRecord("timbot"), }, channels: { timbot: { sdkAppId: "123" }, }, }), pluginId: "timbot", expectedChannels: undefined, expectedChanged: true, }, { name: "does not set channelConfig action when no channel config exists", config: createPluginConfig({ entries: createSinglePluginEntries(), installs: { "my-plugin": createNpmInstallRecord(), }, }), pluginId: "my-plugin", expectedChannels: undefined, expectedChanged: false, }, { name: "does not remove channel config when plugin has no install record", config: createPluginConfig({ entries: { discord: { enabled: true }, }, channels: { discord: { enabled: true, token: "abc" }, }, }), pluginId: "discord", expectedChannels: { discord: { enabled: true, token: "abc", }, }, expectedChanged: false, }, { name: "removes channel config using explicit channelIds when pluginId differs", config: createPluginConfig({ entries: { "timbot-plugin": { enabled: true }, }, installs: { "timbot-plugin": createNpmInstallRecord("timbot-plugin"), }, channels: { timbot: { sdkAppId: "123" }, "timbot-v2": { sdkAppId: "456" }, telegram: { enabled: true }, }, }), pluginId: "timbot-plugin", options: { channelIds: ["timbot", "timbot-v2"], }, expectedChannels: { telegram: { enabled: true }, }, expectedChanged: true, }, { name: "preserves shared channel keys (defaults, modelByChannel)", config: createPluginConfig({ entries: { timbot: { enabled: true }, }, installs: { timbot: createNpmInstallRecord("timbot"), }, channels: { defaults: { groupPolicy: "opt-in" }, modelByChannel: { timbot: "gpt-3.5" } as Record, timbot: { sdkAppId: "123" }, } as unknown as OpenClawConfig["channels"], }), pluginId: "timbot", expectedChannels: { defaults: { groupPolicy: "opt-in" }, modelByChannel: { timbot: "gpt-3.5" }, }, expectedChanged: true, }, { name: "does not remove shared keys even when passed as channelIds", config: createPluginConfig({ entries: { "bad-plugin": { enabled: true }, }, installs: { "bad-plugin": createNpmInstallRecord("bad-plugin"), }, channels: { defaults: { groupPolicy: "opt-in" }, } as unknown as OpenClawConfig["channels"], }), pluginId: "bad-plugin", options: { channelIds: ["defaults"], }, expectedChannels: { defaults: { groupPolicy: "opt-in" }, }, expectedChanged: false, }, { name: "skips channel cleanup when channelIds is empty array (non-channel plugin)", config: createPluginConfig({ entries: { telegram: { enabled: true }, }, installs: { telegram: createNpmInstallRecord("telegram"), }, channels: { telegram: { enabled: true }, }, }), pluginId: "telegram", options: { channelIds: [], }, expectedChannels: { telegram: { enabled: true }, }, expectedChanged: false, }, ] as const)("$name", ({ config, pluginId, expectedChannels, expectedChanged, options }) => { expectChannelCleanupResult({ config, pluginId, expectedChannels, expectedChanged, options, }); }); }); describe("uninstallPlugin", () => { let tempDir: string; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "uninstall-test-")); }); afterEach(async () => { await fs.rm(tempDir, { recursive: true, force: true }); }); it("returns error when plugin not found", async () => { const config = createPluginConfig({}); const result = await uninstallPlugin({ config, pluginId: "nonexistent", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toBe("Plugin not found: nonexistent"); } }); it("removes config entries", async () => { const config = createPluginConfig({ entries: createSinglePluginEntries(), installs: { "my-plugin": createNpmInstallRecord(), }, }); const result = await uninstallPlugin({ config, pluginId: "my-plugin", deleteFiles: false, }); const successfulResult = expectSuccessfulUninstall(result); expect(successfulResult.config.plugins?.entries).toBeUndefined(); expect(successfulResult.config.plugins?.installs).toBeUndefined(); expect(successfulResult.actions.entry).toBe(true); expect(successfulResult.actions.install).toBe(true); }); it("deletes directory when deleteFiles is true", async () => { const { pluginDir, result } = await runDeleteInstalledNpmPluginFixture(tempDir); try { expectSuccessfulUninstallActions(result, { directory: true, }); await expect(fs.access(pluginDir)).rejects.toThrow(); } finally { await fs.rm(pluginDir, { recursive: true, force: true }); } }); it.each([ { name: "preserves directory for linked plugins", setup: async (baseDir: string) => { const pluginDir = await createPluginDirFixture(baseDir); return { config: createPluginConfig({ entries: createSinglePluginEntries(), installs: { "my-plugin": createPathInstallRecord(pluginDir), }, loadPaths: [pluginDir], }), deleteFiles: true, accessPath: pluginDir, expectedAccess: "exists" as const, expectedActions: { directory: false, loadPath: true, }, }; }, }, { name: "does not delete directory when deleteFiles is false", setup: async (baseDir: string) => { const pluginDir = await createPluginDirFixture(baseDir); return { config: createSingleNpmInstallConfig(pluginDir), deleteFiles: false, accessPath: pluginDir, expectedAccess: "exists" as const, expectedActions: { directory: false, }, }; }, }, { name: "succeeds even if directory does not exist", setup: async () => ({ config: createSingleNpmInstallConfig("/nonexistent/path"), deleteFiles: true, expectedActions: { directory: false, warnings: [], }, }), }, ] as const)("$name", async ({ setup }) => { const params = await setup(tempDir); const result = await uninstallPlugin({ config: params.config, pluginId: "my-plugin", deleteFiles: params.deleteFiles, }); expectSuccessfulUninstallActions(result, params.expectedActions); if ("accessPath" in params && "expectedAccess" in params) { await expectPathAccessState(params.accessPath, params.expectedAccess); } }); it("returns a warning when directory deletion fails unexpectedly", async () => { const rmSpy = vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("permission denied")); try { const { result } = await runDeleteInstalledNpmPluginFixture(tempDir); const successfulResult = expectSuccessfulUninstallActions(result, { directory: false, }); expect(successfulResult.warnings).toHaveLength(1); expect(successfulResult.warnings[0]).toContain("Failed to remove plugin directory"); } finally { rmSpy.mockRestore(); } }); it("never deletes arbitrary configured install paths", async () => { const outsideDir = path.join(tempDir, "outside-dir"); const extensionsDir = path.join(tempDir, "extensions"); await fs.mkdir(outsideDir, { recursive: true }); await fs.writeFile(path.join(outsideDir, "index.js"), "// keep me"); const config = createSingleNpmInstallConfig(outsideDir); const result = await uninstallPlugin({ config, pluginId: "my-plugin", deleteFiles: true, extensionsDir, }); expectSuccessfulUninstallActions(result, { directory: false, }); await expect(fs.access(outsideDir)).resolves.toBeUndefined(); }); }); describe("resolveUninstallDirectoryTarget", () => { it("returns null for linked plugins", () => { expect( resolveUninstallDirectoryTarget({ pluginId: "my-plugin", hasInstall: true, installRecord: { source: "path", sourcePath: "/tmp/my-plugin", installPath: "/tmp/my-plugin", }, }), ).toBeNull(); }); it("falls back to default path when configured installPath is untrusted", () => { const extensionsDir = path.join(os.tmpdir(), "openclaw-uninstall-safe"); const target = resolveUninstallDirectoryTarget({ pluginId: "my-plugin", hasInstall: true, installRecord: { source: "npm", spec: "my-plugin@1.0.0", installPath: "/tmp/not-openclaw-plugin-install/my-plugin", }, extensionsDir, }); expect(target).toBe(resolvePluginInstallDir("my-plugin", extensionsDir)); }); });