refactor(plugins): delete registry compat scaffolding (#117749)

* refactor(plugins): delete registry compat scaffolding

* test(plugins): update CLI registry handle mock

* fix(plugins): preserve explicitly initialized hook registries

* test(plugins): update registry ownership fixtures

* fix(channels): restore registry snapshot memo
This commit is contained in:
Peter Steinberger
2026-08-01 21:18:47 -07:00
committed by GitHub
parent a084763814
commit ccee629359
129 changed files with 1677 additions and 6720 deletions
@@ -1,7 +0,0 @@
/** Clears gateway plugin runtime bindings between tests. */
import { gatewaySubagentState } from "./gateway-bindings.js";
export function clearGatewaySubagentRuntime(): void {
gatewaySubagentState.subagent = undefined;
gatewaySubagentState.nodes = undefined;
}
-37
View File
@@ -1,37 +0,0 @@
// Gateway binding helpers expose plugin runtime bindings through gateway-safe singletons.
import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
import type { PluginRuntime } from "./types.js";
const GATEWAY_SUBAGENT_SYMBOL: unique symbol = Symbol.for(
"openclaw.plugin.gatewaySubagentRuntime",
) as unknown as typeof GATEWAY_SUBAGENT_SYMBOL;
type GatewaySubagentState = {
subagent: PluginRuntime["subagent"] | undefined;
nodes: PluginRuntime["nodes"] | undefined;
};
export const gatewaySubagentState = resolveGlobalSingleton<GatewaySubagentState>(
GATEWAY_SUBAGENT_SYMBOL,
() => ({
subagent: undefined,
nodes: undefined,
}),
);
// PHASE2C: Remove this singleton after session-catalog owns its nodes runtime and prepared/request
// registry handles carry concrete gateway bindings across reload instead of using late proxies.
/**
* Set the process-global gateway subagent runtime.
* Called during gateway startup so that gateway-bindable plugin runtimes can
* resolve subagent methods dynamically even when their registry was cached
* before the gateway finished loading plugins.
*/
export function setGatewaySubagentRuntime(subagent: PluginRuntime["subagent"]): void {
gatewaySubagentState.subagent = subagent;
}
export function setGatewayNodesRuntime(nodes: PluginRuntime["nodes"]): void {
gatewaySubagentState.nodes = nodes;
}
+6 -37
View File
@@ -24,8 +24,6 @@ const sandboxContextMocks = vi.hoisted(() => ({
vi.mock("./runtime-model-auth.runtime.js", () => runtimeModelAuthMocks);
vi.mock("../../agents/sandbox/context.js", () => sandboxContextMocks);
import { setGatewayNodesRuntime, setGatewaySubagentRuntime } from "./gateway-bindings.js";
import { clearGatewaySubagentRuntime } from "./gateway-bindings.test-fixtures.js";
import { createPluginRuntime } from "./index.js";
function createCommandResult() {
@@ -80,20 +78,6 @@ function expectRuntimeSubagentRun(
return runtime.subagent.run(params);
}
function createGatewaySubagentRunFixture(params?: { allowGatewaySubagentBinding?: boolean }) {
const run = vi.fn().mockResolvedValue({ runId: "run-1" });
const runtime = params?.allowGatewaySubagentBinding
? createPluginRuntime({ allowGatewaySubagentBinding: true })
: createPluginRuntime();
setGatewaySubagentRuntime({
...createGatewaySubagentRuntime(),
run,
});
return { run, runtime };
}
function expectFunctionKeys(value: Record<string, unknown>, keys: readonly string[]) {
for (const key of keys) {
expect(typeof value[key]).toBe("function");
@@ -122,7 +106,6 @@ describe("plugin runtime command execution", () => {
runtimeModelAuthMocks.resolveApiKeyForProvider.mockReset();
sandboxContextMocks.resolveSandboxContext.mockReset();
resetConfigRuntimeState();
clearGatewaySubagentRuntime();
});
it.each([
@@ -478,15 +461,15 @@ describe("plugin runtime command execution", () => {
});
});
it("keeps subagent unavailable by default even after gateway initialization", () => {
const { runtime } = createGatewaySubagentRunFixture();
it("keeps subagent unavailable by default", () => {
const runtime = createPluginRuntime();
expectGatewaySubagentRunFailure(runtime, { sessionKey: "s-1", message: "hello" });
});
it("late-binds to the gateway subagent when explicitly enabled", async () => {
const { run, runtime } = createGatewaySubagentRunFixture({
allowGatewaySubagentBinding: true,
it("uses an explicit subagent runtime", async () => {
const run = vi.fn().mockResolvedValue({ runId: "run-1" });
const runtime = createPluginRuntime({
subagent: { ...createGatewaySubagentRuntime(), run },
});
await expect(
@@ -511,18 +494,4 @@ describe("plugin runtime command execution", () => {
expect(nodes.list).toHaveBeenCalledWith({ connected: true });
expect(nodes.invoke).toHaveBeenCalledWith({ nodeId: "node-1", command: "browser.proxy" });
});
it("late-binds to gateway nodes when explicitly enabled", async () => {
const nodes = {
list: vi.fn().mockResolvedValue({ nodes: [{ nodeId: "node-1" }] }),
invoke: vi.fn().mockResolvedValue({ ok: true }),
};
const runtime = createPluginRuntime({ allowGatewaySubagentBinding: true });
setGatewayNodesRuntime(nodes);
await expect(runtime.nodes.list({ connected: true })).resolves.toEqual({
nodes: [{ nodeId: "node-1" }],
});
expect(nodes.list).toHaveBeenCalledWith({ connected: true });
});
});
+2 -53
View File
@@ -23,7 +23,6 @@ import {
listRuntimeVideoGenerationProviders,
} from "../../video-generation/runtime.js";
import { listWebSearchProviders, runWebSearch } from "../../web-search/runtime.js";
import { gatewaySubagentState } from "./gateway-bindings.js";
import { createRuntimeAgent } from "./runtime-agent.js";
import { defineCachedValue } from "./runtime-cache.js";
import { createRuntimeChannel } from "./runtime-channel.js";
@@ -183,40 +182,6 @@ function createUnavailableSubagentRuntime(): PluginRuntime["subagent"] {
};
}
// ── Process-global gateway subagent runtime ─────────────────────────
// The gateway creates a real subagent runtime during startup, but gateway-owned
// plugin registries may be loaded (and cached) before the gateway path runs.
// A process-global holder lets explicitly gateway-bindable runtimes resolve the
// active gateway subagent dynamically without changing the default behavior for
// ordinary plugin runtimes.
/**
* Create a late-binding subagent that resolves to:
* 1. An explicitly provided subagent (from runtimeOptions), OR
* 2. The process-global gateway subagent when the caller explicitly opts in, OR
* 3. The unavailable fallback (throws with a clear error message).
*/
function createLateBindingSubagent(
explicit?: PluginRuntime["subagent"],
allowGatewaySubagentBinding = false,
): PluginRuntime["subagent"] {
if (explicit) {
return explicit;
}
const unavailable = createUnavailableSubagentRuntime();
if (!allowGatewaySubagentBinding) {
return unavailable;
}
return new Proxy(unavailable, {
get(_target, prop, _receiver) {
const resolved = gatewaySubagentState.subagent ?? unavailable;
return Reflect.get(resolved, prop, resolved);
},
});
}
function createUnavailableNodesRuntime(): PluginRuntime["nodes"] {
const unavailable = () => {
throw new Error("Plugin node runtime is only available inside the Gateway.");
@@ -227,19 +192,6 @@ function createUnavailableNodesRuntime(): PluginRuntime["nodes"] {
};
}
function createLateBindingNodes(allowGatewayBinding = false): PluginRuntime["nodes"] {
const unavailable = createUnavailableNodesRuntime();
if (!allowGatewayBinding) {
return unavailable;
}
return new Proxy(unavailable, {
get(_target, prop, _receiver) {
const resolved = gatewaySubagentState.nodes ?? unavailable;
return Reflect.get(resolved, prop, resolved);
},
});
}
function createRuntimeWorktrees(): PluginRuntime["worktrees"] {
const loadService = () => import("../../agents/worktrees/service.js");
return {
@@ -317,11 +269,8 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}):
gateway: createRuntimeGateway(),
config: createRuntimeConfig(),
agent,
subagent: createLateBindingSubagent(
_options.subagent,
_options.allowGatewaySubagentBinding === true,
),
nodes: _options.nodes ?? createLateBindingNodes(_options.allowGatewaySubagentBinding === true),
subagent: _options.subagent ?? createUnavailableSubagentRuntime(),
nodes: _options.nodes ?? createUnavailableNodesRuntime(),
sandbox: createRuntimeSandbox(agent),
worktrees: createRuntimeWorktrees(),
system: createRuntimeSystem(),
@@ -1,19 +1,10 @@
// Runtime registry loader tests cover plugin runtime assembly and activation boundaries.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../registry.js";
// Runtime registry loader tests cover the surviving process-root load scopes.
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
loadOpenClawPlugins: vi.fn<typeof import("../loader.js").loadOpenClawPlugins>(),
resolveCompatibleRuntimePluginRegistry:
vi.fn<typeof import("../loader.js").resolveCompatibleRuntimePluginRegistry>(),
resolveRuntimePluginRegistry: vi.fn<typeof import("../loader.js").resolveRuntimePluginRegistry>(),
getActivePluginRegistry: vi.fn<typeof import("../runtime.js").getActivePluginRegistry>(),
getActivePluginRegistryWorkspaceDir:
vi.fn<typeof import("../runtime.js").getActivePluginRegistryWorkspaceDir>(),
resolveConfiguredChannelPluginIds:
vi.fn<typeof import("../channel-plugin-ids.js").resolveConfiguredChannelPluginIds>(),
resolveDiscoverableScopedChannelPluginIds:
vi.fn<typeof import("../channel-plugin-ids.js").resolveDiscoverableScopedChannelPluginIds>(),
resolveChannelPluginIds:
vi.fn<typeof import("../channel-plugin-ids.js").resolveChannelPluginIds>(),
resolveEffectivePluginIds:
@@ -30,69 +21,15 @@ const mocks = vi.hoisted(() => ({
),
}));
let ensurePluginRegistryLoaded: typeof import("./runtime-registry-loader.js").ensurePluginRegistryLoaded;
let resetPluginRegistryLoadedForTests: typeof import("./runtime-registry-loader.js").testing.resetPluginRegistryLoadedForTests;
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error(`expected ${label}`);
}
return value as Record<string, unknown>;
}
function loadOptions(index = 0) {
return requireRecord(mocks.loadOpenClawPlugins.mock.calls[index]?.[0], `load options ${index}`);
}
function configuredChannelOptions(index = 0) {
return requireRecord(
mocks.resolveConfiguredChannelPluginIds.mock.calls[index]?.[0],
`configured channel options ${index}`,
);
}
function scopedChannelOptions(index = 0) {
return requireRecord(
mocks.resolveDiscoverableScopedChannelPluginIds.mock.calls[index]?.[0],
`scoped channel options ${index}`,
);
}
function pluginsConfig(config: Record<string, unknown>) {
return requireRecord(config.plugins, "plugins config");
}
function pluginEntries(config: Record<string, unknown>) {
return requireRecord(pluginsConfig(config).entries, "plugin entries");
}
vi.mock("../loader.js", () => ({
loadOpenClawPlugins: (...args: Parameters<typeof mocks.loadOpenClawPlugins>) =>
mocks.loadOpenClawPlugins(...args),
resolveCompatibleRuntimePluginRegistry: (
...args: Parameters<typeof mocks.resolveCompatibleRuntimePluginRegistry>
) => mocks.resolveCompatibleRuntimePluginRegistry(...args),
resolveRuntimePluginRegistry: (...args: Parameters<typeof mocks.resolveRuntimePluginRegistry>) =>
mocks.resolveRuntimePluginRegistry(...args),
}));
vi.mock("../runtime.js", () => ({
getActivePluginChannelRegistry: () => null,
getActivePluginHttpRouteRegistry: () => null,
getActivePluginRegistry: (...args: Parameters<typeof mocks.getActivePluginRegistry>) =>
mocks.getActivePluginRegistry(...args),
getActivePluginRegistryWorkspaceDir: (
...args: Parameters<typeof mocks.getActivePluginRegistryWorkspaceDir>
) => mocks.getActivePluginRegistryWorkspaceDir(...args),
}));
vi.mock("../channel-plugin-ids.js", () => ({
resolveConfiguredChannelPluginIds: (
...args: Parameters<typeof mocks.resolveConfiguredChannelPluginIds>
) => mocks.resolveConfiguredChannelPluginIds(...args),
resolveDiscoverableScopedChannelPluginIds: (
...args: Parameters<typeof mocks.resolveDiscoverableScopedChannelPluginIds>
) => mocks.resolveDiscoverableScopedChannelPluginIds(...args),
resolveChannelPluginIds: (...args: Parameters<typeof mocks.resolveChannelPluginIds>) =>
mocks.resolveChannelPluginIds(...args),
}));
@@ -120,434 +57,68 @@ vi.mock("../../agents/agent-scope.js", () => ({
mocks.resolveDefaultAgentId(...args),
}));
import { ensurePluginRegistryLoaded } from "./runtime-registry-loader.js";
function requireLoadOptions(): Record<string, unknown> {
const options = mocks.loadOpenClawPlugins.mock.calls[0]?.[0];
if (!options) {
throw new Error("expected plugin load options");
}
return options as Record<string, unknown>;
}
describe("ensurePluginRegistryLoaded", () => {
beforeAll(async () => {
const mod = await import("./runtime-registry-loader.js");
ensurePluginRegistryLoaded = mod.ensurePluginRegistryLoaded;
resetPluginRegistryLoadedForTests = () => mod.testing.resetPluginRegistryLoadedForTests();
});
beforeEach(() => {
mocks.loadOpenClawPlugins.mockReset();
mocks.resolveCompatibleRuntimePluginRegistry.mockReset();
mocks.resolveRuntimePluginRegistry.mockReset();
mocks.getActivePluginRegistry.mockReset();
mocks.getActivePluginRegistryWorkspaceDir.mockReset();
mocks.resolveConfiguredChannelPluginIds.mockReset();
mocks.resolveDiscoverableScopedChannelPluginIds.mockReset();
mocks.resolveChannelPluginIds.mockReset();
mocks.resolveEffectivePluginIds.mockReset();
mocks.applyPluginAutoEnable.mockReset();
mocks.resolvePluginMetadataSnapshot.mockReset();
mocks.resolveAgentWorkspaceDir.mockClear();
mocks.resolveDefaultAgentId.mockClear();
resetPluginRegistryLoadedForTests();
mocks.getActivePluginRegistry.mockReturnValue(null);
mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue(undefined);
mocks.resolveCompatibleRuntimePluginRegistry.mockReturnValue(undefined);
mocks.loadOpenClawPlugins.mockReturnValue(createEmptyPluginRegistry());
mocks.resolveRuntimePluginRegistry.mockImplementation(
(...args: Parameters<typeof mocks.loadOpenClawPlugins>) => mocks.loadOpenClawPlugins(...args),
);
vi.clearAllMocks();
mocks.applyPluginAutoEnable.mockImplementation((params) => ({
config:
params.config && typeof params.config === "object"
? {
...params.config,
plugins: {
entries: {
demo: { enabled: true },
},
},
}
: {},
config: params.config ?? {},
changes: [],
autoEnabledReasons: {
demo: ["demo configured"],
},
autoEnabledReasons: {},
}));
mocks.resolveDiscoverableScopedChannelPluginIds.mockReturnValue([]);
mocks.resolveEffectivePluginIds.mockReturnValue(["demo"]);
});
it("uses the shared runtime load context for configured-channel loads", () => {
const rawConfig = { channels: { demo: { enabled: true } } };
const resolvedConfig = {
...rawConfig,
plugins: {
entries: {
demo: { enabled: true },
},
},
};
const env = { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv;
it("loads configured channel owners through the canonical root loader", () => {
const config = { channels: { demo: { enabled: true } } };
mocks.resolveConfiguredChannelPluginIds.mockReturnValue(["demo-channel"]);
ensurePluginRegistryLoaded({
scope: "configured-channels",
config: rawConfig as never,
env,
activationSourceConfig: { plugins: { allow: ["demo-channel"] } } as never,
});
const channelOptions = configuredChannelOptions();
expect(channelOptions.config).toEqual(resolvedConfig);
expect(channelOptions.activationSourceConfig).toEqual({ plugins: { allow: ["demo-channel"] } });
expect(channelOptions.env).toBe(env);
expect(channelOptions.workspaceDir).toBe("/resolved-workspace");
expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith(
ensurePluginRegistryLoaded({ scope: "configured-channels", config: config as never });
expect(mocks.resolveConfiguredChannelPluginIds).toHaveBeenCalledWith(
expect.objectContaining({ config, workspaceDir: "/resolved-workspace" }),
);
expect(requireLoadOptions()).toEqual(
expect.objectContaining({
config: rawConfig,
env,
onlyPluginIds: ["demo-channel"],
throwOnLoadError: true,
workspaceDir: "/resolved-workspace",
}),
);
const load = loadOptions();
const loadConfig = requireRecord(load.config, "load config");
expect(loadConfig.channels).toEqual(rawConfig.channels);
expect(pluginEntries(loadConfig)).toEqual({
demo: { enabled: true },
"demo-channel": { enabled: true },
});
expect(pluginsConfig(loadConfig).allow).toEqual(["demo-channel"]);
expect(load.activationSourceConfig).toEqual({
plugins: {
allow: ["demo-channel"],
entries: {
"demo-channel": { enabled: true },
},
},
});
expect(load.autoEnabledReasons).toEqual({
demo: ["demo configured"],
});
expect(load.workspaceDir).toBe("/resolved-workspace");
expect(load.onlyPluginIds).toEqual(["demo-channel"]);
expect(load.throwOnLoadError).toBe(true);
});
it("temporarily activates configured-channel owners before loading them", () => {
const rawConfig = { channels: { demo: { enabled: true } } };
mocks.resolveConfiguredChannelPluginIds.mockReturnValue(["activation-only-channel"]);
ensurePluginRegistryLoaded({
scope: "configured-channels",
config: rawConfig as never,
});
const load = loadOptions();
const loadConfig = requireRecord(load.config, "load config");
expect(pluginEntries(loadConfig)["activation-only-channel"]).toEqual({ enabled: true });
expect(pluginsConfig(loadConfig).allow).toEqual(["activation-only-channel"]);
const activation = requireRecord(load.activationSourceConfig, "activation config");
expect(pluginEntries(activation)["activation-only-channel"]).toEqual({ enabled: true });
expect(pluginsConfig(activation).allow).toEqual(["activation-only-channel"]);
expect(load.onlyPluginIds).toEqual(["activation-only-channel"]);
});
it("does not cache scoped loads by explicit plugin ids", () => {
ensurePluginRegistryLoaded({
scope: "configured-channels",
config: {} as never,
onlyPluginIds: ["demo-a"],
});
ensurePluginRegistryLoaded({
scope: "configured-channels",
config: {} as never,
onlyPluginIds: ["demo-b"],
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2);
expect(loadOptions(0).onlyPluginIds).toEqual(["demo-a"]);
expect(loadOptions(1).onlyPluginIds).toEqual(["demo-b"]);
});
it("maps explicit channel scopes to owner plugin ids before loading", () => {
const rawConfig = { channels: { "external-chat": { token: "configured" } } };
mocks.resolveDiscoverableScopedChannelPluginIds.mockReturnValue(["external-chat-plugin"]);
ensurePluginRegistryLoaded({
scope: "configured-channels",
config: rawConfig as never,
onlyChannelIds: ["external-chat"],
});
const channelOptions = scopedChannelOptions();
const channelConfig = requireRecord(channelOptions.config, "scoped channel config");
expect(channelConfig.channels).toEqual(rawConfig.channels);
expect(pluginEntries(channelConfig).demo).toEqual({ enabled: true });
expect(channelOptions.activationSourceConfig).toBe(rawConfig);
expect(channelOptions.channelIds).toEqual(["external-chat"]);
expect(channelOptions.workspaceDir).toBe("/resolved-workspace");
const load = loadOptions();
const loadConfig = requireRecord(load.config, "load config");
expect(pluginsConfig(loadConfig).allow).toEqual(["external-chat-plugin"]);
expect(pluginEntries(loadConfig)["external-chat-plugin"]).toEqual({ enabled: true });
const activation = requireRecord(load.activationSourceConfig, "activation config");
expect(pluginsConfig(activation).allow).toEqual(["external-chat-plugin"]);
expect(pluginEntries(activation)["external-chat-plugin"]).toEqual({ enabled: true });
expect(load.onlyPluginIds).toEqual(["external-chat-plugin"]);
});
it("forwards explicit empty scopes without widening to channel resolution", () => {
ensurePluginRegistryLoaded({
scope: "configured-channels",
config: {} as never,
onlyPluginIds: [],
});
expect(mocks.resolveConfiguredChannelPluginIds).not.toHaveBeenCalled();
expect(mocks.resolveChannelPluginIds).not.toHaveBeenCalled();
expect(loadOptions().onlyPluginIds).toEqual([]);
});
it("preserves empty configured-channel scopes when no owners are activatable", () => {
it("keeps an empty configured-channel scope empty", () => {
mocks.resolveConfiguredChannelPluginIds.mockReturnValue([]);
ensurePluginRegistryLoaded({
scope: "configured-channels",
config: { channels: { demo: { enabled: true } } } as never,
});
ensurePluginRegistryLoaded({ scope: "configured-channels", config: {} });
expect(loadOptions().onlyPluginIds).toEqual([]);
expect(requireLoadOptions().onlyPluginIds).toEqual([]);
});
it("does not forward empty channel scopes for broad channel loads", () => {
mocks.resolveChannelPluginIds.mockReturnValue([]);
it("loads effective plugin ids for the all scope", () => {
const config = { plugins: { enabled: true } };
mocks.resolveEffectivePluginIds.mockReturnValue(["demo", "memory-core"]);
ensurePluginRegistryLoaded({
scope: "channels",
config: {} as never,
});
expect(loadOptions().onlyPluginIds).toBeUndefined();
});
it("derives all-scope runtime loads from effective plugin ids", () => {
const config = {
plugins: { enabled: true },
channels: { "demo-channel-a": { enabled: true } },
};
const env = { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv;
mocks.resolveEffectivePluginIds.mockReturnValue(["demo-effective", "demo-hook"]);
ensurePluginRegistryLoaded({ scope: "all", config: config as never, env });
ensurePluginRegistryLoaded({ scope: "all", config });
expect(mocks.resolveEffectivePluginIds).toHaveBeenCalledWith({
config,
env,
env: process.env,
workspaceDir: "/resolved-workspace",
});
const load = loadOptions();
const loadConfig = requireRecord(load.config, "load config");
expect(loadConfig.channels).toEqual(config.channels);
expect(pluginEntries(loadConfig).demo).toEqual({ enabled: true });
expect(load.onlyPluginIds).toEqual(["demo-effective", "demo-hook"]);
expect(load.throwOnLoadError).toBe(true);
expect(load.workspaceDir).toBe("/resolved-workspace");
});
it("does not reuse non-empty all-scope registries without loader compatibility", () => {
mocks.resolveEffectivePluginIds.mockReturnValue(["demo"]);
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { allow: ["demo"] } } as never,
});
const activeRegistry = createEmptyPluginRegistry();
activeRegistry.plugins.push({
id: "demo",
source: "/tmp/demo.js",
origin: "workspace",
enabled: true,
status: "loaded",
} as never);
mocks.getActivePluginRegistry.mockReturnValue(activeRegistry);
mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue("/resolved-workspace");
mocks.loadOpenClawPlugins.mockClear();
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { allow: ["demo"], entries: { demo: { value: "changed" } } } } as never,
});
expect(loadOptions().onlyPluginIds).toEqual(["demo"]);
});
it("preserves empty all-scope loads instead of widening to all discovered plugins", () => {
mocks.resolveEffectivePluginIds.mockReturnValue([]);
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { enabled: true } } as never,
});
expect(loadOptions().onlyPluginIds).toEqual([]);
});
it("reuses an active empty registry for repeated empty all-scope loads", () => {
mocks.resolveEffectivePluginIds.mockReturnValue([]);
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { enabled: true } } as never,
});
const emptyRegistry = createEmptyPluginRegistry();
mocks.getActivePluginRegistry.mockReturnValue(emptyRegistry);
mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue("/resolved-workspace");
mocks.loadOpenClawPlugins.mockClear();
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { enabled: true } } as never,
});
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
it("does not reuse an empty active registry from another workspace", () => {
mocks.resolveEffectivePluginIds.mockReturnValue([]);
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { enabled: true } } as never,
});
const emptyRegistry = createEmptyPluginRegistry();
mocks.getActivePluginRegistry.mockReturnValue(emptyRegistry);
mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue("/other-workspace");
mocks.loadOpenClawPlugins.mockClear();
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { enabled: true } } as never,
});
expect(loadOptions().onlyPluginIds).toEqual([]);
});
it("does not reuse a non-empty active registry for empty all-scope loads", () => {
mocks.resolveEffectivePluginIds.mockReturnValue([]);
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { enabled: true } } as never,
});
const staleRegistry = createEmptyPluginRegistry();
staleRegistry.plugins.push({
id: "stale",
source: "/tmp/stale.js",
origin: "workspace",
enabled: true,
status: "loaded",
} as never);
mocks.getActivePluginRegistry.mockReturnValue(staleRegistry);
mocks.loadOpenClawPlugins.mockClear();
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { enabled: true } } as never,
});
expect(loadOptions().onlyPluginIds).toEqual([]);
});
it("does not reuse a disabled-record registry for empty all-scope loads", () => {
mocks.resolveEffectivePluginIds.mockReturnValue([]);
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { enabled: true } } as never,
});
const disabledRegistry = createEmptyPluginRegistry();
disabledRegistry.plugins.push({
id: "disabled",
source: "/tmp/disabled.js",
origin: "workspace",
enabled: false,
status: "disabled",
} as never);
mocks.getActivePluginRegistry.mockReturnValue(disabledRegistry);
mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue("/resolved-workspace");
mocks.loadOpenClawPlugins.mockClear();
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { enabled: true } } as never,
});
expect(loadOptions().onlyPluginIds).toEqual([]);
});
it("does not reuse a failed diagnostic registry for explicit plugin scopes", () => {
const failedRegistry = createEmptyPluginRegistry();
failedRegistry.plugins.push({
id: "failed",
source: "/tmp/failed.js",
origin: "workspace",
enabled: true,
status: "error",
} as never);
failedRegistry.diagnostics.push({
level: "error",
pluginId: "failed",
message: "failed to load",
} as never);
mocks.getActivePluginRegistry.mockReturnValue(failedRegistry);
mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue("/resolved-workspace");
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { enabled: true } } as never,
onlyPluginIds: ["failed"],
});
expect(loadOptions().onlyPluginIds).toEqual(["failed"]);
});
it("does not reuse a setup-only registry for explicit plugin scopes", () => {
const setupRegistry = createEmptyPluginRegistry();
setupRegistry.plugins.push({
id: "setup-only",
source: "/tmp/setup-only.js",
origin: "workspace",
enabled: false,
status: "disabled",
} as never);
setupRegistry.channelSetups.push({
pluginId: "setup-only",
} as never);
mocks.getActivePluginRegistry.mockReturnValue(setupRegistry);
mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue("/resolved-workspace");
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { enabled: true } } as never,
onlyPluginIds: ["setup-only"],
});
expect(loadOptions().onlyPluginIds).toEqual(["setup-only"]);
});
it("reuses a compatible active registry instead of forcing a broad reload", () => {
const activeRegistry = createEmptyPluginRegistry();
activeRegistry.plugins.push({
id: "demo",
source: "/tmp/demo.js",
origin: "workspace",
enabled: true,
status: "loaded",
} as never);
mocks.getActivePluginRegistry.mockReturnValue(activeRegistry);
mocks.resolveCompatibleRuntimePluginRegistry.mockReturnValue(activeRegistry);
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { allow: ["demo"] } } as never,
});
expect(mocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled();
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
expect(requireLoadOptions()).toEqual(
expect.objectContaining({
onlyPluginIds: ["demo", "memory-core"],
throwOnLoadError: true,
}),
);
});
});
+41 -202
View File
@@ -1,126 +1,44 @@
// Runtime registry loader assembles activated plugin runtimes from config and registry metadata.
// Runtime registry loader assembles process-root plugin runtimes from config metadata.
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withActivatedPluginIds } from "../activation-context.js";
import {
getLoadedRuntimePluginRegistry,
registryContainsRuntimePluginIds,
} from "../active-runtime-registry.js";
import {
resolveChannelPluginIds,
resolveConfiguredChannelPluginIds,
resolveDiscoverableScopedChannelPluginIds,
} from "../channel-plugin-ids.js";
import { resolveEffectivePluginIds } from "../effective-plugin-ids.js";
import { loadOpenClawPlugins } from "../loader.js";
import {
hasExplicitPluginIdScope,
hasNonEmptyPluginIdScope,
normalizePluginIdScope,
} from "../plugin-scope.js";
import { getActivePluginRegistry, getActivePluginRegistryWorkspaceDir } from "../runtime.js";
import { hasNonEmptyPluginIdScope } from "../plugin-scope.js";
import {
buildPluginRuntimeLoadOptionsFromValues,
resolvePluginRuntimeLoadContext,
} from "./load-context.js";
let pluginRegistryLoaded: "none" | "configured-channels" | "channels" | "all" = "none";
export type PluginRegistryScope = "configured-channels" | "channels" | "all";
function scopeRank(scope: typeof pluginRegistryLoaded): number {
switch (scope) {
case "none":
return 0;
case "configured-channels":
return 1;
case "channels":
return 2;
case "all":
return 3;
}
throw new Error("Unsupported plugin registry scope");
}
function activeRegistrySatisfiesScope(
scope: PluginRegistryScope,
active: ReturnType<typeof getActivePluginRegistry>,
expectedChannelPluginIds: readonly string[],
requestedPluginIds: readonly string[] | undefined,
requestedWorkspaceDir: string | undefined,
): boolean {
if (!active) {
return false;
}
if (requestedPluginIds !== undefined) {
const activeWorkspaceDir = getActivePluginRegistryWorkspaceDir();
if (requestedWorkspaceDir !== undefined && activeWorkspaceDir !== requestedWorkspaceDir) {
return false;
}
return registryContainsRuntimePluginIds(active, requestedPluginIds);
}
const activeChannelPluginIds = new Set(active.channels.map((entry) => entry.plugin.id));
switch (scope) {
case "configured-channels":
case "channels":
return (
active.channels.length > 0 &&
expectedChannelPluginIds.every((pluginId) => activeChannelPluginIds.has(pluginId))
);
case "all":
return false;
}
throw new Error("Unsupported plugin registry scope");
}
function shouldForwardChannelScope(params: {
scope: PluginRegistryScope;
scopedLoad: boolean;
}): boolean {
return !params.scopedLoad && params.scope === "configured-channels";
}
function resolveScopePluginIds(params: {
scope: PluginRegistryScope;
context: ReturnType<typeof resolvePluginRuntimeLoadContext>;
}): string[] {
switch (params.scope) {
case "configured-channels":
return resolveConfiguredChannelPluginIds({
config: params.context.config,
activationSourceConfig: params.context.activationSourceConfig,
workspaceDir: params.context.workspaceDir,
env: params.context.env,
});
case "channels":
return resolveChannelPluginIds({
config: params.context.config,
workspaceDir: params.context.workspaceDir,
env: params.context.env,
});
case "all":
return resolveEffectivePluginIds({
config: params.context.rawConfig,
workspaceDir: params.context.workspaceDir,
env: params.context.env,
});
if (params.scope === "configured-channels") {
return resolveConfiguredChannelPluginIds({
config: params.context.config,
activationSourceConfig: params.context.activationSourceConfig,
workspaceDir: params.context.workspaceDir,
env: params.context.env,
});
}
const unreachableScope: never = params.scope;
return unreachableScope;
}
function resolveOrLoadRuntimePluginRegistry(
loadOptions: NonNullable<Parameters<typeof loadOpenClawPlugins>[0]>,
): void {
if (
!getLoadedRuntimePluginRegistry({
env: loadOptions.env,
loadOptions,
workspaceDir: loadOptions.workspaceDir,
requiredPluginIds: loadOptions.onlyPluginIds,
})
) {
loadOpenClawPlugins(loadOptions);
if (params.scope === "channels") {
return resolveChannelPluginIds({
config: params.context.config,
workspaceDir: params.context.workspaceDir,
env: params.context.env,
});
}
return resolveEffectivePluginIds({
config: params.context.rawConfig,
workspaceDir: params.context.workspaceDir,
env: params.context.env,
});
}
export function ensurePluginRegistryLoaded(options?: {
@@ -129,108 +47,29 @@ export function ensurePluginRegistryLoaded(options?: {
activationSourceConfig?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
onlyPluginIds?: string[];
onlyChannelIds?: string[];
}): void {
const scope = options?.scope ?? "all";
const requestedPluginIdsFromOptions = normalizePluginIdScope(options?.onlyPluginIds);
const requestedChannelIds = normalizePluginIdScope(options?.onlyChannelIds);
const context = resolvePluginRuntimeLoadContext(options);
const requestedChannelOwnerPluginIds =
requestedChannelIds === undefined
? undefined
: resolveDiscoverableScopedChannelPluginIds({
config: context.config,
activationSourceConfig: context.activationSourceConfig,
channelIds: requestedChannelIds,
workspaceDir: context.workspaceDir,
env: context.env,
});
const requestedPluginIds =
requestedChannelOwnerPluginIds === undefined
? requestedPluginIdsFromOptions
: normalizePluginIdScope([
...(requestedPluginIdsFromOptions ?? []),
...requestedChannelOwnerPluginIds,
]);
const scopedLoad = hasExplicitPluginIdScope(requestedPluginIds);
const expectedPluginIds = scopedLoad
? (requestedPluginIds ?? [])
: resolveScopePluginIds({ scope, context });
const active = getActivePluginRegistry();
const requestedPluginIdsForScope =
scope === "all" && expectedPluginIds.length === 0 ? expectedPluginIds : undefined;
if (
!scopedLoad &&
scopeRank(pluginRegistryLoaded) >= scopeRank(scope) &&
activeRegistrySatisfiesScope(
scope,
active,
expectedPluginIds,
requestedPluginIdsForScope,
context.workspaceDir,
)
) {
return;
}
if (
(pluginRegistryLoaded === "none" || scopedLoad) &&
activeRegistrySatisfiesScope(
scope,
active,
expectedPluginIds,
requestedPluginIds,
context.workspaceDir,
)
) {
if (!scopedLoad) {
pluginRegistryLoaded = scope;
}
return;
}
const scopedConfig =
scope === "configured-channels" &&
expectedPluginIds.length > 0 &&
(!scopedLoad || requestedChannelOwnerPluginIds !== undefined)
? (withActivatedPluginIds({
config: context.config,
pluginIds: expectedPluginIds,
}) ?? context.config)
: context.config;
const scopedActivationSourceConfig =
scope === "configured-channels" &&
expectedPluginIds.length > 0 &&
(!scopedLoad || requestedChannelOwnerPluginIds !== undefined)
? (withActivatedPluginIds({
config: context.activationSourceConfig,
pluginIds: expectedPluginIds,
}) ?? context.activationSourceConfig)
: context.activationSourceConfig;
const loadOptions = buildPluginRuntimeLoadOptionsFromValues(
{
...context,
config: scopedConfig,
activationSourceConfig: scopedActivationSourceConfig,
},
{
throwOnLoadError: true,
...(hasExplicitPluginIdScope(requestedPluginIds) ||
shouldForwardChannelScope({ scope, scopedLoad }) ||
hasNonEmptyPluginIdScope(expectedPluginIds) ||
scope === "all"
? { onlyPluginIds: expectedPluginIds }
: {}),
},
const pluginIds = resolveScopePluginIds({ scope, context });
const activateConfigured = scope === "configured-channels" && pluginIds.length > 0;
const config = activateConfigured
? (withActivatedPluginIds({ config: context.config, pluginIds }) ?? context.config)
: context.config;
const activationSourceConfig = activateConfigured
? (withActivatedPluginIds({ config: context.activationSourceConfig, pluginIds }) ??
context.activationSourceConfig)
: context.activationSourceConfig;
loadOpenClawPlugins(
buildPluginRuntimeLoadOptionsFromValues(
{ ...context, config, activationSourceConfig },
{
throwOnLoadError: true,
...(scope === "configured-channels" ||
scope === "all" ||
hasNonEmptyPluginIdScope(pluginIds)
? { onlyPluginIds: pluginIds }
: {}),
},
),
);
resolveOrLoadRuntimePluginRegistry(loadOptions);
if (!scopedLoad) {
pluginRegistryLoaded = scope;
}
}
export const testing = {
resetPluginRegistryLoadedForTests(): void {
pluginRegistryLoaded = "none";
},
};
export { testing as __testing };
@@ -1,126 +0,0 @@
// Verifies scoped registry handles cannot install process-wide runtime state.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../registry-empty.js";
import {
getActivePluginChannelRegistry,
getActivePluginRegistry,
pinActivePluginChannelRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "../runtime.js";
const loaderMocks = vi.hoisted(() => ({
loadAndActivateRootPluginRegistry: vi.fn(),
loadPluginRegistryHandle: vi.fn(),
}));
vi.mock("../loader.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../loader.js")>();
return {
...actual,
loadAndActivateRootPluginRegistry: loaderMocks.loadAndActivateRootPluginRegistry,
loadPluginRegistryHandle: loaderMocks.loadPluginRegistryHandle,
};
});
import {
installRuntimePluginRegistryAtProcessRoot,
loadRuntimePluginRegistryHandle,
} from "./standalone-runtime-registry-loader.js";
beforeEach(() => {
loaderMocks.loadAndActivateRootPluginRegistry.mockReset();
loaderMocks.loadPluginRegistryHandle.mockReset();
});
afterEach(() => {
resetPluginRuntimeStateForTest();
});
describe("standalone runtime registry ownership", () => {
it("returns a scoped handle without replacing active or pinned registries", () => {
const activeRegistry = createEmptyPluginRegistry();
const channelRegistry = createEmptyPluginRegistry();
const scopedRegistry = createEmptyPluginRegistry();
setActivePluginRegistry(activeRegistry, "active-key", "default", "/tmp/ws");
pinActivePluginChannelRegistry(channelRegistry);
loaderMocks.loadPluginRegistryHandle.mockReturnValue(scopedRegistry);
expect(
loadRuntimePluginRegistryHandle({
forceLoad: true,
surface: "channel",
loadOptions: { onlyPluginIds: ["tool-plugin"], workspaceDir: "/tmp/ws" },
}),
).toBe(scopedRegistry);
expect(loaderMocks.loadPluginRegistryHandle).toHaveBeenCalledWith({
activate: false,
cache: false,
onlyPluginIds: ["tool-plugin"],
workspaceDir: "/tmp/ws",
});
expect(getActivePluginRegistry()).toBe(activeRegistry);
expect(getActivePluginChannelRegistry()).toBe(channelRegistry);
});
it("builds an explicit empty scope instead of reusing the active registry", () => {
const activeRegistry = createEmptyPluginRegistry();
const emptyScopedRegistry = createEmptyPluginRegistry();
setActivePluginRegistry(activeRegistry, "active-key", "default", "/tmp/ws");
loaderMocks.loadPluginRegistryHandle.mockReturnValue(emptyScopedRegistry);
expect(
loadRuntimePluginRegistryHandle({
requiredPluginIds: [],
loadOptions: { onlyPluginIds: [], workspaceDir: "/tmp/ws" },
}),
).toBe(emptyScopedRegistry);
expect(loaderMocks.loadPluginRegistryHandle).toHaveBeenCalledWith({
activate: false,
onlyPluginIds: [],
workspaceDir: "/tmp/ws",
});
expect(getActivePluginRegistry()).toBe(activeRegistry);
});
it("uses the activating loader only at the process-root entry point", () => {
const rootRegistry = createEmptyPluginRegistry();
loaderMocks.loadAndActivateRootPluginRegistry.mockReturnValue(rootRegistry);
expect(
installRuntimePluginRegistryAtProcessRoot({
forceLoad: true,
loadOptions: {
onlyPluginIds: ["gateway-plugin"],
workspaceDir: "/tmp/ws",
runtimeOptions: { allowGatewaySubagentBinding: true },
},
}),
).toBe(rootRegistry);
expect(loaderMocks.loadAndActivateRootPluginRegistry).toHaveBeenCalledWith({
activate: true,
cache: false,
onlyPluginIds: ["gateway-plugin"],
workspaceDir: "/tmp/ws",
runtimeOptions: { allowGatewaySubagentBinding: true },
});
expect(loaderMocks.loadPluginRegistryHandle).not.toHaveBeenCalled();
});
it("pins an explicitly installed channel surface", () => {
const rootRegistry = createEmptyPluginRegistry();
loaderMocks.loadAndActivateRootPluginRegistry.mockReturnValue(rootRegistry);
installRuntimePluginRegistryAtProcessRoot({
forceLoad: true,
surface: "channel",
loadOptions: { workspaceDir: "/tmp/ws" },
});
expect(getActivePluginRegistry()).toBe(rootRegistry);
expect(getActivePluginChannelRegistry()).toBe(rootRegistry);
});
});
@@ -1,114 +0,0 @@
// Runtime registry loader entry points distinguish process-root installation from scoped handles.
import {
type ActiveRuntimePluginRegistrySurface,
getLoadedRuntimePluginRegistry,
} from "../active-runtime-registry.js";
import {
loadAndActivateRootPluginRegistry,
loadPluginRegistryHandle,
resolvePluginRegistryLoadCacheKey,
type PluginLoadOptions,
} from "../loader.js";
import type { PluginRegistry } from "../registry-types.js";
import {
pinActivePluginChannelRegistry,
pinActivePluginHttpRouteRegistry,
setActivePluginRegistry,
} from "../runtime.js";
function resolveRuntimeSubagentMode(
loadOptions: PluginLoadOptions,
): "default" | "explicit" | "gateway-bindable" {
if (loadOptions.runtimeOptions?.allowGatewaySubagentBinding === true) {
return "gateway-bindable";
}
if (loadOptions.runtimeOptions?.subagent) {
return "explicit";
}
return "default";
}
function installProcessRootRuntimePluginRegistry(
registry: PluginRegistry,
params: {
loadOptions: PluginLoadOptions;
surface: ActiveRuntimePluginRegistrySurface;
},
): void {
const cacheKey = resolvePluginRegistryLoadCacheKey(params.loadOptions);
const mode = resolveRuntimeSubagentMode(params.loadOptions);
setActivePluginRegistry(registry, cacheKey, mode, params.loadOptions.workspaceDir);
switch (params.surface) {
case "active":
break;
case "channel":
pinActivePluginChannelRegistry(registry);
break;
case "http-route":
pinActivePluginHttpRouteRegistry(registry);
break;
}
}
type RuntimePluginRegistryLoadParams = {
loadOptions: PluginLoadOptions;
forceLoad?: boolean;
requiredPluginIds?: readonly string[];
surface?: ActiveRuntimePluginRegistrySurface;
};
function findLoadedRuntimePluginRegistry(
params: RuntimePluginRegistryLoadParams,
): PluginRegistry | undefined {
if (params.loadOptions.onlyPluginIds?.length === 0) {
return undefined;
}
const requiredPluginIds = params.requiredPluginIds ?? params.loadOptions.onlyPluginIds;
const surface = params.surface ?? "active";
if (!params.forceLoad) {
const existing = getLoadedRuntimePluginRegistry({
env: params.loadOptions.env,
loadOptions: params.loadOptions,
workspaceDir: params.loadOptions.workspaceDir,
requiredPluginIds,
surface,
});
if (existing) {
return existing;
}
}
return undefined;
}
/** Builds or reuses a registry value without changing any process-wide active surface. */
export function loadRuntimePluginRegistryHandle(
params: RuntimePluginRegistryLoadParams,
): PluginRegistry | undefined {
const loadOptions = { ...params.loadOptions, activate: false };
return (
findLoadedRuntimePluginRegistry({ ...params, loadOptions }) ??
loadPluginRegistryHandle(params.forceLoad ? { ...loadOptions, cache: false } : loadOptions)
);
}
/** Installs a registry from a process composition root. Never call from request/run scope. */
export function installRuntimePluginRegistryAtProcessRoot(
params: RuntimePluginRegistryLoadParams,
): PluginRegistry | undefined {
const loadOptions = { ...params.loadOptions, activate: true };
const registry =
findLoadedRuntimePluginRegistry({ ...params, loadOptions }) ??
loadAndActivateRootPluginRegistry(
params.forceLoad ? { ...loadOptions, cache: false } : loadOptions,
);
const surface = params.surface ?? "active";
if (surface === "active") {
return registry;
}
installProcessRootRuntimePluginRegistry(registry, {
loadOptions,
surface,
});
return registry;
}