mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-08 15:51:06 +00:00
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:
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user