mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
a084763814
commit
ccee629359
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user