From 9b7bbd2662f0beb55bb59cd1cb2ac33d62bf50a6 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 21 Apr 2026 19:31:08 -0400 Subject: [PATCH] refactor: split channel presence policy --- CHANGELOG.md | 2 +- src/channels/config-presence.test.ts | 6 + src/channels/config-presence.ts | 36 +- src/commands/status.test.ts | 4 + src/plugins/channel-plugin-ids.test.ts | 107 ++++ src/plugins/channel-plugin-ids.ts | 644 +--------------------- src/plugins/channel-presence-policy.ts | 525 ++++++++++++++++++ src/plugins/gateway-startup-plugin-ids.ts | 200 +++++++ 8 files changed, 896 insertions(+), 628 deletions(-) create mode 100644 src/plugins/channel-presence-policy.ts create mode 100644 src/plugins/gateway-startup-plugin-ids.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea242d7a258..ce28a28b89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Docs: https://docs.openclaw.ai - Discord: keep slash command follow-up chunks ephemeral when the command is configured for ephemeral replies, so long `/status` output no longer leaks fallback model or runtime details into the public channel. (#69869) thanks @gumadeiras. - Plugins/discovery: reject package plugin source entries that escape the package directory before explicit runtime entries or inferred built JavaScript peers can be used. (#69868) thanks @gumadeiras. -- CLI/channels: keep ambient channel env vars and stale persisted auth from surfacing disabled bundled plugins in status, doctor, security audit, and cron delivery validation unless the channel or plugin is effectively enabled or explicitly configured. (#69862) Thanks @gumadeiras. +- CLI/channels: resolve channel presence through a shared policy that keeps ambient env vars and stale persisted auth from surfacing disabled bundled plugins in status, doctor, security audit, and cron delivery validation unless the channel or plugin is effectively enabled or explicitly configured. (#69862) Thanks @gumadeiras. ## 2026.4.21 diff --git a/src/channels/config-presence.test.ts b/src/channels/config-presence.test.ts index c3063e79be5..705e1d7a265 100644 --- a/src/channels/config-presence.test.ts +++ b/src/channels/config-presence.test.ts @@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { hasMeaningfulChannelConfig, hasPotentialConfiguredChannels, + listPotentialConfiguredChannelPresenceSignals, listPotentialConfiguredChannelIds, } from "./config-presence.js"; @@ -90,6 +91,11 @@ describe("config presence", () => { expectedConfigured: true, options: { includePersistedAuthState: false }, }); + expect( + listPotentialConfiguredChannelPresenceSignals({}, env, { + includePersistedAuthState: false, + }), + ).toEqual([{ channelId: "matrix", source: "env" }]); }); it("detects persisted Matrix credentials without config or env", () => { diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 40ebbcae860..1c4833d44b1 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -24,6 +24,13 @@ type ChannelPresenceOptions = { }; }; +export type ChannelPresenceSignalSource = "config" | "env" | "persisted-auth"; + +export type ChannelPresenceSignal = { + channelId: string; + source: ChannelPresenceSignalSource; +}; + export function hasMeaningfulChannelConfig(value: unknown): boolean { if (!isRecord(value)) { return false; @@ -76,6 +83,30 @@ export function listPotentialConfiguredChannelIds( env: NodeJS.ProcessEnv = process.env, options: ChannelPresenceOptions = {}, ): string[] { + return [ + ...new Set( + listPotentialConfiguredChannelPresenceSignals(cfg, env, options).map( + (signal) => signal.channelId, + ), + ), + ]; +} + +export function listPotentialConfiguredChannelPresenceSignals( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, + options: ChannelPresenceOptions = {}, +): ChannelPresenceSignal[] { + const signals: ChannelPresenceSignal[] = []; + const seenSignals = new Set(); + const addSignal = (channelId: string, source: ChannelPresenceSignalSource) => { + const key = `${source}:${channelId}`; + if (seenSignals.has(key)) { + return; + } + seenSignals.add(key); + signals.push({ channelId, source }); + }; const configuredChannelIds = new Set(); const channelIds = listBundledChannelPluginIds(); const channelEnvPrefixes = listChannelEnvPrefixes(channelIds); @@ -87,6 +118,7 @@ export function listPotentialConfiguredChannelIds( } if (hasMeaningfulChannelConfig(value)) { configuredChannelIds.add(key); + addSignal(key, "config"); } } } @@ -98,6 +130,7 @@ export function listPotentialConfiguredChannelIds( for (const [prefix, channelId] of channelEnvPrefixes) { if (key.startsWith(prefix)) { configuredChannelIds.add(channelId); + addSignal(channelId, "env"); } } } @@ -106,11 +139,12 @@ export function listPotentialConfiguredChannelIds( for (const channelId of listPersistedAuthStateChannelIds(options)) { if (hasPersistedAuthState({ channelId, cfg, env, options })) { configuredChannelIds.add(channelId); + addSignal(channelId, "persisted-auth"); } } } - return [...configuredChannelIds]; + return signals.filter((signal) => configuredChannelIds.has(signal.channelId)); } function hasEnvConfiguredChannel( diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 11ec78d9b39..9764f8d290c 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -482,6 +482,10 @@ vi.mock("../channels/config-presence.js", () => ({ ), listPotentialConfiguredChannelIds: (cfg: { channels?: Record }) => Object.keys(cfg.channels ?? {}).filter((key) => key !== "defaults" && key !== "modelByChannel"), + listPotentialConfiguredChannelPresenceSignals: (cfg: { channels?: Record }) => + Object.keys(cfg.channels ?? {}) + .filter((key) => key !== "defaults" && key !== "modelByChannel") + .map((channelId) => ({ channelId, source: "config" })), })); vi.mock("../plugins/memory-runtime.js", () => ({ diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index b2c75c1b507..255dc3af2ae 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; const listPotentialConfiguredChannelIds = vi.hoisted(() => vi.fn()); +const listPotentialConfiguredChannelPresenceSignals = vi.hoisted(() => vi.fn()); const hasPotentialConfiguredChannels = vi.hoisted(() => vi.fn()); const hasMeaningfulChannelConfig = vi.hoisted(() => vi.fn((value: unknown) => { @@ -17,6 +18,7 @@ const loadPluginManifestRegistry = vi.hoisted(() => vi.fn()); vi.mock("../channels/config-presence.js", () => ({ listPotentialConfiguredChannelIds, + listPotentialConfiguredChannelPresenceSignals, hasPotentialConfiguredChannels, hasMeaningfulChannelConfig, })); @@ -34,6 +36,7 @@ import { listConfiguredAnnounceChannelIdsForConfig, listConfiguredChannelIdsForReadOnlyScope, listExplicitConfiguredChannelIdsForConfig, + resolveConfiguredChannelPresencePolicy, resolveConfiguredChannelPluginIds, resolveGatewayStartupPluginIds, } from "./channel-plugin-ids.js"; @@ -329,6 +332,14 @@ describe("resolveGatewayStartupPluginIds", () => { } return ["demo-channel"]; }); + listPotentialConfiguredChannelPresenceSignals + .mockReset() + .mockImplementation((config: OpenClawConfig) => { + return listPotentialConfiguredChannelIds(config).map((channelId: string) => ({ + channelId, + source: "config", + })); + }); hasPotentialConfiguredChannels.mockReset().mockImplementation((config: OpenClawConfig) => { if (Object.prototype.hasOwnProperty.call(config, "channels")) { return Object.keys(config.channels ?? {}).length > 0; @@ -502,6 +513,14 @@ describe("resolveConfiguredChannelPluginIds", () => { } return []; }); + listPotentialConfiguredChannelPresenceSignals + .mockReset() + .mockImplementation((config: OpenClawConfig) => { + return listPotentialConfiguredChannelIds(config).map((channelId: string) => ({ + channelId, + source: "config", + })); + }); hasPotentialConfiguredChannels.mockReset().mockImplementation((config: OpenClawConfig) => { if (Object.prototype.hasOwnProperty.call(config, "channels")) { return Object.keys(config.channels ?? {}).length > 0; @@ -664,6 +683,7 @@ describe("resolveConfiguredChannelPluginIds", () => { describe("listConfiguredChannelIdsForReadOnlyScope", () => { beforeEach(() => { listPotentialConfiguredChannelIds.mockReset().mockReturnValue([]); + listPotentialConfiguredChannelPresenceSignals.mockReset().mockReturnValue([]); hasPotentialConfiguredChannels.mockReset().mockReturnValue(false); hasMeaningfulChannelConfig.mockClear(); loadPluginManifestRegistry.mockReset().mockReturnValue(createManifestRegistryFixture()); @@ -671,6 +691,9 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { it("filters bundled ambient channel triggers through effective activation", () => { listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "demo-channel", source: "env" }, + ]); expect( listConfiguredChannelIdsForReadOnlyScope({ @@ -703,8 +726,41 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { ).toBe(false); }); + it("returns reason-rich policy entries for blocked ambient channel triggers", () => { + listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "demo-channel", source: "env" }, + ]); + + expect( + resolveConfiguredChannelPresencePolicy({ + config: { + plugins: { + allow: ["memory-core"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + DEMO_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toEqual([ + { + channelId: "demo-channel", + sources: ["env"], + effective: false, + pluginIds: [], + blockedReasons: ["not-in-allowlist"], + }, + ]); + }); + it("keeps explicitly enabled bundled ambient channel triggers", () => { listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "demo-channel", source: "env" }, + ]); expect( listConfiguredChannelIdsForReadOnlyScope({ @@ -726,8 +782,51 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { ).toEqual(["demo-channel"]); }); + it("treats enabled-only channel config as explicit read-only intent", () => { + expect( + resolveConfiguredChannelPresencePolicy({ + config: { + channels: { + "demo-channel": { + enabled: true, + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + includePersistedAuthState: false, + }), + ).toEqual([ + { + channelId: "demo-channel", + sources: ["explicit-config"], + effective: true, + pluginIds: ["demo-channel"], + blockedReasons: [], + }, + ]); + + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config: { + channels: { + "demo-channel": { + enabled: true, + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + includePersistedAuthState: false, + }), + ).toEqual(["demo-channel"]); + }); + it("keeps explicitly configured bundled channels discovered from potential ids", () => { listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "demo-channel", source: "config" }, + ]); expect( listConfiguredChannelIdsForReadOnlyScope({ @@ -747,6 +846,9 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { it("blocks explicitly configured bundled channels when plugins are disabled or denied", () => { listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "demo-channel", source: "config" }, + ]); expect( listConfiguredChannelIdsForReadOnlyScope({ @@ -805,6 +907,10 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { it("uses effective read-only channel policy for announce channels", () => { listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel", "demo-other-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "demo-channel", source: "env" }, + { channelId: "demo-other-channel", source: "config" }, + ]); expect( listConfiguredAnnounceChannelIdsForConfig({ @@ -948,6 +1054,7 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { it("uses manifest env vars for read-only channel presence checks", () => { listPotentialConfiguredChannelIds.mockReturnValue([]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([]); hasPotentialConfiguredChannels.mockReturnValue(false); expect( diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts index f19181948b4..8dd6b483f1c 100644 --- a/src/plugins/channel-plugin-ids.ts +++ b/src/plugins/channel-plugin-ids.ts @@ -1,627 +1,19 @@ -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js"; -import { - hasMeaningfulChannelConfig, - listPotentialConfiguredChannelIds, -} from "../channels/config-presence.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - resolveMemoryDreamingConfig, - resolveMemoryDreamingPluginConfig, - resolveMemoryDreamingPluginId, -} from "../memory-host-sdk/dreaming.js"; -import { isSafeChannelEnvVarTriggerName } from "../secrets/channel-env-var-names.js"; -import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; -import { resolveManifestActivationPluginIds } from "./activation-planner.js"; -import { - createPluginActivationSource, - normalizePluginId, - normalizePluginsConfig, - resolveEffectivePluginActivationState, -} from "./config-state.js"; -import { - hasExplicitManifestOwnerTrust, - isActivatedManifestOwner, - isBundledManifestOwner, - passesManifestOwnerBasePolicy, -} from "./manifest-owner-policy.js"; -import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; -import { hasKind } from "./slots.js"; +export { + hasConfiguredChannelsForReadOnlyScope, + hasExplicitChannelConfig, + listConfiguredAnnounceChannelIdsForConfig, + listConfiguredChannelIdsForReadOnlyScope, + listExplicitConfiguredChannelIdsForConfig, + resolveConfiguredChannelPluginIds, + resolveConfiguredChannelPresencePolicy, + resolveDiscoverableScopedChannelPluginIds, + type ConfiguredChannelBlockedReason, + type ConfiguredChannelPresencePolicyEntry, + type ConfiguredChannelPresenceSource, +} from "./channel-presence-policy.js"; -function hasRuntimeContractSurface(plugin: PluginManifestRecord): boolean { - return Boolean( - plugin.providers.length > 0 || - plugin.cliBackends.length > 0 || - plugin.contracts?.speechProviders?.length || - plugin.contracts?.mediaUnderstandingProviders?.length || - plugin.contracts?.imageGenerationProviders?.length || - plugin.contracts?.videoGenerationProviders?.length || - plugin.contracts?.musicGenerationProviders?.length || - plugin.contracts?.webFetchProviders?.length || - plugin.contracts?.webSearchProviders?.length || - plugin.contracts?.memoryEmbeddingProviders?.length || - hasKind(plugin.kind, "memory"), - ); -} - -function isGatewayStartupMemoryPlugin(plugin: PluginManifestRecord): boolean { - return hasKind(plugin.kind, "memory"); -} - -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)); -} - -const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); - -function hasNonEmptyEnvValue(env: NodeJS.ProcessEnv, key: string): boolean { - if (!isSafeChannelEnvVarTriggerName(key)) { - return false; - } - const trimmed = key.trim(); - const value = env[trimmed] ?? env[trimmed.toUpperCase()]; - return typeof value === "string" && value.trim().length > 0; -} - -function listEnvConfiguredManifestChannelIds(params: { - records: readonly PluginManifestRecord[]; - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - env: NodeJS.ProcessEnv; -}): string[] { - const channelIds = new Set(); - const trustConfig = params.activationSourceConfig ?? params.config; - const normalizedConfig = normalizePluginsConfig(trustConfig.plugins); - for (const record of params.records) { - if ( - !isChannelPluginEligibleForScopedOwnership({ - plugin: record, - normalizedConfig, - rootConfig: trustConfig, - }) - ) { - continue; - } - for (const channelId of record.channels) { - const envVars = record.channelEnvVars?.[channelId] ?? []; - if (envVars.some((envVar) => hasNonEmptyEnvValue(params.env, envVar))) { - channelIds.add(channelId); - } - } - } - return [...channelIds].toSorted((left, right) => left.localeCompare(right)); -} - -export function hasExplicitChannelConfig(params: { - config: OpenClawConfig; - channelId: string; -}): boolean { - const channels = params.config.channels; - if (!channels || typeof channels !== "object" || Array.isArray(channels)) { - return false; - } - const entry = (channels as Record)[params.channelId]; - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - return false; - } - return (entry as { enabled?: unknown }).enabled === true || hasMeaningfulChannelConfig(entry); -} - -export function listExplicitConfiguredChannelIdsForConfig(config: OpenClawConfig): string[] { - const channels = config.channels; - if (!channels || typeof channels !== "object" || Array.isArray(channels)) { - return []; - } - return Object.keys(channels) - .filter( - (channelId) => - !IGNORED_CHANNEL_CONFIG_KEYS.has(channelId) && - hasExplicitChannelConfig({ config, channelId }), - ) - .toSorted((left, right) => left.localeCompare(right)); -} - -function recordOwnsChannel(record: PluginManifestRecord, channelId: string): boolean { - const normalizedChannelId = normalizeOptionalLowercaseString(channelId) ?? ""; - if (!normalizedChannelId) { - return false; - } - return [...record.channels, ...(record.activation?.onChannels ?? [])].some( - (ownedChannelId) => - (normalizeOptionalLowercaseString(ownedChannelId) ?? "") === normalizedChannelId, - ); -} - -function isChannelPluginEligibleForEffectiveConfiguredChannel(params: { - plugin: PluginManifestRecord; - channelId: string; - normalizedConfig: ReturnType; - config: OpenClawConfig; - activationSource: ReturnType; -}): boolean { - if ( - !passesManifestOwnerBasePolicy({ - plugin: params.plugin, - normalizedConfig: params.normalizedConfig, - }) - ) { - return false; - } - if (!isBundledManifestOwner(params.plugin)) { - if (params.plugin.origin === "global" || params.plugin.origin === "config") { - return hasExplicitManifestOwnerTrust({ - plugin: params.plugin, - normalizedConfig: params.normalizedConfig, - }); - } - return isActivatedManifestOwner({ - plugin: params.plugin, - normalizedConfig: params.normalizedConfig, - rootConfig: params.activationSource.rootConfig, - }); - } - if ( - hasExplicitChannelConfig({ - config: params.activationSource.rootConfig ?? params.config, - channelId: params.channelId, - }) - ) { - return true; - } - return resolveEffectivePluginActivationState({ - id: params.plugin.id, - origin: params.plugin.origin, - config: params.normalizedConfig, - rootConfig: params.config, - enabledByDefault: params.plugin.enabledByDefault, - activationSource: params.activationSource, - }).enabled; -} - -function filterEffectiveConfiguredChannelIds(params: { - channelIds: Iterable; - records: readonly PluginManifestRecord[]; - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; -}): string[] { - const channelIds = normalizeChannelIds(params.channelIds); - if (channelIds.length === 0) { - return []; - } - const activationSource = createPluginActivationSource({ - config: params.activationSourceConfig ?? params.config, - }); - const normalizedConfig = activationSource.plugins; - const effective = new Set(); - for (const channelId of channelIds) { - if ( - params.records.some( - (record) => - recordOwnsChannel(record, channelId) && - isChannelPluginEligibleForEffectiveConfiguredChannel({ - plugin: record, - channelId, - normalizedConfig, - config: params.config, - activationSource, - }), - ) - ) { - effective.add(channelId); - } - } - return [...effective].toSorted((left, right) => left.localeCompare(right)); -} - -function listConfiguredChannelIdsForPluginScope(params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - workspaceDir?: string; - env: NodeJS.ProcessEnv; - cache?: boolean; - includePersistedAuthState?: boolean; - manifestRecords?: readonly PluginManifestRecord[]; -}): string[] { - const records = - params.manifestRecords ?? - loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - cache: params.cache, - }).plugins; - const channelIds = [ - ...new Set([ - ...listPotentialConfiguredChannelIds(params.config, params.env, { - includePersistedAuthState: params.includePersistedAuthState, - }), - ...listEnvConfiguredManifestChannelIds({ - records, - config: params.config, - activationSourceConfig: params.activationSourceConfig, - env: params.env, - }), - ]), - ]; - return filterEffectiveConfiguredChannelIds({ - channelIds, - records, - config: params.config, - activationSourceConfig: params.activationSourceConfig, - }); -} - -export function listConfiguredChannelIdsForReadOnlyScope(params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - cache?: boolean; - includePersistedAuthState?: boolean; - manifestRecords?: readonly PluginManifestRecord[]; -}): string[] { - const env = params.env ?? process.env; - const workspaceDir = - params.workspaceDir ?? - resolveAgentWorkspaceDir(params.config, resolveDefaultAgentId(params.config)); - return listConfiguredChannelIdsForPluginScope({ - config: params.config, - activationSourceConfig: params.activationSourceConfig, - workspaceDir, - env, - cache: params.cache, - includePersistedAuthState: params.includePersistedAuthState, - manifestRecords: params.manifestRecords, - }); -} - -export function hasConfiguredChannelsForReadOnlyScope(params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - cache?: boolean; - includePersistedAuthState?: boolean; - manifestRecords?: readonly PluginManifestRecord[]; -}): boolean { - return ( - listConfiguredChannelIdsForReadOnlyScope({ - ...params, - }).length > 0 - ); -} - -export function listConfiguredAnnounceChannelIdsForConfig(params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - cache?: boolean; -}): string[] { - const channels = params.config.channels; - const disabledChannelIds = new Set( - channels && typeof channels === "object" && !Array.isArray(channels) - ? Object.entries(channels) - .filter(([, value]) => { - return ( - value && - typeof value === "object" && - !Array.isArray(value) && - (value as { enabled?: unknown }).enabled === false - ); - }) - .map(([channelId]) => channelId) - : [], - ); - return normalizeChannelIds([ - ...listExplicitConfiguredChannelIdsForConfig(params.config), - ...listConfiguredChannelIdsForReadOnlyScope({ - config: params.config, - activationSourceConfig: params.activationSourceConfig, - workspaceDir: params.workspaceDir, - env: params.env, - cache: params.cache, - includePersistedAuthState: false, - }), - ]).filter((channelId) => !disabledChannelIds.has(channelId)); -} - -function isChannelPluginEligibleForScopedOwnership(params: { - plugin: PluginManifestRecord; - normalizedConfig: ReturnType; - rootConfig: OpenClawConfig; -}): boolean { - if ( - !passesManifestOwnerBasePolicy({ - plugin: params.plugin, - normalizedConfig: params.normalizedConfig, - }) - ) { - return false; - } - if (isBundledManifestOwner(params.plugin)) { - return true; - } - if (params.plugin.origin === "global" || params.plugin.origin === "config") { - return hasExplicitManifestOwnerTrust({ - plugin: params.plugin, - normalizedConfig: params.normalizedConfig, - }); - } - return isActivatedManifestOwner({ - plugin: params.plugin, - normalizedConfig: params.normalizedConfig, - rootConfig: params.rootConfig, - }); -} - -function resolveScopedChannelOwnerPluginIds(params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - channelIds: readonly string[]; - workspaceDir?: string; - env: NodeJS.ProcessEnv; - 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 isChannelPluginEligibleForScopedOwnership({ - plugin, - normalizedConfig, - rootConfig: trustConfig, - }); - }) - .map((plugin) => plugin.id) - .toSorted((left, right) => left.localeCompare(right)); -} - -function resolveScopedChannelPluginIds(params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - channelIds: readonly string[]; - workspaceDir?: string; - env: NodeJS.ProcessEnv; - cache?: boolean; -}): string[] { - return resolveScopedChannelOwnerPluginIds(params); -} - -export function resolveDiscoverableScopedChannelPluginIds(params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - channelIds: readonly string[]; - workspaceDir?: string; - env: NodeJS.ProcessEnv; - cache?: boolean; -}): string[] { - return resolveScopedChannelOwnerPluginIds(params); -} - -function resolveGatewayStartupDreamingPluginIds(config: OpenClawConfig): Set { - const dreamingConfig = resolveMemoryDreamingConfig({ - pluginConfig: resolveMemoryDreamingPluginConfig(config), - cfg: config, - }); - if (!dreamingConfig.enabled) { - return new Set(); - } - return new Set(["memory-core", resolveMemoryDreamingPluginId(config)]); -} - -function resolveExplicitMemorySlotStartupPluginId(config: OpenClawConfig): string | undefined { - const configuredSlot = config.plugins?.slots?.memory?.trim(); - if (!configuredSlot || configuredSlot.toLowerCase() === "none") { - return undefined; - } - return normalizePluginId(configuredSlot); -} - -function shouldConsiderForGatewayStartup(params: { - plugin: PluginManifestRecord; - startupDreamingPluginIds: ReadonlySet; - explicitMemorySlotStartupPluginId?: string; -}): boolean { - if (isGatewayStartupSidecar(params.plugin)) { - return true; - } - if (!isGatewayStartupMemoryPlugin(params.plugin)) { - return false; - } - if (params.startupDreamingPluginIds.has(params.plugin.id)) { - return true; - } - return params.explicitMemorySlotStartupPluginId === params.plugin.id; -} - -export function resolveChannelPluginIds(params: { - config: OpenClawConfig; - workspaceDir?: string; - env: NodeJS.ProcessEnv; -}): string[] { - return loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }) - .plugins.filter((plugin) => plugin.channels.length > 0) - .map((plugin) => plugin.id); -} - -export function resolveConfiguredChannelPluginIds(params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - workspaceDir?: string; - env: NodeJS.ProcessEnv; -}): string[] { - const configuredChannelIds = new Set( - listConfiguredChannelIdsForPluginScope({ - config: params.config, - activationSourceConfig: params.activationSourceConfig, - workspaceDir: params.workspaceDir, - env: params.env, - }).map((id) => id.trim()), - ); - if (configuredChannelIds.size === 0) { - return []; - } - return resolveScopedChannelPluginIds({ - ...params, - channelIds: [...configuredChannelIds], - }); -} - -export function resolveConfiguredDeferredChannelPluginIds(params: { - config: OpenClawConfig; - workspaceDir?: string; - env: NodeJS.ProcessEnv; -}): string[] { - const configuredChannelIds = new Set( - listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), - ); - if (configuredChannelIds.size === 0) { - return []; - } - return loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }) - .plugins.filter( - (plugin) => - plugin.channels.some((channelId) => configuredChannelIds.has(channelId)) && - plugin.startupDeferConfiguredChannelFullLoadUntilAfterListen === true, - ) - .map((plugin) => plugin.id); -} - -export function resolveGatewayStartupPluginIds(params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - workspaceDir?: string; - env: NodeJS.ProcessEnv; -}): string[] { - const configuredChannelIds = new Set( - listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), - ); - const pluginsConfig = normalizePluginsConfig(params.config.plugins); - // Startup must classify allowlist exceptions against the raw config snapshot, - // not the auto-enabled effective snapshot, or configured-only channels can be - // misclassified as explicit enablement. - const activationSource = createPluginActivationSource({ - config: params.activationSourceConfig ?? params.config, - }); - const requiredAgentHarnessPluginIds = new Set( - collectConfiguredAgentHarnessRuntimes( - params.activationSourceConfig ?? params.config, - params.env, - ).flatMap((runtime) => - resolveManifestActivationPluginIds({ - trigger: { - kind: "agentHarness", - runtime, - }, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - cache: true, - }), - ), - ); - const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config); - const explicitMemorySlotStartupPluginId = resolveExplicitMemorySlotStartupPluginId( - params.activationSourceConfig ?? params.config, - ); - return loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }) - .plugins.filter((plugin) => { - if (plugin.channels.some((channelId) => configuredChannelIds.has(channelId))) { - return true; - } - if (requiredAgentHarnessPluginIds.has(plugin.id)) { - const activationState = resolveEffectivePluginActivationState({ - id: plugin.id, - origin: plugin.origin, - config: pluginsConfig, - rootConfig: params.config, - enabledByDefault: plugin.enabledByDefault, - activationSource, - }); - return activationState.enabled; - } - if ( - !shouldConsiderForGatewayStartup({ - plugin, - startupDreamingPluginIds, - explicitMemorySlotStartupPluginId, - }) - ) { - return false; - } - const activationState = resolveEffectivePluginActivationState({ - id: plugin.id, - origin: plugin.origin, - config: pluginsConfig, - rootConfig: params.config, - enabledByDefault: plugin.enabledByDefault, - activationSource, - }); - if (!activationState.enabled) { - return false; - } - if (plugin.origin !== "bundled") { - return activationState.explicitlyEnabled; - } - return activationState.source === "explicit" || activationState.source === "default"; - }) - .map((plugin) => plugin.id); -} +export { + resolveChannelPluginIds, + resolveConfiguredDeferredChannelPluginIds, + resolveGatewayStartupPluginIds, +} from "./gateway-startup-plugin-ids.js"; diff --git a/src/plugins/channel-presence-policy.ts b/src/plugins/channel-presence-policy.ts new file mode 100644 index 00000000000..6fb0d46bd7a --- /dev/null +++ b/src/plugins/channel-presence-policy.ts @@ -0,0 +1,525 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + hasMeaningfulChannelConfig, + listPotentialConfiguredChannelPresenceSignals, + type ChannelPresenceSignalSource, +} from "../channels/config-presence.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isSafeChannelEnvVarTriggerName } from "../secrets/channel-env-var-names.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { resolveManifestActivationPluginIds } from "./activation-planner.js"; +import { + createPluginActivationSource, + normalizePluginsConfig, + resolveEffectivePluginActivationState, +} from "./config-state.js"; +import { + hasExplicitManifestOwnerTrust, + isActivatedManifestOwner, + isBundledManifestOwner, + passesManifestOwnerBasePolicy, +} from "./manifest-owner-policy.js"; +import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; + +const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); + +export type ConfiguredChannelPresenceSource = + | "explicit-config" + | Exclude + | "manifest-env"; + +export type ConfiguredChannelBlockedReason = + | "plugins-disabled" + | "blocked-by-denylist" + | "plugin-disabled" + | "not-in-allowlist" + | "workspace-disabled-by-default" + | "bundled-disabled-by-default" + | "untrusted-plugin" + | "no-channel-owner" + | "not-activated"; + +export type ConfiguredChannelPresencePolicyEntry = { + channelId: string; + sources: ConfiguredChannelPresenceSource[]; + effective: boolean; + pluginIds: string[]; + blockedReasons: ConfiguredChannelBlockedReason[]; +}; + +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 hasNonEmptyEnvValue(env: NodeJS.ProcessEnv, key: string): boolean { + if (!isSafeChannelEnvVarTriggerName(key)) { + return false; + } + const trimmed = key.trim(); + const value = env[trimmed] ?? env[trimmed.toUpperCase()]; + return typeof value === "string" && value.trim().length > 0; +} + +export function hasExplicitChannelConfig(params: { + config: OpenClawConfig; + channelId: string; +}): boolean { + const channels = params.config.channels; + if (!channels || typeof channels !== "object" || Array.isArray(channels)) { + return false; + } + const entry = (channels as Record)[params.channelId]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return false; + } + return (entry as { enabled?: unknown }).enabled === true || hasMeaningfulChannelConfig(entry); +} + +export function listExplicitConfiguredChannelIdsForConfig(config: OpenClawConfig): string[] { + const channels = config.channels; + if (!channels || typeof channels !== "object" || Array.isArray(channels)) { + return []; + } + return Object.keys(channels) + .filter( + (channelId) => + !IGNORED_CHANNEL_CONFIG_KEYS.has(channelId) && + hasExplicitChannelConfig({ config, channelId }), + ) + .toSorted((left, right) => left.localeCompare(right)); +} + +function recordOwnsChannel(record: PluginManifestRecord, channelId: string): boolean { + const normalizedChannelId = normalizeOptionalLowercaseString(channelId) ?? ""; + if (!normalizedChannelId) { + return false; + } + return [...record.channels, ...(record.activation?.onChannels ?? [])].some( + (ownedChannelId) => + (normalizeOptionalLowercaseString(ownedChannelId) ?? "") === normalizedChannelId, + ); +} + +function listManifestEnvConfiguredChannelSignals(params: { + records: readonly PluginManifestRecord[]; + activationSourceConfig?: OpenClawConfig; + config: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): Array<{ channelId: string; source: "manifest-env" }> { + const signals: Array<{ channelId: string; source: "manifest-env" }> = []; + const seen = new Set(); + const trustConfig = params.activationSourceConfig ?? params.config; + const normalizedConfig = normalizePluginsConfig(trustConfig.plugins); + for (const record of params.records) { + if ( + !isChannelPluginEligibleForScopedOwnership({ + plugin: record, + normalizedConfig, + rootConfig: trustConfig, + }) + ) { + continue; + } + for (const channelId of record.channels) { + const envVars = record.channelEnvVars?.[channelId] ?? []; + if (!envVars.some((envVar) => hasNonEmptyEnvValue(params.env, envVar))) { + continue; + } + if (seen.has(channelId)) { + continue; + } + seen.add(channelId); + signals.push({ channelId, source: "manifest-env" }); + } + } + return signals.toSorted((left, right) => left.channelId.localeCompare(right.channelId)); +} + +function normalizeActivationBlockedReason(reason?: string): ConfiguredChannelBlockedReason { + switch (reason) { + case "plugins disabled": + return "plugins-disabled"; + case "blocked by denylist": + return "blocked-by-denylist"; + case "disabled in config": + return "plugin-disabled"; + case "not in allowlist": + return "not-in-allowlist"; + case "workspace plugin (disabled by default)": + return "workspace-disabled-by-default"; + case "bundled (disabled by default)": + return "bundled-disabled-by-default"; + default: + return "not-activated"; + } +} + +function resolveBasePolicyBlockedReason(params: { + plugin: Pick; + normalizedConfig: ReturnType; +}): ConfiguredChannelBlockedReason | null { + if (!params.normalizedConfig.enabled) { + return "plugins-disabled"; + } + if (params.normalizedConfig.deny.includes(params.plugin.id)) { + return "blocked-by-denylist"; + } + if (params.normalizedConfig.entries[params.plugin.id]?.enabled === false) { + return "plugin-disabled"; + } + if ( + params.normalizedConfig.allow.length > 0 && + !params.normalizedConfig.allow.includes(params.plugin.id) + ) { + return "not-in-allowlist"; + } + return null; +} + +function isChannelPluginEligibleForScopedOwnership(params: { + plugin: PluginManifestRecord; + normalizedConfig: ReturnType; + rootConfig: OpenClawConfig; +}): boolean { + if ( + !passesManifestOwnerBasePolicy({ + plugin: params.plugin, + normalizedConfig: params.normalizedConfig, + }) + ) { + return false; + } + if (isBundledManifestOwner(params.plugin)) { + return true; + } + if (params.plugin.origin === "global" || params.plugin.origin === "config") { + return hasExplicitManifestOwnerTrust({ + plugin: params.plugin, + normalizedConfig: params.normalizedConfig, + }); + } + return isActivatedManifestOwner({ + plugin: params.plugin, + normalizedConfig: params.normalizedConfig, + rootConfig: params.rootConfig, + }); +} + +function evaluateEffectiveChannelPlugin(params: { + plugin: PluginManifestRecord; + channelId: string; + normalizedConfig: ReturnType; + config: OpenClawConfig; + activationSource: ReturnType; +}): { effective: boolean; pluginId: string; blockedReason?: ConfiguredChannelBlockedReason } { + const baseBlockedReason = resolveBasePolicyBlockedReason({ + plugin: params.plugin, + normalizedConfig: params.normalizedConfig, + }); + if (baseBlockedReason) { + return { + effective: false, + pluginId: params.plugin.id, + blockedReason: baseBlockedReason, + }; + } + + if (!isBundledManifestOwner(params.plugin)) { + if (params.plugin.origin === "global" || params.plugin.origin === "config") { + const trusted = hasExplicitManifestOwnerTrust({ + plugin: params.plugin, + normalizedConfig: params.normalizedConfig, + }); + return trusted + ? { effective: true, pluginId: params.plugin.id } + : { + effective: false, + pluginId: params.plugin.id, + blockedReason: "untrusted-plugin", + }; + } + const activated = isActivatedManifestOwner({ + plugin: params.plugin, + normalizedConfig: params.normalizedConfig, + rootConfig: params.activationSource.rootConfig, + }); + return activated + ? { effective: true, pluginId: params.plugin.id } + : { + effective: false, + pluginId: params.plugin.id, + blockedReason: "untrusted-plugin", + }; + } + + if ( + hasExplicitChannelConfig({ + config: params.activationSource.rootConfig ?? params.config, + channelId: params.channelId, + }) + ) { + return { effective: true, pluginId: params.plugin.id }; + } + + const activationState = resolveEffectivePluginActivationState({ + id: params.plugin.id, + origin: params.plugin.origin, + config: params.normalizedConfig, + rootConfig: params.config, + enabledByDefault: params.plugin.enabledByDefault, + activationSource: params.activationSource, + }); + return activationState.enabled + ? { effective: true, pluginId: params.plugin.id } + : { + effective: false, + pluginId: params.plugin.id, + blockedReason: normalizeActivationBlockedReason(activationState.reason), + }; +} + +function addPolicySignal( + entries: Map>, + channelId: string, + source: ConfiguredChannelPresenceSource, +) { + const normalized = normalizeOptionalLowercaseString(channelId); + if (!normalized) { + return; + } + let sources = entries.get(normalized); + if (!sources) { + sources = new Set(); + entries.set(normalized, sources); + } + sources.add(source); +} + +export function resolveConfiguredChannelPresencePolicy(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + cache?: boolean; + includePersistedAuthState?: boolean; + manifestRecords?: readonly PluginManifestRecord[]; +}): ConfiguredChannelPresencePolicyEntry[] { + const env = params.env ?? process.env; + const workspaceDir = + params.workspaceDir ?? + resolveAgentWorkspaceDir(params.config, resolveDefaultAgentId(params.config)); + const records = + params.manifestRecords ?? + loadPluginManifestRegistry({ + config: params.config, + workspaceDir, + env, + cache: params.cache, + }).plugins; + + const entrySources = new Map>(); + for (const channelId of listExplicitConfiguredChannelIdsForConfig(params.config)) { + addPolicySignal(entrySources, channelId, "explicit-config"); + } + for (const signal of listPotentialConfiguredChannelPresenceSignals(params.config, env, { + includePersistedAuthState: params.includePersistedAuthState, + })) { + if (signal.source === "config") { + continue; + } + addPolicySignal(entrySources, signal.channelId, signal.source); + } + for (const signal of listManifestEnvConfiguredChannelSignals({ + records, + config: params.config, + activationSourceConfig: params.activationSourceConfig, + env, + })) { + addPolicySignal(entrySources, signal.channelId, signal.source); + } + + const activationSource = createPluginActivationSource({ + config: params.activationSourceConfig ?? params.config, + }); + const normalizedConfig = activationSource.plugins; + const entries: ConfiguredChannelPresencePolicyEntry[] = []; + for (const channelId of normalizeChannelIds(entrySources.keys())) { + const owningRecords = records.filter((record) => recordOwnsChannel(record, channelId)); + const evaluations = owningRecords.map((plugin) => + evaluateEffectiveChannelPlugin({ + plugin, + channelId, + normalizedConfig, + config: params.config, + activationSource, + }), + ); + const effectivePluginIds = evaluations + .filter((entry) => entry.effective) + .map((entry) => entry.pluginId); + const blockedReasons = + owningRecords.length === 0 + ? ["no-channel-owner" as const] + : [ + ...new Set( + evaluations + .map((entry) => entry.blockedReason) + .filter((reason): reason is ConfiguredChannelBlockedReason => Boolean(reason)), + ), + ].toSorted((left, right) => left.localeCompare(right)); + entries.push({ + channelId, + sources: [...(entrySources.get(channelId) ?? [])].toSorted((left, right) => + left.localeCompare(right), + ), + effective: effectivePluginIds.length > 0, + pluginIds: dedupeSortedPluginIds(effectivePluginIds), + blockedReasons, + }); + } + return entries; +} + +export function listConfiguredChannelIdsForReadOnlyScope( + params: Parameters[0], +): string[] { + return resolveConfiguredChannelPresencePolicy(params) + .filter((entry) => entry.effective) + .map((entry) => entry.channelId); +} + +export function hasConfiguredChannelsForReadOnlyScope( + params: Parameters[0], +): boolean { + return listConfiguredChannelIdsForReadOnlyScope(params).length > 0; +} + +export function listConfiguredAnnounceChannelIdsForConfig(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + cache?: boolean; +}): string[] { + const channels = params.config.channels; + const disabledChannelIds = new Set( + channels && typeof channels === "object" && !Array.isArray(channels) + ? Object.entries(channels) + .filter(([, value]) => { + return ( + value && + typeof value === "object" && + !Array.isArray(value) && + (value as { enabled?: unknown }).enabled === false + ); + }) + .map(([channelId]) => channelId) + : [], + ); + return normalizeChannelIds([ + ...listExplicitConfiguredChannelIdsForConfig(params.config), + ...listConfiguredChannelIdsForReadOnlyScope({ + config: params.config, + activationSourceConfig: params.activationSourceConfig, + workspaceDir: params.workspaceDir, + env: params.env, + cache: params.cache, + includePersistedAuthState: false, + }), + ]).filter((channelId) => !disabledChannelIds.has(channelId)); +} + +function resolveScopedChannelOwnerPluginIds(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + channelIds: readonly string[]; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + 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 isChannelPluginEligibleForScopedOwnership({ + plugin, + normalizedConfig, + rootConfig: trustConfig, + }); + }) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); +} + +export function resolveDiscoverableScopedChannelPluginIds(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + channelIds: readonly string[]; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + cache?: boolean; +}): string[] { + return resolveScopedChannelOwnerPluginIds(params); +} + +export function resolveConfiguredChannelPluginIds(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + const configuredChannelIds = listConfiguredChannelIdsForReadOnlyScope({ + config: params.config, + activationSourceConfig: params.activationSourceConfig, + workspaceDir: params.workspaceDir, + env: params.env, + }); + if (configuredChannelIds.length === 0) { + return []; + } + return resolveScopedChannelOwnerPluginIds({ + ...params, + channelIds: configuredChannelIds, + }); +} diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts new file mode 100644 index 00000000000..59f225a7738 --- /dev/null +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -0,0 +1,200 @@ +import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js"; +import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + resolveMemoryDreamingConfig, + resolveMemoryDreamingPluginConfig, + resolveMemoryDreamingPluginId, +} from "../memory-host-sdk/dreaming.js"; +import { resolveManifestActivationPluginIds } from "./activation-planner.js"; +import { + createPluginActivationSource, + normalizePluginId, + normalizePluginsConfig, + resolveEffectivePluginActivationState, +} from "./config-state.js"; +import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; +import { hasKind } from "./slots.js"; + +function hasRuntimeContractSurface(plugin: PluginManifestRecord): boolean { + return Boolean( + plugin.providers.length > 0 || + plugin.cliBackends.length > 0 || + plugin.contracts?.speechProviders?.length || + plugin.contracts?.mediaUnderstandingProviders?.length || + plugin.contracts?.imageGenerationProviders?.length || + plugin.contracts?.videoGenerationProviders?.length || + plugin.contracts?.musicGenerationProviders?.length || + plugin.contracts?.webFetchProviders?.length || + plugin.contracts?.webSearchProviders?.length || + plugin.contracts?.memoryEmbeddingProviders?.length || + hasKind(plugin.kind, "memory"), + ); +} + +function isGatewayStartupMemoryPlugin(plugin: PluginManifestRecord): boolean { + return hasKind(plugin.kind, "memory"); +} + +function isGatewayStartupSidecar(plugin: PluginManifestRecord): boolean { + return plugin.channels.length === 0 && !hasRuntimeContractSurface(plugin); +} + +function resolveGatewayStartupDreamingPluginIds(config: OpenClawConfig): Set { + const dreamingConfig = resolveMemoryDreamingConfig({ + pluginConfig: resolveMemoryDreamingPluginConfig(config), + cfg: config, + }); + if (!dreamingConfig.enabled) { + return new Set(); + } + return new Set(["memory-core", resolveMemoryDreamingPluginId(config)]); +} + +function resolveExplicitMemorySlotStartupPluginId(config: OpenClawConfig): string | undefined { + const configuredSlot = config.plugins?.slots?.memory?.trim(); + if (!configuredSlot || configuredSlot.toLowerCase() === "none") { + return undefined; + } + return normalizePluginId(configuredSlot); +} + +function shouldConsiderForGatewayStartup(params: { + plugin: PluginManifestRecord; + startupDreamingPluginIds: ReadonlySet; + explicitMemorySlotStartupPluginId?: string; +}): boolean { + if (isGatewayStartupSidecar(params.plugin)) { + return true; + } + if (!isGatewayStartupMemoryPlugin(params.plugin)) { + return false; + } + if (params.startupDreamingPluginIds.has(params.plugin.id)) { + return true; + } + return params.explicitMemorySlotStartupPluginId === params.plugin.id; +} + +export function resolveChannelPluginIds(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + return loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .plugins.filter((plugin) => plugin.channels.length > 0) + .map((plugin) => plugin.id); +} + +export function resolveConfiguredDeferredChannelPluginIds(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + const configuredChannelIds = new Set( + listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), + ); + if (configuredChannelIds.size === 0) { + return []; + } + return loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .plugins.filter( + (plugin) => + plugin.channels.some((channelId) => configuredChannelIds.has(channelId)) && + plugin.startupDeferConfiguredChannelFullLoadUntilAfterListen === true, + ) + .map((plugin) => plugin.id); +} + +export function resolveGatewayStartupPluginIds(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + const configuredChannelIds = new Set( + listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), + ); + const pluginsConfig = normalizePluginsConfig(params.config.plugins); + // Startup must classify allowlist exceptions against the raw config snapshot, + // not the auto-enabled effective snapshot, or configured-only channels can be + // misclassified as explicit enablement. + const activationSource = createPluginActivationSource({ + config: params.activationSourceConfig ?? params.config, + }); + const requiredAgentHarnessPluginIds = new Set( + collectConfiguredAgentHarnessRuntimes( + params.activationSourceConfig ?? params.config, + params.env, + ).flatMap((runtime) => + resolveManifestActivationPluginIds({ + trigger: { + kind: "agentHarness", + runtime, + }, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + cache: true, + }), + ), + ); + const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config); + const explicitMemorySlotStartupPluginId = resolveExplicitMemorySlotStartupPluginId( + params.activationSourceConfig ?? params.config, + ); + return loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .plugins.filter((plugin) => { + if (plugin.channels.some((channelId) => configuredChannelIds.has(channelId))) { + return true; + } + if (requiredAgentHarnessPluginIds.has(plugin.id)) { + const activationState = resolveEffectivePluginActivationState({ + id: plugin.id, + origin: plugin.origin, + config: pluginsConfig, + rootConfig: params.config, + enabledByDefault: plugin.enabledByDefault, + activationSource, + }); + return activationState.enabled; + } + if ( + !shouldConsiderForGatewayStartup({ + plugin, + startupDreamingPluginIds, + explicitMemorySlotStartupPluginId, + }) + ) { + return false; + } + const activationState = resolveEffectivePluginActivationState({ + id: plugin.id, + origin: plugin.origin, + config: pluginsConfig, + rootConfig: params.config, + enabledByDefault: plugin.enabledByDefault, + activationSource, + }); + if (!activationState.enabled) { + return false; + } + if (plugin.origin !== "bundled") { + return activationState.explicitlyEnabled; + } + return activationState.source === "explicit" || activationState.source === "default"; + }) + .map((plugin) => plugin.id); +}