diff --git a/CHANGELOG.md b/CHANGELOG.md index c0e7c7855f4..9ed4a972275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,7 @@ Docs: https://docs.openclaw.ai - Image understanding: preserve configured provider-prefixed vision model metadata when callers request the model without the provider prefix, so custom image models keep their `input: ["text", "image"]` capability. Fixes #33185. Thanks @Kobe9312 and @vincentkoc. - Plugins/install: restore the previous plugin index records if a concurrent config write conflict interrupts install, update, or uninstall metadata commits. Thanks @shakkernerd. - Plugins/install: reject native plugin archives that do not include a valid `openclaw.plugin.json`, preventing manifestless archives from writing install records that later show missing-manifest diagnostics. Thanks @shakkernerd. +- Plugins/uninstall: remove tracked managed plugin install directories even when the persisted install path differs from the default id-derived target, while still refusing deletes outside the managed extensions root. Thanks @shakkernerd. - Plugins/update: restore previous plugin index records if core update or channel setup hits a concurrent config write conflict after plugin metadata changes. Thanks @shakkernerd. - Plugins/onboarding: defer channel/provider plugin install records until the owning config write commits, keeping setup failures from advancing the plugin index ahead of `openclaw.json`. Thanks @shakkernerd. - Plugins/config: route configure and agent setup writes with pending plugin install records through the plugin index commit helper so provider onboarding metadata is not stripped by plain config writes. Thanks @shakkernerd. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index a15947b032c..5a91d77cc13 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -261,13 +261,10 @@ openclaw plugins uninstall --keep-files `uninstall` removes plugin records from `plugins.entries`, the persisted plugin index, the plugin allowlist, and linked `plugins.load.paths` entries when -applicable. +applicable. Unless `--keep-files` is set, uninstall also removes the tracked +managed install directory when it is inside OpenClaw's plugin extensions root. For active memory plugins, the memory slot resets to `memory-core`. -By default, uninstall also removes the plugin install directory under the active -state-dir plugin root. Use -`--keep-files` to keep files on disk. - `--keep-config` is supported as a deprecated alias for `--keep-files`. ### Update diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index 509c92e52ff..6a9034802f8 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -782,6 +782,25 @@ describe("uninstallPlugin", () => { }); await expect(fs.access(outsideDir)).resolves.toBeUndefined(); }); + + it("deletes tracked managed install paths even when they are not the default target", async () => { + const extensionsDir = path.join(tempDir, "extensions"); + const managedDir = path.join(extensionsDir, "archive-installs", "my-plugin"); + await fs.mkdir(managedDir, { recursive: true }); + await fs.writeFile(path.join(managedDir, "index.js"), "// plugin"); + + const result = await uninstallPlugin({ + config: createSingleNpmInstallConfig(managedDir), + pluginId: "my-plugin", + deleteFiles: true, + extensionsDir, + }); + + expectSuccessfulUninstallActions(result, { + directory: true, + }); + await expect(fs.access(managedDir)).rejects.toThrow(); + }); }); describe("resolveUninstallDirectoryTarget", () => { @@ -814,4 +833,22 @@ describe("resolveUninstallDirectoryTarget", () => { expect(target).toBe(resolvePluginInstallDir("my-plugin", extensionsDir)); }); + + it("uses configured installPath when it stays inside the managed extensions dir", () => { + const extensionsDir = path.join(os.tmpdir(), "openclaw-uninstall-safe"); + const installPath = path.join(extensionsDir, "archive-installs", "my-plugin"); + + expect( + resolveUninstallDirectoryTarget({ + pluginId: "my-plugin", + hasInstall: true, + installRecord: { + source: "archive", + sourcePath: "/tmp/my-plugin.zip", + installPath, + }, + extensionsDir, + }), + ).toBe(installPath); + }); }); diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index b59509b69bc..26a50f2e3b0 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -57,7 +57,12 @@ export function resolveUninstallDirectoryTarget(params: { return configuredPath; } - // Never trust configured installPath blindly for recursive deletes. + if (params.extensionsDir && isPathInsideOrEqual(params.extensionsDir, configuredPath)) { + return configuredPath; + } + + // Never trust configured installPath blindly for recursive deletes outside + // the managed extensions directory. return defaultPath; } @@ -101,6 +106,11 @@ function resolveComparablePath(value: string): string { } } +function isPathInsideOrEqual(parent: string, child: string): boolean { + const relative = path.relative(resolveComparablePath(parent), resolveComparablePath(child)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + /** * Remove plugin references from config (pure config mutation). * Returns a new config with the plugin removed from entries, installs, allow, load.paths, slots,