diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b0095d158..84f54fbec73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Google Chat: normalize custom Google auth transport headers before google-auth/gaxios interceptors run, restoring webhook token verification when certificate retrieval expects Fetch `Headers`. Fixes #76742. Thanks @donbowman. +- Doctor/plugins: reset stale `plugins.slots.memory` and `plugins.slots.contextEngine` references during `doctor --fix`, so cleanup of missing plugin config does not leave unrecoverable slot owners behind. Fixes #76550 and #76551. Thanks @vincentkoc. - Docs/WhatsApp: merge the duplicate top-level `web` objects in the gateway channel config example so copy-pasted WhatsApp config keeps both `web.whatsapp` and reconnect settings. Fixes #76619. Thanks @WadydX. - Plugins/Anthropic: expose Claude thinking profiles from the bundled provider-policy artifact so non-runtime callers keep Opus 4.7 `adaptive`, `xhigh`, and `max` instead of downgrading to `high`. Fixes #76779. Thanks @tomascupr and @iAbhi001. - Discord/native commands: skip slash-command registration and cleanup REST calls when `channels.discord.commands.native=false`, letting low-power gateways start without waiting on disabled native-command lifecycle requests. Fixes #76202. Thanks @vincentkoc. diff --git a/src/commands/doctor/shared/stale-plugin-config.test.ts b/src/commands/doctor/shared/stale-plugin-config.test.ts index 200070e6046..2205d96828e 100644 --- a/src/commands/doctor/shared/stale-plugin-config.test.ts +++ b/src/commands/doctor/shared/stale-plugin-config.test.ts @@ -96,6 +96,56 @@ describe("doctor stale plugin config helpers", () => { }); }); + it("resets stale plugin slots without changing valid slot sentinels", () => { + const cfg = { + plugins: { + slots: { + memory: "acpx", + contextEngine: "missing-engine", + }, + }, + } as OpenClawConfig; + + const hits = scanStalePluginConfig(cfg); + expect(hits).toEqual([ + { + pluginId: "acpx", + pathLabel: "plugins.slots.memory", + surface: "slot", + slotKey: "memory", + }, + { + pluginId: "missing-engine", + pathLabel: "plugins.slots.contextEngine", + surface: "slot", + slotKey: "contextEngine", + }, + ]); + + const result = maybeRepairStalePluginConfig(cfg); + + expect(result.changes).toEqual([ + "- plugins.slots: reset 2 stale plugin slots (memory: acpx -> memory-core, contextEngine: missing-engine -> legacy)", + ]); + expect(result.config.plugins?.slots).toEqual({ + memory: "memory-core", + contextEngine: "legacy", + }); + }); + + it("does not report slot defaults or none as stale plugin refs", () => { + expect( + scanStalePluginConfig({ + plugins: { + slots: { + memory: "none", + contextEngine: "legacy", + }, + }, + } as OpenClawConfig), + ).toEqual([]); + }); + it("formats stale plugin warnings with a doctor hint", () => { const warnings = collectStalePluginConfigWarnings({ hits: [ diff --git a/src/commands/doctor/shared/stale-plugin-config.ts b/src/commands/doctor/shared/stale-plugin-config.ts index cc9d30acc00..ed40921b0ec 100644 --- a/src/commands/doctor/shared/stale-plugin-config.ts +++ b/src/commands/doctor/shared/stale-plugin-config.ts @@ -4,17 +4,19 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { normalizePluginId } from "../../../plugins/config-state.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "../../../plugins/installed-plugin-index-records.js"; import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js"; +import { defaultSlotIdForKey, type PluginSlotKey } from "../../../plugins/slots.js"; import { sanitizeForLog } from "../../../terminal/ansi.js"; import { asObjectRecord } from "./object.js"; const CHANNEL_CONFIG_META_KEYS = new Set(["defaults", "modelByChannel"]); -type StalePluginSurface = "allow" | "entries" | "channel" | "heartbeat" | "modelByChannel"; +type StalePluginSurface = "allow" | "entries" | "slot" | "channel" | "heartbeat" | "modelByChannel"; type StalePluginConfigHit = { pluginId: string; pathLabel: string; surface: StalePluginSurface; + slotKey?: PluginSlotKey; }; type StalePluginRegistryState = { @@ -131,6 +133,32 @@ function scanStalePluginConfigWithState( } } + const slots = asObjectRecord(plugins?.slots); + if (slots) { + for (const slotKey of ["memory", "contextEngine"] as const satisfies readonly PluginSlotKey[]) { + const rawPluginId = slots[slotKey]; + if (typeof rawPluginId !== "string") { + continue; + } + const pluginId = normalizePluginId(rawPluginId); + const defaultSlotId = defaultSlotIdForKey(slotKey); + if ( + !pluginId || + rawPluginId.trim().toLowerCase() === "none" || + pluginId === normalizePluginId(defaultSlotId) || + knownIds.has(pluginId) + ) { + continue; + } + hits.push({ + pluginId: rawPluginId, + pathLabel: `plugins.slots.${slotKey}`, + surface: "slot", + slotKey, + }); + } + } + const staleChannelIds = collectDanglingChannelIds({ cfg, registryState, @@ -236,6 +264,9 @@ function formatStalePluginHitWarning(hit: StalePluginConfigHit): string { if (hit.surface === "allow" || hit.surface === "entries") { return `- ${hit.pathLabel}: stale plugin reference "${hit.pluginId}" was found.`; } + if (hit.surface === "slot") { + return `- ${hit.pathLabel}: slot references missing plugin "${hit.pluginId}".`; + } if (hit.surface === "channel") { return `- ${hit.pathLabel}: dangling channel config for missing plugin "${hit.pluginId}" was found.`; } @@ -310,6 +341,19 @@ export function maybeRepairStalePluginConfig( } } + const slotHits = hits.filter( + (hit): hit is StalePluginConfigHit & { slotKey: PluginSlotKey } => + hit.surface === "slot" && hit.slotKey !== undefined, + ); + if (slotHits.length > 0) { + const slots = asObjectRecord(nextPlugins?.slots); + if (slots) { + for (const hit of slotHits) { + slots[hit.slotKey] = defaultSlotIdForKey(hit.slotKey); + } + } + } + const channelIds = hits.filter((hit) => hit.surface === "channel").map((hit) => hit.pluginId); if (channelIds.length > 0) { removeDanglingChannelReferences(next, channelIds); @@ -326,6 +370,11 @@ export function maybeRepairStalePluginConfig( `- plugins.entries: removed ${entryIds.length} stale plugin entr${entryIds.length === 1 ? "y" : "ies"} (${entryIds.join(", ")})`, ); } + if (slotHits.length > 0) { + changes.push( + `- plugins.slots: reset ${slotHits.length} stale plugin slot${slotHits.length === 1 ? "" : "s"} (${slotHits.map((hit) => `${hit.slotKey}: ${hit.pluginId} -> ${defaultSlotIdForKey(hit.slotKey)}`).join(", ")})`, + ); + } if (channelIds.length > 0) { changes.push( `- channels: removed ${channelIds.length} stale channel config${channelIds.length === 1 ? "" : "s"} (${channelIds.join(", ")})`,