fix: scope runtime plugin preload to effective plugins

This commit is contained in:
Peter Steinberger
2026-05-02 15:54:37 +01:00
parent 5980040894
commit da2a8bd6bb
9 changed files with 314 additions and 40 deletions

View File

@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
const mocks = vi.hoisted(() => ({
applyPluginAutoEnable:
vi.fn<typeof import("../config/plugin-auto-enable.js").applyPluginAutoEnable>(),
listExplicitlyDisabledChannelIdsForConfig: vi.fn(),
listPotentialConfiguredChannelIds: vi.fn(),
listExplicitConfiguredChannelIdsForConfig: vi.fn(),
loadGatewayStartupPluginPlan:
vi.fn<typeof import("./channel-plugin-ids.js").loadGatewayStartupPluginPlan>(),
resolveConfiguredChannelPluginIds:
vi.fn<typeof import("./channel-plugin-ids.js").resolveConfiguredChannelPluginIds>(),
loadManifestMetadataSnapshot:
vi.fn<typeof import("./manifest-contract-eligibility.js").loadManifestMetadataSnapshot>(),
passesManifestOwnerBasePolicy:
vi.fn<typeof import("./manifest-owner-policy.js").passesManifestOwnerBasePolicy>(),
}));
vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: (...args: Parameters<typeof mocks.applyPluginAutoEnable>) =>
mocks.applyPluginAutoEnable(...args),
}));
vi.mock("../channels/config-presence.js", () => ({
listExplicitlyDisabledChannelIdsForConfig: (
...args: Parameters<typeof mocks.listExplicitlyDisabledChannelIdsForConfig>
) => mocks.listExplicitlyDisabledChannelIdsForConfig(...args),
listPotentialConfiguredChannelIds: (
...args: Parameters<typeof mocks.listPotentialConfiguredChannelIds>
) => mocks.listPotentialConfiguredChannelIds(...args),
}));
vi.mock("./channel-plugin-ids.js", () => ({
listExplicitConfiguredChannelIdsForConfig: (
...args: Parameters<typeof mocks.listExplicitConfiguredChannelIdsForConfig>
) => mocks.listExplicitConfiguredChannelIdsForConfig(...args),
loadGatewayStartupPluginPlan: (...args: Parameters<typeof mocks.loadGatewayStartupPluginPlan>) =>
mocks.loadGatewayStartupPluginPlan(...args),
resolveConfiguredChannelPluginIds: (
...args: Parameters<typeof mocks.resolveConfiguredChannelPluginIds>
) => mocks.resolveConfiguredChannelPluginIds(...args),
}));
vi.mock("./manifest-contract-eligibility.js", () => ({
loadManifestMetadataSnapshot: (...args: Parameters<typeof mocks.loadManifestMetadataSnapshot>) =>
mocks.loadManifestMetadataSnapshot(...args),
}));
vi.mock("./manifest-owner-policy.js", () => ({
passesManifestOwnerBasePolicy: (
...args: Parameters<typeof mocks.passesManifestOwnerBasePolicy>
) => mocks.passesManifestOwnerBasePolicy(...args),
}));
import { resolveEffectivePluginIds } from "./effective-plugin-ids.js";
function resolve(config: OpenClawConfig): string[] {
return resolveEffectivePluginIds({
config,
env: {},
workspaceDir: "/workspace",
});
}
describe("resolveEffectivePluginIds", () => {
beforeEach(() => {
mocks.applyPluginAutoEnable.mockReset();
mocks.listExplicitlyDisabledChannelIdsForConfig.mockReset();
mocks.listPotentialConfiguredChannelIds.mockReset();
mocks.listExplicitConfiguredChannelIdsForConfig.mockReset();
mocks.loadGatewayStartupPluginPlan.mockReset();
mocks.resolveConfiguredChannelPluginIds.mockReset();
mocks.loadManifestMetadataSnapshot.mockReset();
mocks.passesManifestOwnerBasePolicy.mockReset();
mocks.applyPluginAutoEnable.mockImplementation((params) => ({
config: params.config ?? {},
changes: [],
autoEnabledReasons: {},
}));
mocks.listExplicitlyDisabledChannelIdsForConfig.mockReturnValue([]);
mocks.listPotentialConfiguredChannelIds.mockReturnValue([]);
mocks.listExplicitConfiguredChannelIdsForConfig.mockReturnValue([]);
mocks.loadGatewayStartupPluginPlan.mockReturnValue({
channelPluginIds: [],
configuredDeferredChannelPluginIds: [],
pluginIds: [],
});
mocks.resolveConfiguredChannelPluginIds.mockReturnValue([]);
mocks.loadManifestMetadataSnapshot.mockReturnValue({
plugins: [],
} as unknown as PluginMetadataSnapshot);
mocks.passesManifestOwnerBasePolicy.mockReturnValue(true);
});
it("includes a selected context-engine slot even when omitted from explicit allow and entries", () => {
expect(
resolve({
plugins: {
slots: { contextEngine: "lossless-claw" },
},
}),
).toEqual(["lossless-claw"]);
});
it("keeps the built-in legacy context engine out of plugin preload ids", () => {
expect(
resolve({
plugins: {
slots: { contextEngine: "legacy" },
},
}),
).toEqual([]);
});
it.each([
{
name: "plugins disabled",
plugins: {
enabled: false,
slots: { contextEngine: "lossless-claw" },
},
},
{
name: "denylisted",
plugins: {
deny: ["lossless-claw"],
slots: { contextEngine: "lossless-claw" },
},
},
{
name: "entry disabled",
plugins: {
entries: {
"lossless-claw": { enabled: false },
},
slots: { contextEngine: "lossless-claw" },
},
},
] satisfies Array<{ name: string; plugins: NonNullable<OpenClawConfig["plugins"]> }>)(
"does not preload a selected context-engine slot when $name",
({ plugins }) => {
expect(resolve({ plugins })).toEqual([]);
},
);
});

View File

@@ -13,6 +13,7 @@ import {
import { normalizePluginsConfig } from "./config-state.js";
import { loadManifestMetadataSnapshot } from "./manifest-contract-eligibility.js";
import { passesManifestOwnerBasePolicy } from "./manifest-owner-policy.js";
import { defaultSlotIdForKey } from "./slots.js";
function collectConfiguredChannelIds(
config: OpenClawConfig,
@@ -120,6 +121,24 @@ function collectExplicitEffectivePluginIds(config: OpenClawConfig): string[] {
return [...ids].toSorted((left, right) => left.localeCompare(right));
}
function collectSelectedContextEnginePluginIds(config: OpenClawConfig): string[] {
const plugins = normalizePluginsConfig(config.plugins);
if (!plugins.enabled) {
return [];
}
const pluginId = plugins.slots.contextEngine;
if (!pluginId || pluginId === defaultSlotIdForKey("contextEngine")) {
return [];
}
if (plugins.deny.includes(pluginId)) {
return [];
}
if (plugins.entries[pluginId]?.enabled === false) {
return [];
}
return [pluginId];
}
export function resolveEffectivePluginIds(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
@@ -132,6 +151,9 @@ export function resolveEffectivePluginIds(params: {
});
const effectiveConfig = autoEnabled.config;
const ids = new Set(collectExplicitEffectivePluginIds(effectiveConfig));
for (const pluginId of collectSelectedContextEnginePluginIds(effectiveConfig)) {
ids.add(pluginId);
}
const configuredChannelIds = collectConfiguredChannelIds(
effectiveConfig,
params.config,

View File

@@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({
vi.fn<typeof import("../channel-plugin-ids.js").resolveDiscoverableScopedChannelPluginIds>(),
resolveChannelPluginIds:
vi.fn<typeof import("../channel-plugin-ids.js").resolveChannelPluginIds>(),
resolveEffectivePluginIds:
vi.fn<typeof import("../effective-plugin-ids.js").resolveEffectivePluginIds>(),
applyPluginAutoEnable:
vi.fn<typeof import("../../config/plugin-auto-enable.js").applyPluginAutoEnable>(),
resolveAgentWorkspaceDir: vi.fn<
@@ -55,6 +57,11 @@ vi.mock("../channel-plugin-ids.js", () => ({
mocks.resolveChannelPluginIds(...args),
}));
vi.mock("../effective-plugin-ids.js", () => ({
resolveEffectivePluginIds: (...args: Parameters<typeof mocks.resolveEffectivePluginIds>) =>
mocks.resolveEffectivePluginIds(...args),
}));
vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: (...args: Parameters<typeof mocks.applyPluginAutoEnable>) =>
mocks.applyPluginAutoEnable(...args),
@@ -82,6 +89,7 @@ describe("ensurePluginRegistryLoaded", () => {
mocks.resolveConfiguredChannelPluginIds.mockReset();
mocks.resolveDiscoverableScopedChannelPluginIds.mockReset();
mocks.resolveChannelPluginIds.mockReset();
mocks.resolveEffectivePluginIds.mockReset();
mocks.applyPluginAutoEnable.mockReset();
mocks.resolveAgentWorkspaceDir.mockClear();
mocks.resolveDefaultAgentId.mockClear();
@@ -111,6 +119,7 @@ describe("ensurePluginRegistryLoaded", () => {
},
}));
mocks.resolveDiscoverableScopedChannelPluginIds.mockReturnValue([]);
mocks.resolveEffectivePluginIds.mockReturnValue(["demo"]);
});
it("uses the shared runtime load context for configured-channel loads", () => {
@@ -328,8 +337,63 @@ describe("ensurePluginRegistryLoaded", () => {
).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 });
expect(mocks.resolveEffectivePluginIds).toHaveBeenCalledWith({
config,
env,
workspaceDir: "/resolved-workspace",
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
...config,
plugins: expect.objectContaining({
entries: expect.objectContaining({
demo: { enabled: true },
}),
}),
}),
onlyPluginIds: ["demo-effective", "demo-hook"],
throwOnLoadError: true,
workspaceDir: "/resolved-workspace",
}),
);
});
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(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [],
}),
);
});
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);

View File

@@ -6,6 +6,7 @@ import {
resolveConfiguredChannelPluginIds,
resolveDiscoverableScopedChannelPluginIds,
} from "../channel-plugin-ids.js";
import { resolveEffectivePluginIds } from "../effective-plugin-ids.js";
import { loadOpenClawPlugins } from "../loader.js";
import {
hasExplicitPluginIdScope,
@@ -75,6 +76,35 @@ function shouldForwardChannelScope(params: {
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,
});
}
const unreachableScope: never = params.scope;
return unreachableScope;
}
function resolveOrLoadRuntimePluginRegistry(
loadOptions: NonNullable<Parameters<typeof loadOpenClawPlugins>[0]>,
): void {
@@ -121,33 +151,21 @@ export function ensurePluginRegistryLoaded(options?: {
...requestedChannelOwnerPluginIds,
]);
const scopedLoad = hasExplicitPluginIdScope(requestedPluginIds);
const expectedChannelPluginIds = scopedLoad
const expectedPluginIds = scopedLoad
? (requestedPluginIds ?? [])
: scope === "configured-channels"
? resolveConfiguredChannelPluginIds({
config: context.config,
activationSourceConfig: context.activationSourceConfig,
workspaceDir: context.workspaceDir,
env: context.env,
})
: scope === "channels"
? resolveChannelPluginIds({
config: context.config,
workspaceDir: context.workspaceDir,
env: context.env,
})
: [];
: resolveScopePluginIds({ scope, context });
const active = getActivePluginRegistry();
const requestedPluginIdsForScope = scope === "all" ? expectedPluginIds : undefined;
if (
!scopedLoad &&
scopeRank(pluginRegistryLoaded) >= scopeRank(scope) &&
activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds, undefined)
activeRegistrySatisfiesScope(scope, active, expectedPluginIds, requestedPluginIdsForScope)
) {
return;
}
if (
(pluginRegistryLoaded === "none" || scopedLoad) &&
activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds, requestedPluginIds)
activeRegistrySatisfiesScope(scope, active, expectedPluginIds, requestedPluginIds)
) {
if (!scopedLoad) {
pluginRegistryLoaded = scope;
@@ -156,20 +174,20 @@ export function ensurePluginRegistryLoaded(options?: {
}
const scopedConfig =
scope === "configured-channels" &&
expectedChannelPluginIds.length > 0 &&
expectedPluginIds.length > 0 &&
(!scopedLoad || requestedChannelOwnerPluginIds !== undefined)
? (withActivatedPluginIds({
config: context.config,
pluginIds: expectedChannelPluginIds,
pluginIds: expectedPluginIds,
}) ?? context.config)
: context.config;
const scopedActivationSourceConfig =
scope === "configured-channels" &&
expectedChannelPluginIds.length > 0 &&
expectedPluginIds.length > 0 &&
(!scopedLoad || requestedChannelOwnerPluginIds !== undefined)
? (withActivatedPluginIds({
config: context.activationSourceConfig,
pluginIds: expectedChannelPluginIds,
pluginIds: expectedPluginIds,
}) ?? context.activationSourceConfig)
: context.activationSourceConfig;
const loadOptions = buildPluginRuntimeLoadOptionsFromValues(
@@ -182,8 +200,9 @@ export function ensurePluginRegistryLoaded(options?: {
throwOnLoadError: true,
...(hasExplicitPluginIdScope(requestedPluginIds) ||
shouldForwardChannelScope({ scope, scopedLoad }) ||
hasNonEmptyPluginIdScope(expectedChannelPluginIds)
? { onlyPluginIds: expectedChannelPluginIds }
hasNonEmptyPluginIdScope(expectedPluginIds) ||
scope === "all"
? { onlyPluginIds: expectedPluginIds }
: {}),
},
);