diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 2fb4c54b7aa..88ce9a749a5 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -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 diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 1642db8f9de..26c8fd98edc 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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 diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index a2723a0b0b2..edcf7c50564 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -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 = {}; diff --git a/src/commands/channel-setup/plugin-install.ts b/src/commands/channel-setup/plugin-install.ts index 1fd751a2655..0749ee457f3 100644 --- a/src/commands/channel-setup/plugin-install.ts +++ b/src/commands/channel-setup/plugin-install.ts @@ -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: { diff --git a/src/plugins/activation-planner.ts b/src/plugins/activation-planner.ts index 930fee12c57..2e870c7167c 100644 --- a/src/plugins/activation-planner.ts +++ b/src/plugins/activation-planner.ts @@ -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) => diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 00792260ca8..56a7643abfe 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -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(); + 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([]); + }); +}); diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts index 5bcfe70bbed..0f357ff5881 100644 --- a/src/plugins/channel-plugin-ids.ts +++ b/src/plugins/channel-plugin-ids.ts @@ -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[] { + return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); +} + +function normalizeChannelIds(channelIds: Iterable): 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; +}): boolean { + return ( + params.normalizedConfig.allow.includes(params.plugin.id) || + params.normalizedConfig.entries[params.plugin.id]?.enabled === true + ); +} + +function passesExplicitChannelOwnershipPolicy(params: { + plugin: PluginManifestRecord; + normalizedConfig: ReturnType; +}): 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; + 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; + 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 { 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: { diff --git a/src/plugins/runtime/runtime-registry-loader.test.ts b/src/plugins/runtime/runtime-registry-loader.test.ts index aa1c9c1ba96..2ed895910b7 100644 --- a/src/plugins/runtime/runtime-registry-loader.test.ts +++ b/src/plugins/runtime/runtime-registry-loader.test.ts @@ -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(); + }); }); diff --git a/src/plugins/runtime/runtime-registry-loader.ts b/src/plugins/runtime/runtime-registry-loader.ts index 7e87f6654dd..811b072b751 100644 --- a/src/plugins/runtime/runtime-registry-loader.ts +++ b/src/plugins/runtime/runtime-registry-loader.ts @@ -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;