From de6f548a7ca82d2ef0dff6c4fb826f65a75386ab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 03:09:08 +0100 Subject: [PATCH] fix: suppress disabled channel read-only presence --- CHANGELOG.md | 4 +- src/channels/plugins/read-only.test.ts | 51 ++++++++++++++ src/plugins/channel-plugin-ids.test.ts | 94 ++++++++++++++++++++++++++ src/plugins/channel-presence-policy.ts | 39 ++++++----- 4 files changed, 169 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18fb779654a..d64c8eb1f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,14 +15,12 @@ Docs: https://docs.openclaw.ai - Browser/Chrome MCP: reset cached existing-session control sessions when a `navigate_page` call times out, so one stuck navigation no longer poisons the browser profile until a gateway restart. (#69733) Thanks @ayeshakhalid192007-dev. - Browser/Chrome MCP: propagate click timeouts and abort signals to existing-session actions so a stuck click fails fast and reconnects instead of poisoning the browser tool until gateway restart. (#63524) Thanks @dongseok0. - OpenCode Go: canonicalize stale bundled `opencode-go` base URLs from `/go` or `/go/v1` to `/zen/go` or `/zen/go/v1`, so older generated model metadata stops hitting the 404 HTML endpoint. (#69898) +- CLI/channels: honor `channels..enabled=false` as a hard read-only presence opt-out, so env vars, manifest env vars, or stale persisted auth state no longer make disabled channel plugins appear in status, doctor, or setup-only discovery. - Channels/preview streaming: centralize draft-preview finalization so Slack, Discord, Mattermost, and Matrix no longer flush temporary preview messages for media/error finals, and preserve first-reply threading for normal fallback delivery. - 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. - Control UI/config: preserve intentionally empty raw config snapshots when clearing pending updates so reset restores the original bytes instead of synthesizing JSON for blank config files. (#68178) Thanks @BunsDev. - -### Fixes - - memory-core/dreaming: surface a `Dreaming status: blocked` line in `openclaw memory status` when dreaming is enabled but the heartbeat that drives the managed cron is not firing for the default agent, and add a Troubleshooting section to the dreaming docs covering the two common causes (per-agent `heartbeat` blocks excluding `main`, and `heartbeat.every` set to `0`/empty/invalid), so the silent failure described in #69843 becomes legible on the status surface. ## 2026.4.21 diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index e6dcbe7bdba..bcc7a7384d7 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -477,6 +477,32 @@ describe("listReadOnlyChannelPluginsForConfig", () => { expect(fs.existsSync(fullMarker)).toBe(false); }); + it("does not promote disabled external channels from manifest env", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "external-chat", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + "external-chat": { enabled: false }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env, EXTERNAL_CHAT_TOKEN: "configured" }, + includePersistedAuthState: false, + }, + ); + + expect(plugins.some((entry) => entry.id === "external-chat")).toBe(false); + expect(fs.existsSync(setupMarker)).toBe(false); + 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( @@ -496,6 +522,31 @@ describe("listReadOnlyChannelPluginsForConfig", () => { expect(fs.existsSync(fullMarker)).toBe(false); }); + it("does not promote explicitly disabled bundled channels from ambient env", () => { + const { channelId, envVar, fullMarker, pluginId, setupMarker } = + writeBundledSetupChannelPlugin(); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + [channelId]: { enabled: false }, + }, + plugins: { + entries: { + [pluginId]: { enabled: true }, + }, + }, + } 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(); diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 8234153f1ca..2c8232aaa05 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -958,6 +958,100 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { ).toEqual([]); }); + it("treats disabled channel config as a hard read-only env suppressor", () => { + listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "demo-channel", source: "env" }, + ]); + + const config = { + channels: { + "Demo-Channel": { + enabled: false, + token: "stale-token", + }, + }, + plugins: { + entries: { + "demo-channel": { + enabled: true, + }, + }, + }, + } as OpenClawConfig; + + expect( + resolveConfiguredChannelPresencePolicy({ + config, + workspaceDir: "/tmp", + env: { + DEMO_CHANNEL_TOKEN: "ambient", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toEqual([]); + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config, + workspaceDir: "/tmp", + env: { + DEMO_CHANNEL_TOKEN: "ambient", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toEqual([]); + }); + + it("treats disabled channel config as a hard persisted-auth suppressor", () => { + listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "demo-channel", source: "persisted-auth" }, + ]); + + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config: { + channels: { + "demo-channel": { + enabled: false, + }, + }, + plugins: { + entries: { + "demo-channel": { + enabled: true, + }, + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }), + ).toEqual([]); + }); + + it("treats disabled channel config as a hard manifest-env suppressor", () => { + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config: { + channels: { + "external-env-channel": { + enabled: false, + }, + }, + plugins: { + allow: ["external-env-channel-plugin"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + EXTERNAL_ENV_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toEqual([]); + }); + it("lets explicit bundled channel config bypass restrictive allowlists", () => { const config = { channels: { diff --git a/src/plugins/channel-presence-policy.ts b/src/plugins/channel-presence-policy.ts index 94f4f61f09e..f5a2008ac4d 100644 --- a/src/plugins/channel-presence-policy.ts +++ b/src/plugins/channel-presence-policy.ts @@ -322,6 +322,24 @@ function addPolicySignal( sources.add(source); } +function listDisabledChannelIdsForConfig(config: OpenClawConfig): string[] { + const channels = config.channels; + if (!channels || typeof channels !== "object" || Array.isArray(channels)) { + return []; + } + return 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)); +} + export function resolveConfiguredChannelPresencePolicy(params: { config: OpenClawConfig; activationSourceConfig?: OpenClawConfig; @@ -344,6 +362,7 @@ export function resolveConfiguredChannelPresencePolicy(params: { cache: params.cache, }).plugins; + const disabledChannelIds = new Set(listDisabledChannelIdsForConfig(params.config)); const entrySources = new Map>(); for (const channelId of listExplicitConfiguredChannelIdsForConfig(params.config)) { addPolicySignal(entrySources, channelId, "explicit-config"); @@ -364,6 +383,9 @@ export function resolveConfiguredChannelPresencePolicy(params: { })) { addPolicySignal(entrySources, signal.channelId, signal.source); } + for (const channelId of disabledChannelIds) { + entrySources.delete(channelId); + } const activationSource = createPluginActivationSource({ config: params.activationSourceConfig ?? params.config, @@ -428,22 +450,7 @@ export function listConfiguredAnnounceChannelIdsForConfig(params: { 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)) - : [], - ); + const disabledChannelIds = new Set(listDisabledChannelIdsForConfig(params.config)); return normalizeChannelIds([ ...listExplicitConfiguredChannelIdsForConfig(params.config), ...listConfiguredChannelIdsForReadOnlyScope({