Skip to content

Commit fd0aac2

Browse files
committed
Plugins: add runtime registry compatibility helper
1 parent 4beb231 commit fd0aac2

4 files changed

Lines changed: 176 additions & 48 deletions

File tree

src/agents/runtime-plugins.test.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
const hoisted = vi.hoisted(() => ({
44
loadOpenClawPlugins: vi.fn(),
5-
getActivePluginRegistryKey: vi.fn<() => string | null>(),
5+
getCompatibleActivePluginRegistry: vi.fn(),
66
}));
77

88
vi.mock("../plugins/loader.js", () => ({
99
loadOpenClawPlugins: hoisted.loadOpenClawPlugins,
10-
}));
11-
12-
vi.mock("../plugins/runtime.js", () => ({
13-
getActivePluginRegistryKey: hoisted.getActivePluginRegistryKey,
10+
getCompatibleActivePluginRegistry: hoisted.getCompatibleActivePluginRegistry,
1411
}));
1512

1613
describe("ensureRuntimePluginsLoaded", () => {
1714
beforeEach(() => {
1815
hoisted.loadOpenClawPlugins.mockReset();
19-
hoisted.getActivePluginRegistryKey.mockReset();
20-
hoisted.getActivePluginRegistryKey.mockReturnValue(null);
16+
hoisted.getCompatibleActivePluginRegistry.mockReset();
17+
hoisted.getCompatibleActivePluginRegistry.mockReturnValue(undefined);
2118
vi.resetModules();
2219
});
2320

2421
it("does not reactivate plugins when a process already has an active registry", async () => {
2522
const { ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js");
26-
hoisted.getActivePluginRegistryKey.mockReturnValue("gateway-registry");
23+
hoisted.getCompatibleActivePluginRegistry.mockReturnValue({});
2724

2825
ensureRuntimePluginsLoaded({
2926
config: {} as never,
@@ -34,7 +31,7 @@ describe("ensureRuntimePluginsLoaded", () => {
3431
expect(hoisted.loadOpenClawPlugins).not.toHaveBeenCalled();
3532
});
3633

37-
it("loads runtime plugins when no active registry exists", async () => {
34+
it("loads runtime plugins when no compatible active registry exists", async () => {
3835
const { ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js");
3936

4037
ensureRuntimePluginsLoaded({
@@ -51,4 +48,23 @@ describe("ensureRuntimePluginsLoaded", () => {
5148
},
5249
});
5350
});
51+
52+
it("reloads when the current active registry is incompatible with the request", async () => {
53+
const { ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js");
54+
55+
ensureRuntimePluginsLoaded({
56+
config: {} as never,
57+
workspaceDir: "/tmp/workspace",
58+
allowGatewaySubagentBinding: true,
59+
});
60+
61+
expect(hoisted.getCompatibleActivePluginRegistry).toHaveBeenCalledWith({
62+
config: {} as never,
63+
workspaceDir: "/tmp/workspace",
64+
runtimeOptions: {
65+
allowGatewaySubagentBinding: true,
66+
},
67+
});
68+
expect(hoisted.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
69+
});
5470
});

src/agents/runtime-plugins.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,27 @@
11
import type { OpenClawConfig } from "../config/config.js";
2-
import { loadOpenClawPlugins } from "../plugins/loader.js";
3-
import { getActivePluginRegistryKey } from "../plugins/runtime.js";
2+
import { getCompatibleActivePluginRegistry, loadOpenClawPlugins } from "../plugins/loader.js";
43
import { resolveUserPath } from "../utils.js";
54

65
export function ensureRuntimePluginsLoaded(params: {
76
config?: OpenClawConfig;
87
workspaceDir?: string | null;
98
allowGatewaySubagentBinding?: boolean;
109
}): void {
11-
if (getActivePluginRegistryKey()) {
12-
return;
13-
}
14-
1510
const workspaceDir =
1611
typeof params.workspaceDir === "string" && params.workspaceDir.trim()
1712
? resolveUserPath(params.workspaceDir)
1813
: undefined;
19-
20-
loadOpenClawPlugins({
14+
const loadOptions = {
2115
config: params.config,
2216
workspaceDir,
2317
runtimeOptions: params.allowGatewaySubagentBinding
2418
? {
2519
allowGatewaySubagentBinding: true,
2620
}
2721
: undefined,
28-
});
22+
};
23+
if (getCompatibleActivePluginRegistry(loadOptions)) {
24+
return;
25+
}
26+
loadOpenClawPlugins(loadOptions);
2927
}

src/plugins/loader.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import { clearPluginCommands, getPluginCommandSpecs } from "./command-registry-s
88
import { clearPluginDiscoveryCache } from "./discovery.js";
99
import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js";
1010
import { createHookRunner } from "./hooks.js";
11-
import { __testing, clearPluginLoaderCache, loadOpenClawPlugins } from "./loader.js";
11+
import {
12+
__testing,
13+
clearPluginLoaderCache,
14+
getCompatibleActivePluginRegistry,
15+
loadOpenClawPlugins,
16+
resolvePluginLoadCacheContext,
17+
} from "./loader.js";
1218
import { clearPluginManifestRegistryCache } from "./manifest-registry.js";
1319
import {
1420
getMemoryEmbeddingProvider,
@@ -27,6 +33,7 @@ import { createEmptyPluginRegistry } from "./registry.js";
2733
import {
2834
getActivePluginRegistry,
2935
getActivePluginRegistryKey,
36+
resetPluginRuntimeStateForTest,
3037
setActivePluginRegistry,
3138
} from "./runtime.js";
3239

@@ -763,6 +770,7 @@ afterEach(() => {
763770
clearPluginLoaderCache();
764771
clearPluginDiscoveryCache();
765772
clearPluginManifestRegistryCache();
773+
resetPluginRuntimeStateForTest();
766774
resetDiagnosticEventsForTest();
767775
if (prevBundledDir === undefined) {
768776
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
@@ -3539,6 +3547,53 @@ export const runtimeValue = helperValue;`,
35393547
});
35403548
});
35413549

3550+
describe("getCompatibleActivePluginRegistry", () => {
3551+
it("reuses the active registry only when the load context cache key matches", () => {
3552+
const registry = createEmptyPluginRegistry();
3553+
const loadOptions = {
3554+
config: {
3555+
plugins: {
3556+
allow: ["demo"],
3557+
load: { paths: ["/tmp/demo.js"] },
3558+
},
3559+
},
3560+
workspaceDir: "/tmp/workspace-a",
3561+
runtimeOptions: {
3562+
allowGatewaySubagentBinding: true,
3563+
},
3564+
};
3565+
const { cacheKey } = resolvePluginLoadCacheContext(loadOptions);
3566+
setActivePluginRegistry(registry, cacheKey);
3567+
3568+
expect(getCompatibleActivePluginRegistry(loadOptions)).toBe(registry);
3569+
expect(
3570+
getCompatibleActivePluginRegistry({
3571+
...loadOptions,
3572+
workspaceDir: "/tmp/workspace-b",
3573+
}),
3574+
).toBeUndefined();
3575+
expect(
3576+
getCompatibleActivePluginRegistry({
3577+
...loadOptions,
3578+
onlyPluginIds: ["demo"],
3579+
}),
3580+
).toBeUndefined();
3581+
expect(
3582+
getCompatibleActivePluginRegistry({
3583+
...loadOptions,
3584+
runtimeOptions: undefined,
3585+
}),
3586+
).toBeUndefined();
3587+
});
3588+
3589+
it("falls back to the current active runtime when no compatibility-shaping inputs are supplied", () => {
3590+
const registry = createEmptyPluginRegistry();
3591+
setActivePluginRegistry(registry, "startup-registry");
3592+
3593+
expect(getCompatibleActivePluginRegistry()).toBe(registry);
3594+
});
3595+
});
3596+
35423597
describe("clearPluginLoaderCache", () => {
35433598
it("resets registered memory plugin registries", () => {
35443599
registerMemoryEmbeddingProvider({

src/plugins/loader.ts

Lines changed: 88 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ import {
3737
import { isPathInside, safeStatSync } from "./path-safety.js";
3838
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
3939
import { resolvePluginCacheInputs } from "./roots.js";
40-
import { setActivePluginRegistry } from "./runtime.js";
40+
import {
41+
getActivePluginRegistry,
42+
getActivePluginRegistryKey,
43+
setActivePluginRegistry,
44+
} from "./runtime.js";
4145
import type { CreatePluginRuntimeOptions } from "./runtime/index.js";
4246
import type { PluginRuntime } from "./runtime/types.js";
4347
import { validateJsonSchemaValue } from "./schema-validator.js";
@@ -239,6 +243,80 @@ function normalizeScopedPluginIds(ids?: string[]): string[] | undefined {
239243
return normalized.length > 0 ? normalized : undefined;
240244
}
241245

246+
function resolveRuntimeSubagentMode(
247+
runtimeOptions: PluginLoadOptions["runtimeOptions"],
248+
): "default" | "explicit" | "gateway-bindable" {
249+
if (runtimeOptions?.allowGatewaySubagentBinding === true) {
250+
return "gateway-bindable";
251+
}
252+
if (runtimeOptions?.subagent) {
253+
return "explicit";
254+
}
255+
return "default";
256+
}
257+
258+
function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
259+
return Boolean(
260+
options.config !== undefined ||
261+
options.workspaceDir !== undefined ||
262+
options.env !== undefined ||
263+
options.onlyPluginIds?.length ||
264+
options.runtimeOptions !== undefined ||
265+
options.pluginSdkResolution !== undefined ||
266+
options.includeSetupOnlyChannelPlugins === true ||
267+
options.preferSetupRuntimeForChannelPlugins === true,
268+
);
269+
}
270+
271+
export function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
272+
const env = options.env ?? process.env;
273+
const cfg = applyTestPluginDefaults(options.config ?? {}, env);
274+
const normalized = normalizePluginsConfig(cfg.plugins);
275+
const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds);
276+
const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true;
277+
const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true;
278+
const cacheKey = buildCacheKey({
279+
workspaceDir: options.workspaceDir,
280+
plugins: normalized,
281+
installs: cfg.plugins?.installs,
282+
env,
283+
onlyPluginIds,
284+
includeSetupOnlyChannelPlugins,
285+
preferSetupRuntimeForChannelPlugins,
286+
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
287+
pluginSdkResolution: options.pluginSdkResolution,
288+
});
289+
return {
290+
env,
291+
cfg,
292+
normalized,
293+
onlyPluginIds,
294+
includeSetupOnlyChannelPlugins,
295+
preferSetupRuntimeForChannelPlugins,
296+
shouldActivate: options.activate !== false,
297+
cacheKey,
298+
};
299+
}
300+
301+
export function getCompatibleActivePluginRegistry(
302+
options: PluginLoadOptions = {},
303+
): PluginRegistry | undefined {
304+
const activeRegistry = getActivePluginRegistry() ?? undefined;
305+
if (!activeRegistry) {
306+
return undefined;
307+
}
308+
if (!hasExplicitCompatibilityInputs(options)) {
309+
return activeRegistry;
310+
}
311+
const activeCacheKey = getActivePluginRegistryKey();
312+
if (!activeCacheKey) {
313+
return undefined;
314+
}
315+
return resolvePluginLoadCacheContext(options).cacheKey === activeCacheKey
316+
? activeRegistry
317+
: undefined;
318+
}
319+
242320
function validatePluginConfig(params: {
243321
schema?: Record<string, unknown>;
244322
cacheKey?: string;
@@ -687,38 +765,19 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
687765
"loadOpenClawPlugins: activate:false requires cache:false to prevent command registry divergence",
688766
);
689767
}
690-
const env = options.env ?? process.env;
691-
// Test env: default-disable plugins unless explicitly configured.
692-
// This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident.
693-
const cfg = applyTestPluginDefaults(options.config ?? {}, env);
694-
const logger = options.logger ?? defaultLogger();
695-
const validateOnly = options.mode === "validate";
696-
const normalized = normalizePluginsConfig(cfg.plugins);
697-
const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds);
698-
const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null;
699-
const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true;
700-
const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true;
701-
const shouldActivate = options.activate !== false;
702-
// NOTE: `activate` is intentionally excluded from the cache key. All non-activating
703-
// (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they
704-
// never read from or write to the cache. Including `activate` here would be misleading
705-
// — it would imply mixed-activate caching is supported, when in practice it is not.
706-
const cacheKey = buildCacheKey({
707-
workspaceDir: options.workspaceDir,
708-
plugins: normalized,
709-
installs: cfg.plugins?.installs,
768+
const {
710769
env,
770+
cfg,
771+
normalized,
711772
onlyPluginIds,
712773
includeSetupOnlyChannelPlugins,
713774
preferSetupRuntimeForChannelPlugins,
714-
runtimeSubagentMode:
715-
options.runtimeOptions?.allowGatewaySubagentBinding === true
716-
? "gateway-bindable"
717-
: options.runtimeOptions?.subagent
718-
? "explicit"
719-
: "default",
720-
pluginSdkResolution: options.pluginSdkResolution,
721-
});
775+
shouldActivate,
776+
cacheKey,
777+
} = resolvePluginLoadCacheContext(options);
778+
const logger = options.logger ?? defaultLogger();
779+
const validateOnly = options.mode === "validate";
780+
const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null;
722781
const cacheEnabled = options.cache !== false;
723782
if (cacheEnabled) {
724783
const cached = getCachedPluginRegistry(cacheKey);

0 commit comments

Comments
 (0)