fix(doctor): reset stale plugin slots

This commit is contained in:
Vincent Koc
2026-05-03 11:33:13 -07:00
parent 9d5fedb9b5
commit 4e0e6f8ef3
3 changed files with 101 additions and 1 deletions

View File

@@ -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.

View File

@@ -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: [

View File

@@ -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(", ")})`,