Skip to content

Commit 8524849

Browse files
yfgeobviyus
andauthored
fix: cache external plugin catalog lookups in auto-enable (openclaw#66246) (thanks @yfge)
* fix: cache external plugin catalog lookups in auto-enable Fixes openclaw#66159 * test: restore readFileSync spy in plugin auto-enable test * refactor: distill plugin auto-enable cache path * fix: cache external plugin catalog lookups in auto-enable (openclaw#66246) (thanks @yfge) --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
1 parent 55604a9 commit 8524849

4 files changed

Lines changed: 99 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
1414
- 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.
1515
- 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.
1616
- 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.
17+
- 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.
1718

1819
## 2026.4.14-beta.1
1920

src/config/plugin-auto-enable.channels.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from "node:fs";
22
import path from "node:path";
3-
import { afterEach, describe, expect, it } from "vitest";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
44
import {
55
applyPluginAutoEnable,
66
materializePluginAutoEnableCandidates,
@@ -104,6 +104,83 @@ describe("applyPluginAutoEnable channels", () => {
104104
expect(result.config.plugins?.entries?.["env-primary"]).toBeUndefined();
105105
});
106106

107+
it("memoizes external catalog preferOver lookups within one auto-enable pass", () => {
108+
const stateDir = makeTempDir();
109+
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
110+
fs.mkdirSync(path.dirname(catalogPath), { recursive: true });
111+
fs.writeFileSync(
112+
catalogPath,
113+
JSON.stringify({
114+
entries: [
115+
{
116+
name: "@openclaw/env-primary",
117+
openclaw: {
118+
channel: {
119+
id: "env-primary",
120+
label: "Env Primary",
121+
selectionLabel: "Env Primary",
122+
docsPath: "/channels/env-primary",
123+
blurb: "Env primary entry",
124+
},
125+
install: {
126+
npmSpec: "@openclaw/env-primary",
127+
},
128+
},
129+
},
130+
{
131+
name: "@openclaw/env-secondary",
132+
openclaw: {
133+
channel: {
134+
id: "env-secondary",
135+
label: "Env Secondary",
136+
selectionLabel: "Env Secondary",
137+
docsPath: "/channels/env-secondary",
138+
blurb: "Env secondary entry",
139+
preferOver: ["env-primary"],
140+
},
141+
install: {
142+
npmSpec: "@openclaw/env-secondary",
143+
},
144+
},
145+
},
146+
],
147+
}),
148+
"utf-8",
149+
);
150+
151+
const readFileSpy = vi.spyOn(fs, "readFileSync");
152+
153+
try {
154+
materializePluginAutoEnableCandidates({
155+
config: {
156+
channels: {
157+
"env-primary": { token: "primary" },
158+
"env-secondary": { token: "secondary" },
159+
},
160+
},
161+
candidates: Array.from({ length: 20 }, (_, index) => ({
162+
pluginId: index % 2 === 0 ? "env-primary" : "env-secondary",
163+
kind: "channel-configured" as const,
164+
channelId: index % 2 === 0 ? "env-primary" : "env-secondary",
165+
})),
166+
env: {
167+
...makeIsolatedEnv(),
168+
OPENCLAW_STATE_DIR: stateDir,
169+
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
170+
},
171+
manifestRegistry: makeRegistry([]),
172+
});
173+
174+
expect(
175+
readFileSpy.mock.calls.filter(([filePath]) =>
176+
String(filePath).endsWith("plugins/catalog.json"),
177+
),
178+
).toHaveLength(2);
179+
} finally {
180+
readFileSpy.mockRestore();
181+
}
182+
});
183+
107184
describe("third-party channel plugins (pluginId ≠ channelId)", () => {
108185
it("uses the plugin manifest id, not the channel id, for plugins.entries", () => {
109186
const result = applyWithApnChannelConfig();

src/config/plugin-auto-enable.prefer-over.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ function resolvePreferredOverIds(
122122
return resolveExternalCatalogPreferOver(channelId, env);
123123
}
124124

125+
function getPluginAutoEnableCandidateCacheKey(candidate: PluginAutoEnableCandidate): string {
126+
return `${candidate.pluginId}:${candidate.kind === "channel-configured" ? candidate.channelId : candidate.pluginId}`;
127+
}
128+
125129
export function shouldSkipPreferredPluginAutoEnable(params: {
126130
config: OpenClawConfig;
127131
entry: PluginAutoEnableCandidate;
@@ -130,7 +134,19 @@ export function shouldSkipPreferredPluginAutoEnable(params: {
130134
registry: PluginManifestRegistry;
131135
isPluginDenied: (config: OpenClawConfig, pluginId: string) => boolean;
132136
isPluginExplicitlyDisabled: (config: OpenClawConfig, pluginId: string) => boolean;
137+
preferOverCache: Map<string, string[]>;
133138
}): boolean {
139+
const getPreferredOverIds = (candidate: PluginAutoEnableCandidate): string[] => {
140+
const cacheKey = getPluginAutoEnableCandidateCacheKey(candidate);
141+
const cached = params.preferOverCache.get(cacheKey);
142+
if (cached) {
143+
return cached;
144+
}
145+
const resolved = resolvePreferredOverIds(candidate, params.env, params.registry);
146+
params.preferOverCache.set(cacheKey, resolved);
147+
return resolved;
148+
};
149+
134150
for (const other of params.configured) {
135151
if (other.pluginId === params.entry.pluginId) {
136152
continue;
@@ -141,9 +157,7 @@ export function shouldSkipPreferredPluginAutoEnable(params: {
141157
) {
142158
continue;
143159
}
144-
if (
145-
resolvePreferredOverIds(other, params.env, params.registry).includes(params.entry.pluginId)
146-
) {
160+
if (getPreferredOverIds(other).includes(params.entry.pluginId)) {
147161
return true;
148162
}
149163
}

src/config/plugin-auto-enable.shared.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,8 @@ export function materializePluginAutoEnableCandidatesInternal(params: {
660660
return { config: next, changes, autoEnabledReasons: {} };
661661
}
662662

663+
const preferOverCache = new Map<string, string[]>();
664+
663665
for (const entry of params.candidates) {
664666
const builtInChannelId = normalizeChatChannelId(entry.pluginId);
665667
if (isPluginDenied(next, entry.pluginId) || isPluginExplicitlyDisabled(next, entry.pluginId)) {
@@ -674,6 +676,7 @@ export function materializePluginAutoEnableCandidatesInternal(params: {
674676
registry: params.manifestRegistry,
675677
isPluginDenied,
676678
isPluginExplicitlyDisabled,
679+
preferOverCache,
677680
})
678681
) {
679682
continue;

0 commit comments

Comments
 (0)