From f4478a142a415db8e81adb4fe235b04cc6c05677 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 21 Apr 2026 20:51:09 -0400 Subject: [PATCH] Fix channel presence gating for disabled plugins (#69862) Merged via squash. Prepared head SHA: f76f6212b229d06537a9141e6da8033e23f7d0c3 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/plugins/manifest.md | 5 +- src/channels/config-presence.test.ts | 6 + src/channels/config-presence.ts | 36 +- src/channels/plugins/read-only.test.ts | 154 +++++ src/channels/plugins/read-only.ts | 25 +- src/commands/channels/status-config-format.ts | 4 +- src/commands/channels/status.ts | 1 + src/commands/doctor-state-integrity.test.ts | 1 + src/commands/doctor-state-integrity.ts | 24 +- .../shared/channel-plugin-blockers.test.ts | 48 +- .../doctor/shared/channel-plugin-blockers.ts | 6 +- src/commands/status-all/channels.ts | 6 +- src/commands/status.link-channel.ts | 6 +- src/commands/status.scan-overview.test.ts | 4 +- src/commands/status.scan-overview.ts | 6 +- src/commands/status.scan.fast-json.ts | 5 +- src/commands/status.scan.ts | 7 +- src/commands/status.summary.ts | 11 +- src/commands/status.test.ts | 4 + src/gateway/server-methods/cron.ts | 10 +- src/gateway/server.cron.test.ts | 54 ++ src/infra/channel-summary.ts | 12 +- src/plugins/channel-plugin-ids.test.ts | 517 +++++++++++++++++ src/plugins/channel-plugin-ids.ts | 489 +--------------- src/plugins/channel-presence-policy.ts | 547 ++++++++++++++++++ src/plugins/gateway-startup-plugin-ids.ts | 267 +++++++++ src/plugins/manifest-owner-policy.ts | 2 + src/security/audit.ts | 13 +- 29 files changed, 1740 insertions(+), 531 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 fcec7c6424c..ce28a28b89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +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: 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/docs/plugins/manifest.md b/docs/plugins/manifest.md index 7421cdbddf8..5312d162c7f 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -651,7 +651,10 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s hardcoding the owning provider. - `channelEnvVars` is the cheap metadata path for shell-env fallback, setup prompts, and similar channel surfaces that should not boot plugin runtime - just to inspect env names. + just to inspect env names. Env names are metadata, not activation by + themselves: status, audit, cron delivery validation, and other read-only + surfaces still apply plugin trust and effective activation policy before they + treat an env var as a configured channel. - `providerAuthChoices` is the cheap metadata path for auth-choice pickers, `--auth-choice` resolution, preferred-provider mapping, and simple onboarding CLI flag registration before provider runtime loads. For runtime wizard 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/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index a8c61df5116..e6dcbe7bdba 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -155,6 +155,117 @@ module.exports = { return { pluginDir, fullMarker, setupMarker }; } +function writeBundledSetupChannelPlugin( + options: { + pluginId?: string; + channelId?: string; + envVar?: string; + } = {}, +) { + const bundledRoot = makeTempDir(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledRoot; + const pluginId = options.pluginId ?? "bundled-chat"; + const channelId = options.channelId ?? pluginId; + const envVar = options.envVar ?? "BUNDLED_CHAT_TOKEN"; + const pluginDir = path.join(bundledRoot, pluginId); + fs.mkdirSync(pluginDir, { recursive: true }); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: `@openclaw/${pluginId}`, + version: "1.0.0", + type: "commonjs", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + channel: { + id: channelId, + label: "Bundled Chat", + selectionLabel: "Bundled Chat", + docsPath: `/channels/${channelId}`, + blurb: "bundled setup entry", + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: pluginId, + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: [channelId], + channelEnvVars: { + [channelId]: [envVar], + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + kind: "bundled-channel-entry", + id: ${JSON.stringify(pluginId)}, + name: "Bundled Chat", + description: "full entry", + register() {}, + loadChannelPlugin() { + return { + id: ${JSON.stringify(channelId)}, + meta: { id: ${JSON.stringify(channelId)}, label: "Bundled Chat" }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + }; + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `module.exports = { + kind: "bundled-channel-setup-entry", + loadSetupPlugin() { + require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); + return { + id: ${JSON.stringify(channelId)}, + meta: { + id: ${JSON.stringify(channelId)}, + label: "Bundled Chat", + selectionLabel: "Bundled Chat", + docsPath: ${JSON.stringify(`/channels/${channelId}`)}, + blurb: "bundled setup entry", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + }; + }, +};`, + "utf-8", + ); + + return { bundledRoot, pluginDir, fullMarker, setupMarker, pluginId, channelId, envVar }; +} + afterEach(() => { resetPluginLoaderTestStateForTest(); }); @@ -366,6 +477,49 @@ describe("listReadOnlyChannelPluginsForConfig", () => { expect(fs.existsSync(fullMarker)).toBe(false); }); + it("does not promote disabled bundled channels from ambient env", () => { + const { channelId, envVar, fullMarker, setupMarker } = writeBundledSetupChannelPlugin(); + const plugins = listReadOnlyChannelPluginsForConfig( + { + plugins: { + allow: ["memory-core"], + }, + } as never, + { + env: { ...process.env, [envVar]: "configured" }, + includePersistedAuthState: false, + }, + ); + + expect(plugins.some((entry) => entry.id === channelId)).toBe(false); + expect(fs.existsSync(setupMarker)).toBe(false); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("keeps explicitly enabled bundled channels visible from env configuration", () => { + const { channelId, envVar, fullMarker, pluginId, setupMarker } = + writeBundledSetupChannelPlugin(); + const plugins = listReadOnlyChannelPluginsForConfig( + { + plugins: { + allow: [pluginId], + entries: { + [pluginId]: { enabled: true }, + }, + }, + } as never, + { + env: { ...process.env, [envVar]: "configured" }, + includePersistedAuthState: false, + }, + ); + + const plugin = plugins.find((entry) => entry.id === channelId); + expect(plugin?.meta.blurb).toBe("bundled setup entry"); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + it("accepts option-like env keys through the explicit env option", () => { const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ pluginId: "external-chat-plugin", diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 7bbe4ebc0d5..889f2b56c3c 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -290,18 +290,10 @@ function resolveReadOnlyWorkspaceDir( return options.workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); } -function listExternalChannelManifestRecords(params: { - cfg: OpenClawConfig; - workspaceDir?: string; - env: NodeJS.ProcessEnv; - cache?: boolean; -}): PluginManifestRecord[] { - return loadPluginManifestRegistry({ - config: params.cfg, - workspaceDir: params.workspaceDir, - env: params.env, - cache: params.cache, - }).plugins.filter((plugin) => plugin.origin !== "bundled" && plugin.channels.length > 0); +function listExternalChannelManifestRecords( + records: readonly PluginManifestRecord[], +): PluginManifestRecord[] { + return records.filter((plugin) => plugin.origin !== "bundled" && plugin.channels.length > 0); } function resolveExternalReadOnlyChannelPluginIds(params: { @@ -353,12 +345,13 @@ export function resolveReadOnlyChannelPluginsForConfig( ): ReadOnlyChannelPluginResolution { const env = options.env ?? process.env; const workspaceDir = resolveReadOnlyWorkspaceDir(cfg, options); - const externalManifestRecords = listExternalChannelManifestRecords({ - cfg, + const manifestRecords = loadPluginManifestRegistry({ + config: cfg, workspaceDir, env, cache: options.cache, - }); + }).plugins; + const externalManifestRecords = listExternalChannelManifestRecords(manifestRecords); const configuredChannelIds = [ ...new Set( listConfiguredChannelIdsForReadOnlyScope({ @@ -368,7 +361,7 @@ export function resolveReadOnlyChannelPluginsForConfig( env, cache: options.cache, includePersistedAuthState: options.includePersistedAuthState, - manifestRecords: externalManifestRecords, + manifestRecords, }), ), ]; diff --git a/src/commands/channels/status-config-format.ts b/src/commands/channels/status-config-format.ts index 42a0ef0302e..031fd85df8e 100644 --- a/src/commands/channels/status-config-format.ts +++ b/src/commands/channels/status-config-format.ts @@ -47,8 +47,10 @@ export async function formatConfigChannelsStatusLines( return buildChannelAccountLine(provider, account, bits); }); - const plugins = listReadOnlyChannelPluginsForConfig(cfg); const sourceConfig = opts?.sourceConfig ?? cfg; + const plugins = listReadOnlyChannelPluginsForConfig(cfg, { + activationSourceConfig: sourceConfig, + }); for (const plugin of plugins) { const accountIds = plugin.config.listAccountIds(cfg); if (!accountIds.length) { diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index c774b86e5c3..d4d1ef2427f 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -211,6 +211,7 @@ export async function channelsStatusCommand( }, configuredChannels: listConfiguredChannelIdsForReadOnlyScope({ config: resolvedConfig, + activationSourceConfig: cfg, env: process.env, includePersistedAuthState: false, }), diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index f1cf4057818..d125b01b620 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -14,6 +14,7 @@ vi.mock("../channels/plugins/bundled-ids.js", () => ({ })); vi.mock("../channels/plugins/persisted-auth-state.js", () => ({ + listBundledChannelIdsWithPersistedAuthState: () => ["matrix", "whatsapp"], hasBundledChannelPersistedAuthState: () => false, })); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index feef9fbdffa..3b7ed456e6b 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -2,8 +2,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { listBundledChannelPluginIds } from "../channels/plugins/bundled-ids.js"; -import { hasBundledChannelPersistedAuthState } from "../channels/plugins/persisted-auth-state.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import { @@ -21,6 +19,7 @@ import { loadSessionStore } from "../config/sessions/store-load.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { resolveMemoryBackendConfig } from "../memory-host-sdk/engine-storage.js"; +import { listConfiguredChannelIdsForReadOnlyScope } from "../plugins/channel-plugin-ids.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; import { asNullableObjectRecord } from "../shared/record-coerce.js"; @@ -549,10 +548,23 @@ function shouldRequireOAuthDir(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boo if (!channels) { return false; } - for (const channelId of listBundledChannelPluginIds()) { - if (hasBundledChannelPersistedAuthState({ channelId, cfg, env })) { - return true; - } + const withPersistedAuth = new Set( + listConfiguredChannelIdsForReadOnlyScope({ + config: cfg, + env, + cache: true, + }), + ); + const withoutPersistedAuth = new Set( + listConfiguredChannelIdsForReadOnlyScope({ + config: cfg, + env, + cache: true, + includePersistedAuthState: false, + }), + ); + if ([...withPersistedAuth].some((channelId) => !withoutPersistedAuth.has(channelId))) { + return true; } // Pairing allowlists are persisted under credentials/-allowFrom.json. for (const [channelId, channelCfg] of Object.entries(channels)) { diff --git a/src/commands/doctor/shared/channel-plugin-blockers.test.ts b/src/commands/doctor/shared/channel-plugin-blockers.test.ts index 69943353764..61e899aaf3f 100644 --- a/src/commands/doctor/shared/channel-plugin-blockers.test.ts +++ b/src/commands/doctor/shared/channel-plugin-blockers.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as configPresence from "../../../channels/config-presence.js"; import * as manifestRegistry from "../../../plugins/manifest-registry.js"; import { scanConfiguredChannelPluginBlockers } from "./channel-plugin-blockers.js"; @@ -9,7 +8,6 @@ describe("channel plugin blockers", () => { }); it("skips plugin registry work when config has no plugin blocker surfaces", () => { - const presenceSpy = vi.spyOn(configPresence, "listPotentialConfiguredChannelIds"); const registrySpy = vi.spyOn(manifestRegistry, "loadPluginManifestRegistry"); const hits = scanConfiguredChannelPluginBlockers({ @@ -25,12 +23,10 @@ describe("channel plugin blockers", () => { }); expect(hits).toEqual([]); - expect(presenceSpy).not.toHaveBeenCalled(); expect(registrySpy).not.toHaveBeenCalled(); }); it("still evaluates configured channels when plugins are disabled globally", () => { - vi.spyOn(configPresence, "listPotentialConfiguredChannelIds").mockReturnValue(["slack"]); vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ plugins: [ { @@ -66,4 +62,48 @@ describe("channel plugin blockers", () => { }, ]); }); + + it("ignores ambient channel env when reporting plugin blockers", () => { + vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ + plugins: [ + { + id: "slack", + origin: "bundled", + channels: ["slack"], + enabledByDefault: true, + }, + { + id: "telegram", + origin: "bundled", + channels: ["telegram"], + enabledByDefault: true, + }, + ], + diagnostics: [], + } as unknown as ReturnType); + + const hits = scanConfiguredChannelPluginBlockers( + { + plugins: { + enabled: false, + }, + channels: { + telegram: { + botToken: "configured", + }, + }, + }, + { + SLACK_BOT_TOKEN: "ambient", + } as NodeJS.ProcessEnv, + ); + + expect(hits).toEqual([ + { + channelId: "telegram", + pluginId: "telegram", + reason: "plugins disabled", + }, + ]); + }); }); diff --git a/src/commands/doctor/shared/channel-plugin-blockers.ts b/src/commands/doctor/shared/channel-plugin-blockers.ts index 56815e48d82..0facd1e0d56 100644 --- a/src/commands/doctor/shared/channel-plugin-blockers.ts +++ b/src/commands/doctor/shared/channel-plugin-blockers.ts @@ -1,5 +1,5 @@ -import { listPotentialConfiguredChannelIds } from "../../../channels/config-presence.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { listExplicitConfiguredChannelIdsForConfig } from "../../../plugins/channel-plugin-ids.js"; import { normalizePluginsConfig, resolveEffectivePluginActivationState, @@ -39,9 +39,7 @@ export function scanConfiguredChannelPluginBlockers( if (!hasExplicitChannelPluginBlockerConfig(cfg)) { return []; } - const configuredChannelIds = new Set( - listPotentialConfiguredChannelIds(cfg, env).map((id) => id.trim()), - ); + const configuredChannelIds = new Set(listExplicitConfiguredChannelIdsForConfig(cfg)); if (configuredChannelIds.size === 0) { return []; } diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index db7bdb438e8..c2582c903c0 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -205,7 +205,10 @@ export async function buildChannelsTable( rows: Array>; }> = []; - for (const plugin of listReadOnlyChannelPluginsForConfig(cfg)) { + const sourceConfig = opts?.sourceConfig ?? cfg; + for (const plugin of listReadOnlyChannelPluginsForConfig(cfg, { + activationSourceConfig: sourceConfig, + })) { const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, @@ -215,7 +218,6 @@ export async function buildChannelsTable( const resolvedAccountIds = accountIds.length > 0 ? accountIds : [defaultAccountId]; const accounts: ChannelAccountRow[] = []; - const sourceConfig = opts?.sourceConfig ?? cfg; for (const accountId of resolvedAccountIds) { accounts.push( await resolveChannelAccountRow({ diff --git a/src/commands/status.link-channel.ts b/src/commands/status.link-channel.ts index 01b744c69ac..0d01a3c2567 100644 --- a/src/commands/status.link-channel.ts +++ b/src/commands/status.link-channel.ts @@ -14,8 +14,12 @@ export type LinkChannelContext = { export async function resolveLinkChannelContext( cfg: OpenClawConfig, + options: { sourceConfig?: OpenClawConfig } = {}, ): Promise { - for (const plugin of listReadOnlyChannelPluginsForConfig(cfg)) { + const sourceConfig = options.sourceConfig ?? cfg; + for (const plugin of listReadOnlyChannelPluginsForConfig(cfg, { + activationSourceConfig: sourceConfig, + })) { const { defaultAccountId, account, enabled, configured } = await resolveDefaultChannelAccountContext(plugin, cfg, { mode: "read_only", diff --git a/src/commands/status.scan-overview.test.ts b/src/commands/status.scan-overview.test.ts index d44f3508514..24f502adcbf 100644 --- a/src/commands/status.scan-overview.test.ts +++ b/src/commands/status.scan-overview.test.ts @@ -13,8 +13,8 @@ const mocks = vi.hoisted(() => ({ buildChannelsTable: vi.fn(), })); -vi.mock("../channels/config-presence.js", () => ({ - hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels, +vi.mock("../plugins/channel-plugin-ids.js", () => ({ + hasConfiguredChannelsForReadOnlyScope: mocks.hasPotentialConfiguredChannels, })); vi.mock("../cli/command-config-resolution.js", () => ({ diff --git a/src/commands/status.scan-overview.ts b/src/commands/status.scan-overview.ts index e2ba85e6c37..ccf26d12251 100644 --- a/src/commands/status.scan-overview.ts +++ b/src/commands/status.scan-overview.ts @@ -133,7 +133,7 @@ export async function collectStatusScanOverview(params: { showSecrets: boolean; runtime?: RuntimeEnv; allowMissingConfigFastPath?: boolean; - resolveHasConfiguredChannels?: (cfg: OpenClawConfig) => boolean; + resolveHasConfiguredChannels?: (cfg: OpenClawConfig, sourceConfig: OpenClawConfig) => boolean; includeChannelsData?: boolean; useGatewayCallOverridesForChannelsStatus?: boolean; progress?: { @@ -177,8 +177,8 @@ export async function collectStatusScanOverview(params: { }); params.progress?.tick(); const hasConfiguredChannels = params.resolveHasConfiguredChannels - ? params.resolveHasConfiguredChannels(cfg) - : hasConfiguredChannelsForReadOnlyScope({ config: cfg }); + ? params.resolveHasConfiguredChannels(cfg, sourceConfig) + : hasConfiguredChannelsForReadOnlyScope({ config: cfg, activationSourceConfig: sourceConfig }); const osSummary = resolveOsSummary(); const bootstrap = await createStatusScanCoreBootstrap< Awaited> diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index edd58db00e7..9b84eca6b61 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -13,7 +13,7 @@ type StatusJsonScanPolicy = { commandName: string; allowMissingConfigFastPath?: boolean; includeChannelSummary?: boolean; - resolveHasConfiguredChannels: (cfg: OpenClawConfig) => boolean; + resolveHasConfiguredChannels: (cfg: OpenClawConfig, sourceConfig: OpenClawConfig) => boolean; resolveMemory: Parameters[0]["resolveMemory"]; }; @@ -58,9 +58,10 @@ export async function scanStatusJsonFast( commandName: "status --json", allowMissingConfigFastPath: true, includeChannelSummary: false, - resolveHasConfiguredChannels: (cfg) => + resolveHasConfiguredChannels: (cfg, sourceConfig) => hasConfiguredChannelsForReadOnlyScope({ config: cfg, + activationSourceConfig: sourceConfig, env: process.env, includePersistedAuthState: false, }), diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index cce6c103f6c..3d6e9812f71 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -25,8 +25,11 @@ export async function scanStatus( _runtime, { commandName: "status --json", - resolveHasConfiguredChannels: (cfg) => - hasConfiguredChannelsForReadOnlyScope({ config: cfg }), + resolveHasConfiguredChannels: (cfg, sourceConfig) => + hasConfiguredChannelsForReadOnlyScope({ + config: cfg, + activationSourceConfig: sourceConfig, + }), resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) => await resolveStatusMemoryStatusSnapshot({ cfg, diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index f808cefbb6b..e0f46ba6187 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -118,14 +118,15 @@ export async function getStatusSummary( resolveSessionModelRef, } = await loadStatusSummaryRuntimeModule(); const cfg = options.config ?? (await loadConfigIoModule()).loadConfig(); + const channelScopeConfig = + options.sourceConfig === undefined + ? { config: cfg } + : { config: cfg, activationSourceConfig: options.sourceConfig }; const needsChannelPlugins = - includeChannelSummary && - hasConfiguredChannelsForReadOnlyScope({ - config: cfg, - }); + includeChannelSummary && hasConfiguredChannelsForReadOnlyScope(channelScopeConfig); const linkContext = needsChannelPlugins ? await loadLinkChannelModule().then(({ resolveLinkChannelContext }) => - resolveLinkChannelContext(cfg), + resolveLinkChannelContext(cfg, { sourceConfig: options.sourceConfig }), ) : null; const agentList = listGatewayAgentsBasic(cfg); 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/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 2ad388f7ce8..cd3fc392f44 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -1,4 +1,3 @@ -import { listPotentialConfiguredChannelIds } from "../../channels/config-presence.js"; import { loadConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveCronDeliveryPreviews } from "../../cron/delivery-preview.js"; @@ -13,6 +12,7 @@ import { isInvalidCronSessionTargetIdError } from "../../cron/session-target.js" import type { CronDelivery, CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js"; import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { listConfiguredAnnounceChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { ErrorCodes, @@ -30,9 +30,11 @@ import { import type { GatewayRequestHandlers } from "./types.js"; function listConfiguredAnnounceChannelIds(cfg: OpenClawConfig): string[] { - return listPotentialConfiguredChannelIds(cfg, process.env, { - includePersistedAuthState: false, - }).filter((channelId) => cfg.channels?.[channelId]?.enabled !== false); + return listConfiguredAnnounceChannelIdsForConfig({ + config: cfg, + env: process.env, + cache: true, + }); } function assertConfiguredAnnounceChannel(params: { diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index dd07aed7506..79c4e1f1398 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -740,6 +740,12 @@ describe("gateway server cron", () => { appToken: "xapp-slack-token", }, }, + plugins: { + entries: { + telegram: { enabled: true }, + slack: { enabled: true }, + }, + }, }); const { server, ws } = await startServerWithClient(); @@ -763,6 +769,43 @@ describe("gateway server cron", () => { } }); + test("ignores ambient disabled channel env when validating announce delivery", async () => { + vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-ambient"); + vi.stubEnv("TELEGRAM_BOT_TOKEN", "ambient-telegram"); + const { prevSkipCron } = await setupCronTestRun({ + tempPrefix: "openclaw-gw-cron-ambient-disabled-delivery-", + cronEnabled: false, + }); + + await writeCronConfig({ + session: { + mainKey: "main", + }, + plugins: { + allow: ["memory-core"], + }, + }); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + try { + const addRes = await rpcReq(ws, "cron.add", { + name: "ambient disabled announce", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + delivery: { mode: "announce" }, + }); + + expect(addRes.ok).toBe(true); + } finally { + await cleanupCronTestRun({ ws, server, prevSkipCron }); + } + }); + test("rejects ambiguous announce delivery on update when multiple channels are configured", async () => { const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-ambiguous-delivery-update-", @@ -782,6 +825,12 @@ describe("gateway server cron", () => { appToken: "xapp-slack-token", }, }, + plugins: { + entries: { + telegram: { enabled: true }, + slack: { enabled: true }, + }, + }, }); const { server, ws } = await startServerWithClient(); @@ -832,6 +881,11 @@ describe("gateway server cron", () => { appToken: "xapp-slack-token", }, }, + plugins: { + entries: { + slack: { enabled: true }, + }, + }, }); const { server, ws } = await startServerWithClient(); diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index 440fe77ab85..f5812d5b198 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -48,9 +48,14 @@ async function loadChannelSummaryConfig(): Promise { return loadConfig(); } -async function listChannelSummaryPlugins(cfg: OpenClawConfig): Promise { +async function listChannelSummaryPlugins(params: { + cfg: OpenClawConfig; + sourceConfig: OpenClawConfig; +}): Promise { const { listReadOnlyChannelPluginsForConfig } = await import("../channels/plugins/read-only.js"); - return listReadOnlyChannelPluginsForConfig(cfg); + return listReadOnlyChannelPluginsForConfig(params.cfg, { + activationSourceConfig: params.sourceConfig, + }); } const buildAccountDetails = (params: { @@ -123,7 +128,8 @@ export async function buildChannelSummary( resolved.colorize && color ? color(value) : value; const sourceConfig = options?.sourceConfig ?? effective; - const plugins = options?.plugins ?? (await listChannelSummaryPlugins(effective)); + const plugins = + options?.plugins ?? (await listChannelSummaryPlugins({ cfg: effective, sourceConfig })); for (const plugin of plugins) { const accountIds = plugin.config.listAccountIds(effective); const defaultAccountId = diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 8eed1735507..8234153f1ca 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -2,12 +2,25 @@ 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) => { + return ( + !!value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).some((key) => key !== "enabled") + ); + }), +); const loadPluginManifestRegistry = vi.hoisted(() => vi.fn()); vi.mock("../channels/config-presence.js", () => ({ listPotentialConfiguredChannelIds, + listPotentialConfiguredChannelPresenceSignals, hasPotentialConfiguredChannels, + hasMeaningfulChannelConfig, })); vi.mock("./manifest-registry.js", async (importOriginal) => { @@ -20,7 +33,11 @@ vi.mock("./manifest-registry.js", async (importOriginal) => { import { hasConfiguredChannelsForReadOnlyScope, + listConfiguredAnnounceChannelIdsForConfig, listConfiguredChannelIdsForReadOnlyScope, + listExplicitConfiguredChannelIdsForConfig, + resolveConfiguredChannelPresencePolicy, + resolveConfiguredDeferredChannelPluginIds, resolveConfiguredChannelPluginIds, resolveGatewayStartupPluginIds, } from "./channel-plugin-ids.js"; @@ -165,6 +182,25 @@ function createManifestRegistryFixture() { }; } +function createManifestRegistryFixtureWithWorkspaceDemoChannel() { + const fixture = createManifestRegistryFixture(); + return { + ...fixture, + plugins: [ + ...fixture.plugins, + { + id: "workspace-demo-channel-plugin", + channels: ["demo-channel"], + startupDeferConfiguredChannelFullLoadUntilAfterListen: true, + origin: "workspace", + enabledByDefault: undefined, + providers: [], + cliBackends: [], + }, + ], + }; +} + function expectStartupPluginIds(params: { config: OpenClawConfig; activationSourceConfig?: OpenClawConfig; @@ -316,6 +352,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; @@ -394,6 +438,74 @@ describe("resolveGatewayStartupPluginIds", () => { expectStartupPluginIdsCase({ config: effectiveConfig, activationSourceConfig: rawConfig, + expected: ["browser"], + }); + }); + + it("does not let weak channel presence start untrusted workspace channel owners", () => { + loadPluginManifestRegistry + .mockReset() + .mockReturnValue(createManifestRegistryFixtureWithWorkspaceDemoChannel()); + listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "demo-channel", source: "env" }, + ]); + + const config = {} as OpenClawConfig; + + expectStartupPluginIdsCase({ + config, + env: { + DEMO_CHANNEL_ANYTHING: "1", + } as NodeJS.ProcessEnv, + expected: ["demo-channel", "browser"], + }); + expect( + resolveConfiguredDeferredChannelPluginIds({ + config, + workspaceDir: "/tmp", + env: { + DEMO_CHANNEL_ANYTHING: "1", + } as NodeJS.ProcessEnv, + }), + ).toEqual([]); + }); + + it("keeps explicitly trusted deferred channel owners eligible at startup", () => { + loadPluginManifestRegistry + .mockReset() + .mockReturnValue(createManifestRegistryFixtureWithWorkspaceDemoChannel()); + expect( + resolveConfiguredDeferredChannelPluginIds({ + config: { + channels: { + "demo-channel": { + token: "configured", + }, + }, + plugins: { + allow: ["workspace-demo-channel-plugin"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }), + ).toEqual(["workspace-demo-channel-plugin"]); + }); + + it("preserves explicit bundled channel config under restrictive allowlists", () => { + expectStartupPluginIdsCase({ + config: { + channels: { + "demo-channel": { + token: "configured", + }, + }, + plugins: { + allow: ["browser"], + }, + } as OpenClawConfig, + env: {}, expected: ["demo-channel", "browser"], }); }); @@ -489,6 +601,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; @@ -523,6 +643,25 @@ describe("resolveConfiguredChannelPluginIds", () => { ).toEqual([]); }); + it("keeps explicitly configured bundled channel owners under restrictive allowlists", () => { + expect( + resolveConfiguredChannelPluginIds({ + config: { + channels: { + "demo-channel": { + token: "configured", + }, + }, + plugins: { + allow: ["browser"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }), + ).toEqual(["demo-channel"]); + }); + it("blocks bundled activation owners when explicitly denied", () => { expect( resolveConfiguredChannelPluginIds({ @@ -651,10 +790,387 @@ describe("resolveConfiguredChannelPluginIds", () => { describe("listConfiguredChannelIdsForReadOnlyScope", () => { beforeEach(() => { listPotentialConfiguredChannelIds.mockReset().mockReturnValue([]); + listPotentialConfiguredChannelPresenceSignals.mockReset().mockReturnValue([]); hasPotentialConfiguredChannels.mockReset().mockReturnValue(false); + hasMeaningfulChannelConfig.mockClear(); loadPluginManifestRegistry.mockReset().mockReturnValue(createManifestRegistryFixture()); }); + it("filters bundled ambient channel triggers through effective activation", () => { + listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "demo-channel", source: "env" }, + ]); + + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config: { + plugins: { + allow: ["memory-core"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + DEMO_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toEqual([]); + + expect( + hasConfiguredChannelsForReadOnlyScope({ + config: { + plugins: { + allow: ["memory-core"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + DEMO_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).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({ + config: { + plugins: { + entries: { + "demo-channel": { + enabled: true, + }, + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + DEMO_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).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("does not treat disabled stale channel config as explicit read-only intent", () => { + const config = { + channels: { + "demo-channel": { + enabled: false, + token: "stale-token", + }, + }, + } as OpenClawConfig; + + expect(listExplicitConfiguredChannelIdsForConfig(config)).toEqual([]); + expect( + resolveConfiguredChannelPresencePolicy({ + config, + workspaceDir: "/tmp", + env: {}, + includePersistedAuthState: false, + }), + ).toEqual([]); + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config, + workspaceDir: "/tmp", + env: {}, + includePersistedAuthState: false, + }), + ).toEqual([]); + }); + + it("lets explicit bundled channel config bypass restrictive allowlists", () => { + const config = { + channels: { + "demo-channel": { + token: "configured", + }, + }, + plugins: { + allow: ["browser"], + }, + } as OpenClawConfig; + + expect( + resolveConfiguredChannelPresencePolicy({ + config, + workspaceDir: "/tmp", + env: {}, + includePersistedAuthState: false, + }), + ).toEqual([ + { + channelId: "demo-channel", + sources: ["explicit-config"], + effective: true, + pluginIds: ["demo-channel"], + blockedReasons: [], + }, + ]); + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config, + 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({ + config: { + channels: { + "demo-channel": { + token: "configured", + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + includePersistedAuthState: false, + }), + ).toEqual(["demo-channel"]); + }); + + 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({ + config: { + channels: { + "demo-channel": { + token: "configured", + }, + }, + plugins: { + enabled: false, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + includePersistedAuthState: false, + }), + ).toEqual([]); + + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config: { + channels: { + "demo-channel": { + token: "configured", + }, + }, + plugins: { + deny: ["demo-channel"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + includePersistedAuthState: false, + }), + ).toEqual([]); + }); + + it("lists explicit configured channels without ambient env triggers", () => { + expect( + listExplicitConfiguredChannelIdsForConfig({ + channels: { + defaults: { + model: "sonnet-4.6", + }, + "demo-channel": { + token: "configured", + }, + "demo-other-channel": { + enabled: false, + }, + }, + } as OpenClawConfig), + ).toEqual(["demo-channel"]); + }); + + it("does not let disabled mixed-case channel config announce ambient matches", () => { + listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "demo-channel", source: "env" }, + ]); + + expect( + listConfiguredAnnounceChannelIdsForConfig({ + config: { + channels: { + "Demo-Channel": { + enabled: false, + token: "stale-token", + }, + }, + plugins: { + entries: { + "demo-channel": { + enabled: true, + }, + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + DEMO_CHANNEL_TOKEN: "ambient", + } as NodeJS.ProcessEnv, + }), + ).toEqual([]); + }); + + 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({ + config: { + channels: { + "demo-other-channel": { + token: "configured", + }, + }, + plugins: { + allow: ["demo-other-channel"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + DEMO_CHANNEL_TOKEN: "ambient", + } as NodeJS.ProcessEnv, + }), + ).toEqual(["demo-other-channel"]); + }); + + it("does not treat activation-only declarations as channel ownership", () => { + listPotentialConfiguredChannelIds.mockReturnValue(["activation-only-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "activation-only-channel", source: "env" }, + ]); + + expect( + resolveConfiguredChannelPresencePolicy({ + config: { + plugins: { + entries: { + "activation-only-channel-plugin": { + enabled: true, + }, + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + ACTIVATION_ONLY_CHANNEL_TOKEN: "ambient", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toEqual([ + { + channelId: "activation-only-channel", + sources: ["env"], + effective: false, + pluginIds: [], + blockedReasons: ["no-channel-owner"], + }, + ]); + }); + it("uses manifest env vars as read-only configured channel triggers", () => { expect( listConfiguredChannelIdsForReadOnlyScope({ @@ -777,6 +1293,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 2758945a919..8dd6b483f1c 100644 --- a/src/plugins/channel-plugin-ids.ts +++ b/src/plugins/channel-plugin-ids.ts @@ -1,472 +1,19 @@ -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js"; -import { - hasPotentialConfiguredChannels, - 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)); -} - -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)); -} - -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; - return [ - ...new Set([ - ...listPotentialConfiguredChannelIds(params.config, params.env, { - includePersistedAuthState: params.includePersistedAuthState, - }), - ...listEnvConfiguredManifestChannelIds({ - records, - config: params.config, - activationSourceConfig: params.activationSourceConfig, - env: params.env, - }), - ]), - ].toSorted((left, right) => left.localeCompare(right)); -} - -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 { - const env = params.env ?? process.env; - if ( - hasPotentialConfiguredChannels(params.config, env, { - includePersistedAuthState: params.includePersistedAuthState, - }) - ) { - return true; - } - return ( - listConfiguredChannelIdsForReadOnlyScope({ - ...params, - env, - }).length > 0 - ); -} - -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..94f4f61f09e --- /dev/null +++ b/src/plugins/channel-presence-policy.ts @@ -0,0 +1,547 @@ +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; + } + const enabled = (entry as { enabled?: unknown }).enabled; + if (enabled === false) { + return false; + } + return 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 recordDeclaresChannel(record: PluginManifestRecord, channelId: string): boolean { + const normalizedChannelId = normalizeOptionalLowercaseString(channelId) ?? ""; + if (!normalizedChannelId) { + return false; + } + return record.channels.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; + allowRestrictiveAllowlistBypass?: boolean; +}): 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.allowRestrictiveAllowlistBypass !== true && + 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; + channelId?: string; +}): boolean { + const allowRestrictiveAllowlistBypass = + params.channelId !== undefined && + isBundledManifestOwner(params.plugin) && + hasExplicitChannelConfig({ + config: params.rootConfig, + channelId: params.channelId, + }); + if ( + !passesManifestOwnerBasePolicy({ + plugin: params.plugin, + normalizedConfig: params.normalizedConfig, + allowRestrictiveAllowlistBypass, + }) + ) { + 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 explicitBundledChannelConfig = + isBundledManifestOwner(params.plugin) && + hasExplicitChannelConfig({ + config: params.activationSource.rootConfig ?? params.config, + channelId: params.channelId, + }); + const baseBlockedReason = resolveBasePolicyBlockedReason({ + plugin: params.plugin, + normalizedConfig: params.normalizedConfig, + allowRestrictiveAllowlistBypass: explicitBundledChannelConfig, + }); + 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 (explicitBundledChannelConfig) { + 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) => recordDeclaresChannel(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]) => normalizeOptionalLowercaseString(channelId)) + .filter((channelId): channelId is string => Boolean(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, + channelId: channelIds.find((channelId) => recordDeclaresChannel(plugin, channelId)), + }); + }) + .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 = normalizeChannelIds([ + ...listConfiguredChannelIdsForReadOnlyScope({ + config: params.config, + activationSourceConfig: params.activationSourceConfig, + workspaceDir: params.workspaceDir, + env: params.env, + }), + ...listExplicitConfiguredChannelIdsForConfig(params.activationSourceConfig ?? params.config), + ]); + 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..944197818b1 --- /dev/null +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -0,0 +1,267 @@ +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 { hasExplicitChannelConfig } from "./channel-presence-policy.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; +} + +function hasConfiguredStartupChannel(params: { + plugin: PluginManifestRecord; + configuredChannelIds: ReadonlySet; +}): boolean { + return params.plugin.channels.some((channelId) => params.configuredChannelIds.has(channelId)); +} + +function canStartConfiguredChannelPlugin(params: { + plugin: PluginManifestRecord; + config: OpenClawConfig; + pluginsConfig: ReturnType; + activationSource: ReturnType; +}): boolean { + if (!params.pluginsConfig.enabled) { + return false; + } + if (params.pluginsConfig.deny.includes(params.plugin.id)) { + return false; + } + if (params.pluginsConfig.entries[params.plugin.id]?.enabled === false) { + return false; + } + const explicitBundledChannelConfig = + params.plugin.origin === "bundled" && + params.plugin.channels.some((channelId) => + hasExplicitChannelConfig({ + config: params.activationSource.rootConfig ?? params.config, + channelId, + }), + ); + if ( + params.pluginsConfig.allow.length > 0 && + !params.pluginsConfig.allow.includes(params.plugin.id) && + !explicitBundledChannelConfig + ) { + return false; + } + if (params.plugin.origin === "bundled") { + return true; + } + const activationState = resolveEffectivePluginActivationState({ + id: params.plugin.id, + origin: params.plugin.origin, + config: params.pluginsConfig, + rootConfig: params.config, + enabledByDefault: params.plugin.enabledByDefault, + activationSource: params.activationSource, + }); + return activationState.enabled && activationState.explicitlyEnabled; +} + +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 []; + } + const pluginsConfig = normalizePluginsConfig(params.config.plugins); + const activationSource = createPluginActivationSource({ + config: params.config, + }); + return loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .plugins.filter( + (plugin) => + hasConfiguredStartupChannel({ plugin, configuredChannelIds }) && + plugin.startupDeferConfiguredChannelFullLoadUntilAfterListen === true && + canStartConfiguredChannelPlugin({ + plugin, + config: params.config, + pluginsConfig, + activationSource, + }), + ) + .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 (hasConfiguredStartupChannel({ plugin, configuredChannelIds })) { + return canStartConfiguredChannelPlugin({ + plugin, + config: params.config, + pluginsConfig, + activationSource, + }); + } + 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); +} diff --git a/src/plugins/manifest-owner-policy.ts b/src/plugins/manifest-owner-policy.ts index a668b127df0..44d7b5d6322 100644 --- a/src/plugins/manifest-owner-policy.ts +++ b/src/plugins/manifest-owner-policy.ts @@ -24,6 +24,7 @@ export function passesManifestOwnerBasePolicy(params: { plugin: Pick; normalizedConfig: NormalizedPluginsConfig; allowExplicitlyDisabled?: boolean; + allowRestrictiveAllowlistBypass?: boolean; }): boolean { if (!params.normalizedConfig.enabled) { return false; @@ -38,6 +39,7 @@ export function passesManifestOwnerBasePolicy(params: { return false; } if ( + params.allowRestrictiveAllowlistBypass !== true && params.normalizedConfig.allow.length > 0 && !params.normalizedConfig.allow.includes(params.plugin.id) ) { diff --git a/src/security/audit.ts b/src/security/audit.ts index 31493bca751..b27c82c30bc 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1,7 +1,6 @@ import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; -import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import type { listChannelPlugins } from "../channels/plugins/index.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; @@ -14,7 +13,10 @@ import { } from "../infra/exec-safe-bin-runtime-policy.js"; import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js"; import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; -import { resolveConfiguredChannelPluginIds } from "../plugins/channel-plugin-ids.js"; +import { + hasConfiguredChannelsForReadOnlyScope, + resolveConfiguredChannelPluginIds, +} from "../plugins/channel-plugin-ids.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { asNullableRecord } from "../shared/record-coerce.js"; @@ -1009,7 +1011,12 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise