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
This commit is contained in:
Vincent Koc
2026-04-04 11:15:32 +09:00
committed by GitHub
parent 945b198c76
commit 9e389cff3d
36 changed files with 1524 additions and 290 deletions

View File

@@ -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`

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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"],
},
},

View File

@@ -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<Config>): C
guilds: {
"*": {
requireMention: true,
channels: { c1: { allow: true } },
channels: { c1: { enabled: true } },
},
},
},

View File

@@ -41,7 +41,7 @@ export type DiscordGuildEntryResolved = {
reactionNotifications?: "off" | "own" | "all" | "allowlist";
users?: string[];
roles?: string[];
channels?: Record<string, { allow?: boolean } & DiscordChannelOverrideConfig>;
channels?: Record<string, DiscordChannelOverrideConfig>;
};
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,

View File

@@ -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,
},

View File

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

View File

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

View File

@@ -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,
},
},

View File

@@ -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 },
},
},
},

View File

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

View File

@@ -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"],
},
},

View File

@@ -63,7 +63,6 @@ export function isSenderAllowed(
type GoogleChatGroupEntry = {
requireMention?: boolean;
allow?: boolean;
enabled?: boolean;
users?: Array<string | number>;
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") {

View File

@@ -21,7 +21,6 @@ export type SlackChannelConfigResolved = {
export type SlackChannelConfigEntry = {
enabled?: boolean;
allow?: boolean;
requireMention?: boolean;
allowBots?: boolean;
users?: Array<string | number>;
@@ -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;

View File

@@ -30,7 +30,7 @@ export function createSlackSystemEventTestHarness(overrides?: SlackSystemEventTe
? {
C1: {
users: overrides.channelUsers,
allow: true,
enabled: true,
},
}
: undefined,

View File

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

View File

@@ -695,7 +695,7 @@ describe("Slack native command argument menus", () => {
function createPolicyHarness(overrides?: {
groupPolicy?: "open" | "allowlist";
channelsConfig?: Record<string, { allow?: boolean; requireMention?: boolean }>;
channelsConfig?: Record<string, { enabled?: boolean; requireMention?: boolean }>;
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",
});

View File

@@ -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),

View File

@@ -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 },
});
});
});

View File

@@ -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,

View File

@@ -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 },
});
});
});

View File

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

View File

@@ -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",
},

View File

@@ -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<string, unknown> | undefined)
?.allow,
).toBeUndefined();
expect(
(
snap.sourceConfig.channels?.googlechat?.groups?.["spaces/aaa"] as
| Record<string, unknown>
| undefined
)?.allow,
).toBeUndefined();
expect(
(
snap.sourceConfig.channels?.discord?.guilds?.["100"]?.channels?.general as
| Record<string, unknown>
| undefined
)?.allow,
).toBeUndefined();
});
});
it("accepts telegram groupMentionsOnly via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {

View File

@@ -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);
},
);

View File

@@ -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<string, unknown>;
gateway?: Record<string, unknown>;
};
expect(persisted.gateway).toEqual({
auth: { mode: "token" },
});
expect(
(
(persisted.channels?.slack as { channels?: Record<string, unknown> } | undefined)
?.channels?.ops as Record<string, unknown> | undefined
)?.enabled,
).toBe(false);
expect(
(
(persisted.channels?.googlechat as { groups?: Record<string, unknown> } | undefined)
?.groups?.["spaces/aaa"] as Record<string, unknown> | undefined
)?.enabled,
).toBe(true);
expect(
(
(
(persisted.channels?.discord as { guilds?: Record<string, unknown> } | undefined)
?.guilds?.["100"] as { channels?: Record<string, unknown> } | undefined
)?.channels?.general as Record<string, unknown> | undefined
)?.enabled,
).toBe(false);
expect(
(
(persisted.channels?.slack as { channels?: Record<string, unknown> } | undefined)
?.channels?.ops as Record<string, unknown> | undefined
)?.allow,
).toBeUndefined();
});
});
});

View File

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

View File

@@ -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<string, unknown>, 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<string, unknown>) => boolean,
): boolean {
const accounts = getRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((entry) => matchEntry(getRecord(entry) ?? {}));
}
function hasLegacyAllowAlias(entry: Record<string, unknown>): boolean {
return hasOwnKey(entry, "allow");
}
function migrateAllowAliasForPath(params: {
entry: Record<string, unknown>;
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.<id>.streamMode is legacy; use channels.telegram.accounts.<id>.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.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.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.<id>.streamMode and boolean channels.slack.accounts.<id>.streaming are legacy; use channels.slack.accounts.<id>.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.<id>.allow is legacy; use channels.slack.channels.<id>.enabled instead (auto-migrated on load).",
match: (value) => hasLegacySlackChannelAllowAlias(value),
},
{
path: ["channels", "slack", "accounts"],
message:
"channels.slack.accounts.<id>.channels.<id>.allow is legacy; use channels.slack.accounts.<id>.channels.<id>.enabled instead (auto-migrated on load).",
match: (value) => hasLegacyKeysInAccounts(value, hasLegacySlackChannelAllowAlias),
},
{
path: ["channels", "googlechat"],
message:
"channels.googlechat.groups.<id>.allow is legacy; use channels.googlechat.groups.<id>.enabled instead (auto-migrated on load).",
match: (value) => hasLegacyGoogleChatGroupAllowAlias(value),
},
{
path: ["channels", "googlechat", "accounts"],
message:
"channels.googlechat.accounts.<id>.groups.<id>.allow is legacy; use channels.googlechat.accounts.<id>.groups.<id>.enabled instead (auto-migrated on load).",
match: (value) => hasLegacyKeysInAccounts(value, hasLegacyGoogleChatGroupAllowAlias),
},
{
path: ["channels", "discord"],
message:
"channels.discord.guilds.<id>.channels.<id>.allow is legacy; use channels.discord.guilds.<id>.channels.<id>.enabled instead (auto-migrated on load).",
match: (value) => hasLegacyDiscordGuildChannelAllowAlias(value),
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.guilds.<id>.channels.<id>.allow is legacy; use channels.discord.accounts.<id>.guilds.<id>.channels.<id>.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.<provider>.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<string, unknown>;
pathPrefix: string;
}) => {
const migrateCommonStreamingMode = (
resolveMode: (entry: Record<string, unknown>) => 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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;
},
}),
];

View File

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

View File

@@ -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. */

View File

@@ -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. */

View File

@@ -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: [] };
}

View File

@@ -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,

View File

@@ -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 },
},
},
},