From fd0aac297c96cb397af4e9257b06da5805dae385 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sat, 28 Mar 2026 00:06:52 -0400 Subject: [PATCH] Plugins: add runtime registry compatibility helper --- src/agents/runtime-plugins.test.ts | 34 ++++++--- src/agents/runtime-plugins.ts | 16 ++-- src/plugins/loader.test.ts | 57 +++++++++++++- src/plugins/loader.ts | 117 ++++++++++++++++++++++------- 4 files changed, 176 insertions(+), 48 deletions(-) diff --git a/src/agents/runtime-plugins.test.ts b/src/agents/runtime-plugins.test.ts index 4025507ec46..802085a00ed 100644 --- a/src/agents/runtime-plugins.test.ts +++ b/src/agents/runtime-plugins.test.ts @@ -2,28 +2,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => ({ loadOpenClawPlugins: vi.fn(), - getActivePluginRegistryKey: vi.fn<() => string | null>(), + getCompatibleActivePluginRegistry: vi.fn(), })); vi.mock("../plugins/loader.js", () => ({ loadOpenClawPlugins: hoisted.loadOpenClawPlugins, -})); - -vi.mock("../plugins/runtime.js", () => ({ - getActivePluginRegistryKey: hoisted.getActivePluginRegistryKey, + getCompatibleActivePluginRegistry: hoisted.getCompatibleActivePluginRegistry, })); describe("ensureRuntimePluginsLoaded", () => { beforeEach(() => { hoisted.loadOpenClawPlugins.mockReset(); - hoisted.getActivePluginRegistryKey.mockReset(); - hoisted.getActivePluginRegistryKey.mockReturnValue(null); + hoisted.getCompatibleActivePluginRegistry.mockReset(); + hoisted.getCompatibleActivePluginRegistry.mockReturnValue(undefined); vi.resetModules(); }); it("does not reactivate plugins when a process already has an active registry", async () => { const { ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js"); - hoisted.getActivePluginRegistryKey.mockReturnValue("gateway-registry"); + hoisted.getCompatibleActivePluginRegistry.mockReturnValue({}); ensureRuntimePluginsLoaded({ config: {} as never, @@ -34,7 +31,7 @@ describe("ensureRuntimePluginsLoaded", () => { expect(hoisted.loadOpenClawPlugins).not.toHaveBeenCalled(); }); - it("loads runtime plugins when no active registry exists", async () => { + it("loads runtime plugins when no compatible active registry exists", async () => { const { ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js"); ensureRuntimePluginsLoaded({ @@ -51,4 +48,23 @@ describe("ensureRuntimePluginsLoaded", () => { }, }); }); + + it("reloads when the current active registry is incompatible with the request", async () => { + const { ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js"); + + ensureRuntimePluginsLoaded({ + config: {} as never, + workspaceDir: "/tmp/workspace", + allowGatewaySubagentBinding: true, + }); + + expect(hoisted.getCompatibleActivePluginRegistry).toHaveBeenCalledWith({ + config: {} as never, + workspaceDir: "/tmp/workspace", + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }); + expect(hoisted.loadOpenClawPlugins).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts index 14c9f99af9b..474a7d6fdd5 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -1,6 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; -import { loadOpenClawPlugins } from "../plugins/loader.js"; -import { getActivePluginRegistryKey } from "../plugins/runtime.js"; +import { getCompatibleActivePluginRegistry, loadOpenClawPlugins } from "../plugins/loader.js"; import { resolveUserPath } from "../utils.js"; export function ensureRuntimePluginsLoaded(params: { @@ -8,16 +7,11 @@ export function ensureRuntimePluginsLoaded(params: { workspaceDir?: string | null; allowGatewaySubagentBinding?: boolean; }): void { - if (getActivePluginRegistryKey()) { - return; - } - const workspaceDir = typeof params.workspaceDir === "string" && params.workspaceDir.trim() ? resolveUserPath(params.workspaceDir) : undefined; - - loadOpenClawPlugins({ + const loadOptions = { config: params.config, workspaceDir, runtimeOptions: params.allowGatewaySubagentBinding @@ -25,5 +19,9 @@ export function ensureRuntimePluginsLoaded(params: { allowGatewaySubagentBinding: true, } : undefined, - }); + }; + if (getCompatibleActivePluginRegistry(loadOptions)) { + return; + } + loadOpenClawPlugins(loadOptions); } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 939c1726f38..b64480e32c4 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -8,7 +8,13 @@ import { clearPluginCommands, getPluginCommandSpecs } from "./command-registry-s import { clearPluginDiscoveryCache } from "./discovery.js"; import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js"; import { createHookRunner } from "./hooks.js"; -import { __testing, clearPluginLoaderCache, loadOpenClawPlugins } from "./loader.js"; +import { + __testing, + clearPluginLoaderCache, + getCompatibleActivePluginRegistry, + loadOpenClawPlugins, + resolvePluginLoadCacheContext, +} from "./loader.js"; import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; import { getMemoryEmbeddingProvider, @@ -27,6 +33,7 @@ import { createEmptyPluginRegistry } from "./registry.js"; import { getActivePluginRegistry, getActivePluginRegistryKey, + resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "./runtime.js"; @@ -763,6 +770,7 @@ afterEach(() => { clearPluginLoaderCache(); clearPluginDiscoveryCache(); clearPluginManifestRegistryCache(); + resetPluginRuntimeStateForTest(); resetDiagnosticEventsForTest(); if (prevBundledDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; @@ -3539,6 +3547,53 @@ export const runtimeValue = helperValue;`, }); }); +describe("getCompatibleActivePluginRegistry", () => { + it("reuses the active registry only when the load context cache key matches", () => { + const registry = createEmptyPluginRegistry(); + const loadOptions = { + config: { + plugins: { + allow: ["demo"], + load: { paths: ["/tmp/demo.js"] }, + }, + }, + workspaceDir: "/tmp/workspace-a", + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }; + const { cacheKey } = resolvePluginLoadCacheContext(loadOptions); + setActivePluginRegistry(registry, cacheKey); + + expect(getCompatibleActivePluginRegistry(loadOptions)).toBe(registry); + expect( + getCompatibleActivePluginRegistry({ + ...loadOptions, + workspaceDir: "/tmp/workspace-b", + }), + ).toBeUndefined(); + expect( + getCompatibleActivePluginRegistry({ + ...loadOptions, + onlyPluginIds: ["demo"], + }), + ).toBeUndefined(); + expect( + getCompatibleActivePluginRegistry({ + ...loadOptions, + runtimeOptions: undefined, + }), + ).toBeUndefined(); + }); + + it("falls back to the current active runtime when no compatibility-shaping inputs are supplied", () => { + const registry = createEmptyPluginRegistry(); + setActivePluginRegistry(registry, "startup-registry"); + + expect(getCompatibleActivePluginRegistry()).toBe(registry); + }); +}); + describe("clearPluginLoaderCache", () => { it("resets registered memory plugin registries", () => { registerMemoryEmbeddingProvider({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 5e881b4c4aa..c6890fcf0e9 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -37,7 +37,11 @@ import { import { isPathInside, safeStatSync } from "./path-safety.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { resolvePluginCacheInputs } from "./roots.js"; -import { setActivePluginRegistry } from "./runtime.js"; +import { + getActivePluginRegistry, + getActivePluginRegistryKey, + setActivePluginRegistry, +} from "./runtime.js"; import type { CreatePluginRuntimeOptions } from "./runtime/index.js"; import type { PluginRuntime } from "./runtime/types.js"; import { validateJsonSchemaValue } from "./schema-validator.js"; @@ -239,6 +243,80 @@ function normalizeScopedPluginIds(ids?: string[]): string[] | undefined { return normalized.length > 0 ? normalized : undefined; } +function resolveRuntimeSubagentMode( + runtimeOptions: PluginLoadOptions["runtimeOptions"], +): "default" | "explicit" | "gateway-bindable" { + if (runtimeOptions?.allowGatewaySubagentBinding === true) { + return "gateway-bindable"; + } + if (runtimeOptions?.subagent) { + return "explicit"; + } + return "default"; +} + +function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean { + return Boolean( + options.config !== undefined || + options.workspaceDir !== undefined || + options.env !== undefined || + options.onlyPluginIds?.length || + options.runtimeOptions !== undefined || + options.pluginSdkResolution !== undefined || + options.includeSetupOnlyChannelPlugins === true || + options.preferSetupRuntimeForChannelPlugins === true, + ); +} + +export function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { + const env = options.env ?? process.env; + const cfg = applyTestPluginDefaults(options.config ?? {}, env); + const normalized = normalizePluginsConfig(cfg.plugins); + const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds); + const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; + const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true; + const cacheKey = buildCacheKey({ + workspaceDir: options.workspaceDir, + plugins: normalized, + installs: cfg.plugins?.installs, + env, + onlyPluginIds, + includeSetupOnlyChannelPlugins, + preferSetupRuntimeForChannelPlugins, + runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions), + pluginSdkResolution: options.pluginSdkResolution, + }); + return { + env, + cfg, + normalized, + onlyPluginIds, + includeSetupOnlyChannelPlugins, + preferSetupRuntimeForChannelPlugins, + shouldActivate: options.activate !== false, + cacheKey, + }; +} + +export function getCompatibleActivePluginRegistry( + options: PluginLoadOptions = {}, +): PluginRegistry | undefined { + const activeRegistry = getActivePluginRegistry() ?? undefined; + if (!activeRegistry) { + return undefined; + } + if (!hasExplicitCompatibilityInputs(options)) { + return activeRegistry; + } + const activeCacheKey = getActivePluginRegistryKey(); + if (!activeCacheKey) { + return undefined; + } + return resolvePluginLoadCacheContext(options).cacheKey === activeCacheKey + ? activeRegistry + : undefined; +} + function validatePluginConfig(params: { schema?: Record; cacheKey?: string; @@ -687,38 +765,19 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi "loadOpenClawPlugins: activate:false requires cache:false to prevent command registry divergence", ); } - const env = options.env ?? process.env; - // Test env: default-disable plugins unless explicitly configured. - // This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident. - const cfg = applyTestPluginDefaults(options.config ?? {}, env); - const logger = options.logger ?? defaultLogger(); - const validateOnly = options.mode === "validate"; - const normalized = normalizePluginsConfig(cfg.plugins); - const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds); - const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null; - const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; - const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true; - const shouldActivate = options.activate !== false; - // NOTE: `activate` is intentionally excluded from the cache key. All non-activating - // (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they - // never read from or write to the cache. Including `activate` here would be misleading - // — it would imply mixed-activate caching is supported, when in practice it is not. - const cacheKey = buildCacheKey({ - workspaceDir: options.workspaceDir, - plugins: normalized, - installs: cfg.plugins?.installs, + const { env, + cfg, + normalized, onlyPluginIds, includeSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, - runtimeSubagentMode: - options.runtimeOptions?.allowGatewaySubagentBinding === true - ? "gateway-bindable" - : options.runtimeOptions?.subagent - ? "explicit" - : "default", - pluginSdkResolution: options.pluginSdkResolution, - }); + shouldActivate, + cacheKey, + } = resolvePluginLoadCacheContext(options); + const logger = options.logger ?? defaultLogger(); + const validateOnly = options.mode === "validate"; + const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null; const cacheEnabled = options.cache !== false; if (cacheEnabled) { const cached = getCachedPluginRegistry(cacheKey);