diff --git a/CHANGELOG.md b/CHANGELOG.md index fcec7c6424c..ea242d7a258 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: 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. ## 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/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..0fefab9892b 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -368,7 +368,6 @@ export function resolveReadOnlyChannelPluginsForConfig( env, cache: options.cache, includePersistedAuthState: options.includePersistedAuthState, - manifestRecords: externalManifestRecords, }), ), ]; 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/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..b2c75c1b507 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -3,11 +3,22 @@ import type { OpenClawConfig } from "../config/config.js"; const listPotentialConfiguredChannelIds = 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, hasPotentialConfiguredChannels, + hasMeaningfulChannelConfig, })); vi.mock("./manifest-registry.js", async (importOriginal) => { @@ -20,7 +31,9 @@ vi.mock("./manifest-registry.js", async (importOriginal) => { import { hasConfiguredChannelsForReadOnlyScope, + listConfiguredAnnounceChannelIdsForConfig, listConfiguredChannelIdsForReadOnlyScope, + listExplicitConfiguredChannelIdsForConfig, resolveConfiguredChannelPluginIds, resolveGatewayStartupPluginIds, } from "./channel-plugin-ids.js"; @@ -652,9 +665,167 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { beforeEach(() => { listPotentialConfiguredChannelIds.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"]); + + 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("keeps explicitly enabled bundled ambient channel triggers", () => { + listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + + 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("keeps explicitly configured bundled channels discovered from potential ids", () => { + listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + + 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"]); + + 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("uses effective read-only channel policy for announce channels", () => { + listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel", "demo-other-channel"]); + + 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("uses manifest env vars as read-only configured channel triggers", () => { expect( listConfiguredChannelIdsForReadOnlyScope({ diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts index 2758945a919..f19181948b4 100644 --- a/src/plugins/channel-plugin-ids.ts +++ b/src/plugins/channel-plugin-ids.ts @@ -1,7 +1,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js"; import { - hasPotentialConfiguredChannels, + hasMeaningfulChannelConfig, listPotentialConfiguredChannelIds, } from "../channels/config-presence.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -66,6 +66,8 @@ function normalizeChannelIds(channelIds: Iterable): string[] { ).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; @@ -104,6 +106,127 @@ function listEnvConfiguredManifestChannelIds(params: { 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; @@ -121,7 +244,7 @@ function listConfiguredChannelIdsForPluginScope(params: { env: params.env, cache: params.cache, }).plugins; - return [ + const channelIds = [ ...new Set([ ...listPotentialConfiguredChannelIds(params.config, params.env, { includePersistedAuthState: params.includePersistedAuthState, @@ -133,7 +256,13 @@ function listConfiguredChannelIdsForPluginScope(params: { env: params.env, }), ]), - ].toSorted((left, right) => left.localeCompare(right)); + ]; + return filterEffectiveConfiguredChannelIds({ + channelIds, + records, + config: params.config, + activationSourceConfig: params.activationSourceConfig, + }); } export function listConfiguredChannelIdsForReadOnlyScope(params: { @@ -169,22 +298,48 @@ export function hasConfiguredChannelsForReadOnlyScope(params: { 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 ); } +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; 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