diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index 6a9034802f8..dae137d81c6 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -801,6 +801,26 @@ describe("uninstallPlugin", () => { }); await expect(fs.access(managedDir)).rejects.toThrow(); }); + + it("deletes tracked installs from a recorded managed extensions root", async () => { + const currentExtensionsDir = path.join(tempDir, "current", "extensions"); + const recordedExtensionsDir = path.join(tempDir, "recorded", "extensions"); + const installPath = resolvePluginInstallDir("my-plugin", recordedExtensionsDir); + await fs.mkdir(installPath, { recursive: true }); + await fs.writeFile(path.join(installPath, "index.js"), "// plugin"); + + const result = await uninstallPlugin({ + config: createSingleNpmInstallConfig(installPath), + pluginId: "my-plugin", + deleteFiles: true, + extensionsDir: currentExtensionsDir, + }); + + expectSuccessfulUninstallActions(result, { + directory: true, + }); + await expect(fs.access(installPath)).rejects.toThrow(); + }); }); describe("resolveUninstallDirectoryTarget", () => { @@ -851,4 +871,27 @@ describe("resolveUninstallDirectoryTarget", () => { }), ).toBe(installPath); }); + + it("uses configured installPath when it is under the recorded managed extensions root", () => { + const currentExtensionsDir = path.join(os.tmpdir(), "openclaw-uninstall-current", "extensions"); + const recordedExtensionsDir = path.join( + os.tmpdir(), + "openclaw-uninstall-recorded", + "extensions", + ); + const installPath = resolvePluginInstallDir("my-plugin", recordedExtensionsDir); + + expect( + resolveUninstallDirectoryTarget({ + pluginId: "my-plugin", + hasInstall: true, + installRecord: { + source: "npm", + spec: "my-plugin@1.0.0", + installPath, + }, + extensionsDir: currentExtensionsDir, + }), + ).toBe(installPath); + }); }); diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index 26a50f2e3b0..6badad6e019 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -61,11 +61,39 @@ export function resolveUninstallDirectoryTarget(params: { return configuredPath; } + const recordedManagedPath = resolveRecordedManagedInstallPath({ + pluginId: params.pluginId, + installPath: configuredPath, + }); + if (recordedManagedPath) { + return recordedManagedPath; + } + // Never trust configured installPath blindly for recursive deletes outside // the managed extensions directory. return defaultPath; } +function resolveRecordedManagedInstallPath(params: { + pluginId: string; + installPath: string; +}): string | null { + const resolvedInstallPath = path.resolve(params.installPath); + const recordedExtensionsDir = path.dirname(resolvedInstallPath); + if (path.basename(recordedExtensionsDir) !== "extensions") { + return null; + } + + try { + const canonicalInstallPath = path.resolve( + resolvePluginInstallDir(params.pluginId, recordedExtensionsDir), + ); + return canonicalInstallPath === resolvedInstallPath ? params.installPath : null; + } catch { + return null; + } +} + const SHARED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); /**