mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07: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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 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 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.
|
- 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>`.
|
- 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.
|
- 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", () => ({
|
vi.mock("./stale-plugin-config.js", () => ({
|
||||||
scanStalePluginConfig: (cfg: {
|
scanStalePluginConfig: (cfg: {
|
||||||
plugins?: { allow?: string[]; entries?: Record<string, unknown> };
|
plugins?: { allow?: string[]; entries?: Record<string, unknown> };
|
||||||
|
channels?: Record<string, unknown>;
|
||||||
}) => {
|
}) => {
|
||||||
const knownIds = new Set(manifestState.plugins.map((plugin) => plugin.id));
|
const knownIds = new Set(manifestState.plugins.map((plugin) => plugin.id));
|
||||||
const ids = [...(cfg.plugins?.allow ?? []), ...Object.keys(cfg.plugins?.entries ?? {})];
|
const hits = [...(cfg.plugins?.allow ?? []), ...Object.keys(cfg.plugins?.entries ?? {})]
|
||||||
return [...new Set(ids)].filter((id) => !knownIds.has(id)).map((id) => ({ id }));
|
.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: () =>
|
isStalePluginAutoRepairBlocked: () =>
|
||||||
manifestState.diagnostics.some((diagnostic) => diagnostic.level === "error"),
|
manifestState.diagnostics.some((diagnostic) => diagnostic.level === "error"),
|
||||||
@@ -113,16 +121,19 @@ vi.mock("./stale-plugin-config.js", () => ({
|
|||||||
}: {
|
}: {
|
||||||
autoRepairBlocked: boolean;
|
autoRepairBlocked: boolean;
|
||||||
doctorFixCommand: string;
|
doctorFixCommand: string;
|
||||||
hits: Array<{ id: string }>;
|
hits: Array<{ id: string; surface: string }>;
|
||||||
}) =>
|
}) =>
|
||||||
hits.map(
|
hits.map((hit) => {
|
||||||
(hit) =>
|
const prefix =
|
||||||
`plugins.allow: stale plugin reference "${hit.id}". plugins.entries.${hit.id} is unused. ${
|
hit.surface === "channel"
|
||||||
autoRepairBlocked
|
? `channels.${hit.id}: dangling channel config.`
|
||||||
? `Auto-removal is paused; rerun "${doctorFixCommand}".`
|
: `plugins.allow: stale plugin reference "${hit.id}". plugins.entries.${hit.id} is unused.`;
|
||||||
: `Run "${doctorFixCommand}".`
|
return `${prefix} ${
|
||||||
}`,
|
autoRepairBlocked
|
||||||
),
|
? `Auto-removal is paused; rerun "${doctorFixCommand}".`
|
||||||
|
: `Run "${doctorFixCommand}".`
|
||||||
|
}`;
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./bundled-plugin-load-paths.js", () => ({
|
vi.mock("./bundled-plugin-load-paths.js", () => ({
|
||||||
@@ -239,6 +250,23 @@ describe("doctor preview warnings", () => {
|
|||||||
expect(warnings[0]).not.toContain("Auto-removal is paused");
|
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 () => {
|
it("includes bundled plugin load path migration warnings", async () => {
|
||||||
const packageRoot = path.resolve("app-node-modules", "openclaw");
|
const packageRoot = path.resolve("app-node-modules", "openclaw");
|
||||||
const legacyPath = path.join(packageRoot, "extensions", "feishu");
|
const legacyPath = path.join(packageRoot, "extensions", "feishu");
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export async function collectDoctorPreviewWarnings(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPluginConfig) {
|
if (hasPluginConfig || hasChannelConfig) {
|
||||||
const {
|
const {
|
||||||
collectStalePluginConfigWarnings,
|
collectStalePluginConfigWarnings,
|
||||||
isStalePluginAutoRepairBlocked,
|
isStalePluginAutoRepairBlocked,
|
||||||
@@ -139,7 +139,9 @@ export async function collectDoctorPreviewWarnings(params: {
|
|||||||
}).join("\n"),
|
}).join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPluginConfig) {
|
||||||
const { collectCodexRouteWarnings } = await import("./codex-route-warnings.js");
|
const { collectCodexRouteWarnings } = await import("./codex-route-warnings.js");
|
||||||
warnings.push(...collectCodexRouteWarnings({ cfg: params.cfg, env }));
|
warnings.push(...collectCodexRouteWarnings({ cfg: params.cfg, env }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { OpenClawConfig } from "../../../config/config.js";
|
import type { OpenClawConfig } from "../../../config/config.js";
|
||||||
|
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
|
||||||
import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js";
|
import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js";
|
||||||
import * as manifestRegistry from "../../../plugins/manifest-registry.js";
|
import * as manifestRegistry from "../../../plugins/manifest-registry.js";
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +9,18 @@ import {
|
|||||||
scanStalePluginConfig,
|
scanStalePluginConfig,
|
||||||
} from "./stale-plugin-config.js";
|
} 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 {
|
function manifest(id: string): PluginManifestRecord {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -25,6 +38,8 @@ function manifest(id: string): PluginManifestRecord {
|
|||||||
|
|
||||||
describe("doctor stale plugin config helpers", () => {
|
describe("doctor stale plugin config helpers", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReset();
|
||||||
|
installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReturnValue({});
|
||||||
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
|
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
|
||||||
plugins: [manifest("discord"), manifest("voice-call"), manifest("openai")],
|
plugins: [manifest("discord"), manifest("voice-call"), manifest("openai")],
|
||||||
diagnostics: [],
|
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", () => {
|
it("does not auto-repair stale refs while plugin discovery has errors", () => {
|
||||||
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
|
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js";
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js";
|
||||||
|
import { CHANNEL_IDS } from "../../../channels/ids.js";
|
||||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||||
import { normalizePluginId } from "../../../plugins/config-state.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 { loadPluginManifestRegistryForPluginRegistry } from "../../../plugins/plugin-registry.js";
|
||||||
import { sanitizeForLog } from "../../../terminal/ansi.js";
|
import { sanitizeForLog } from "../../../terminal/ansi.js";
|
||||||
import { asObjectRecord } from "./object.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 = {
|
type StalePluginConfigHit = {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@@ -15,6 +19,8 @@ type StalePluginConfigHit = {
|
|||||||
|
|
||||||
type StalePluginRegistryState = {
|
type StalePluginRegistryState = {
|
||||||
knownIds: Set<string>;
|
knownIds: Set<string>;
|
||||||
|
knownChannelIds: Set<string>;
|
||||||
|
missingInstalledIds: Set<string>;
|
||||||
hasDiscoveryErrors: boolean;
|
hasDiscoveryErrors: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,8 +35,37 @@ function collectPluginRegistryState(
|
|||||||
env,
|
env,
|
||||||
includeDisabled: true,
|
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 {
|
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"),
|
hasDiscoveryErrors: registry.diagnostics.some((diag) => diag.level === "error"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -46,22 +81,19 @@ export function scanStalePluginConfig(
|
|||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
env?: NodeJS.ProcessEnv,
|
env?: NodeJS.ProcessEnv,
|
||||||
): StalePluginConfigHit[] {
|
): StalePluginConfigHit[] {
|
||||||
const plugins = asObjectRecord(cfg.plugins);
|
return scanStalePluginConfigWithState(cfg, collectPluginRegistryState(cfg, env));
|
||||||
if (!plugins) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return scanStalePluginConfigWithState(plugins, collectPluginRegistryState(cfg, env));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scanStalePluginConfigWithState(
|
function scanStalePluginConfigWithState(
|
||||||
plugins: Record<string, unknown>,
|
cfg: OpenClawConfig,
|
||||||
registryState: StalePluginRegistryState,
|
registryState: StalePluginRegistryState,
|
||||||
): StalePluginConfigHit[] {
|
): StalePluginConfigHit[] {
|
||||||
|
const plugins = asObjectRecord(cfg.plugins);
|
||||||
const { knownIds } = registryState;
|
const { knownIds } = registryState;
|
||||||
const hits: StalePluginConfigHit[] = [];
|
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) {
|
for (const rawPluginId of allow) {
|
||||||
if (typeof rawPluginId !== "string") {
|
if (typeof rawPluginId !== "string") {
|
||||||
continue;
|
continue;
|
||||||
@@ -75,26 +107,139 @@ function scanStalePluginConfigWithState(
|
|||||||
pathLabel: "plugins.allow",
|
pathLabel: "plugins.allow",
|
||||||
surface: "allow",
|
surface: "allow",
|
||||||
});
|
});
|
||||||
|
staleEvidenceIds.add(pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = asObjectRecord(plugins.entries);
|
const entries = asObjectRecord(plugins?.entries);
|
||||||
if (!entries) {
|
if (entries) {
|
||||||
return hits;
|
for (const rawPluginId of Object.keys(entries)) {
|
||||||
}
|
const pluginId = normalizePluginId(rawPluginId);
|
||||||
for (const rawPluginId of Object.keys(entries)) {
|
if (!pluginId || knownIds.has(pluginId)) {
|
||||||
if (knownIds.has(normalizePluginId(rawPluginId))) {
|
continue;
|
||||||
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({
|
hits.push({
|
||||||
pluginId: rawPluginId,
|
pluginId: channelId,
|
||||||
pathLabel: `plugins.entries.${rawPluginId}`,
|
pathLabel: `channels.${channelId}`,
|
||||||
surface: "entries",
|
surface: "channel",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const hit of collectDependentChannelConfigHits(cfg, staleChannelIds)) {
|
||||||
|
hits.push(hit);
|
||||||
|
}
|
||||||
|
|
||||||
return hits;
|
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: {
|
export function collectStalePluginConfigWarnings(params: {
|
||||||
hits: StalePluginConfigHit[];
|
hits: StalePluginConfigHit[];
|
||||||
doctorFixCommand: string;
|
doctorFixCommand: string;
|
||||||
@@ -103,16 +248,14 @@ export function collectStalePluginConfigWarnings(params: {
|
|||||||
if (params.hits.length === 0) {
|
if (params.hits.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const lines = params.hits.map(
|
const lines = params.hits.map((hit) => formatStalePluginHitWarning(hit));
|
||||||
(hit) => `- ${hit.pathLabel}: stale plugin reference "${hit.pluginId}" was found.`,
|
|
||||||
);
|
|
||||||
if (params.autoRepairBlocked) {
|
if (params.autoRepairBlocked) {
|
||||||
lines.push(
|
lines.push(
|
||||||
`- Auto-removal is paused because plugin discovery currently has errors. Fix plugin discovery first, then rerun "${params.doctorFixCommand}".`,
|
`- Auto-removal is paused because plugin discovery currently has errors. Fix plugin discovery first, then rerun "${params.doctorFixCommand}".`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
lines.push(
|
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));
|
return lines.map((line) => sanitizeForLog(line));
|
||||||
@@ -125,29 +268,21 @@ export function maybeRepairStalePluginConfig(
|
|||||||
config: OpenClawConfig;
|
config: OpenClawConfig;
|
||||||
changes: string[];
|
changes: string[];
|
||||||
} {
|
} {
|
||||||
const plugins = asObjectRecord(cfg.plugins);
|
|
||||||
if (!plugins) {
|
|
||||||
return { config: cfg, changes: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const registryState = collectPluginRegistryState(cfg, env);
|
const registryState = collectPluginRegistryState(cfg, env);
|
||||||
if (registryState.hasDiscoveryErrors) {
|
if (registryState.hasDiscoveryErrors) {
|
||||||
return { config: cfg, changes: [] };
|
return { config: cfg, changes: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const hits = scanStalePluginConfigWithState(plugins, registryState);
|
const hits = scanStalePluginConfigWithState(cfg, registryState);
|
||||||
if (hits.length === 0) {
|
if (hits.length === 0) {
|
||||||
return { config: cfg, changes: [] };
|
return { config: cfg, changes: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = structuredClone(cfg);
|
const next = structuredClone(cfg);
|
||||||
const nextPlugins = asObjectRecord(next.plugins);
|
const nextPlugins = asObjectRecord(next.plugins);
|
||||||
if (!nextPlugins) {
|
|
||||||
return { config: cfg, changes: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowIds = hits.filter((hit) => hit.surface === "allow").map((hit) => hit.pluginId);
|
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)));
|
const staleAllowIds = new Set(allowIds.map((pluginId) => normalizePluginId(pluginId)));
|
||||||
nextPlugins.allow = nextPlugins.allow.filter(
|
nextPlugins.allow = nextPlugins.allow.filter(
|
||||||
(pluginId) => typeof pluginId !== "string" || !staleAllowIds.has(normalizePluginId(pluginId)),
|
(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);
|
const entryIds = hits.filter((hit) => hit.surface === "entries").map((hit) => hit.pluginId);
|
||||||
if (entryIds.length > 0) {
|
if (entryIds.length > 0) {
|
||||||
const entries = asObjectRecord(nextPlugins.entries);
|
const entries = asObjectRecord(nextPlugins?.entries);
|
||||||
if (entries) {
|
if (entries) {
|
||||||
const staleEntryIds = new Set(entryIds.map((pluginId) => normalizePluginId(pluginId)));
|
const staleEntryIds = new Set(entryIds.map((pluginId) => normalizePluginId(pluginId)));
|
||||||
for (const pluginId of Object.keys(entries)) {
|
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[] = [];
|
const changes: string[] = [];
|
||||||
if (allowIds.length > 0) {
|
if (allowIds.length > 0) {
|
||||||
changes.push(
|
changes.push(
|
||||||
@@ -178,6 +318,78 @@ export function maybeRepairStalePluginConfig(
|
|||||||
`- plugins.entries: removed ${entryIds.length} stale plugin entr${entryIds.length === 1 ? "y" : "ies"} (${entryIds.join(", ")})`,
|
`- 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 };
|
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