hardening: bound plugin loader cache

This commit is contained in:
Gustavo Madeira Santana
2026-03-12 15:22:50 +00:00
parent 3eb7065ebd
commit 6e8852a188
2 changed files with 70 additions and 2 deletions

View File

@@ -623,6 +623,47 @@ describe("loadOpenClawPlugins", () => {
expect(third).toBe(second);
});
it("evicts least recently used registries when the loader cache exceeds its cap", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "cache-eviction",
filename: "cache-eviction.cjs",
body: `module.exports = { id: "cache-eviction", register() {} };`,
});
const stateDirs = Array.from({ length: __testing.maxPluginRegistryCacheEntries + 1 }, () =>
makeTempDir(),
);
const loadWithStateDir = (stateDir: string) =>
loadOpenClawPlugins({
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
config: {
plugins: {
allow: ["cache-eviction"],
load: {
paths: [plugin.file],
},
},
},
});
const first = loadWithStateDir(stateDirs[0] ?? makeTempDir());
const second = loadWithStateDir(stateDirs[1] ?? makeTempDir());
expect(loadWithStateDir(stateDirs[0] ?? makeTempDir())).toBe(first);
for (const stateDir of stateDirs.slice(2)) {
loadWithStateDir(stateDir);
}
expect(loadWithStateDir(stateDirs[0] ?? makeTempDir())).toBe(first);
expect(loadWithStateDir(stateDirs[1] ?? makeTempDir())).not.toBe(second);
});
it("normalizes bundled plugin env overrides against the provided env", () => {
const bundledDir = makeTempDir();
const homeDir = path.dirname(bundledDir);

View File

@@ -49,6 +49,7 @@ export type PluginLoadOptions = {
mode?: "full" | "validate";
};
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32;
const registryCache = new Map<string, PluginRegistry>();
export function clearPluginLoaderCache(): void {
@@ -171,8 +172,34 @@ export const __testing = {
listPluginSdkExportedSubpaths,
resolvePluginSdkAliasCandidateOrder,
resolvePluginSdkAliasFile,
maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES,
};
function getCachedPluginRegistry(cacheKey: string): PluginRegistry | undefined {
const cached = registryCache.get(cacheKey);
if (!cached) {
return undefined;
}
// Refresh insertion order so frequently reused registries survive eviction.
registryCache.delete(cacheKey);
registryCache.set(cacheKey, cached);
return cached;
}
function setCachedPluginRegistry(cacheKey: string, registry: PluginRegistry): void {
if (registryCache.has(cacheKey)) {
registryCache.delete(cacheKey);
}
registryCache.set(cacheKey, registry);
while (registryCache.size > MAX_PLUGIN_REGISTRY_CACHE_ENTRIES) {
const oldestKey = registryCache.keys().next().value;
if (!oldestKey) {
break;
}
registryCache.delete(oldestKey);
}
}
function buildCacheKey(params: {
workspaceDir?: string;
plugins: NormalizedPluginsConfig;
@@ -503,7 +530,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
});
const cacheEnabled = options.cache !== false;
if (cacheEnabled) {
const cached = registryCache.get(cacheKey);
const cached = getCachedPluginRegistry(cacheKey);
if (cached) {
activatePluginRegistry(cached, cacheKey);
return cached;
@@ -863,7 +890,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
});
if (cacheEnabled) {
registryCache.set(cacheKey, registry);
setCachedPluginRegistry(cacheKey, registry);
}
activatePluginRegistry(registry, cacheKey);
return registry;