feat(plugins): narrow channel loads from manifests (#65429)

* feat(plugins): narrow channel loads from manifests

* fix(plugins): harden channel owner activation trust

* fix(plugins): preserve empty channel scopes

* fix(plugins): honor channel-owner policy gates

* fix(plugins): keep channel setup and scope fallbacks correct

* fix(plugins): keep channel trust tied to source config
This commit is contained in:
Vincent Koc
2026-04-12 17:24:15 +01:00
committed by GitHub
parent 50fcdb36a8
commit b7b3846793
9 changed files with 831 additions and 21 deletions

View File

@@ -527,10 +527,12 @@ actual behavior such as hooks, tools, commands, or provider flows.
Optional manifest `activation` and `setup` blocks stay on the control plane.
They are metadata-only descriptors for activation planning and setup discovery;
they do not replace runtime registration, `register(...)`, or `setupEntry`.
The first live activation consumers now use manifest command and provider hints
The first live activation consumers now use manifest command, channel, and provider hints
to narrow plugin loading before broader registry materialization:
- CLI loading narrows to plugins that own the requested primary command
- channel setup/plugin resolution narrows to plugins that own the requested
channel id
- explicit provider setup/runtime resolution narrows to plugins that own the
requested provider id

View File

@@ -249,6 +249,8 @@ Current live consumers:
- command-triggered CLI planning falls back to legacy
`commandAliases[].cliCommand` or `commandAliases[].name`
- channel-triggered setup/channel planning falls back to legacy `channels[]`
ownership when explicit channel activation metadata is missing
- provider-triggered setup/runtime planning falls back to legacy
`providers[]` and top-level `cliBackends[]` ownership when explicit provider
activation metadata is missing

View File

@@ -658,6 +658,304 @@ describe("ensureChannelSetupPluginInstalled", () => {
);
});
it("scopes snapshots by activation-declared channel ownership when direct channel lists are empty", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "custom-telegram-plugin",
channels: [],
activation: {
onChannels: ["telegram"],
},
},
],
diagnostics: [],
});
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["custom-telegram-plugin"],
}),
);
expect(loadPluginManifestRegistry).toHaveBeenCalledWith(
expect.objectContaining({
cache: false,
}),
);
});
it("uses uncached manifest discovery for activation-declared setup scoping", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "custom-telegram-plugin",
channels: [],
activation: {
onChannels: ["telegram"],
},
},
],
diagnostics: [],
});
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadPluginManifestRegistry).toHaveBeenCalled();
expect(
loadPluginManifestRegistry.mock.calls.every(
([params]) => (params as { cache?: boolean }).cache === false,
),
).toBe(true);
});
it("does not trust unconfigured workspace activation-only channel ownership during setup", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "evil-telegram-shadow",
channels: [],
origin: "workspace",
activation: {
onChannels: ["telegram"],
},
},
],
diagnostics: [],
});
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: ["evil-telegram-shadow"],
}),
);
expect(
(vi.mocked(loadOpenClawPlugins).mock.calls[0]?.[0] as { onlyPluginIds?: string[] })
.onlyPluginIds,
).toBeUndefined();
});
it("does not trust allowlist-excluded bundled activation-only channel ownership during setup", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {
plugins: {
allow: ["other-plugin"],
},
};
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "custom-telegram-plugin",
channels: [],
origin: "bundled",
activation: {
onChannels: ["telegram"],
},
},
],
diagnostics: [],
});
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: ["custom-telegram-plugin"],
}),
);
expect(
(vi.mocked(loadOpenClawPlugins).mock.calls[0]?.[0] as { onlyPluginIds?: string[] })
.onlyPluginIds,
).toBeUndefined();
});
it("does not trust explicitly denied bundled activation-only channel ownership during setup", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {
plugins: {
deny: ["custom-telegram-plugin"],
},
};
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "custom-telegram-plugin",
channels: [],
origin: "bundled",
activation: {
onChannels: ["telegram"],
},
},
],
diagnostics: [],
});
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: ["custom-telegram-plugin"],
}),
);
expect(
(vi.mocked(loadOpenClawPlugins).mock.calls[0]?.[0] as { onlyPluginIds?: string[] })
.onlyPluginIds,
).toBeUndefined();
});
it("does not trust explicitly disabled workspace activation-only channel ownership during setup", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {
plugins: {
enabled: true,
allow: ["evil-telegram-shadow"],
entries: {
"evil-telegram-shadow": { enabled: false },
},
},
};
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "evil-telegram-shadow",
channels: [],
origin: "workspace",
activation: {
onChannels: ["telegram"],
},
},
],
diagnostics: [],
});
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: ["evil-telegram-shadow"],
}),
);
expect(
(vi.mocked(loadOpenClawPlugins).mock.calls[0]?.[0] as { onlyPluginIds?: string[] })
.onlyPluginIds,
).toBeUndefined();
});
it("does not trust explicitly disabled bundled activation-only channel ownership during setup", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {
plugins: {
entries: {
"custom-telegram-plugin": { enabled: false },
},
},
};
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "custom-telegram-plugin",
channels: [],
origin: "bundled",
activation: {
onChannels: ["telegram"],
},
},
],
diagnostics: [],
});
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: ["custom-telegram-plugin"],
}),
);
expect(
(vi.mocked(loadOpenClawPlugins).mock.calls[0]?.[0] as { onlyPluginIds?: string[] })
.onlyPluginIds,
).toBeUndefined();
});
it("does not trust unenabled global activation-only channel ownership during setup", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "custom-telegram-global",
channels: [],
origin: "global",
activation: {
onChannels: ["telegram"],
},
},
],
diagnostics: [],
});
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: ["custom-telegram-global"],
}),
);
expect(
(vi.mocked(loadOpenClawPlugins).mock.calls[0]?.[0] as { onlyPluginIds?: string[] })
.onlyPluginIds,
).toBeUndefined();
});
it("scopes snapshots by plugin id when channel and plugin ids differ", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};

View File

@@ -10,13 +10,13 @@ import {
findBundledPluginSourceInMap,
resolveBundledPluginSources,
} from "../../plugins/bundled-sources.js";
import { resolveDiscoverableScopedChannelPluginIds } from "../../plugins/channel-plugin-ids.js";
import { clearPluginDiscoveryCache } from "../../plugins/discovery.js";
import { enablePluginInConfig } from "../../plugins/enable.js";
import { installPluginFromNpmSpec } from "../../plugins/install.js";
import { buildNpmResolutionInstallFields, recordPluginInstall } from "../../plugins/installs.js";
import { loadOpenClawPlugins } from "../../plugins/loader.js";
import { createPluginLoaderLogger } from "../../plugins/logger.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { getActivePluginChannelRegistry } from "../../plugins/runtime.js";
import type { RuntimeEnv } from "../../runtime.js";
@@ -286,13 +286,14 @@ function resolveUniqueManifestScopedChannelPluginId(params: {
channel: string;
workspaceDir?: string;
}): string | undefined {
const matches = loadPluginManifestRegistry({
const matches = resolveDiscoverableScopedChannelPluginIds({
config: params.cfg,
channelIds: [params.channel],
workspaceDir: params.workspaceDir,
cache: false,
env: process.env,
}).plugins.filter((plugin) => plugin.channels.includes(params.channel));
return matches.length === 1 ? matches[0]?.id : undefined;
cache: false,
});
return matches.length === 1 ? matches[0] : undefined;
}
export function reloadChannelSetupPluginRegistryForChannel(params: {

View File

@@ -18,6 +18,7 @@ export function resolveManifestActivationPluginIds(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
cache?: boolean;
origin?: PluginOrigin;
onlyPluginIds?: readonly string[];
}): string[] {
@@ -29,6 +30,7 @@ export function resolveManifestActivationPluginIds(params: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache,
})
.plugins.filter(
(plugin) =>

View File

@@ -2,17 +2,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const listPotentialConfiguredChannelIds = vi.hoisted(() => vi.fn());
const hasPotentialConfiguredChannels = vi.hoisted(() => vi.fn());
const loadPluginManifestRegistry = vi.hoisted(() => vi.fn());
vi.mock("../channels/config-presence.js", () => ({
listPotentialConfiguredChannelIds,
hasPotentialConfiguredChannels,
}));
vi.mock("./manifest-registry.js", () => ({
loadPluginManifestRegistry,
}));
vi.mock("./manifest-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./manifest-registry.js")>();
return {
...actual,
loadPluginManifestRegistry,
};
});
import { resolveGatewayStartupPluginIds } from "./channel-plugin-ids.js";
import {
resolveConfiguredChannelPluginIds,
resolveGatewayStartupPluginIds,
} from "./channel-plugin-ids.js";
function createManifestRegistryFixture() {
return {
@@ -49,6 +58,39 @@ function createManifestRegistryFixture() {
providers: ["demo-provider"],
cliBackends: ["demo-cli"],
},
{
id: "activation-only-channel-plugin",
channels: [],
activation: {
onChannels: ["activation-only-channel"],
},
origin: "bundled",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
{
id: "workspace-activation-channel-plugin",
channels: [],
activation: {
onChannels: ["workspace-activation-channel"],
},
origin: "workspace",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
{
id: "global-activation-channel-plugin",
channels: [],
activation: {
onChannels: ["global-activation-channel"],
},
origin: "global",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
{
id: "voice-call",
channels: [],
@@ -198,6 +240,12 @@ describe("resolveGatewayStartupPluginIds", () => {
}
return ["demo-channel"];
});
hasPotentialConfiguredChannels.mockReset().mockImplementation((config: OpenClawConfig) => {
if (Object.prototype.hasOwnProperty.call(config, "channels")) {
return Object.keys(config.channels ?? {}).length > 0;
}
return true;
});
loadPluginManifestRegistry.mockReset().mockReturnValue(createManifestRegistryFixture());
});
@@ -303,3 +351,154 @@ describe("resolveGatewayStartupPluginIds", () => {
});
});
});
describe("resolveConfiguredChannelPluginIds", () => {
beforeEach(() => {
listPotentialConfiguredChannelIds.mockReset().mockImplementation((config: OpenClawConfig) => {
if (Object.prototype.hasOwnProperty.call(config, "channels")) {
return Object.keys(config.channels ?? {});
}
return [];
});
hasPotentialConfiguredChannels.mockReset().mockImplementation((config: OpenClawConfig) => {
if (Object.prototype.hasOwnProperty.call(config, "channels")) {
return Object.keys(config.channels ?? {}).length > 0;
}
return false;
});
loadPluginManifestRegistry.mockReset().mockReturnValue(createManifestRegistryFixture());
});
it("uses manifest activation channel ownership before falling back to direct channel lists", () => {
expect(
resolveConfiguredChannelPluginIds({
config: createStartupConfig({
channelIds: ["activation-only-channel"],
}),
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual(["activation-only-channel-plugin"]);
});
it("keeps bundled activation owners behind restrictive allowlists", () => {
expect(
resolveConfiguredChannelPluginIds({
config: createStartupConfig({
channelIds: ["activation-only-channel"],
allowPluginIds: ["browser"],
}),
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual([]);
});
it("blocks bundled activation owners when explicitly denied", () => {
expect(
resolveConfiguredChannelPluginIds({
config: {
channels: {
"activation-only-channel": { enabled: true },
},
plugins: {
deny: ["activation-only-channel-plugin"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual([]);
});
it("blocks bundled activation owners when plugins are globally disabled", () => {
expect(
resolveConfiguredChannelPluginIds({
config: {
channels: {
"activation-only-channel": { enabled: true },
},
plugins: {
enabled: false,
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual([]);
});
it("filters untrusted workspace activation owners from configured-channel runtime planning", () => {
expect(
resolveConfiguredChannelPluginIds({
config: createStartupConfig({
channelIds: ["workspace-activation-channel"],
}),
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual([]);
});
it("filters untrusted global activation owners from configured-channel runtime planning", () => {
expect(
resolveConfiguredChannelPluginIds({
config: createStartupConfig({
channelIds: ["global-activation-channel"],
}),
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual([]);
});
it("keeps explicitly enabled global activation owners eligible for configured-channel runtime planning", () => {
expect(
resolveConfiguredChannelPluginIds({
config: createStartupConfig({
channelIds: ["global-activation-channel"],
enabledPluginIds: ["global-activation-channel-plugin"],
}),
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual(["global-activation-channel-plugin"]);
});
it("does not treat auto-enabled non-bundled channel owners as explicitly trusted", () => {
expect(
resolveConfiguredChannelPluginIds({
config: createStartupConfig({
channelIds: ["global-activation-channel"],
enabledPluginIds: ["global-activation-channel-plugin"],
}),
activationSourceConfig: createStartupConfig({
channelIds: ["global-activation-channel"],
}),
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual([]);
});
it("blocks bundled activation owners when explicitly disabled", () => {
expect(
resolveConfiguredChannelPluginIds({
config: {
channels: {
"activation-only-channel": { enabled: true },
},
plugins: {
entries: {
"activation-only-channel-plugin": {
enabled: false,
},
},
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual([]);
});
});

View File

@@ -5,6 +5,8 @@ import {
resolveMemoryDreamingPluginConfig,
resolveMemoryDreamingPluginId,
} from "../memory-host-sdk/dreaming.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { resolveManifestActivationPluginIds } from "./activation-planner.js";
import {
createPluginActivationSource,
normalizePluginId,
@@ -38,6 +40,190 @@ function isGatewayStartupSidecar(plugin: PluginManifestRecord): boolean {
return plugin.channels.length === 0 && !hasRuntimeContractSurface(plugin);
}
function dedupeSortedPluginIds(values: Iterable<string>): string[] {
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
}
function normalizeChannelIds(channelIds: Iterable<string>): string[] {
return Array.from(
new Set(
[...channelIds]
.map((channelId) => normalizeOptionalLowercaseString(channelId))
.filter((channelId): channelId is string => Boolean(channelId)),
),
).toSorted((left, right) => left.localeCompare(right));
}
function isBundledChannelOwner(plugin: PluginManifestRecord): boolean {
return plugin.origin === "bundled";
}
function hasExplicitNonBundledChannelOwnerTrust(params: {
plugin: PluginManifestRecord;
normalizedConfig: ReturnType<typeof normalizePluginsConfig>;
}): boolean {
return (
params.normalizedConfig.allow.includes(params.plugin.id) ||
params.normalizedConfig.entries[params.plugin.id]?.enabled === true
);
}
function passesExplicitChannelOwnershipPolicy(params: {
plugin: PluginManifestRecord;
normalizedConfig: ReturnType<typeof normalizePluginsConfig>;
}): boolean {
if (!params.normalizedConfig.enabled) {
return false;
}
if (params.normalizedConfig.deny.includes(params.plugin.id)) {
return false;
}
if (params.normalizedConfig.entries[params.plugin.id]?.enabled === false) {
return false;
}
if (
params.normalizedConfig.allow.length > 0 &&
!params.normalizedConfig.allow.includes(params.plugin.id)
) {
return false;
}
return true;
}
function isChannelPluginEligibleForSetupDiscovery(params: {
plugin: PluginManifestRecord;
normalizedConfig: ReturnType<typeof normalizePluginsConfig>;
rootConfig: OpenClawConfig;
}): boolean {
if (!passesExplicitChannelOwnershipPolicy(params)) {
return false;
}
if (isBundledChannelOwner(params.plugin)) {
return true;
}
if (params.plugin.origin === "global" || params.plugin.origin === "config") {
return hasExplicitNonBundledChannelOwnerTrust(params);
}
return resolveEffectivePluginActivationState({
id: params.plugin.id,
origin: params.plugin.origin,
config: params.normalizedConfig,
rootConfig: params.rootConfig,
enabledByDefault: params.plugin.enabledByDefault,
}).activated;
}
function isChannelPluginEligibleForRuntimeOwnerActivation(params: {
plugin: PluginManifestRecord;
normalizedConfig: ReturnType<typeof normalizePluginsConfig>;
rootConfig: OpenClawConfig;
}): boolean {
if (!passesExplicitChannelOwnershipPolicy(params)) {
return false;
}
if (isBundledChannelOwner(params.plugin)) {
return true;
}
if (params.plugin.origin === "global" || params.plugin.origin === "config") {
return hasExplicitNonBundledChannelOwnerTrust(params);
}
return resolveEffectivePluginActivationState({
id: params.plugin.id,
origin: params.plugin.origin,
config: params.normalizedConfig,
rootConfig: params.rootConfig,
enabledByDefault: params.plugin.enabledByDefault,
}).activated;
}
function resolveScopedChannelOwnerPluginIds(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
channelIds: readonly string[];
workspaceDir?: string;
env: NodeJS.ProcessEnv;
mode: "runtime" | "setup";
cache?: boolean;
}): string[] {
const channelIds = normalizeChannelIds(params.channelIds);
if (channelIds.length === 0) {
return [];
}
const registry = loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache,
});
const trustConfig = params.activationSourceConfig ?? params.config;
const normalizedConfig = normalizePluginsConfig(trustConfig.plugins);
const candidateIds = dedupeSortedPluginIds(
channelIds.flatMap((channelId) => {
return resolveManifestActivationPluginIds({
trigger: {
kind: "channel",
channel: channelId,
},
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache,
});
}),
);
if (candidateIds.length === 0) {
return [];
}
const candidateIdSet = new Set(candidateIds);
return registry.plugins
.filter((plugin) => {
if (!candidateIdSet.has(plugin.id)) {
return false;
}
return params.mode === "setup"
? isChannelPluginEligibleForSetupDiscovery({
plugin,
normalizedConfig,
rootConfig: trustConfig,
})
: isChannelPluginEligibleForRuntimeOwnerActivation({
plugin,
normalizedConfig,
rootConfig: trustConfig,
});
})
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function resolveScopedChannelPluginIds(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
channelIds: readonly string[];
workspaceDir?: string;
env: NodeJS.ProcessEnv;
cache?: boolean;
}): string[] {
return resolveScopedChannelOwnerPluginIds({
...params,
mode: "runtime",
});
}
export function resolveDiscoverableScopedChannelPluginIds(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
channelIds: readonly string[];
workspaceDir?: string;
env: NodeJS.ProcessEnv;
cache?: boolean;
}): string[] {
return resolveScopedChannelOwnerPluginIds({
...params,
mode: "setup",
});
}
function resolveGatewayStartupDreamingPluginIds(config: OpenClawConfig): Set<string> {
const dreamingConfig = resolveMemoryDreamingConfig({
pluginConfig: resolveMemoryDreamingPluginConfig(config),
@@ -99,7 +285,10 @@ export function resolveConfiguredChannelPluginIds(params: {
if (configuredChannelIds.size === 0) {
return [];
}
return resolveChannelPluginIds(params).filter((pluginId) => configuredChannelIds.has(pluginId));
return resolveScopedChannelPluginIds({
...params,
channelIds: [...configuredChannelIds],
});
}
export function resolveConfiguredDeferredChannelPluginIds(params: {

View File

@@ -112,6 +112,7 @@ describe("ensurePluginRegistryLoaded", () => {
expect(mocks.resolveConfiguredChannelPluginIds).toHaveBeenCalledWith(
expect.objectContaining({
config: resolvedConfig,
activationSourceConfig: { plugins: { allow: ["demo-channel"] } },
env,
workspaceDir: "/resolved-workspace",
}),
@@ -122,8 +123,24 @@ describe("ensurePluginRegistryLoaded", () => {
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
config: resolvedConfig,
activationSourceConfig: { plugins: { allow: ["demo-channel"] } },
config: expect.objectContaining({
...resolvedConfig,
plugins: expect.objectContaining({
entries: expect.objectContaining({
demo: { enabled: true },
"demo-channel": { enabled: true },
}),
allow: ["demo-channel"],
}),
}),
activationSourceConfig: {
plugins: {
allow: ["demo-channel"],
entries: {
"demo-channel": { enabled: true },
},
},
},
autoEnabledReasons: {
demo: ["demo configured"],
},
@@ -134,6 +151,39 @@ describe("ensurePluginRegistryLoaded", () => {
);
});
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,
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.objectContaining({
"activation-only-channel": { enabled: true },
}),
allow: ["activation-only-channel"],
}),
}),
activationSourceConfig: expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.objectContaining({
"activation-only-channel": { enabled: true },
}),
allow: ["activation-only-channel"],
}),
}),
onlyPluginIds: ["activation-only-channel"],
}),
);
});
it("does not cache scoped loads by explicit plugin ids", () => {
ensurePluginRegistryLoaded({
scope: "configured-channels",
@@ -172,4 +222,37 @@ describe("ensurePluginRegistryLoaded", () => {
}),
);
});
it("preserves empty configured-channel scopes when no owners are activatable", () => {
mocks.resolveConfiguredChannelPluginIds.mockReturnValue([]);
ensurePluginRegistryLoaded({
scope: "configured-channels",
config: { channels: { demo: { enabled: true } } } as never,
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [],
}),
);
});
it("does not forward empty channel scopes for broad channel loads", () => {
mocks.resolveChannelPluginIds.mockReturnValue([]);
ensurePluginRegistryLoaded({
scope: "channels",
config: {} as never,
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: [],
}),
);
expect(
(mocks.loadOpenClawPlugins.mock.calls[0]?.[0] as { onlyPluginIds?: string[] }).onlyPluginIds,
).toBeUndefined();
});
});

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withActivatedPluginIds } from "../activation-context.js";
import {
resolveChannelPluginIds,
resolveConfiguredChannelPluginIds,
@@ -10,7 +11,10 @@ import {
normalizePluginIdScope,
} from "../plugin-scope.js";
import { getActivePluginRegistry } from "../runtime.js";
import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext } from "./load-context.js";
import {
buildPluginRuntimeLoadOptionsFromValues,
resolvePluginRuntimeLoadContext,
} from "./load-context.js";
let pluginRegistryLoaded: "none" | "configured-channels" | "channels" | "all" = "none";
@@ -62,6 +66,13 @@ function activeRegistrySatisfiesScope(
throw new Error("Unsupported plugin registry scope");
}
function shouldForwardChannelScope(params: {
scope: PluginRegistryScope;
scopedLoad: boolean;
}): boolean {
return !params.scopedLoad && params.scope === "configured-channels";
}
export function ensurePluginRegistryLoaded(options?: {
scope?: PluginRegistryScope;
config?: OpenClawConfig;
@@ -78,6 +89,7 @@ export function ensurePluginRegistryLoaded(options?: {
: scope === "configured-channels"
? resolveConfiguredChannelPluginIds({
config: context.config,
activationSourceConfig: context.activationSourceConfig,
workspaceDir: context.workspaceDir,
env: context.env,
})
@@ -105,14 +117,36 @@ export function ensurePluginRegistryLoaded(options?: {
}
return;
}
const scopedConfig =
!scopedLoad && scope === "configured-channels" && expectedChannelPluginIds.length > 0
? (withActivatedPluginIds({
config: context.config,
pluginIds: expectedChannelPluginIds,
}) ?? context.config)
: context.config;
const scopedActivationSourceConfig =
!scopedLoad && scope === "configured-channels" && expectedChannelPluginIds.length > 0
? (withActivatedPluginIds({
config: context.activationSourceConfig,
pluginIds: expectedChannelPluginIds,
}) ?? context.activationSourceConfig)
: context.activationSourceConfig;
loadOpenClawPlugins(
buildPluginRuntimeLoadOptions(context, {
throwOnLoadError: true,
...(hasExplicitPluginIdScope(requestedPluginIds) ||
hasNonEmptyPluginIdScope(expectedChannelPluginIds)
? { onlyPluginIds: expectedChannelPluginIds }
: {}),
}),
buildPluginRuntimeLoadOptionsFromValues(
{
...context,
config: scopedConfig,
activationSourceConfig: scopedActivationSourceConfig,
},
{
throwOnLoadError: true,
...(hasExplicitPluginIdScope(requestedPluginIds) ||
shouldForwardChannelScope({ scope, scopedLoad }) ||
hasNonEmptyPluginIdScope(expectedChannelPluginIds)
? { onlyPluginIds: expectedChannelPluginIds }
: {}),
},
),
);
if (!scopedLoad) {
pluginRegistryLoaded = scope;