Plugins: add runtime registry compatibility helper

This commit is contained in:
Gustavo Madeira Santana
2026-03-28 00:06:52 -04:00
parent 4beb231fd8
commit fd0aac297c
4 changed files with 176 additions and 48 deletions

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -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({

View File

@@ -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<string, unknown>;
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);