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

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