fix: sync built-in channel enablement across config paths

This commit is contained in:
Peter Steinberger
2026-02-23 19:40:32 +00:00
parent 69b17a37e8
commit 87603b5c45
10 changed files with 213 additions and 86 deletions

View File

@@ -4,7 +4,7 @@ import type { OpenClawConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
normalizePluginsConfig,
resolveEnableState,
resolveEffectiveEnableState,
resolveMemorySlotDecision,
} from "../../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
@@ -36,7 +36,12 @@ export function resolvePluginSkillDirs(params: {
if (!record.skills || record.skills.length === 0) {
continue;
}
const enableState = resolveEnableState(record.id, record.origin, normalizedPlugins);
const enableState = resolveEffectiveEnableState({
id: record.id,
origin: record.origin,
config: normalizedPlugins,
rootConfig: params.config,
});
if (!enableState.enabled) {
continue;
}

View File

@@ -29,4 +29,40 @@ describe("setPluginEnabledInConfig", () => {
enabled: false,
});
});
it("keeps built-in channel and plugin entry flags in sync", () => {
const config = {
channels: {
telegram: {
enabled: true,
dmPolicy: "open",
},
},
plugins: {
entries: {
telegram: {
enabled: true,
},
},
},
} as OpenClawConfig;
const disabled = setPluginEnabledInConfig(config, "telegram", false);
expect(disabled.channels?.telegram).toEqual({
enabled: false,
dmPolicy: "open",
});
expect(disabled.plugins?.entries?.telegram).toEqual({
enabled: false,
});
const reenabled = setPluginEnabledInConfig(disabled, "telegram", true);
expect(reenabled.channels?.telegram).toEqual({
enabled: true,
dmPolicy: "open",
});
expect(reenabled.plugins?.entries?.telegram).toEqual({
enabled: true,
});
});
});

View File

@@ -1,21 +1 @@
import type { OpenClawConfig } from "../config/config.js";
export function setPluginEnabledInConfig(
config: OpenClawConfig,
pluginId: string,
enabled: boolean,
): OpenClawConfig {
return {
...config,
plugins: {
...config.plugins,
entries: {
...config.plugins?.entries,
[pluginId]: {
...(config.plugins?.entries?.[pluginId] as object | undefined),
enabled,
},
},
},
};
}
export { setPluginEnabledInConfig } from "../plugins/toggle-config.js";

View File

@@ -3,7 +3,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js";
import {
normalizePluginsConfig,
resolveEnableState,
resolveEffectiveEnableState,
resolveMemorySlotDecision,
} from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
@@ -373,7 +373,12 @@ function validateConfigObjectWithPluginsBase(
const entry = normalizedPlugins.entries[pluginId];
const entryHasConfig = Boolean(entry?.config);
const enableState = resolveEnableState(pluginId, record.origin, normalizedPlugins);
const enableState = resolveEffectiveEnableState({
id: pluginId,
origin: record.origin,
config: normalizedPlugins,
rootConfig: config,
});
let enabled = enableState.enabled;
let reason = enableState.reason;

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { normalizePluginsConfig } from "./config-state.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
describe("normalizePluginsConfig", () => {
it("uses default memory slot when not specified", () => {
@@ -48,3 +48,48 @@ describe("normalizePluginsConfig", () => {
expect(result.slots.memory).toBe("memory-core");
});
});
describe("resolveEffectiveEnableState", () => {
it("enables bundled channels when channels.<id>.enabled=true", () => {
const normalized = normalizePluginsConfig({
enabled: true,
});
const state = resolveEffectiveEnableState({
id: "telegram",
origin: "bundled",
config: normalized,
rootConfig: {
channels: {
telegram: {
enabled: true,
},
},
},
});
expect(state).toEqual({ enabled: true });
});
it("keeps explicit plugin-level disable authoritative", () => {
const normalized = normalizePluginsConfig({
enabled: true,
entries: {
telegram: {
enabled: false,
},
},
});
const state = resolveEffectiveEnableState({
id: "telegram",
origin: "bundled",
config: normalized,
rootConfig: {
channels: {
telegram: {
enabled: true,
},
},
},
});
expect(state).toEqual({ enabled: false, reason: "disabled in config" });
});
});

View File

@@ -1,3 +1,4 @@
import { normalizeChatChannelId } from "../channels/registry.js";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginRecord } from "./registry.js";
import { defaultSlotIdForKey } from "./slots.js";
@@ -194,6 +195,42 @@ export function resolveEnableState(
return { enabled: true };
}
export function isBundledChannelEnabledByChannelConfig(
cfg: OpenClawConfig | undefined,
pluginId: string,
): boolean {
if (!cfg) {
return false;
}
const channelId = normalizeChatChannelId(pluginId);
if (!channelId) {
return false;
}
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[channelId];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
return (entry as Record<string, unknown>).enabled === true;
}
export function resolveEffectiveEnableState(params: {
id: string;
origin: PluginRecord["origin"];
config: NormalizedPluginsConfig;
rootConfig?: OpenClawConfig;
}): { enabled: boolean; reason?: string } {
const base = resolveEnableState(params.id, params.origin, params.config);
if (
!base.enabled &&
base.reason === "bundled (disabled by default)" &&
isBundledChannelEnabledByChannelConfig(params.rootConfig, params.id)
) {
return { enabled: true };
}
return base;
}
export function resolveMemorySlotDecision(params: {
id: string;
kind?: string;

View File

@@ -32,12 +32,12 @@ describe("enablePluginInConfig", () => {
expect(result.reason).toBe("blocked by denylist");
});
it("writes built-in channels to channels.<id>.enabled instead of plugins.entries", () => {
it("writes built-in channels to channels.<id>.enabled and plugins.entries", () => {
const cfg: OpenClawConfig = {};
const result = enablePluginInConfig(cfg, "telegram");
expect(result.enabled).toBe(true);
expect(result.config.channels?.telegram?.enabled).toBe(true);
expect(result.config.plugins?.entries?.telegram).toBeUndefined();
expect(result.config.plugins?.entries?.telegram?.enabled).toBe(true);
});
it("adds built-in channel id to allowlist when allowlist is configured", () => {
@@ -51,4 +51,25 @@ describe("enablePluginInConfig", () => {
expect(result.config.channels?.telegram?.enabled).toBe(true);
expect(result.config.plugins?.allow).toEqual(["memory-core", "telegram"]);
});
it("re-enables built-in channels after explicit plugin-level disable", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
enabled: true,
},
},
plugins: {
entries: {
telegram: {
enabled: false,
},
},
},
};
const result = enablePluginInConfig(cfg, "telegram");
expect(result.enabled).toBe(true);
expect(result.config.channels?.telegram?.enabled).toBe(true);
expect(result.config.plugins?.entries?.telegram?.enabled).toBe(true);
});
});

View File

@@ -1,6 +1,7 @@
import { normalizeChatChannelId } from "../channels/registry.js";
import type { OpenClawConfig } from "../config/config.js";
import { ensurePluginAllowlisted } from "../config/plugins-allowlist.js";
import { setPluginEnabledInConfig } from "./toggle-config.js";
export type PluginEnableResult = {
config: OpenClawConfig;
@@ -17,41 +18,7 @@ export function enablePluginInConfig(cfg: OpenClawConfig, pluginId: string): Plu
if (cfg.plugins?.deny?.includes(pluginId) || cfg.plugins?.deny?.includes(resolvedId)) {
return { config: cfg, enabled: false, reason: "blocked by denylist" };
}
if (builtInChannelId) {
const channels = cfg.channels as Record<string, unknown> | undefined;
const existing = channels?.[builtInChannelId];
const existingRecord =
existing && typeof existing === "object" && !Array.isArray(existing)
? (existing as Record<string, unknown>)
: {};
let next: OpenClawConfig = {
...cfg,
channels: {
...cfg.channels,
[builtInChannelId]: {
...existingRecord,
enabled: true,
},
},
};
next = ensurePluginAllowlisted(next, resolvedId);
return { config: next, enabled: true };
}
const entries = {
...cfg.plugins?.entries,
[resolvedId]: {
...(cfg.plugins?.entries?.[resolvedId] as Record<string, unknown> | undefined),
enabled: true,
},
};
let next: OpenClawConfig = {
...cfg,
plugins: {
...cfg.plugins,
entries,
},
};
let next = setPluginEnabledInConfig(cfg, resolvedId, true);
next = ensurePluginAllowlisted(next, resolvedId);
return { config: next, enabled: true };
}

View File

@@ -2,7 +2,6 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { normalizeChatChannelId } from "../channels/registry.js";
import type { OpenClawConfig } from "../config/config.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
@@ -12,7 +11,7 @@ import { clearPluginCommands } from "./commands.js";
import {
applyTestPluginDefaults,
normalizePluginsConfig,
resolveEnableState,
resolveEffectiveEnableState,
resolveMemorySlotDecision,
type NormalizedPluginsConfig,
} from "./config-state.js";
@@ -176,19 +175,6 @@ function createPluginRecord(params: {
};
}
function isBundledChannelEnabledByChannelConfig(cfg: OpenClawConfig, pluginId: string): boolean {
const channelId = normalizeChatChannelId(pluginId);
if (!channelId) {
return false;
}
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[channelId];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
return (entry as Record<string, unknown>).enabled === true;
}
function recordPluginError(params: {
logger: PluginLogger;
registry: PluginRegistry;
@@ -486,14 +472,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
let enableState = resolveEnableState(pluginId, candidate.origin, normalized);
if (
!enableState.enabled &&
enableState.reason === "bundled (disabled by default)" &&
isBundledChannelEnabledByChannelConfig(cfg, pluginId)
) {
enableState = { enabled: true };
}
const enableState = resolveEffectiveEnableState({
id: pluginId,
origin: candidate.origin,
config: normalized,
rootConfig: cfg,
});
const entry = normalized.entries[pluginId];
const record = createPluginRecord({
id: pluginId,

View File

@@ -0,0 +1,47 @@
import { normalizeChatChannelId } from "../channels/registry.js";
import type { OpenClawConfig } from "../config/config.js";
export function setPluginEnabledInConfig(
config: OpenClawConfig,
pluginId: string,
enabled: boolean,
): OpenClawConfig {
const builtInChannelId = normalizeChatChannelId(pluginId);
const resolvedId = builtInChannelId ?? pluginId;
const next: OpenClawConfig = {
...config,
plugins: {
...config.plugins,
entries: {
...config.plugins?.entries,
[resolvedId]: {
...(config.plugins?.entries?.[resolvedId] as object | undefined),
enabled,
},
},
},
};
if (!builtInChannelId) {
return next;
}
const channels = config.channels as Record<string, unknown> | undefined;
const existing = channels?.[builtInChannelId];
const existingRecord =
existing && typeof existing === "object" && !Array.isArray(existing)
? (existing as Record<string, unknown>)
: {};
return {
...next,
channels: {
...config.channels,
[builtInChannelId]: {
...existingRecord,
enabled,
},
},
};
}