From 5214f16e295910880ea5cc29d2fbe31ca866f256 Mon Sep 17 00:00:00 2001 From: JARVIS-Glasses Date: Wed, 13 May 2026 21:12:46 +0200 Subject: [PATCH] fix(update): clear stale plugin refs after failed updates --- src/plugins/update.test.ts | 58 ++++++++++++++++++++++++++++++++++++++ src/plugins/update.ts | 45 +++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index b109a1ad9b1..416979aa2a8 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -1729,6 +1729,64 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("clears stale plugin policy and slot references when disabling failed updates", async () => { + const warn = vi.fn(); + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: false, + error: "security scan blocked install", + }); + const config = { + plugins: { + allow: ["demo", "keep"], + deny: ["demo", "blocked"], + slots: { + memory: "demo", + contextEngine: "demo", + }, + entries: { + demo: { + enabled: true, + }, + }, + installs: { + demo: { + source: "npm" as const, + spec: "@acme/demo", + installPath: "/tmp/demo", + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await updateNpmInstalledPlugins({ + config, + disableOnFailure: true, + logger: { warn }, + }); + + const message = + 'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: security scan blocked install'; + expect(warn).toHaveBeenCalledWith(message); + expect(result.changed).toBe(true); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: false, + }); + expect(result.config.plugins?.installs?.demo).toEqual(config.plugins.installs.demo); + expect(result.config.plugins?.allow).toEqual(["keep"]); + expect(result.config.plugins?.deny).toEqual(["blocked"]); + expect(result.config.plugins?.slots).toEqual({ + memory: "memory-core", + contextEngine: "legacy", + }); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + message, + }, + ]); + }); + it("aborts exact pinned npm plugin updates on integrity drift by default", async () => { const warn = vi.fn(); installPluginFromNpmSpecMock.mockImplementation( diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 69cba96c4df..3a43a34d51c 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -48,6 +48,7 @@ import { resolveOfficialExternalPluginInstall, } from "./official-external-plugin-catalog.js"; import { linkOpenClawPeerDependencies } from "./plugin-peer-link.js"; +import { defaultSlotIdForKey } from "./slots.js"; export type PluginUpdateLogger = { info?: (message: string) => void; @@ -760,14 +761,52 @@ function createPluginUpdateIntegrityDriftHandler(params: { }; } +function removeDisabledPluginIdFromList( + list: string[] | undefined, + pluginId: string, +): string[] | undefined { + if (!Array.isArray(list) || !list.includes(pluginId)) { + return list; + } + const next = list.filter((id) => id !== pluginId); + return next.length > 0 ? next : undefined; +} + +function resetDisabledPluginSlots( + slots: NonNullable["slots"] | undefined, + pluginId: string, +): NonNullable["slots"] | undefined { + if (!slots) { + return slots; + } + let next = slots; + if (next.memory === pluginId) { + next = { + ...next, + memory: defaultSlotIdForKey("memory"), + }; + } + if (next.contextEngine === pluginId) { + next = { + ...next, + contextEngine: defaultSlotIdForKey("contextEngine"), + }; + } + return next; +} + function disablePluginConfigEntry(config: OpenClawConfig, pluginId: string): OpenClawConfig { - const existingEntry = config.plugins?.entries?.[pluginId]; + const pluginsConfig = config.plugins ?? {}; + const existingEntry = pluginsConfig.entries?.[pluginId]; return { ...config, plugins: { - ...config.plugins, + ...pluginsConfig, + allow: removeDisabledPluginIdFromList(pluginsConfig.allow, pluginId), + deny: removeDisabledPluginIdFromList(pluginsConfig.deny, pluginId), + slots: resetDisabledPluginSlots(pluginsConfig.slots, pluginId), entries: { - ...config.plugins?.entries, + ...pluginsConfig.entries, [pluginId]: { ...existingEntry, enabled: false,