fix: suppress disabled channel read-only presence

This commit is contained in:
Peter Steinberger
2026-04-22 03:09:08 +01:00
parent 8d021ee7bf
commit de6f548a7c
4 changed files with 169 additions and 19 deletions

View File

@@ -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.<id>.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

View File

@@ -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();

View File

@@ -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: {

View File

@@ -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<string, Set<ConfiguredChannelPresenceSource>>();
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({