diff --git a/CHANGELOG.md b/CHANGELOG.md index cc10e8c1a52..e59064b70e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Agents/gateway-tool: reject `config.patch` and `config.apply` calls from the model-facing gateway tool when they would newly enable any flag enumerated by `openclaw security audit` (for example `dangerouslyDisableDeviceAuth`, `allowInsecureAuth`, `dangerouslyAllowHostHeaderOriginFallback`, `hooks.gmail.allowUnsafeExternalContent`, `tools.exec.applyPatch.workspaceOnly: false`); already-enabled flags pass through unchanged so non-dangerous edits in the same patch still apply, and direct authenticated operator RPC behavior is unchanged. (#62006) Thanks @eleqtrizit. - Telegram/forum topics: persist learned topic names to the Telegram session sidecar store so agent context can keep using human topic names after a restart instead of relearning from future service metadata. (#66107) Thanks @obviyus. - Doctor/systemd: keep `openclaw doctor --repair` and service reinstall from re-embedding dotenv-backed secrets in user systemd units, while preserving newer inline overrides over stale state-dir `.env` values. (#66249) Thanks @tmimmanuel. +- Doctor/plugins: cache external `preferOver` catalog lookups within each plugin auto-enable pass so large `agents.list` configs no longer peg CPU and repeatedly reread plugin catalogs during doctor/plugins resolution. (#66246) Thanks @yfge. ## 2026.4.14-beta.1 diff --git a/src/config/plugin-auto-enable.channels.test.ts b/src/config/plugin-auto-enable.channels.test.ts index 11fb2bcb2f5..750b776a9e4 100644 --- a/src/config/plugin-auto-enable.channels.test.ts +++ b/src/config/plugin-auto-enable.channels.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { applyPluginAutoEnable, materializePluginAutoEnableCandidates, @@ -104,6 +104,83 @@ describe("applyPluginAutoEnable channels", () => { expect(result.config.plugins?.entries?.["env-primary"]).toBeUndefined(); }); + it("memoizes external catalog preferOver lookups within one auto-enable pass", () => { + const stateDir = makeTempDir(); + const catalogPath = path.join(stateDir, "plugins", "catalog.json"); + fs.mkdirSync(path.dirname(catalogPath), { recursive: true }); + fs.writeFileSync( + catalogPath, + JSON.stringify({ + entries: [ + { + name: "@openclaw/env-primary", + openclaw: { + channel: { + id: "env-primary", + label: "Env Primary", + selectionLabel: "Env Primary", + docsPath: "/channels/env-primary", + blurb: "Env primary entry", + }, + install: { + npmSpec: "@openclaw/env-primary", + }, + }, + }, + { + name: "@openclaw/env-secondary", + openclaw: { + channel: { + id: "env-secondary", + label: "Env Secondary", + selectionLabel: "Env Secondary", + docsPath: "/channels/env-secondary", + blurb: "Env secondary entry", + preferOver: ["env-primary"], + }, + install: { + npmSpec: "@openclaw/env-secondary", + }, + }, + }, + ], + }), + "utf-8", + ); + + const readFileSpy = vi.spyOn(fs, "readFileSync"); + + try { + materializePluginAutoEnableCandidates({ + config: { + channels: { + "env-primary": { token: "primary" }, + "env-secondary": { token: "secondary" }, + }, + }, + candidates: Array.from({ length: 20 }, (_, index) => ({ + pluginId: index % 2 === 0 ? "env-primary" : "env-secondary", + kind: "channel-configured" as const, + channelId: index % 2 === 0 ? "env-primary" : "env-secondary", + })), + env: { + ...makeIsolatedEnv(), + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + manifestRegistry: makeRegistry([]), + }); + + expect( + readFileSpy.mock.calls.filter(([filePath]) => + String(filePath).endsWith("plugins/catalog.json"), + ), + ).toHaveLength(2); + } finally { + readFileSpy.mockRestore(); + } + }); + describe("third-party channel plugins (pluginId ≠ channelId)", () => { it("uses the plugin manifest id, not the channel id, for plugins.entries", () => { const result = applyWithApnChannelConfig(); diff --git a/src/config/plugin-auto-enable.prefer-over.ts b/src/config/plugin-auto-enable.prefer-over.ts index 1665f622f79..bc021139b76 100644 --- a/src/config/plugin-auto-enable.prefer-over.ts +++ b/src/config/plugin-auto-enable.prefer-over.ts @@ -122,6 +122,10 @@ function resolvePreferredOverIds( return resolveExternalCatalogPreferOver(channelId, env); } +function getPluginAutoEnableCandidateCacheKey(candidate: PluginAutoEnableCandidate): string { + return `${candidate.pluginId}:${candidate.kind === "channel-configured" ? candidate.channelId : candidate.pluginId}`; +} + export function shouldSkipPreferredPluginAutoEnable(params: { config: OpenClawConfig; entry: PluginAutoEnableCandidate; @@ -130,7 +134,19 @@ export function shouldSkipPreferredPluginAutoEnable(params: { registry: PluginManifestRegistry; isPluginDenied: (config: OpenClawConfig, pluginId: string) => boolean; isPluginExplicitlyDisabled: (config: OpenClawConfig, pluginId: string) => boolean; + preferOverCache: Map; }): boolean { + const getPreferredOverIds = (candidate: PluginAutoEnableCandidate): string[] => { + const cacheKey = getPluginAutoEnableCandidateCacheKey(candidate); + const cached = params.preferOverCache.get(cacheKey); + if (cached) { + return cached; + } + const resolved = resolvePreferredOverIds(candidate, params.env, params.registry); + params.preferOverCache.set(cacheKey, resolved); + return resolved; + }; + for (const other of params.configured) { if (other.pluginId === params.entry.pluginId) { continue; @@ -141,9 +157,7 @@ export function shouldSkipPreferredPluginAutoEnable(params: { ) { continue; } - if ( - resolvePreferredOverIds(other, params.env, params.registry).includes(params.entry.pluginId) - ) { + if (getPreferredOverIds(other).includes(params.entry.pluginId)) { return true; } } diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 8226673e487..24a570136a3 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -660,6 +660,8 @@ export function materializePluginAutoEnableCandidatesInternal(params: { return { config: next, changes, autoEnabledReasons: {} }; } + const preferOverCache = new Map(); + for (const entry of params.candidates) { const builtInChannelId = normalizeChatChannelId(entry.pluginId); if (isPluginDenied(next, entry.pluginId) || isPluginExplicitlyDisabled(next, entry.pluginId)) { @@ -674,6 +676,7 @@ export function materializePluginAutoEnableCandidatesInternal(params: { registry: params.manifestRegistry, isPluginDenied, isPluginExplicitlyDisabled, + preferOverCache, }) ) { continue;