diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index b9498e3caf7..7e7d358bb1c 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -1,13 +1,27 @@ import { describe, expect, it } from "vitest"; -import { - validateConfigObjectRawWithPlugins, - validateConfigObjectWithPlugins, -} from "../../../config/validation.js"; -import { applyLegacyDoctorMigrations, migrateLegacyConfig } from "./legacy-config-migrate.js"; +import type { OpenClawConfig } from "../../../config/types.js"; +import { LEGACY_CONFIG_MIGRATIONS } from "./legacy-config-migrations.js"; + +function migrateLegacyConfigForTest(raw: unknown): { + config: OpenClawConfig | null; + changes: string[]; +} { + if (!raw || typeof raw !== "object") { + return { config: null, changes: [] }; + } + const next = structuredClone(raw) as Record; + const changes: string[] = []; + for (const migration of LEGACY_CONFIG_MIGRATIONS) { + migration.apply(next, changes); + } + return changes.length === 0 + ? { config: null, changes } + : { config: next as OpenClawConfig, changes }; +} describe("legacy migrate audio transcription", () => { it("does not rewrite removed routing.transcribeAudio migrations", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ routing: { transcribeAudio: { command: ["whisper", "--model", "base"], @@ -21,7 +35,7 @@ describe("legacy migrate audio transcription", () => { }); it("does not rewrite removed routing.transcribeAudio migrations when new config exists", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ routing: { transcribeAudio: { command: ["whisper", "--model", "tiny"], @@ -41,7 +55,7 @@ describe("legacy migrate audio transcription", () => { }); it("drops invalid audio.transcription payloads", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ audio: { transcription: { command: [{}], @@ -57,7 +71,7 @@ describe("legacy migrate audio transcription", () => { describe("legacy migrate mention routing", () => { it("does not rewrite removed routing.groupChat.requireMention migrations", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ routing: { groupChat: { requireMention: true, @@ -70,7 +84,7 @@ describe("legacy migrate mention routing", () => { }); it("does not rewrite removed channels.telegram.requireMention migrations", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ channels: { telegram: { requireMention: false, @@ -81,90 +95,11 @@ describe("legacy migrate mention routing", () => { expect(res.changes).toEqual([]); expect(res.config).toBeNull(); }); - - it("moves channels.telegram.groupMentionsOnly into groups.*.requireMention", () => { - const res = migrateLegacyConfig({ - channels: { - telegram: { - groupMentionsOnly: true, - }, - }, - }); - - expect(res.changes).toContain( - 'Moved channels.telegram.groupMentionsOnly → channels.telegram.groups."*".requireMention.', - ); - expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(true); - expect( - (res.config?.channels?.telegram as { groupMentionsOnly?: unknown } | undefined) - ?.groupMentionsOnly, - ).toBeUndefined(); - }); - - it('keeps explicit channels.telegram.groups."*".requireMention when migrating groupMentionsOnly', () => { - const res = migrateLegacyConfig({ - channels: { - telegram: { - groupMentionsOnly: true, - groups: { - "*": { - requireMention: false, - }, - }, - }, - }, - }); - - expect(res.changes).toContain( - 'Removed channels.telegram.groupMentionsOnly (channels.telegram.groups."*" already set).', - ); - expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false); - expect( - (res.config?.channels?.telegram as { groupMentionsOnly?: unknown } | undefined) - ?.groupMentionsOnly, - ).toBeUndefined(); - }); - - it("does not overwrite invalid channels.telegram.groups when migrating groupMentionsOnly", () => { - const res = migrateLegacyConfig({ - channels: { - telegram: { - groupMentionsOnly: true, - groups: [], - }, - }, - }); - - expect(res.config).toBeNull(); - expect(res.changes).toEqual([ - "Skipped channels.telegram.groupMentionsOnly migration because channels.telegram.groups already has an incompatible shape; fix remaining issues manually.", - "Migration applied, but config still invalid; fix remaining issues manually.", - ]); - }); - - it('does not overwrite invalid channels.telegram.groups."*" when migrating groupMentionsOnly', () => { - const res = migrateLegacyConfig({ - channels: { - telegram: { - groupMentionsOnly: true, - groups: { - "*": false, - }, - }, - }, - }); - - expect(res.config).toBeNull(); - expect(res.changes).toEqual([ - "Skipped channels.telegram.groupMentionsOnly migration because channels.telegram.groups already has an incompatible shape; fix remaining issues manually.", - "Migration applied, but config still invalid; fix remaining issues manually.", - ]); - }); }); describe("legacy migrate sandbox scope aliases", () => { it("moves agents.defaults.sandbox.perSession into scope", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ agents: { defaults: { sandbox: { @@ -183,7 +118,7 @@ describe("legacy migrate sandbox scope aliases", () => { }); it("moves agents.list[].sandbox.perSession into scope", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ agents: { list: [ { @@ -205,7 +140,7 @@ describe("legacy migrate sandbox scope aliases", () => { }); it("drops legacy sandbox perSession when scope is already set", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ agents: { defaults: { sandbox: { @@ -235,17 +170,16 @@ describe("legacy migrate sandbox scope aliases", () => { }, }; - const res = migrateLegacyConfig(raw); + const res = migrateLegacyConfigForTest(raw); expect(res.changes).toEqual([]); expect(res.config).toBeNull(); - expect(validateConfigObjectWithPlugins(raw).ok).toBe(false); }); }); describe("legacy migrate channel streaming aliases", () => { it("migrates preview-channel legacy streaming fields into the nested streaming shape", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ channels: { telegram: { streamMode: "block", @@ -358,13 +292,13 @@ describe("legacy migrate channel streaming aliases", () => { }, }, }; - const res = migrateLegacyConfig(raw); - const migrated = applyLegacyDoctorMigrations(raw); + const res = migrateLegacyConfigForTest(raw); + const migrated = migrateLegacyConfigForTest(raw); expect(res.changes).toContain( "Moved channels.slack.streaming (boolean) → channels.slack.streaming.mode (off).", ); - expect((migrated.next as { channels?: { slack?: unknown } }).channels?.slack).toMatchObject({ + expect(migrated.config?.channels?.slack).toMatchObject({ streaming: { mode: "off", nativeTransport: false, @@ -386,18 +320,7 @@ describe("legacy migrate channel streaming aliases", () => { }, }; - const validated = validateConfigObjectWithPlugins(raw); - expect(validated.ok).toBe(false); - if (validated.ok) { - return; - } - expect(validated.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ path: "channels.googlechat" }), - expect.objectContaining({ path: "channels.googlechat.accounts" }), - ]), - ); - const res = migrateLegacyConfig(raw); + const res = migrateLegacyConfigForTest(raw); expect(res.changes).toContain( "Removed channels.googlechat.streamMode (legacy key no longer used).", ); @@ -446,33 +369,7 @@ describe("legacy migrate nested channel enabled aliases", () => { }, }; - const validated = validateConfigObjectWithPlugins(raw); - expect(validated.ok).toBe(false); - if (validated.ok) { - return; - } - expect(validated.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ path: "channels.slack" }), - expect.objectContaining({ path: "channels.googlechat" }), - expect.objectContaining({ path: "channels.discord" }), - ]), - ); - - const rawValidated = validateConfigObjectRawWithPlugins(raw); - expect(rawValidated.ok).toBe(false); - if (rawValidated.ok) { - return; - } - expect(rawValidated.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ path: "channels.slack" }), - expect.objectContaining({ path: "channels.googlechat" }), - expect.objectContaining({ path: "channels.discord" }), - ]), - ); - - const migrated = migrateLegacyConfig(raw); + const migrated = migrateLegacyConfigForTest(raw); expect(migrated.config?.channels?.slack?.channels?.ops).toEqual({ enabled: false, }); @@ -484,8 +381,8 @@ describe("legacy migrate nested channel enabled aliases", () => { }); }); - it("moves legacy allow toggles into enabled for slack, googlechat, discord, matrix, and zalouser", () => { - const res = migrateLegacyConfig({ + it("moves legacy allow toggles into enabled for slack, googlechat, and discord", () => { + const res = migrateLegacyConfigForTest({ channels: { slack: { channels: { @@ -543,38 +440,6 @@ describe("legacy migrate nested channel enabled aliases", () => { }, }, }, - matrix: { - groups: { - "!ops:example.org": { - allow: false, - }, - }, - accounts: { - work: { - rooms: { - "!legacy:example.org": { - allow: true, - }, - }, - }, - }, - }, - zalouser: { - groups: { - "group:trusted": { - allow: false, - }, - }, - accounts: { - work: { - groups: { - "group:legacy": { - allow: true, - }, - }, - }, - }, - }, }, }); @@ -596,18 +461,6 @@ describe("legacy migrate nested channel enabled aliases", () => { 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.changes).toContain( - "Moved channels.matrix.groups.!ops:example.org.allow → channels.matrix.groups.!ops:example.org.enabled (false).", - ); - expect(res.changes).toContain( - "Moved channels.matrix.accounts.work.rooms.!legacy:example.org.allow → channels.matrix.accounts.work.rooms.!legacy:example.org.enabled (true).", - ); - expect(res.changes).toContain( - "Moved channels.zalouser.groups.group:trusted.allow → channels.zalouser.groups.group:trusted.enabled (false).", - ); - expect(res.changes).toContain( - "Moved channels.zalouser.accounts.work.groups.group:legacy.allow → channels.zalouser.accounts.work.groups.group:legacy.enabled (true).", - ); expect(res.config?.channels?.slack?.channels?.ops).toEqual({ enabled: false, }); @@ -617,34 +470,10 @@ describe("legacy migrate nested channel enabled aliases", () => { expect(res.config?.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({ enabled: false, }); - expect(res.config?.channels?.matrix?.groups?.["!ops:example.org"]).toEqual({ - enabled: false, - }); - expect( - ( - res.config?.channels?.matrix?.accounts?.work as - | { rooms?: Record } - | undefined - )?.rooms?.["!legacy:example.org"], - ).toEqual({ - enabled: true, - }); - expect(res.config?.channels?.zalouser?.groups?.["group:trusted"]).toEqual({ - enabled: false, - }); - expect( - ( - res.config?.channels?.zalouser?.accounts?.work as - | { groups?: Record } - | undefined - )?.groups?.["group:legacy"], - ).toEqual({ - enabled: true, - }); }); it("drops legacy allow when enabled is already set", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ channels: { slack: { channels: { @@ -666,72 +495,9 @@ describe("legacy migrate nested channel enabled aliases", () => { }); }); -describe("legacy migrate bundled channel private-network aliases", () => { - it("rejects legacy Mattermost private-network aliases during validation and normalizes them in migration", () => { - const raw = { - channels: { - mattermost: { - allowPrivateNetwork: true, - accounts: { - work: { - allowPrivateNetwork: false, - }, - }, - }, - }, - }; - - const validated = validateConfigObjectWithPlugins(raw); - expect(validated.ok).toBe(false); - if (validated.ok) { - return; - } - expect(validated.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ path: "channels.mattermost" }), - expect.objectContaining({ path: "channels.mattermost.accounts" }), - ]), - ); - - const rawValidated = validateConfigObjectRawWithPlugins(raw); - expect(rawValidated.ok).toBe(false); - if (rawValidated.ok) { - return; - } - expect(rawValidated.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ path: "channels.mattermost" }), - expect.objectContaining({ path: "channels.mattermost.accounts" }), - ]), - ); - - const res = migrateLegacyConfig(raw); - expect(res.config?.channels?.mattermost).toEqual( - expect.objectContaining({ - network: { - dangerouslyAllowPrivateNetwork: true, - }, - accounts: { - work: expect.objectContaining({ - network: { - dangerouslyAllowPrivateNetwork: false, - }, - }), - }, - }), - ); - expect(res.changes).toEqual( - expect.arrayContaining([ - "Moved channels.mattermost.allowPrivateNetwork → channels.mattermost.network.dangerouslyAllowPrivateNetwork (true).", - "Moved channels.mattermost.accounts.work.allowPrivateNetwork → channels.mattermost.accounts.work.network.dangerouslyAllowPrivateNetwork (false).", - ]), - ); - }); -}); - describe("legacy migrate x_search auth", () => { it("moves only legacy x_search auth into plugin-owned xai config", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ tools: { web: { x_search: { @@ -763,7 +529,7 @@ describe("legacy migrate x_search auth", () => { describe("legacy migrate heartbeat config", () => { it("moves top-level heartbeat into agents.defaults.heartbeat", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m", @@ -779,7 +545,7 @@ describe("legacy migrate heartbeat config", () => { }); it("moves top-level heartbeat visibility into channels.defaults.heartbeat", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ heartbeat: { showOk: true, showAlerts: false, @@ -797,7 +563,7 @@ describe("legacy migrate heartbeat config", () => { }); it("keeps explicit agents.defaults.heartbeat values when merging top-level heartbeat", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m", @@ -824,7 +590,7 @@ describe("legacy migrate heartbeat config", () => { }); it("keeps explicit channels.defaults.heartbeat values when merging top-level heartbeat visibility", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ heartbeat: { showOk: true, showAlerts: true, @@ -851,7 +617,7 @@ describe("legacy migrate heartbeat config", () => { }); it("preserves agents.defaults.heartbeat precedence over top-level heartbeat legacy key", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ agents: { defaults: { heartbeat: { @@ -876,7 +642,7 @@ describe("legacy migrate heartbeat config", () => { }); it("drops blocked prototype keys when migrating top-level heartbeat", () => { - const res = migrateLegacyConfig( + const res = migrateLegacyConfigForTest( JSON.parse( '{"heartbeat":{"every":"30m","__proto__":{"polluted":true},"showOk":true}}', ) as Record, @@ -892,7 +658,7 @@ describe("legacy migrate heartbeat config", () => { }); it("records a migration change when removing empty top-level heartbeat", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ heartbeat: {}, }); @@ -904,7 +670,7 @@ describe("legacy migrate heartbeat config", () => { describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { it("seeds allowedOrigins for bind=lan with no existing controlUi config", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ gateway: { bind: "lan", auth: { mode: "token", token: "tok" }, @@ -919,7 +685,7 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { }); it("seeds allowedOrigins using configured port", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ gateway: { bind: "lan", port: 9000, @@ -933,7 +699,7 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { }); it("seeds allowedOrigins including custom bind host for bind=custom", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ gateway: { bind: "custom", customBindHost: "192.168.1.100", @@ -947,7 +713,7 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { it("does not overwrite existing allowedOrigins — returns null (no migration needed)", () => { // When allowedOrigins already exists, the migration is a no-op. // applyLegacyDoctorMigrations returns next=null when changes.length===0, so config is null. - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ gateway: { bind: "lan", auth: { mode: "token", token: "tok" }, @@ -959,7 +725,7 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { }); it("does not migrate when dangerouslyAllowHostHeaderOriginFallback is set — returns null", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ gateway: { bind: "lan", auth: { mode: "token", token: "tok" }, @@ -971,7 +737,7 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { }); it("seeds allowedOrigins when existing entries are blank strings", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ gateway: { bind: "lan", auth: { mode: "token", token: "tok" }, @@ -986,7 +752,7 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { }); it("does not migrate loopback bind — returns null", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ gateway: { bind: "loopback", auth: { mode: "token", token: "tok" }, @@ -997,7 +763,7 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { }); it("preserves existing controlUi fields when seeding allowedOrigins", () => { - const res = migrateLegacyConfig({ + const res = migrateLegacyConfigForTest({ gateway: { bind: "lan", auth: { mode: "token", token: "tok" },