mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:40:44 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user