mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix: clean stale plugin channel config
This commit is contained in:
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Memory-core: re-resolve the active runtime config whenever `memory_search` or `memory_get` executes, so provider changes made by `config.patch` stop leaving stale embedding backends behind in existing tool instances. Fixes #61098. Thanks @BradGroux and @Linux2010.
|
||||
- WebChat: keep bare `/new` and `/reset` startup instructions out of visible chat history while preserving `/reset <note>` as user-visible transcript text. Fixes #72369. Thanks @collynes and @haishmg.
|
||||
- CLI/doctor: remove dangling channel config, heartbeat targets, and channel model overrides when stale plugin repair removes a missing channel plugin, preventing Gateway boot loops after failed plugin reinstalls. Fixes #65293. Thanks @yidecode.
|
||||
- Channels/setup: treat bundled channel plugins as already bundled during `channels add` and onboarding, enabling them without writing redundant `plugins.load.paths` entries or path install records. Fixes #72740. Thanks @iCodePoet.
|
||||
- WhatsApp: honor gateway `HTTPS_PROXY` / `HTTP_PROXY` env vars for QR-login WebSocket connections, while respecting `NO_PROXY`, so proxied networks no longer fall back to direct `mmg.whatsapp.net` connections that time out with 408. Fixes #72547; supersedes #72692. Thanks @mebusw and @SymbolStar.
|
||||
- Bonjour: default mDNS advertisements to the system hostname when it is DNS-safe, avoiding `openclaw.local` probing conflicts and Gateway restart loops on hosts such as `Lobster` or `ubuntu`. Fixes #72355 and #72689; supersedes #72694. Thanks @mscheuerlein-bot, @gcusms, @moyuwuhen601, @pavan987, @zml-0912, @hhq365, and @SymbolStar.
|
||||
|
||||
@@ -44,6 +44,7 @@ Notes:
|
||||
- State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.<timestamp>` to reclaim space safely.
|
||||
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
|
||||
- Doctor repairs missing bundled plugin runtime dependencies without writing into packaged global installs. For root-owned npm installs or hardened systemd units, set `OPENCLAW_PLUGIN_STAGE_DIR` to a writable directory such as `/var/lib/openclaw/plugin-runtime-deps`; it can also be a path-list such as `/opt/openclaw/plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps`, where earlier roots are read-only lookup layers and the final root is the repair target.
|
||||
- Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy.
|
||||
- Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup.
|
||||
- Doctor auto-migrates legacy flat Talk config (`talk.voiceId`, `talk.modelId`, and friends) into `talk.provider` + `talk.providers.<provider>`.
|
||||
- Repeat `doctor --fix` runs no longer report/apply Talk normalization when the only difference is object key order.
|
||||
|
||||
@@ -99,10 +99,18 @@ vi.mock("./channel-plugin-blockers.js", () => ({
|
||||
vi.mock("./stale-plugin-config.js", () => ({
|
||||
scanStalePluginConfig: (cfg: {
|
||||
plugins?: { allow?: string[]; entries?: Record<string, unknown> };
|
||||
channels?: Record<string, unknown>;
|
||||
}) => {
|
||||
const knownIds = new Set(manifestState.plugins.map((plugin) => plugin.id));
|
||||
const ids = [...(cfg.plugins?.allow ?? []), ...Object.keys(cfg.plugins?.entries ?? {})];
|
||||
return [...new Set(ids)].filter((id) => !knownIds.has(id)).map((id) => ({ id }));
|
||||
const hits = [...(cfg.plugins?.allow ?? []), ...Object.keys(cfg.plugins?.entries ?? {})]
|
||||
.filter((id) => !knownIds.has(id))
|
||||
.map((id) => ({ id, surface: "plugin" }));
|
||||
if (cfg.channels?.["openclaw-weixin"]) {
|
||||
hits.push({ id: "openclaw-weixin", surface: "channel" });
|
||||
}
|
||||
return hits.filter(
|
||||
(hit, index) => hits.findIndex((candidate) => candidate.id === hit.id) === index,
|
||||
);
|
||||
},
|
||||
isStalePluginAutoRepairBlocked: () =>
|
||||
manifestState.diagnostics.some((diagnostic) => diagnostic.level === "error"),
|
||||
@@ -113,16 +121,19 @@ vi.mock("./stale-plugin-config.js", () => ({
|
||||
}: {
|
||||
autoRepairBlocked: boolean;
|
||||
doctorFixCommand: string;
|
||||
hits: Array<{ id: string }>;
|
||||
hits: Array<{ id: string; surface: string }>;
|
||||
}) =>
|
||||
hits.map(
|
||||
(hit) =>
|
||||
`plugins.allow: stale plugin reference "${hit.id}". plugins.entries.${hit.id} is unused. ${
|
||||
autoRepairBlocked
|
||||
? `Auto-removal is paused; rerun "${doctorFixCommand}".`
|
||||
: `Run "${doctorFixCommand}".`
|
||||
}`,
|
||||
),
|
||||
hits.map((hit) => {
|
||||
const prefix =
|
||||
hit.surface === "channel"
|
||||
? `channels.${hit.id}: dangling channel config.`
|
||||
: `plugins.allow: stale plugin reference "${hit.id}". plugins.entries.${hit.id} is unused.`;
|
||||
return `${prefix} ${
|
||||
autoRepairBlocked
|
||||
? `Auto-removal is paused; rerun "${doctorFixCommand}".`
|
||||
: `Run "${doctorFixCommand}".`
|
||||
}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./bundled-plugin-load-paths.js", () => ({
|
||||
@@ -239,6 +250,23 @@ describe("doctor preview warnings", () => {
|
||||
expect(warnings[0]).not.toContain("Auto-removal is paused");
|
||||
});
|
||||
|
||||
it("includes stale channel config warnings without plugin config", async () => {
|
||||
const warnings = await collectDoctorPreviewWarnings({
|
||||
cfg: {
|
||||
channels: {
|
||||
"openclaw-weixin": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
});
|
||||
|
||||
expect(warnings).toEqual([
|
||||
expect.stringContaining("channels.openclaw-weixin: dangling channel config"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes bundled plugin load path migration warnings", async () => {
|
||||
const packageRoot = path.resolve("app-node-modules", "openclaw");
|
||||
const legacyPath = path.join(packageRoot, "extensions", "feishu");
|
||||
|
||||
@@ -123,7 +123,7 @@ export async function collectDoctorPreviewWarnings(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPluginConfig) {
|
||||
if (hasPluginConfig || hasChannelConfig) {
|
||||
const {
|
||||
collectStalePluginConfigWarnings,
|
||||
isStalePluginAutoRepairBlocked,
|
||||
@@ -139,7 +139,9 @@ export async function collectDoctorPreviewWarnings(params: {
|
||||
}).join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPluginConfig) {
|
||||
const { collectCodexRouteWarnings } = await import("./codex-route-warnings.js");
|
||||
warnings.push(...collectCodexRouteWarnings({ cfg: params.cfg, env }));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
|
||||
import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js";
|
||||
import * as manifestRegistry from "../../../plugins/manifest-registry.js";
|
||||
import {
|
||||
@@ -8,6 +9,18 @@ import {
|
||||
scanStalePluginConfig,
|
||||
} from "./stale-plugin-config.js";
|
||||
|
||||
const installedPluginIndexMocks = vi.hoisted(() => ({
|
||||
loadInstalledPluginIndexInstallRecordsSync: vi.fn<() => Record<string, PluginInstallRecord>>(
|
||||
() => ({}),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/installed-plugin-index-records.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../../plugins/installed-plugin-index-records.js")>()),
|
||||
loadInstalledPluginIndexInstallRecordsSync:
|
||||
installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync,
|
||||
}));
|
||||
|
||||
function manifest(id: string): PluginManifestRecord {
|
||||
return {
|
||||
id,
|
||||
@@ -25,6 +38,8 @@ function manifest(id: string): PluginManifestRecord {
|
||||
|
||||
describe("doctor stale plugin config helpers", () => {
|
||||
beforeEach(() => {
|
||||
installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReset();
|
||||
installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReturnValue({});
|
||||
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
|
||||
plugins: [manifest("discord"), manifest("voice-call"), manifest("openai")],
|
||||
diagnostics: [],
|
||||
@@ -99,6 +114,113 @@ describe("doctor stale plugin config helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes stale third-party channel config and dependent channel refs", () => {
|
||||
const result = maybeRepairStalePluginConfig({
|
||||
plugins: {
|
||||
allow: ["discord", "openclaw-weixin"],
|
||||
entries: {
|
||||
discord: { enabled: true },
|
||||
"openclaw-weixin": { enabled: true },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
"openclaw-weixin": {
|
||||
enabled: true,
|
||||
token: "stale",
|
||||
},
|
||||
telegram: {
|
||||
botToken: "keep",
|
||||
},
|
||||
modelByChannel: {
|
||||
openai: {
|
||||
"openclaw-weixin": "openai/gpt-5.4",
|
||||
telegram: "openai/gpt-5.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
target: "openclaw-weixin",
|
||||
every: "30m",
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "pi",
|
||||
heartbeat: {
|
||||
target: "openclaw-weixin",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ops",
|
||||
heartbeat: {
|
||||
target: "telegram",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
"- plugins.allow: removed 1 stale plugin id (openclaw-weixin)",
|
||||
"- plugins.entries: removed 1 stale plugin entry (openclaw-weixin)",
|
||||
"- channels: removed 1 stale channel config (openclaw-weixin)",
|
||||
"- agents heartbeat: removed 2 stale heartbeat targets (openclaw-weixin)",
|
||||
"- channels.modelByChannel: removed 1 stale channel model override (openclaw-weixin)",
|
||||
]);
|
||||
expect(result.config.plugins?.allow).toEqual(["discord"]);
|
||||
expect(result.config.plugins?.entries).toEqual({
|
||||
discord: { enabled: true },
|
||||
});
|
||||
expect(result.config.channels?.["openclaw-weixin"]).toBeUndefined();
|
||||
expect(result.config.channels?.telegram).toEqual({ botToken: "keep" });
|
||||
expect(result.config.channels?.modelByChannel).toEqual({
|
||||
openai: {
|
||||
telegram: "openai/gpt-5.4",
|
||||
},
|
||||
});
|
||||
expect(result.config.agents?.defaults?.heartbeat).toEqual({ every: "30m" });
|
||||
expect(result.config.agents?.list?.[0]?.heartbeat).toEqual({});
|
||||
expect(result.config.agents?.list?.[1]?.heartbeat).toEqual({ target: "telegram" });
|
||||
});
|
||||
|
||||
it("does not remove unknown channel config without stale plugin evidence", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegrm: {
|
||||
botToken: "typo",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(scanStalePluginConfig(cfg)).toEqual([]);
|
||||
expect(maybeRepairStalePluginConfig(cfg)).toEqual({ config: cfg, changes: [] });
|
||||
});
|
||||
|
||||
it("uses missing persisted install records as stale channel evidence", () => {
|
||||
installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReturnValue({
|
||||
"openclaw-weixin": {
|
||||
source: "npm",
|
||||
resolvedName: "@tencent-weixin/openclaw-weixin",
|
||||
installedAt: "2026-04-12T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const result = maybeRepairStalePluginConfig({
|
||||
channels: {
|
||||
"openclaw-weixin": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
"- channels: removed 1 stale channel config (openclaw-weixin)",
|
||||
]);
|
||||
expect(result.config.channels?.["openclaw-weixin"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not auto-repair stale refs while plugin discovery has errors", () => {
|
||||
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
|
||||
plugins: [],
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js";
|
||||
import { CHANNEL_IDS } from "../../../channels/ids.js";
|
||||
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 { loadPluginManifestRegistryForPluginRegistry } from "../../../plugins/plugin-registry.js";
|
||||
import { sanitizeForLog } from "../../../terminal/ansi.js";
|
||||
import { asObjectRecord } from "./object.js";
|
||||
|
||||
type StalePluginSurface = "allow" | "entries";
|
||||
const CHANNEL_CONFIG_META_KEYS = new Set(["defaults", "modelByChannel"]);
|
||||
|
||||
type StalePluginSurface = "allow" | "entries" | "channel" | "heartbeat" | "modelByChannel";
|
||||
|
||||
type StalePluginConfigHit = {
|
||||
pluginId: string;
|
||||
@@ -15,6 +19,8 @@ type StalePluginConfigHit = {
|
||||
|
||||
type StalePluginRegistryState = {
|
||||
knownIds: Set<string>;
|
||||
knownChannelIds: Set<string>;
|
||||
missingInstalledIds: Set<string>;
|
||||
hasDiscoveryErrors: boolean;
|
||||
};
|
||||
|
||||
@@ -29,8 +35,37 @@ function collectPluginRegistryState(
|
||||
env,
|
||||
includeDisabled: true,
|
||||
});
|
||||
const knownIds = new Set(registry.plugins.map((plugin) => plugin.id));
|
||||
const installedIds = new Set<string>();
|
||||
for (const pluginId of Object.keys(cfg.plugins?.installs ?? {})) {
|
||||
const normalized = normalizePluginId(pluginId);
|
||||
if (normalized) {
|
||||
installedIds.add(normalized);
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (const pluginId of Object.keys(loadInstalledPluginIndexInstallRecordsSync({ env }))) {
|
||||
const normalized = normalizePluginId(pluginId);
|
||||
if (normalized) {
|
||||
installedIds.add(normalized);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Missing/corrupt install-record state must not block normal doctor scans.
|
||||
}
|
||||
const knownChannelIds = new Set(CHANNEL_IDS.map((channelId) => normalizePluginId(channelId)));
|
||||
for (const plugin of registry.plugins) {
|
||||
for (const channelId of plugin.channels) {
|
||||
const normalized = normalizePluginId(channelId);
|
||||
if (normalized) {
|
||||
knownChannelIds.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
knownIds: new Set(registry.plugins.map((plugin) => plugin.id)),
|
||||
knownIds,
|
||||
knownChannelIds,
|
||||
missingInstalledIds: new Set([...installedIds].filter((pluginId) => !knownIds.has(pluginId))),
|
||||
hasDiscoveryErrors: registry.diagnostics.some((diag) => diag.level === "error"),
|
||||
};
|
||||
}
|
||||
@@ -46,22 +81,19 @@ export function scanStalePluginConfig(
|
||||
cfg: OpenClawConfig,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): StalePluginConfigHit[] {
|
||||
const plugins = asObjectRecord(cfg.plugins);
|
||||
if (!plugins) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return scanStalePluginConfigWithState(plugins, collectPluginRegistryState(cfg, env));
|
||||
return scanStalePluginConfigWithState(cfg, collectPluginRegistryState(cfg, env));
|
||||
}
|
||||
|
||||
function scanStalePluginConfigWithState(
|
||||
plugins: Record<string, unknown>,
|
||||
cfg: OpenClawConfig,
|
||||
registryState: StalePluginRegistryState,
|
||||
): StalePluginConfigHit[] {
|
||||
const plugins = asObjectRecord(cfg.plugins);
|
||||
const { knownIds } = registryState;
|
||||
const hits: StalePluginConfigHit[] = [];
|
||||
const staleEvidenceIds = new Set(registryState.missingInstalledIds);
|
||||
|
||||
const allow = Array.isArray(plugins.allow) ? plugins.allow : [];
|
||||
const allow = Array.isArray(plugins?.allow) ? plugins.allow : [];
|
||||
for (const rawPluginId of allow) {
|
||||
if (typeof rawPluginId !== "string") {
|
||||
continue;
|
||||
@@ -75,26 +107,139 @@ function scanStalePluginConfigWithState(
|
||||
pathLabel: "plugins.allow",
|
||||
surface: "allow",
|
||||
});
|
||||
staleEvidenceIds.add(pluginId);
|
||||
}
|
||||
|
||||
const entries = asObjectRecord(plugins.entries);
|
||||
if (!entries) {
|
||||
return hits;
|
||||
}
|
||||
for (const rawPluginId of Object.keys(entries)) {
|
||||
if (knownIds.has(normalizePluginId(rawPluginId))) {
|
||||
continue;
|
||||
const entries = asObjectRecord(plugins?.entries);
|
||||
if (entries) {
|
||||
for (const rawPluginId of Object.keys(entries)) {
|
||||
const pluginId = normalizePluginId(rawPluginId);
|
||||
if (!pluginId || knownIds.has(pluginId)) {
|
||||
continue;
|
||||
}
|
||||
hits.push({
|
||||
pluginId: rawPluginId,
|
||||
pathLabel: `plugins.entries.${rawPluginId}`,
|
||||
surface: "entries",
|
||||
});
|
||||
staleEvidenceIds.add(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
const staleChannelIds = collectDanglingChannelIds({
|
||||
cfg,
|
||||
registryState,
|
||||
staleEvidenceIds,
|
||||
});
|
||||
for (const channelId of staleChannelIds) {
|
||||
hits.push({
|
||||
pluginId: rawPluginId,
|
||||
pathLabel: `plugins.entries.${rawPluginId}`,
|
||||
surface: "entries",
|
||||
pluginId: channelId,
|
||||
pathLabel: `channels.${channelId}`,
|
||||
surface: "channel",
|
||||
});
|
||||
}
|
||||
for (const hit of collectDependentChannelConfigHits(cfg, staleChannelIds)) {
|
||||
hits.push(hit);
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
function collectDanglingChannelIds(params: {
|
||||
cfg: OpenClawConfig;
|
||||
registryState: StalePluginRegistryState;
|
||||
staleEvidenceIds: ReadonlySet<string>;
|
||||
}): string[] {
|
||||
const channels = asObjectRecord(params.cfg.channels);
|
||||
if (!channels) {
|
||||
return [];
|
||||
}
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const channelId of Object.keys(channels)) {
|
||||
if (CHANNEL_CONFIG_META_KEYS.has(channelId)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizePluginId(channelId);
|
||||
if (
|
||||
!normalized ||
|
||||
params.registryState.knownChannelIds.has(normalized) ||
|
||||
!params.staleEvidenceIds.has(normalized) ||
|
||||
seen.has(normalized)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
ids.push(channelId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectDependentChannelConfigHits(
|
||||
cfg: OpenClawConfig,
|
||||
channelIds: readonly string[],
|
||||
): StalePluginConfigHit[] {
|
||||
if (channelIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const staleChannelIds = new Set(channelIds.map((channelId) => normalizePluginId(channelId)));
|
||||
const hits: StalePluginConfigHit[] = [];
|
||||
const defaultTarget = cfg.agents?.defaults?.heartbeat?.target;
|
||||
if (typeof defaultTarget === "string" && staleChannelIds.has(normalizePluginId(defaultTarget))) {
|
||||
hits.push({
|
||||
pluginId: defaultTarget,
|
||||
pathLabel: "agents.defaults.heartbeat.target",
|
||||
surface: "heartbeat",
|
||||
});
|
||||
}
|
||||
for (const [index, agent] of (cfg.agents?.list ?? []).entries()) {
|
||||
const target = agent?.heartbeat?.target;
|
||||
if (typeof target !== "string" || !staleChannelIds.has(normalizePluginId(target))) {
|
||||
continue;
|
||||
}
|
||||
hits.push({
|
||||
pluginId: target,
|
||||
pathLabel: `agents.list.${index}.heartbeat.target`,
|
||||
surface: "heartbeat",
|
||||
});
|
||||
}
|
||||
|
||||
const modelByChannel = asObjectRecord(cfg.channels?.modelByChannel);
|
||||
if (modelByChannel) {
|
||||
for (const [providerId, channelMap] of Object.entries(modelByChannel)) {
|
||||
const channels = asObjectRecord(channelMap);
|
||||
if (!channels) {
|
||||
continue;
|
||||
}
|
||||
for (const channelId of Object.keys(channels)) {
|
||||
if (!staleChannelIds.has(normalizePluginId(channelId))) {
|
||||
continue;
|
||||
}
|
||||
hits.push({
|
||||
pluginId: channelId,
|
||||
pathLabel: `channels.modelByChannel.${providerId}.${channelId}`,
|
||||
surface: "modelByChannel",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
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 === "channel") {
|
||||
return `- ${hit.pathLabel}: dangling channel config for missing plugin "${hit.pluginId}" was found.`;
|
||||
}
|
||||
if (hit.surface === "heartbeat") {
|
||||
return `- ${hit.pathLabel}: heartbeat target references missing channel plugin "${hit.pluginId}".`;
|
||||
}
|
||||
return `- ${hit.pathLabel}: model override references missing channel plugin "${hit.pluginId}".`;
|
||||
}
|
||||
|
||||
export function collectStalePluginConfigWarnings(params: {
|
||||
hits: StalePluginConfigHit[];
|
||||
doctorFixCommand: string;
|
||||
@@ -103,16 +248,14 @@ export function collectStalePluginConfigWarnings(params: {
|
||||
if (params.hits.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const lines = params.hits.map(
|
||||
(hit) => `- ${hit.pathLabel}: stale plugin reference "${hit.pluginId}" was found.`,
|
||||
);
|
||||
const lines = params.hits.map((hit) => formatStalePluginHitWarning(hit));
|
||||
if (params.autoRepairBlocked) {
|
||||
lines.push(
|
||||
`- Auto-removal is paused because plugin discovery currently has errors. Fix plugin discovery first, then rerun "${params.doctorFixCommand}".`,
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`- Run "${params.doctorFixCommand}" to remove stale plugins.allow and plugins.entries ids.`,
|
||||
`- Run "${params.doctorFixCommand}" to remove stale plugin ids and dangling channel references.`,
|
||||
);
|
||||
}
|
||||
return lines.map((line) => sanitizeForLog(line));
|
||||
@@ -125,29 +268,21 @@ export function maybeRepairStalePluginConfig(
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
} {
|
||||
const plugins = asObjectRecord(cfg.plugins);
|
||||
if (!plugins) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const registryState = collectPluginRegistryState(cfg, env);
|
||||
if (registryState.hasDiscoveryErrors) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const hits = scanStalePluginConfigWithState(plugins, registryState);
|
||||
const hits = scanStalePluginConfigWithState(cfg, registryState);
|
||||
if (hits.length === 0) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const next = structuredClone(cfg);
|
||||
const nextPlugins = asObjectRecord(next.plugins);
|
||||
if (!nextPlugins) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const allowIds = hits.filter((hit) => hit.surface === "allow").map((hit) => hit.pluginId);
|
||||
if (allowIds.length > 0 && Array.isArray(nextPlugins.allow)) {
|
||||
if (allowIds.length > 0 && Array.isArray(nextPlugins?.allow)) {
|
||||
const staleAllowIds = new Set(allowIds.map((pluginId) => normalizePluginId(pluginId)));
|
||||
nextPlugins.allow = nextPlugins.allow.filter(
|
||||
(pluginId) => typeof pluginId !== "string" || !staleAllowIds.has(normalizePluginId(pluginId)),
|
||||
@@ -156,7 +291,7 @@ export function maybeRepairStalePluginConfig(
|
||||
|
||||
const entryIds = hits.filter((hit) => hit.surface === "entries").map((hit) => hit.pluginId);
|
||||
if (entryIds.length > 0) {
|
||||
const entries = asObjectRecord(nextPlugins.entries);
|
||||
const entries = asObjectRecord(nextPlugins?.entries);
|
||||
if (entries) {
|
||||
const staleEntryIds = new Set(entryIds.map((pluginId) => normalizePluginId(pluginId)));
|
||||
for (const pluginId of Object.keys(entries)) {
|
||||
@@ -167,6 +302,11 @@ export function maybeRepairStalePluginConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const channelIds = hits.filter((hit) => hit.surface === "channel").map((hit) => hit.pluginId);
|
||||
if (channelIds.length > 0) {
|
||||
removeDanglingChannelReferences(next, channelIds);
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
if (allowIds.length > 0) {
|
||||
changes.push(
|
||||
@@ -178,6 +318,78 @@ export function maybeRepairStalePluginConfig(
|
||||
`- plugins.entries: removed ${entryIds.length} stale plugin entr${entryIds.length === 1 ? "y" : "ies"} (${entryIds.join(", ")})`,
|
||||
);
|
||||
}
|
||||
if (channelIds.length > 0) {
|
||||
changes.push(
|
||||
`- channels: removed ${channelIds.length} stale channel config${channelIds.length === 1 ? "" : "s"} (${channelIds.join(", ")})`,
|
||||
);
|
||||
const heartbeatCount = hits.filter((hit) => hit.surface === "heartbeat").length;
|
||||
if (heartbeatCount > 0) {
|
||||
changes.push(
|
||||
`- agents heartbeat: removed ${heartbeatCount} stale heartbeat target${heartbeatCount === 1 ? "" : "s"} (${channelIds.join(", ")})`,
|
||||
);
|
||||
}
|
||||
const modelByChannelCount = hits.filter((hit) => hit.surface === "modelByChannel").length;
|
||||
if (modelByChannelCount > 0) {
|
||||
changes.push(
|
||||
`- channels.modelByChannel: removed ${modelByChannelCount} stale channel model override${modelByChannelCount === 1 ? "" : "s"} (${channelIds.join(", ")})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { config: next, changes };
|
||||
}
|
||||
|
||||
function removeDanglingChannelReferences(config: OpenClawConfig, channelIds: readonly string[]) {
|
||||
const staleChannelIds = new Set(channelIds.map((channelId) => normalizePluginId(channelId)));
|
||||
const channels = asObjectRecord(config.channels);
|
||||
if (channels) {
|
||||
for (const channelId of Object.keys(channels)) {
|
||||
if (CHANNEL_CONFIG_META_KEYS.has(channelId)) {
|
||||
continue;
|
||||
}
|
||||
if (staleChannelIds.has(normalizePluginId(channelId))) {
|
||||
delete channels[channelId];
|
||||
}
|
||||
}
|
||||
|
||||
const modelByChannel = asObjectRecord(channels.modelByChannel);
|
||||
if (modelByChannel) {
|
||||
for (const [providerId, channelMap] of Object.entries(modelByChannel)) {
|
||||
const channelsForProvider = asObjectRecord(channelMap);
|
||||
if (!channelsForProvider) {
|
||||
continue;
|
||||
}
|
||||
for (const channelId of Object.keys(channelsForProvider)) {
|
||||
if (staleChannelIds.has(normalizePluginId(channelId))) {
|
||||
delete channelsForProvider[channelId];
|
||||
}
|
||||
}
|
||||
if (Object.keys(channelsForProvider).length === 0) {
|
||||
delete modelByChannel[providerId];
|
||||
}
|
||||
}
|
||||
if (Object.keys(modelByChannel).length === 0) {
|
||||
delete channels.modelByChannel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultsHeartbeat = config.agents?.defaults?.heartbeat;
|
||||
if (
|
||||
defaultsHeartbeat &&
|
||||
typeof defaultsHeartbeat.target === "string" &&
|
||||
staleChannelIds.has(normalizePluginId(defaultsHeartbeat.target))
|
||||
) {
|
||||
delete defaultsHeartbeat.target;
|
||||
}
|
||||
for (const agent of config.agents?.list ?? []) {
|
||||
const heartbeat = agent.heartbeat;
|
||||
if (
|
||||
heartbeat &&
|
||||
typeof heartbeat.target === "string" &&
|
||||
staleChannelIds.has(normalizePluginId(heartbeat.target))
|
||||
) {
|
||||
delete heartbeat.target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user