From 9e389cff3d5d85ddcdd987611c19b90b9871e29e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 4 Apr 2026 11:15:32 +0900 Subject: [PATCH] fix(config): migrate legacy group allow aliases (#60597) * fix(config): migrate legacy group allow aliases * fix(config): inline legacy streaming migration helpers * refactor(config): rename legacy account matcher helper * chore(agents): codify config contract boundaries * fix(config): keep legacy allow aliases writable * Update AGENTS.md --- AGENTS.md | 5 + docs/.generated/config-baseline.channel.json | 191 ++++-- docs/.generated/config-baseline.json | 191 ++++-- extensions/discord/src/audit.ts | 3 - extensions/discord/src/monitor.test.ts | 29 +- .../src/monitor.tool-result.test-helpers.ts | 6 +- extensions/discord/src/monitor/allow-list.ts | 4 +- ...age-handler.preflight.acp-bindings.test.ts | 3 - .../monitor/message-handler.preflight.test.ts | 14 +- .../discord/src/monitor/monitor.test.ts | 4 +- .../native-command.commands-allowfrom.test.ts | 4 +- .../native-command.plugin-dispatch.test.ts | 10 +- extensions/discord/src/setup-core.ts | 2 +- .../googlechat/src/monitor-access.test.ts | 4 +- extensions/googlechat/src/monitor-access.ts | 3 +- .../slack/src/monitor/channel-config.ts | 5 +- .../events/system-event-test-harness.ts | 2 +- extensions/slack/src/monitor/monitor.test.ts | 16 +- extensions/slack/src/monitor/slash.test.ts | 4 +- extensions/slack/src/setup-core.ts | 2 +- extensions/slack/src/shared.test.ts | 25 + extensions/slack/src/shared.ts | 2 +- .../setup-group-access-configure.test.ts | 6 +- src/commands/doctor-config-flow.test.ts | 118 ++++ ...ndled-channel-config-metadata.generated.ts | 207 ++++--- src/config/config-misc.test.ts | 110 ++++ src/config/config.discord.test.ts | 4 +- src/config/io.write-config.test.ts | 86 ++- src/config/legacy-migrate.test.ts | 176 +++++- src/config/legacy.migrations.channels.ts | 544 ++++++++++++++++++ src/config/types.discord.ts | 1 - src/config/types.googlechat.ts | 4 +- src/config/types.slack.ts | 4 +- src/config/validation.ts | 10 +- src/config/zod-schema.providers-core.ts | 3 - src/security/audit.test.ts | 12 +- 36 files changed, 1524 insertions(+), 290 deletions(-) create mode 100644 extensions/slack/src/shared.test.ts diff --git a/AGENTS.md b/AGENTS.md index b35a1f0bcff..55e4ffabb9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,11 @@ - Public docs: `docs/gateway/protocol.md`, `docs/gateway/bridge-protocol.md`, `docs/concepts/architecture.md` - Definition files: `src/gateway/protocol/schema.ts`, `src/gateway/protocol/schema/*.ts`, `src/gateway/protocol/index.ts` - Rule: protocol changes are contract changes. Prefer additive evolution; incompatible changes require explicit versioning, docs, and client/codegen follow-through. +- Config contract boundary: + - Canonical public config lives in exported config types, zod/schema surfaces, schema help/labels, generated config metadata, config baselines, and any user-facing gateway/config payloads. Keep those surfaces aligned. + - When a legacy config key is retired from the public contract, remove it from every public config surface above. Keep backward compatibility only through raw-config migration/doctor seams unless explicit product policy says otherwise. + - Do not reintroduce removed legacy aliases into public types/schema/help/baselines “for convenience”. If old configs still need to load, handle that in `legacy.migrations.*`, config ingest, or `openclaw doctor --fix`. + - `hooks.internal.entries` is the canonical public hook config model. `hooks.internal.handlers` is compatibility-only input and must not be re-exposed in public schema/help/baseline surfaces. - Bundled plugin contract boundary: - Public docs: `docs/plugins/architecture.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-overview.md` - Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/plugins/public-artifacts.ts` diff --git a/docs/.generated/config-baseline.channel.json b/docs/.generated/config-baseline.channel.json index 9763dd17138..08f78e5b768 100644 --- a/docs/.generated/config-baseline.channel.json +++ b/docs/.generated/config-baseline.channel.json @@ -37,6 +37,137 @@ "tags": [], "hasChildren": true }, + { + "path": "channels.bluebubbles.accounts.*.actions", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.bluebubbles.accounts.*.actions.addParticipant", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.edit", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.leaveGroup", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.reactions", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.removeParticipant", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.renameGroup", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.reply", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.sendAttachment", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.sendWithEffect", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.setGroupIcon", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.unsend", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.bluebubbles.accounts.*.allowFrom", "kind": "channel", @@ -1970,16 +2101,6 @@ "tags": [], "hasChildren": true }, - { - "path": "channels.discord.accounts.*.guilds.*.channels.*.allow", - "kind": "channel", - "type": "boolean", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "channels.discord.accounts.*.guilds.*.channels.*.autoArchiveDuration", "kind": "channel", @@ -4473,16 +4594,6 @@ "tags": [], "hasChildren": true }, - { - "path": "channels.discord.guilds.*.channels.*.allow", - "kind": "channel", - "type": "boolean", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "channels.discord.guilds.*.channels.*.autoArchiveDuration", "kind": "channel", @@ -8466,16 +8577,6 @@ "tags": [], "hasChildren": true }, - { - "path": "channels.googlechat.accounts.*.groups.*.allow", - "kind": "channel", - "type": "boolean", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "channels.googlechat.accounts.*.groups.*.enabled", "kind": "channel", @@ -9147,16 +9248,6 @@ "tags": [], "hasChildren": true }, - { - "path": "channels.googlechat.groups.*.allow", - "kind": "channel", - "type": "boolean", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "channels.googlechat.groups.*.enabled", "kind": "channel", @@ -20851,16 +20942,6 @@ "tags": [], "hasChildren": true }, - { - "path": "channels.slack.accounts.*.channels.*.allow", - "kind": "channel", - "type": "boolean", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "channels.slack.accounts.*.channels.*.allowBots", "kind": "channel", @@ -22313,16 +22394,6 @@ "tags": [], "hasChildren": true }, - { - "path": "channels.slack.channels.*.allow", - "kind": "channel", - "type": "boolean", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "channels.slack.channels.*.allowBots", "kind": "channel", diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index 41b017a0e6b..b07b4ee3151 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -28067,6 +28067,137 @@ "tags": [], "hasChildren": true }, + { + "path": "channels.bluebubbles.accounts.*.actions", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.bluebubbles.accounts.*.actions.addParticipant", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.edit", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.leaveGroup", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.reactions", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.removeParticipant", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.renameGroup", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.reply", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.sendAttachment", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.sendWithEffect", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.setGroupIcon", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.bluebubbles.accounts.*.actions.unsend", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.bluebubbles.accounts.*.allowFrom", "kind": "channel", @@ -30000,16 +30131,6 @@ "tags": [], "hasChildren": true }, - { - "path": "channels.discord.accounts.*.guilds.*.channels.*.allow", - "kind": "channel", - "type": "boolean", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "channels.discord.accounts.*.guilds.*.channels.*.autoArchiveDuration", "kind": "channel", @@ -32503,16 +32624,6 @@ "tags": [], "hasChildren": true }, - { - "path": "channels.discord.guilds.*.channels.*.allow", - "kind": "channel", - "type": "boolean", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "channels.discord.guilds.*.channels.*.autoArchiveDuration", "kind": "channel", @@ -36496,16 +36607,6 @@ "tags": [], "hasChildren": true }, - { - "path": "channels.googlechat.accounts.*.groups.*.allow", - "kind": "channel", - "type": "boolean", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "channels.googlechat.accounts.*.groups.*.enabled", "kind": "channel", @@ -37177,16 +37278,6 @@ "tags": [], "hasChildren": true }, - { - "path": "channels.googlechat.groups.*.allow", - "kind": "channel", - "type": "boolean", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "channels.googlechat.groups.*.enabled", "kind": "channel", @@ -48881,16 +48972,6 @@ "tags": [], "hasChildren": true }, - { - "path": "channels.slack.accounts.*.channels.*.allow", - "kind": "channel", - "type": "boolean", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "channels.slack.accounts.*.channels.*.allowBots", "kind": "channel", @@ -50343,16 +50424,6 @@ "tags": [], "hasChildren": true }, - { - "path": "channels.slack.channels.*.allow", - "kind": "channel", - "type": "boolean", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "channels.slack.channels.*.allowBots", "kind": "channel", diff --git a/extensions/discord/src/audit.ts b/extensions/discord/src/audit.ts index 79bc9b5b5fc..f1cec274ff1 100644 --- a/extensions/discord/src/audit.ts +++ b/extensions/discord/src/audit.ts @@ -30,9 +30,6 @@ function shouldAuditChannelConfig(config: DiscordGuildChannelConfig | undefined) if (!config) { return true; } - if (config.allow === false) { - return false; - } if (config.enabled === false) { return false; } diff --git a/extensions/discord/src/monitor.test.ts b/extensions/discord/src/monitor.test.ts index 4e3843f7de4..2229d9008b2 100644 --- a/extensions/discord/src/monitor.test.ts +++ b/extensions/discord/src/monitor.test.ts @@ -54,7 +54,7 @@ function createAutoThreadMentionContext() { const guildInfo: DiscordGuildEntryResolved = { requireMention: true, channels: { - general: { allow: true, autoThread: true }, + general: { enabled: true, autoThread: true }, }, }; const channelConfig = resolveDiscordChannelConfig({ @@ -301,12 +301,11 @@ describe("discord guild/channel resolution", () => { it("resolves channel config by slug", () => { const guildInfo: DiscordGuildEntryResolved = { channels: { - general: { allow: true }, + general: { enabled: true }, help: { - allow: true, + enabled: true, requireMention: true, skills: ["search"], - enabled: false, users: ["123"], systemPrompt: "Use short answers.", autoThread: true, @@ -340,7 +339,7 @@ describe("discord guild/channel resolution", () => { it("denies channel when config present but no match", () => { const guildInfo: DiscordGuildEntryResolved = { channels: { - general: { allow: true }, + general: { enabled: true }, }, }; const channel = resolveDiscordChannelConfig({ @@ -368,8 +367,8 @@ describe("discord guild/channel resolution", () => { it("inherits parent config for thread channels", () => { const guildInfo: DiscordGuildEntryResolved = { channels: { - general: { allow: true }, - random: { allow: false }, + general: { enabled: true }, + random: { enabled: false }, }, }; const thread = resolveDiscordChannelConfigWithFallback({ @@ -388,8 +387,8 @@ describe("discord guild/channel resolution", () => { it("does not match thread name/slug when resolving allowlists", () => { const guildInfo: DiscordGuildEntryResolved = { channels: { - general: { allow: true }, - random: { allow: false }, + general: { enabled: true }, + random: { enabled: false }, }, }; const thread = resolveDiscordChannelConfigWithFallback({ @@ -408,8 +407,8 @@ describe("discord guild/channel resolution", () => { it("applies wildcard channel config when no specific match", () => { const guildInfo: DiscordGuildEntryResolved = { channels: { - general: { allow: true, requireMention: false }, - "*": { allow: true, autoThread: true, requireMention: true }, + general: { enabled: true, requireMention: false }, + "*": { enabled: true, autoThread: true, requireMention: true }, }, }; // Specific channel should NOT use wildcard @@ -440,7 +439,7 @@ describe("discord guild/channel resolution", () => { it("falls back to wildcard when thread channel and parent are missing", () => { const guildInfo: DiscordGuildEntryResolved = { channels: { - "*": { allow: true, requireMention: false }, + "*": { enabled: true, requireMention: false }, }, }; const thread = resolveDiscordChannelConfigWithFallback({ @@ -481,7 +480,7 @@ describe("discord mention gating", () => { const guildInfo: DiscordGuildEntryResolved = { requireMention: true, channels: { - general: { allow: true }, + general: { enabled: true }, }, }; const channelConfig = resolveDiscordChannelConfig({ @@ -527,7 +526,7 @@ describe("discord mention gating", () => { const guildInfo: DiscordGuildEntryResolved = { requireMention: true, channels: { - "parent-1": { allow: true, requireMention: false }, + "parent-1": { enabled: true, requireMention: false }, }, }; const channelConfig = resolveDiscordChannelConfigWithFallback({ @@ -1174,7 +1173,7 @@ describe("discord DM reaction handling", () => { roles: ["role:blocked-role"], channels: { "channel-1": { - allow: true, + enabled: true, roles: ["role:trusted-role"], }, }, diff --git a/extensions/discord/src/monitor.tool-result.test-helpers.ts b/extensions/discord/src/monitor.tool-result.test-helpers.ts index beaa21e07ba..0b3eaefe41e 100644 --- a/extensions/discord/src/monitor.tool-result.test-helpers.ts +++ b/extensions/discord/src/monitor.tool-result.test-helpers.ts @@ -37,7 +37,7 @@ export const CATEGORY_GUILD_CFG = { guilds: { "*": { requireMention: false, - channels: { c1: { allow: true } }, + channels: { c1: { enabled: true } }, }, }, }, @@ -122,7 +122,7 @@ export async function createCategoryGuildHandler(runtimeError?: (err: unknown) = return createGuildHandler({ cfg: CATEGORY_GUILD_CFG, guildEntries: { - "*": { requireMention: false, channels: { c1: { allow: true } } }, + "*": { requireMention: false, channels: { c1: { enabled: true } } }, }, runtimeError, }); @@ -298,7 +298,7 @@ export function createMentionRequiredGuildConfig(overrides?: Partial): C guilds: { "*": { requireMention: true, - channels: { c1: { allow: true } }, + channels: { c1: { enabled: true } }, }, }, }, diff --git a/extensions/discord/src/monitor/allow-list.ts b/extensions/discord/src/monitor/allow-list.ts index 2816789cd44..9f06dc6b61b 100644 --- a/extensions/discord/src/monitor/allow-list.ts +++ b/extensions/discord/src/monitor/allow-list.ts @@ -41,7 +41,7 @@ export type DiscordGuildEntryResolved = { reactionNotifications?: "off" | "own" | "all" | "allowlist"; users?: string[]; roles?: string[]; - channels?: Record; + channels?: Record; }; export type DiscordChannelConfigResolved = DiscordChannelOverrideConfig & { @@ -394,7 +394,7 @@ function resolveDiscordChannelConfigEntry( entry: DiscordChannelEntry, ): DiscordChannelConfigResolved { const resolved: DiscordChannelConfigResolved = { - allowed: entry.allow !== false, + allowed: entry.enabled !== false, requireMention: entry.requireMention, ignoreOtherMentions: entry.ignoreOtherMentions, skills: entry.skills, diff --git a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts index 4cbc37e004c..e2922c4f8ab 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts @@ -177,7 +177,6 @@ function createAllowedGuildEntries(requireMention = false) { id: GUILD_ID, channels: { [CHANNEL_ID]: { - allow: true, enabled: true, requireMention, }, @@ -250,7 +249,6 @@ describe("preflightDiscordMessage configured ACP bindings", () => { id: GUILD_ID, channels: { [CHANNEL_ID]: { - allow: true, enabled: false, }, }, @@ -272,7 +270,6 @@ describe("preflightDiscordMessage configured ACP bindings", () => { id: GUILD_ID, channels: { [CHANNEL_ID]: { - allow: true, enabled: true, requireMention: false, }, diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 1761ac6c6b4..ccb16585856 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -621,7 +621,7 @@ describe("preflightDiscordMessage", () => { [guildId]: { channels: { [channelId]: { - allow: true, + enabled: true, requireMention: true, }, }, @@ -655,7 +655,7 @@ describe("preflightDiscordMessage", () => { "guild-1": { channels: { "ch-1": { - allow: true, + enabled: true, requireMention: false, }, }, @@ -704,7 +704,7 @@ describe("preflightDiscordMessage", () => { "guild-1": { channels: { [parentId]: { - allow: true, + enabled: true, requireMention: false, }, }, @@ -890,7 +890,7 @@ describe("preflightDiscordMessage", () => { "guild-1": { channels: { [channelId]: { - allow: true, + enabled: true, requireMention: true, }, }, @@ -958,7 +958,7 @@ describe("preflightDiscordMessage", () => { [guildId]: { channels: { [channelId]: { - allow: true, + enabled: true, requireMention: true, users: ["user-1"], }, @@ -1004,7 +1004,7 @@ describe("preflightDiscordMessage", () => { }), discordConfig: {} as DiscordConfig, guildEntries: { - "guild-1": { channels: { [channelId]: { allow: true, requireMention: true } } }, + "guild-1": { channels: { [channelId]: { enabled: true, requireMention: true } } }, }, }); expect(result).toBeNull(); @@ -1048,7 +1048,7 @@ describe("preflightDiscordMessage", () => { }), discordConfig: {} as DiscordConfig, guildEntries: { - "guild-1": { channels: { [channelId]: { allow: true, requireMention: true } } }, + "guild-1": { channels: { [channelId]: { enabled: true, requireMention: true } } }, }, }); expect(result).not.toBeNull(); diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index 412ced186b8..c5819212fdb 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -460,7 +460,7 @@ describe("discord component interactions", () => { channels: { discord: { replyToMode: "first", groupPolicy: "allowlist" } }, } as OpenClawConfig, discordConfig: createDiscordConfig({ groupPolicy: "allowlist" }), - guildEntries: { g1: { channels: { "guild-channel": { allow: true, enabled: false } } } }, + guildEntries: { g1: { channels: { "guild-channel": { enabled: false } } } }, }), ); const { interaction, reply } = createComponentButtonInteraction({ @@ -494,7 +494,7 @@ describe("discord component interactions", () => { channels: { discord: { replyToMode: "first", groupPolicy: "allowlist" } }, } as OpenClawConfig, discordConfig: createDiscordConfig({ groupPolicy: "allowlist" }), - guildEntries: { g1: { channels: { "guild-channel": { allow: false } } } }, + guildEntries: { g1: { channels: { "guild-channel": { enabled: false } } } }, }), ); const { interaction, reply } = createComponentButtonInteraction({ diff --git a/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts b/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts index bb4c04d6089..8ac641fead1 100644 --- a/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts +++ b/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts @@ -39,7 +39,7 @@ function createConfig(): OpenClawConfig { "345678901234567890": { channels: { "234567890123456789": { - allow: true, + enabled: true, requireMention: false, }, }, @@ -222,7 +222,7 @@ describe("Discord native slash commands with commands.allowFrom", () => { "345678901234567890": { channels: { "234567890123456789": { - allow: true, + enabled: true, requireMention: false, }, }, diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index bee1714fb0a..a75b16c0e30 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -122,7 +122,7 @@ function createConfiguredAcpCase(params: { guilds: { [params.guildId!]: { channels: { - [params.channelId]: { allow: true, requireMention: false }, + [params.channelId]: { enabled: true, requireMention: false }, }, }, }, @@ -422,7 +422,7 @@ describe("Discord native plugin command dispatch", () => { "345678901234567890": { channels: { "234567890123456789": { - allow: true, + enabled: true, requireMention: false, }, }, @@ -557,11 +557,11 @@ describe("Discord native plugin command dispatch", () => { "345678901234567890": { channels: { "thread-123": { - allow: true, + enabled: true, requireMention: false, }, "parent-456": { - allow: true, + enabled: true, requireMention: false, }, }, @@ -658,7 +658,7 @@ describe("Discord native plugin command dispatch", () => { guilds: { [guildId]: { channels: { - [channelId]: { allow: true, requireMention: false }, + [channelId]: { enabled: true, requireMention: false }, }, }, }, diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index 29aefe6635d..30a05a94a1d 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -52,7 +52,7 @@ export function setDiscordGuildChannelAllowlist( const existing = guilds[guildKey] ?? {}; if (entry.channelKey) { const channels = { ...existing.channels }; - channels[entry.channelKey] = { allow: true }; + channels[entry.channelKey] = { enabled: true }; guilds[guildKey] = { ...existing, channels }; } else { guilds[guildKey] = existing; diff --git a/extensions/googlechat/src/monitor-access.test.ts b/extensions/googlechat/src/monitor-access.test.ts index bed16bfc2f0..1934d0f1ad0 100644 --- a/extensions/googlechat/src/monitor-access.test.ts +++ b/extensions/googlechat/src/monitor-access.test.ts @@ -230,7 +230,7 @@ describe("googlechat inbound access policy", () => { config: { groups: { "spaces/AAA": { - allow: true, + enabled: true, }, }, }, @@ -337,7 +337,7 @@ describe("googlechat inbound access policy", () => { users: ["users/alice"], }, "Finance Ops": { - allow: false, + enabled: false, users: ["users/bob"], }, }, diff --git a/extensions/googlechat/src/monitor-access.ts b/extensions/googlechat/src/monitor-access.ts index dff3d82ddbe..dc9458d8f14 100644 --- a/extensions/googlechat/src/monitor-access.ts +++ b/extensions/googlechat/src/monitor-access.ts @@ -63,7 +63,6 @@ export function isSenderAllowed( type GoogleChatGroupEntry = { requireMention?: boolean; - allow?: boolean; enabled?: boolean; users?: Array; systemPrompt?: string; @@ -242,7 +241,7 @@ export async function applyGoogleChatInboundAccessPolicy(params: { groupPolicy, routeAllowlistConfigured: groupAllowlistConfigured, routeMatched: Boolean(groupEntry), - routeEnabled: groupEntry?.enabled !== false && groupEntry?.allow !== false, + routeEnabled: groupEntry?.enabled !== false, }); if (!routeAccess.allowed) { if (routeAccess.reason === "disabled") { diff --git a/extensions/slack/src/monitor/channel-config.ts b/extensions/slack/src/monitor/channel-config.ts index 4aca5fc1422..1480304eeb7 100644 --- a/extensions/slack/src/monitor/channel-config.ts +++ b/extensions/slack/src/monitor/channel-config.ts @@ -21,7 +21,6 @@ export type SlackChannelConfigResolved = { export type SlackChannelConfigEntry = { enabled?: boolean; - allow?: boolean; requireMention?: boolean; allowBots?: boolean; users?: Array; @@ -135,9 +134,7 @@ export function resolveSlackChannelConfig(params: { } const resolved = matched ?? fallback ?? {}; - const allowed = - firstDefined(resolved.enabled, resolved.allow, fallback?.enabled, fallback?.allow, true) ?? - true; + const allowed = firstDefined(resolved.enabled, fallback?.enabled, true) ?? true; const requireMention = firstDefined(resolved.requireMention, fallback?.requireMention, requireMentionDefault) ?? requireMentionDefault; diff --git a/extensions/slack/src/monitor/events/system-event-test-harness.ts b/extensions/slack/src/monitor/events/system-event-test-harness.ts index 73a50d0444c..432cdb4d53d 100644 --- a/extensions/slack/src/monitor/events/system-event-test-harness.ts +++ b/extensions/slack/src/monitor/events/system-event-test-harness.ts @@ -30,7 +30,7 @@ export function createSlackSystemEventTestHarness(overrides?: SlackSystemEventTe ? { C1: { users: overrides.channelUsers, - allow: true, + enabled: true, }, } : undefined, diff --git a/extensions/slack/src/monitor/monitor.test.ts b/extensions/slack/src/monitor/monitor.test.ts index 14f110a084f..dfc5f0bd6c6 100644 --- a/extensions/slack/src/monitor/monitor.test.ts +++ b/extensions/slack/src/monitor/monitor.test.ts @@ -35,7 +35,7 @@ describe("resolveSlackChannelConfig", () => { it("uses wildcard entries when no direct channel config exists", () => { const res = resolveSlackChannelConfig({ channelId: "C1", - channels: { "*": { allow: true, requireMention: false } }, + channels: { "*": { enabled: true, requireMention: false } }, defaultRequireMention: true, }); expect(res).toMatchObject({ @@ -49,7 +49,7 @@ describe("resolveSlackChannelConfig", () => { it("uses direct match metadata when channel config exists", () => { const res = resolveSlackChannelConfig({ channelId: "C1", - channels: { C1: { allow: true, requireMention: false } }, + channels: { C1: { enabled: true, requireMention: false } }, defaultRequireMention: true, }); expect(res).toMatchObject({ @@ -63,7 +63,7 @@ describe("resolveSlackChannelConfig", () => { // Users commonly copy them in lowercase from docs or older CLI output. const res = resolveSlackChannelConfig({ channelId: "C0ABC12345", // pragma: allowlist secret - channels: { c0abc12345: { allow: true, requireMention: false } }, + channels: { c0abc12345: { enabled: true, requireMention: false } }, defaultRequireMention: true, }); expect(res).toMatchObject({ allowed: true, requireMention: false }); @@ -73,7 +73,7 @@ describe("resolveSlackChannelConfig", () => { // Defensive: also handle the inverse direction. const res = resolveSlackChannelConfig({ channelId: "c0abc12345", // pragma: allowlist secret - channels: { C0ABC12345: { allow: true, requireMention: false } }, + channels: { C0ABC12345: { enabled: true, requireMention: false } }, defaultRequireMention: true, }); expect(res).toMatchObject({ allowed: true, requireMention: false }); @@ -83,7 +83,7 @@ describe("resolveSlackChannelConfig", () => { const res = resolveSlackChannelConfig({ channelId: "C1", channelName: "ops-room", - channels: { "ops-room": { allow: true, requireMention: false } }, + channels: { "ops-room": { enabled: true, requireMention: false } }, defaultRequireMention: true, }); expect(res).toMatchObject({ allowed: false, requireMention: true }); @@ -93,7 +93,7 @@ describe("resolveSlackChannelConfig", () => { const res = resolveSlackChannelConfig({ channelId: "C1", channelName: "ops-room", - channels: { "ops-room": { allow: true, requireMention: false } }, + channels: { "ops-room": { enabled: true, requireMention: false } }, defaultRequireMention: true, allowNameMatching: true, }); @@ -266,8 +266,8 @@ describe("isChannelAllowed with groupPolicy and channelsConfig", () => { ...baseParams(), groupPolicy: "open", channelsConfig: { - C_ALLOWED: { allow: true }, - C_DENIED: { allow: false }, + C_ALLOWED: { enabled: true }, + C_DENIED: { enabled: false }, }, }); // Explicitly allowed channel diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index f56f4bfdece..46ecab6f94d 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -695,7 +695,7 @@ describe("Slack native command argument menus", () => { function createPolicyHarness(overrides?: { groupPolicy?: "open" | "allowlist"; - channelsConfig?: Record; + channelsConfig?: Record; channelId?: string; channelName?: string; allowFrom?: string[]; @@ -864,7 +864,7 @@ describe("slack slash commands channel policy", () => { it("blocks explicitly denied channels when groupPolicy is open", async () => { const harness = createPolicyHarness({ groupPolicy: "open", - channelsConfig: { C_DENIED: { allow: false } }, + channelsConfig: { C_DENIED: { enabled: false } }, channelId: "C_DENIED", channelName: "denied", }); diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 74a6ff93ff1..00069e82d8d 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -237,7 +237,7 @@ export function createSlackSetupWizardBase(handlers: { resolveSlackAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", currentEntries: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => Object.entries(resolveSlackAccount({ cfg, accountId }).config.channels ?? {}) - .filter(([, value]) => value?.allow !== false && value?.enabled !== false) + .filter(([, value]) => value?.enabled !== false) .map(([key]) => key), updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => Boolean(resolveSlackAccount({ cfg, accountId }).config.channels), diff --git a/extensions/slack/src/shared.test.ts b/extensions/slack/src/shared.test.ts new file mode 100644 index 00000000000..69181159e61 --- /dev/null +++ b/extensions/slack/src/shared.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { setSlackChannelAllowlist } from "./shared.js"; + +describe("setSlackChannelAllowlist", () => { + it("writes canonical enabled entries for setup-generated channel allowlists", () => { + const result = setSlackChannelAllowlist( + { + channels: { + slack: { + accounts: { + work: {}, + }, + }, + }, + }, + "work", + ["C123", "C456"], + ); + + expect(result.channels?.slack?.accounts?.work?.channels).toEqual({ + C123: { enabled: true }, + C456: { enabled: true }, + }); + }); +}); diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 229dbc652f1..e51497ec796 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -119,7 +119,7 @@ export function setSlackChannelAllowlist( accountId: string, channelKeys: string[], ): OpenClawConfig { - const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); + const channels = Object.fromEntries(channelKeys.map((key) => [key, { enabled: true }])); return patchChannelConfigForAccount({ cfg, channel: SLACK_CHANNEL, diff --git a/src/channels/plugins/setup-group-access-configure.test.ts b/src/channels/plugins/setup-group-access-configure.test.ts index bb3b0307501..f3e01f8f9dc 100644 --- a/src/channels/plugins/setup-group-access-configure.test.ts +++ b/src/channels/plugins/setup-group-access-configure.test.ts @@ -154,7 +154,7 @@ describe("configureChannelAccessWithAllowlist", () => { ...params.cfg.channels, slack: { ...params.cfg.channels?.slack, - channels: Object.fromEntries(params.resolved.map((id) => [id, { allow: true }])), + channels: Object.fromEntries(params.resolved.map((id) => [id, { enabled: true }])), }, }, }; @@ -170,8 +170,8 @@ describe("configureChannelAccessWithAllowlist", () => { expect(calls).toEqual(["resolve", "setPolicy", "apply"]); expect(next.channels?.slack?.channels).toEqual({ - C1: { allow: true }, - C2: { allow: true }, + C1: { enabled: true }, + C2: { enabled: true }, }); }); }); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 8adc9fbbe86..90dd8cdd3c7 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -708,6 +708,124 @@ describe("doctor config flow", () => { } }); + it("warns clearly about legacy nested channel allow aliases and points to doctor --fix", async () => { + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + await runDoctorConfigWithInput({ + config: { + channels: { + slack: { + channels: { + ops: { + allow: false, + }, + }, + }, + googlechat: { + groups: { + "spaces/aaa": { + allow: false, + }, + }, + }, + discord: { + guilds: { + "100": { + channels: { + general: { + allow: false, + }, + }, + }, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect( + noteSpy.mock.calls.some( + ([message, title]) => + title === "Legacy config keys detected" && + String(message).includes("channels.slack:") && + String(message).includes("channels.slack.channels..allow is legacy"), + ), + ).toBe(true); + expect( + noteSpy.mock.calls.some( + ([message, title]) => + title === "Legacy config keys detected" && + String(message).includes("channels.googlechat:") && + String(message).includes("channels.googlechat.groups..allow is legacy"), + ), + ).toBe(true); + expect( + noteSpy.mock.calls.some( + ([message, title]) => + title === "Legacy config keys detected" && + String(message).includes("channels.discord:") && + String(message).includes("channels.discord.guilds..channels..allow is legacy"), + ), + ).toBe(true); + expect( + noteSpy.mock.calls.some( + ([message, title]) => + title === "Doctor" && + String(message).includes('Run "openclaw doctor --fix" to migrate legacy config keys.'), + ), + ).toBe(true); + } finally { + noteSpy.mockRestore(); + } + }); + + it("repairs legacy nested channel allow aliases on repair", async () => { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + channels: { + slack: { + channels: { + ops: { + allow: false, + }, + }, + }, + googlechat: { + groups: { + "spaces/aaa": { + allow: false, + }, + }, + }, + discord: { + guilds: { + "100": { + channels: { + general: { + allow: false, + }, + }, + }, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.cfg.channels?.slack?.channels?.ops).toEqual({ + enabled: false, + }); + expect(result.cfg.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({ + enabled: false, + }); + expect(result.cfg.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({ + enabled: false, + }); + }); + it("sanitizes config-derived doctor warnings and changes before logging", async () => { const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); try { diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index f998e46bafb..4463cc218f2 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -26,6 +26,69 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, additionalProperties: false, }, + actions: { + type: "object", + properties: { + reactions: { + default: true, + type: "boolean", + }, + edit: { + default: true, + type: "boolean", + }, + unsend: { + default: true, + type: "boolean", + }, + reply: { + default: true, + type: "boolean", + }, + sendWithEffect: { + default: true, + type: "boolean", + }, + renameGroup: { + default: true, + type: "boolean", + }, + setGroupIcon: { + default: true, + type: "boolean", + }, + addParticipant: { + default: true, + type: "boolean", + }, + removeParticipant: { + default: true, + type: "boolean", + }, + leaveGroup: { + default: true, + type: "boolean", + }, + sendAttachment: { + default: true, + type: "boolean", + }, + }, + required: [ + "reactions", + "edit", + "unsend", + "reply", + "sendWithEffect", + "renameGroup", + "setGroupIcon", + "addParticipant", + "removeParticipant", + "leaveGroup", + "sendAttachment", + ], + additionalProperties: false, + }, serverUrl: { type: "string", }, @@ -234,6 +297,69 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, additionalProperties: false, }, + actions: { + type: "object", + properties: { + reactions: { + default: true, + type: "boolean", + }, + edit: { + default: true, + type: "boolean", + }, + unsend: { + default: true, + type: "boolean", + }, + reply: { + default: true, + type: "boolean", + }, + sendWithEffect: { + default: true, + type: "boolean", + }, + renameGroup: { + default: true, + type: "boolean", + }, + setGroupIcon: { + default: true, + type: "boolean", + }, + addParticipant: { + default: true, + type: "boolean", + }, + removeParticipant: { + default: true, + type: "boolean", + }, + leaveGroup: { + default: true, + type: "boolean", + }, + sendAttachment: { + default: true, + type: "boolean", + }, + }, + required: [ + "reactions", + "edit", + "unsend", + "reply", + "sendWithEffect", + "renameGroup", + "setGroupIcon", + "addParticipant", + "removeParticipant", + "leaveGroup", + "sendAttachment", + ], + additionalProperties: false, + }, serverUrl: { type: "string", }, @@ -428,69 +554,6 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ defaultAccount: { type: "string", }, - actions: { - type: "object", - properties: { - reactions: { - default: true, - type: "boolean", - }, - edit: { - default: true, - type: "boolean", - }, - unsend: { - default: true, - type: "boolean", - }, - reply: { - default: true, - type: "boolean", - }, - sendWithEffect: { - default: true, - type: "boolean", - }, - renameGroup: { - default: true, - type: "boolean", - }, - setGroupIcon: { - default: true, - type: "boolean", - }, - addParticipant: { - default: true, - type: "boolean", - }, - removeParticipant: { - default: true, - type: "boolean", - }, - leaveGroup: { - default: true, - type: "boolean", - }, - sendAttachment: { - default: true, - type: "boolean", - }, - }, - required: [ - "reactions", - "edit", - "unsend", - "reply", - "sendWithEffect", - "renameGroup", - "setGroupIcon", - "addParticipant", - "removeParticipant", - "leaveGroup", - "sendAttachment", - ], - additionalProperties: false, - }, }, required: ["enrichGroupParticipantsFromContacts"], additionalProperties: false, @@ -1006,9 +1069,6 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ additionalProperties: { type: "object", properties: { - allow: { - type: "boolean", - }, requireMention: { type: "boolean", }, @@ -2151,9 +2211,6 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ additionalProperties: { type: "object", properties: { - allow: { - type: "boolean", - }, requireMention: { type: "boolean", }, @@ -4180,9 +4237,6 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ enabled: { type: "boolean", }, - allow: { - type: "boolean", - }, requireMention: { type: "boolean", }, @@ -4562,9 +4616,6 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ enabled: { type: "boolean", }, - allow: { - type: "boolean", - }, requireMention: { type: "boolean", }, @@ -10598,9 +10649,6 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ enabled: { type: "boolean", }, - allow: { - type: "boolean", - }, requireMention: { type: "boolean", }, @@ -11437,9 +11485,6 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ enabled: { type: "boolean", }, - allow: { - type: "boolean", - }, requireMention: { type: "boolean", }, diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index a2b580ab326..424d8b7af50 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -791,6 +791,116 @@ describe("config strict validation", () => { }); }); + it("accepts legacy nested channel allow aliases via auto-migration and reports legacyIssues", async () => { + await withTempHome(async (home) => { + await writeOpenClawConfig(home, { + channels: { + slack: { + channels: { + ops: { + allow: false, + }, + }, + accounts: { + work: { + channels: { + general: { + allow: true, + }, + }, + }, + }, + }, + googlechat: { + groups: { + "spaces/aaa": { + allow: false, + }, + }, + accounts: { + work: { + groups: { + "spaces/bbb": { + allow: true, + }, + }, + }, + }, + }, + discord: { + guilds: { + "100": { + channels: { + general: { + allow: false, + }, + }, + }, + }, + accounts: { + work: { + guilds: { + "200": { + channels: { + help: { + allow: true, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const snap = await readConfigFileSnapshot(); + + expect(snap.valid).toBe(true); + expect(snap.legacyIssues.some((issue) => issue.path === "channels.slack")).toBe(true); + expect(snap.legacyIssues.some((issue) => issue.path === "channels.slack.accounts")).toBe( + true, + ); + expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat")).toBe(true); + expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat.accounts")).toBe( + true, + ); + expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord")).toBe(true); + expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.accounts")).toBe( + true, + ); + expect(snap.sourceConfig.channels?.slack?.channels?.ops).toMatchObject({ + enabled: false, + }); + expect(snap.sourceConfig.channels?.googlechat?.groups?.["spaces/aaa"]).toMatchObject({ + enabled: false, + }); + expect(snap.sourceConfig.channels?.discord?.guilds?.["100"]?.channels?.general).toMatchObject( + { + enabled: false, + }, + ); + expect( + (snap.sourceConfig.channels?.slack?.channels?.ops as Record | undefined) + ?.allow, + ).toBeUndefined(); + expect( + ( + snap.sourceConfig.channels?.googlechat?.groups?.["spaces/aaa"] as + | Record + | undefined + )?.allow, + ).toBeUndefined(); + expect( + ( + snap.sourceConfig.channels?.discord?.guilds?.["100"]?.channels?.general as + | Record + | undefined + )?.allow, + ).toBeUndefined(); + }); + }); + it("accepts telegram groupMentionsOnly via auto-migration and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { diff --git a/src/config/config.discord.test.ts b/src/config/config.discord.test.ts index 0954891c65b..e8a9b99e31a 100644 --- a/src/config/config.discord.test.ts +++ b/src/config/config.discord.test.ts @@ -36,7 +36,7 @@ describe("config discord", () => { requireMention: false, users: ["steipete"], channels: { - general: { allow: true, autoThread: true }, + general: { enabled: true, autoThread: true }, }, }, }, @@ -53,7 +53,7 @@ describe("config discord", () => { expect(cfg.channels?.discord?.actions?.stickerUploads).toBe(false); expect(cfg.channels?.discord?.actions?.channels).toBe(true); expect(cfg.channels?.discord?.guilds?.["123"]?.slug).toBe("friends-of-openclaw"); - expect(cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true); + expect(cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.enabled).toBe(true); expect(cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.autoThread).toBe(true); }, ); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 5fddea8c6d0..509cd8460f5 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -9,7 +9,12 @@ import type { OpenClawConfig } from "./types.js"; // AJV JSON Schema carries a `default` value. This lets the #56772 regression // test exercise the exact code path that caused the bug: AJV injecting // defaults during the write-back validation pass. -const mockLoadPluginManifestRegistry = vi.hoisted(() => vi.fn()); +const mockLoadPluginManifestRegistry = vi.hoisted(() => + vi.fn(() => ({ + diagnostics: [], + plugins: [], + })), +); vi.mock("../plugins/manifest-registry.js", () => ({ loadPluginManifestRegistry: (...args: unknown[]) => mockLoadPluginManifestRegistry(...args), @@ -734,4 +739,83 @@ describe("config io write", () => { expect(last.watchCommand).toBe("gateway --force"); }); }); + + it("accepts unrelated writes when the file still contains legacy nested allow aliases", async () => { + await withSuiteHome(async (home) => { + const { configPath, io, snapshot } = await writeConfigAndCreateIo({ + home, + initialConfig: { + channels: { + slack: { + channels: { + ops: { + allow: false, + }, + }, + }, + googlechat: { + groups: { + "spaces/aaa": { + allow: true, + }, + }, + }, + discord: { + guilds: { + "100": { + channels: { + general: { + allow: false, + }, + }, + }, + }, + }, + }, + }, + }); + + const next = structuredClone(snapshot.config); + next.gateway = { + ...next.gateway, + auth: { mode: "token" }, + }; + + await io.writeConfigFile(next); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + channels?: Record; + gateway?: Record; + }; + expect(persisted.gateway).toEqual({ + auth: { mode: "token" }, + }); + expect( + ( + (persisted.channels?.slack as { channels?: Record } | undefined) + ?.channels?.ops as Record | undefined + )?.enabled, + ).toBe(false); + expect( + ( + (persisted.channels?.googlechat as { groups?: Record } | undefined) + ?.groups?.["spaces/aaa"] as Record | undefined + )?.enabled, + ).toBe(true); + expect( + ( + ( + (persisted.channels?.discord as { guilds?: Record } | undefined) + ?.guilds?.["100"] as { channels?: Record } | undefined + )?.channels?.general as Record | undefined + )?.enabled, + ).toBe(false); + expect( + ( + (persisted.channels?.slack as { channels?: Record } | undefined) + ?.channels?.ops as Record | undefined + )?.allow, + ).toBeUndefined(); + }); + }); }); diff --git a/src/config/legacy-migrate.test.ts b/src/config/legacy-migrate.test.ts index c6b71a4711b..5c19b88e272 100644 --- a/src/config/legacy-migrate.test.ts +++ b/src/config/legacy-migrate.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { migrateLegacyConfig } from "./legacy-migrate.js"; -import { validateConfigObjectWithPlugins } from "./validation.js"; +import { + validateConfigObjectRawWithPlugins, + validateConfigObjectWithPlugins, +} from "./validation.js"; describe("legacy migrate audio transcription", () => { it("does not rewrite removed routing.transcribeAudio migrations", () => { @@ -508,6 +511,177 @@ describe("legacy migrate channel streaming aliases", () => { }); }); +describe("legacy migrate nested channel enabled aliases", () => { + it("accepts legacy allow aliases through with-plugins validation and normalizes them", () => { + const raw = { + channels: { + slack: { + channels: { + ops: { + allow: false, + }, + }, + }, + googlechat: { + groups: { + "spaces/aaa": { + allow: true, + }, + }, + }, + discord: { + guilds: { + "100": { + channels: { + general: { + allow: false, + }, + }, + }, + }, + }, + }, + }; + + const validated = validateConfigObjectWithPlugins(raw); + expect(validated.ok).toBe(true); + if (!validated.ok) { + return; + } + expect(validated.config.channels?.slack?.channels?.ops).toEqual({ + enabled: false, + }); + expect(validated.config.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({ + enabled: true, + }); + expect(validated.config.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({ + enabled: false, + }); + + const rawValidated = validateConfigObjectRawWithPlugins(raw); + expect(rawValidated.ok).toBe(true); + if (!rawValidated.ok) { + return; + } + expect(rawValidated.config.channels?.slack?.channels?.ops).toEqual({ + enabled: false, + }); + }); + + it("moves legacy allow toggles into enabled for slack, googlechat, and discord", () => { + const res = migrateLegacyConfig({ + channels: { + slack: { + channels: { + ops: { + allow: false, + }, + }, + accounts: { + work: { + channels: { + general: { + allow: true, + }, + }, + }, + }, + }, + googlechat: { + groups: { + "spaces/aaa": { + allow: false, + }, + }, + accounts: { + work: { + groups: { + "spaces/bbb": { + allow: true, + }, + }, + }, + }, + }, + discord: { + guilds: { + "100": { + channels: { + general: { + allow: false, + }, + }, + }, + }, + accounts: { + work: { + guilds: { + "200": { + channels: { + help: { + allow: true, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(res.changes).toContain( + "Moved channels.slack.channels.ops.allow → channels.slack.channels.ops.enabled.", + ); + expect(res.changes).toContain( + "Moved channels.slack.accounts.work.channels.general.allow → channels.slack.accounts.work.channels.general.enabled.", + ); + expect(res.changes).toContain( + "Moved channels.googlechat.groups.spaces/aaa.allow → channels.googlechat.groups.spaces/aaa.enabled.", + ); + expect(res.changes).toContain( + "Moved channels.googlechat.accounts.work.groups.spaces/bbb.allow → channels.googlechat.accounts.work.groups.spaces/bbb.enabled.", + ); + expect(res.changes).toContain( + "Moved channels.discord.guilds.100.channels.general.allow → channels.discord.guilds.100.channels.general.enabled.", + ); + expect(res.changes).toContain( + "Moved channels.discord.accounts.work.guilds.200.channels.help.allow → channels.discord.accounts.work.guilds.200.channels.help.enabled.", + ); + expect(res.config?.channels?.slack?.channels?.ops).toEqual({ + enabled: false, + }); + expect(res.config?.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({ + enabled: false, + }); + expect(res.config?.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({ + enabled: false, + }); + }); + + it("drops legacy allow when enabled is already set", () => { + const res = migrateLegacyConfig({ + channels: { + slack: { + channels: { + ops: { + allow: true, + enabled: false, + }, + }, + }, + }, + }); + + expect(res.changes).toContain( + "Removed channels.slack.channels.ops.allow (channels.slack.channels.ops.enabled already set).", + ); + expect(res.config?.channels?.slack?.channels?.ops).toEqual({ + enabled: false, + }); + }); +}); + describe("legacy migrate x_search auth", () => { it("moves only legacy x_search auth into plugin-owned xai config", () => { const res = migrateLegacyConfig({ diff --git a/src/config/legacy.migrations.channels.ts b/src/config/legacy.migrations.channels.ts index 3e505a22b3a..13a5cff2b29 100644 --- a/src/config/legacy.migrations.channels.ts +++ b/src/config/legacy.migrations.channels.ts @@ -5,10 +5,158 @@ import { type LegacyConfigRule, } from "./legacy.shared.js"; +type StreamingMode = "off" | "partial" | "block" | "progress"; +type DiscordPreviewStreamMode = "off" | "partial" | "block"; +type TelegramPreviewStreamMode = "off" | "partial" | "block"; +type SlackLegacyDraftStreamMode = "replace" | "status_final" | "append"; + function hasOwnKey(target: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(target, key); } +function normalizeStreamingMode(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + return normalized || null; +} + +function parseStreamingMode(value: unknown): StreamingMode | null { + const normalized = normalizeStreamingMode(value); + if ( + normalized === "off" || + normalized === "partial" || + normalized === "block" || + normalized === "progress" + ) { + return normalized; + } + return null; +} + +function parseDiscordPreviewStreamMode(value: unknown): DiscordPreviewStreamMode | null { + const parsed = parseStreamingMode(value); + if (!parsed) { + return null; + } + return parsed === "progress" ? "partial" : parsed; +} + +function parseTelegramPreviewStreamMode(value: unknown): TelegramPreviewStreamMode | null { + const parsed = parseStreamingMode(value); + if (!parsed) { + return null; + } + return parsed === "progress" ? "partial" : parsed; +} + +function parseSlackLegacyDraftStreamMode(value: unknown): SlackLegacyDraftStreamMode | null { + const normalized = normalizeStreamingMode(value); + if (normalized === "replace" || normalized === "status_final" || normalized === "append") { + return normalized; + } + return null; +} + +function mapSlackLegacyDraftStreamModeToStreaming(mode: SlackLegacyDraftStreamMode): StreamingMode { + if (mode === "append") { + return "block"; + } + if (mode === "status_final") { + return "progress"; + } + return "partial"; +} + +function resolveTelegramPreviewStreamMode( + params: { + streamMode?: unknown; + streaming?: unknown; + } = {}, +): TelegramPreviewStreamMode { + const parsedStreaming = parseStreamingMode(params.streaming); + if (parsedStreaming) { + return parsedStreaming === "progress" ? "partial" : parsedStreaming; + } + + const legacy = parseTelegramPreviewStreamMode(params.streamMode); + if (legacy) { + return legacy; + } + if (typeof params.streaming === "boolean") { + return params.streaming ? "partial" : "off"; + } + return "partial"; +} + +function resolveDiscordPreviewStreamMode( + params: { + streamMode?: unknown; + streaming?: unknown; + } = {}, +): DiscordPreviewStreamMode { + const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming); + if (parsedStreaming) { + return parsedStreaming; + } + + const legacy = parseDiscordPreviewStreamMode(params.streamMode); + if (legacy) { + return legacy; + } + if (typeof params.streaming === "boolean") { + return params.streaming ? "partial" : "off"; + } + return "off"; +} + +function resolveSlackStreamingMode( + params: { + streamMode?: unknown; + streaming?: unknown; + } = {}, +): StreamingMode { + const parsedStreaming = parseStreamingMode(params.streaming); + if (parsedStreaming) { + return parsedStreaming; + } + const legacyStreamMode = parseSlackLegacyDraftStreamMode(params.streamMode); + if (legacyStreamMode) { + return mapSlackLegacyDraftStreamModeToStreaming(legacyStreamMode); + } + if (typeof params.streaming === "boolean") { + return params.streaming ? "partial" : "off"; + } + return "partial"; +} + +function resolveSlackNativeStreaming( + params: { + nativeStreaming?: unknown; + streaming?: unknown; + } = {}, +): boolean { + if (typeof params.nativeStreaming === "boolean") { + return params.nativeStreaming; + } + if (typeof params.streaming === "boolean") { + return params.streaming; + } + return true; +} + +function formatSlackStreamModeMigrationMessage(pathPrefix: string, resolvedStreaming: string) { + return `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming (${resolvedStreaming}).`; +} + +function formatSlackStreamingBooleanMigrationMessage( + pathPrefix: string, + resolvedNativeStreaming: boolean, +) { + return `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`; +} + function hasLegacyThreadBindingTtl(value: unknown): boolean { const threadBindings = getRecord(value); return Boolean(threadBindings && hasOwnKey(threadBindings, "ttlHours")); @@ -70,6 +218,104 @@ function hasLegacyThreadBindingTtlInAnyChannel(value: unknown): boolean { }); } +function hasLegacyTelegramStreamingKeys(value: unknown): boolean { + const entry = getRecord(value); + if (!entry) { + return false; + } + return entry.streamMode !== undefined; +} + +function hasLegacyDiscordStreamingKeys(value: unknown): boolean { + const entry = getRecord(value); + if (!entry) { + return false; + } + return entry.streamMode !== undefined || typeof entry.streaming === "boolean"; +} + +function hasLegacySlackStreamingKeys(value: unknown): boolean { + const entry = getRecord(value); + if (!entry) { + return false; + } + return entry.streamMode !== undefined || typeof entry.streaming === "boolean"; +} + +function hasLegacyKeysInAccounts( + value: unknown, + matchEntry: (entry: Record) => boolean, +): boolean { + const accounts = getRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((entry) => matchEntry(getRecord(entry) ?? {})); +} + +function hasLegacyAllowAlias(entry: Record): boolean { + return hasOwnKey(entry, "allow"); +} + +function migrateAllowAliasForPath(params: { + entry: Record; + pathPrefix: string; + changes: string[]; +}): boolean { + if (!hasLegacyAllowAlias(params.entry)) { + return false; + } + + const legacyAllow = params.entry.allow; + const hadEnabled = params.entry.enabled !== undefined; + if (!hadEnabled) { + params.entry.enabled = legacyAllow; + } + delete params.entry.allow; + + if (hadEnabled) { + params.changes.push( + `Removed ${params.pathPrefix}.allow (${params.pathPrefix}.enabled already set).`, + ); + } else { + params.changes.push(`Moved ${params.pathPrefix}.allow → ${params.pathPrefix}.enabled.`); + } + return true; +} + +function hasLegacySlackChannelAllowAlias(value: unknown): boolean { + const entry = getRecord(value); + const channels = getRecord(entry?.channels); + if (!channels) { + return false; + } + return Object.values(channels).some((channel) => hasLegacyAllowAlias(getRecord(channel) ?? {})); +} + +function hasLegacyGoogleChatGroupAllowAlias(value: unknown): boolean { + const entry = getRecord(value); + const groups = getRecord(entry?.groups); + if (!groups) { + return false; + } + return Object.values(groups).some((group) => hasLegacyAllowAlias(getRecord(group) ?? {})); +} + +function hasLegacyDiscordGuildChannelAllowAlias(value: unknown): boolean { + const entry = getRecord(value); + const guilds = getRecord(entry?.guilds); + if (!guilds) { + return false; + } + return Object.values(guilds).some((guildValue) => { + const channels = getRecord(getRecord(guildValue)?.channels); + if (!channels) { + return false; + } + return Object.values(channels).some((channel) => hasLegacyAllowAlias(getRecord(channel) ?? {})); + }); +} + const THREAD_BINDING_RULES: LegacyConfigRule[] = [ { path: ["session", "threadBindings"], @@ -85,6 +331,84 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [ }, ]; +const CHANNEL_STREAMING_RULES: LegacyConfigRule[] = [ + { + path: ["channels", "telegram"], + message: + "channels.telegram.streamMode is legacy; use channels.telegram.streaming instead (auto-migrated on load).", + match: (value) => hasLegacyTelegramStreamingKeys(value), + }, + { + path: ["channels", "telegram", "accounts"], + message: + "channels.telegram.accounts..streamMode is legacy; use channels.telegram.accounts..streaming instead (auto-migrated on load).", + match: (value) => hasLegacyKeysInAccounts(value, hasLegacyTelegramStreamingKeys), + }, + { + path: ["channels", "discord"], + message: + "channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming with enum values instead (auto-migrated on load).", + match: (value) => hasLegacyDiscordStreamingKeys(value), + }, + { + path: ["channels", "discord", "accounts"], + message: + "channels.discord.accounts..streamMode and boolean channels.discord.accounts..streaming are legacy; use channels.discord.accounts..streaming with enum values instead (auto-migrated on load).", + match: (value) => hasLegacyKeysInAccounts(value, hasLegacyDiscordStreamingKeys), + }, + { + path: ["channels", "slack"], + message: + "channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming with enum values instead (auto-migrated on load).", + match: (value) => hasLegacySlackStreamingKeys(value), + }, + { + path: ["channels", "slack", "accounts"], + message: + "channels.slack.accounts..streamMode and boolean channels.slack.accounts..streaming are legacy; use channels.slack.accounts..streaming with enum values instead (auto-migrated on load).", + match: (value) => hasLegacyKeysInAccounts(value, hasLegacySlackStreamingKeys), + }, +]; + +const CHANNEL_ENABLED_ALIAS_RULES: LegacyConfigRule[] = [ + { + path: ["channels", "slack"], + message: + "channels.slack.channels..allow is legacy; use channels.slack.channels..enabled instead (auto-migrated on load).", + match: (value) => hasLegacySlackChannelAllowAlias(value), + }, + { + path: ["channels", "slack", "accounts"], + message: + "channels.slack.accounts..channels..allow is legacy; use channels.slack.accounts..channels..enabled instead (auto-migrated on load).", + match: (value) => hasLegacyKeysInAccounts(value, hasLegacySlackChannelAllowAlias), + }, + { + path: ["channels", "googlechat"], + message: + "channels.googlechat.groups..allow is legacy; use channels.googlechat.groups..enabled instead (auto-migrated on load).", + match: (value) => hasLegacyGoogleChatGroupAllowAlias(value), + }, + { + path: ["channels", "googlechat", "accounts"], + message: + "channels.googlechat.accounts..groups..allow is legacy; use channels.googlechat.accounts..groups..enabled instead (auto-migrated on load).", + match: (value) => hasLegacyKeysInAccounts(value, hasLegacyGoogleChatGroupAllowAlias), + }, + { + path: ["channels", "discord"], + message: + "channels.discord.guilds..channels..allow is legacy; use channels.discord.guilds..channels..enabled instead (auto-migrated on load).", + match: (value) => hasLegacyDiscordGuildChannelAllowAlias(value), + }, + { + path: ["channels", "discord", "accounts"], + message: + "channels.discord.accounts..guilds..channels..allow is legacy; use channels.discord.accounts..guilds..channels..enabled instead (auto-migrated on load).", + match: (value) => hasLegacyKeysInAccounts(value, hasLegacyDiscordGuildChannelAllowAlias), + }, +]; + export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ defineLegacyConfigMigration({ id: "thread-bindings.ttlHours->idleHours", @@ -139,4 +463,224 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ raw.channels = channels; }, }), + defineLegacyConfigMigration({ + id: "channels.streaming-keys->channels.streaming", + describe: + "Normalize legacy streaming keys to channels..streaming (Telegram/Discord/Slack)", + legacyRules: CHANNEL_STREAMING_RULES, + apply: (raw, changes) => { + const channels = getRecord(raw.channels); + if (!channels) { + return; + } + + const migrateProviderEntry = (params: { + provider: "telegram" | "discord" | "slack"; + entry: Record; + pathPrefix: string; + }) => { + const migrateCommonStreamingMode = ( + resolveMode: (entry: Record) => string, + ) => { + const hasLegacyStreamMode = params.entry.streamMode !== undefined; + const legacyStreaming = params.entry.streaming; + if (!hasLegacyStreamMode && typeof legacyStreaming !== "boolean") { + return false; + } + const resolved = resolveMode(params.entry); + params.entry.streaming = resolved; + if (hasLegacyStreamMode) { + delete params.entry.streamMode; + changes.push( + `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`, + ); + } + if (typeof legacyStreaming === "boolean") { + changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`); + } + return true; + }; + + const hasLegacyStreamMode = params.entry.streamMode !== undefined; + const legacyStreaming = params.entry.streaming; + const legacyNativeStreaming = params.entry.nativeStreaming; + + if (params.provider === "telegram") { + migrateCommonStreamingMode(resolveTelegramPreviewStreamMode); + return; + } + + if (params.provider === "discord") { + migrateCommonStreamingMode(resolveDiscordPreviewStreamMode); + return; + } + + if (!hasLegacyStreamMode && typeof legacyStreaming !== "boolean") { + return; + } + const resolvedStreaming = resolveSlackStreamingMode(params.entry); + const resolvedNativeStreaming = resolveSlackNativeStreaming(params.entry); + params.entry.streaming = resolvedStreaming; + params.entry.nativeStreaming = resolvedNativeStreaming; + if (hasLegacyStreamMode) { + delete params.entry.streamMode; + changes.push(formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming)); + } + if (typeof legacyStreaming === "boolean") { + changes.push( + formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming), + ); + } else if (typeof legacyNativeStreaming !== "boolean" && hasLegacyStreamMode) { + changes.push(`Set ${params.pathPrefix}.nativeStreaming → ${resolvedNativeStreaming}.`); + } + }; + + const migrateProvider = (provider: "telegram" | "discord" | "slack") => { + const providerEntry = getRecord(channels[provider]); + if (!providerEntry) { + return; + } + migrateProviderEntry({ + provider, + entry: providerEntry, + pathPrefix: `channels.${provider}`, + }); + const accounts = getRecord(providerEntry.accounts); + if (!accounts) { + return; + } + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = getRecord(accountValue); + if (!account) { + continue; + } + migrateProviderEntry({ + provider, + entry: account, + pathPrefix: `channels.${provider}.accounts.${accountId}`, + }); + } + }; + + migrateProvider("telegram"); + migrateProvider("discord"); + migrateProvider("slack"); + }, + }), + defineLegacyConfigMigration({ + id: "channels.allow->channels.enabled", + describe: + "Normalize legacy nested channel allow toggles to enabled (Slack/Google Chat/Discord)", + legacyRules: CHANNEL_ENABLED_ALIAS_RULES, + apply: (raw, changes) => { + const channels = getRecord(raw.channels); + if (!channels) { + return; + } + + const migrateSlackEntry = (entry: Record, pathPrefix: string) => { + const channelEntries = getRecord(entry.channels); + if (!channelEntries) { + return; + } + for (const [channelId, channelRaw] of Object.entries(channelEntries)) { + const channel = getRecord(channelRaw); + if (!channel) { + continue; + } + migrateAllowAliasForPath({ + entry: channel, + pathPrefix: `${pathPrefix}.channels.${channelId}`, + changes, + }); + channelEntries[channelId] = channel; + } + entry.channels = channelEntries; + }; + + const migrateGoogleChatEntry = (entry: Record, pathPrefix: string) => { + const groups = getRecord(entry.groups); + if (!groups) { + return; + } + for (const [groupId, groupRaw] of Object.entries(groups)) { + const group = getRecord(groupRaw); + if (!group) { + continue; + } + migrateAllowAliasForPath({ + entry: group, + pathPrefix: `${pathPrefix}.groups.${groupId}`, + changes, + }); + groups[groupId] = group; + } + entry.groups = groups; + }; + + const migrateDiscordEntry = (entry: Record, pathPrefix: string) => { + const guilds = getRecord(entry.guilds); + if (!guilds) { + return; + } + for (const [guildId, guildRaw] of Object.entries(guilds)) { + const guild = getRecord(guildRaw); + if (!guild) { + continue; + } + const channelEntries = getRecord(guild.channels); + if (!channelEntries) { + guilds[guildId] = guild; + continue; + } + for (const [channelId, channelRaw] of Object.entries(channelEntries)) { + const channel = getRecord(channelRaw); + if (!channel) { + continue; + } + migrateAllowAliasForPath({ + entry: channel, + pathPrefix: `${pathPrefix}.guilds.${guildId}.channels.${channelId}`, + changes, + }); + channelEntries[channelId] = channel; + } + guild.channels = channelEntries; + guilds[guildId] = guild; + } + entry.guilds = guilds; + }; + + const migrateProviderAccounts = ( + provider: "slack" | "googlechat" | "discord", + migrateEntry: (entry: Record, pathPrefix: string) => void, + ) => { + const providerEntry = getRecord(channels[provider]); + if (!providerEntry) { + return; + } + migrateEntry(providerEntry, `channels.${provider}`); + const accounts = getRecord(providerEntry.accounts); + if (!accounts) { + channels[provider] = providerEntry; + return; + } + for (const [accountId, accountRaw] of Object.entries(accounts)) { + const account = getRecord(accountRaw); + if (!account) { + continue; + } + migrateEntry(account, `channels.${provider}.accounts.${accountId}`); + accounts[accountId] = account; + } + providerEntry.accounts = accounts; + channels[provider] = providerEntry; + }; + + migrateProviderAccounts("slack", migrateSlackEntry); + migrateProviderAccounts("googlechat", migrateGoogleChatEntry); + migrateProviderAccounts("discord", migrateDiscordEntry); + raw.channels = channels; + }, + }), ]; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 2906144e1ba..15b3a6a0a44 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -38,7 +38,6 @@ export type DiscordDmConfig = { }; export type DiscordGuildChannelConfig = { - allow?: boolean; requireMention?: boolean; /** * If true, drop messages that mention another user/role but not this one (not @everyone/@here). diff --git a/src/config/types.googlechat.ts b/src/config/types.googlechat.ts index 928372ed7f4..7c3966b3479 100644 --- a/src/config/types.googlechat.ts +++ b/src/config/types.googlechat.ts @@ -18,10 +18,8 @@ export type GoogleChatDmConfig = { }; export type GoogleChatGroupConfig = { - /** If false, disable the bot in this space. (Alias for allow: false.) */ + /** If false, disable the bot in this space. */ enabled?: boolean; - /** Legacy allow toggle; prefer enabled. */ - allow?: boolean; /** Require mentioning the bot to trigger replies. */ requireMention?: boolean; /** Allowlist of users that can invoke the bot in this space. */ diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index a0bac9d4fa2..907a556be1e 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -29,10 +29,8 @@ export type SlackDmConfig = { }; export type SlackChannelConfig = { - /** If false, disable the bot in this channel. (Alias for allow: false.) */ + /** If false, disable the bot in this channel. */ enabled?: boolean; - /** Legacy channel allow toggle; prefer enabled. */ - allow?: boolean; /** Require mentioning the bot to trigger replies. */ requireMention?: boolean; /** Optional tool policy overrides for this channel. */ diff --git a/src/config/validation.ts b/src/config/validation.ts index fc83b8ffd76..98177fe1bc4 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -25,7 +25,7 @@ import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-di import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js"; import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.generated.js"; import { collectChannelSchemaMetadata } from "./channel-config-metadata.js"; -import { findLegacyConfigIssues } from "./legacy.js"; +import { applyLegacyMigrations, findLegacyConfigIssues } from "./legacy.js"; import { materializeRuntimeConfig } from "./materialize.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { coerceSecretRef } from "./types.secrets.js"; @@ -543,7 +543,13 @@ function validateConfigObjectWithPluginsBase( raw: unknown, opts: { applyDefaults: boolean; env?: NodeJS.ProcessEnv }, ): ValidateConfigWithPluginsResult { - const base = opts.applyDefaults ? validateConfigObject(raw) : validateConfigObjectRaw(raw); + // Config edit flows often start from raw parsed files that may still contain legacy keys. + // Accept known legacy inputs here by normalizing them before schema/plugin validation. + const migrated = applyLegacyMigrations(raw); + const normalizedRaw = migrated.next ?? raw; + const base = opts.applyDefaults + ? validateConfigObject(normalizedRaw) + : validateConfigObjectRaw(normalizedRaw); if (!base.ok) { return { ok: false, issues: base.issues, warnings: [] }; } diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 1f36059722d..032062f493a 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -405,7 +405,6 @@ export const DiscordDmSchema = z export const DiscordGuildChannelSchema = z .object({ - allow: z.boolean().optional(), requireMention: z.boolean().optional(), ignoreOtherMentions: z.boolean().optional(), tools: ToolPolicySchema, @@ -757,7 +756,6 @@ export const GoogleChatDmSchema = z export const GoogleChatGroupSchema = z .object({ enabled: z.boolean().optional(), - allow: z.boolean().optional(), requireMention: z.boolean().optional(), users: z.array(z.union([z.string(), z.number()])).optional(), systemPrompt: z.string().optional(), @@ -831,7 +829,6 @@ export const SlackDmSchema = z export const SlackChannelSchema = z .object({ enabled: z.boolean().optional(), - allow: z.boolean().optional(), requireMention: z.boolean().optional(), tools: ToolPolicySchema, toolsBySender: ToolPolicyBySenderSchema, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 58ef241eb76..9aa281145c3 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -2309,7 +2309,7 @@ describe("security audit", () => { guilds: { "123": { channels: { - general: { allow: true }, + general: { enabled: true }, }, }, }, @@ -2330,7 +2330,7 @@ describe("security audit", () => { guilds: { "123": { channels: { - general: { allow: true }, + general: { enabled: true }, }, }, }, @@ -2373,7 +2373,7 @@ describe("security audit", () => { guilds: { "123": { channels: { - general: { allow: true }, + general: { enabled: true }, }, }, }, @@ -2388,7 +2388,7 @@ describe("security audit", () => { guilds: { "123": { channels: { - general: { allow: true }, + general: { enabled: true }, }, }, }, @@ -2957,7 +2957,7 @@ describe("security audit", () => { guilds: { "123": { channels: { - general: { allow: true }, + general: { enabled: true }, }, }, }, @@ -3759,7 +3759,7 @@ describe("security audit", () => { guilds: { "1234567890": { channels: { - "7777777777": { allow: true }, + "7777777777": { enabled: true }, }, }, },