refactor: move legacy config migration behind doctor

This commit is contained in:
Peter Steinberger
2026-04-05 16:12:04 +01:00
parent 7a3443e9ac
commit 97878b853a
29 changed files with 159 additions and 199 deletions

View File

@@ -16,9 +16,9 @@ function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
@@ -26,13 +26,13 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "bluebubbles"],
message:
"channels.bluebubbles.allowPrivateNetwork is legacy; use channels.bluebubbles.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.bluebubbles.allowPrivateNetwork is legacy; use channels.bluebubbles.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "bluebubbles", "accounts"],
message:
"channels.bluebubbles.accounts.<id>.allowPrivateNetwork is legacy; use channels.bluebubbles.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.bluebubbles.accounts.<id>.allowPrivateNetwork is legacy; use channels.bluebubbles.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];

View File

@@ -17,9 +17,9 @@ function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
@@ -88,13 +88,13 @@ const BLUEBUBBLES_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "bluebubbles"],
message:
"channels.bluebubbles.allowPrivateNetwork is legacy; use channels.bluebubbles.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.bluebubbles.allowPrivateNetwork is legacy; use channels.bluebubbles.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "bluebubbles", "accounts"],
message:
"channels.bluebubbles.accounts.<id>.allowPrivateNetwork is legacy; use channels.bluebubbles.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.bluebubbles.accounts.<id>.allowPrivateNetwork is legacy; use channels.bluebubbles.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];

View File

@@ -286,13 +286,13 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "discord", "voice", "tts"],
message:
"channels.discord.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.voice.tts.providers.<provider> (auto-migrated on load).",
'channels.discord.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.voice.tts.providers.<provider>. Run "openclaw doctor --fix".',
match: hasLegacyTtsProviderKeys,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.accounts.<id>.voice.tts.providers.<provider> (auto-migrated on load).",
'channels.discord.accounts.<id>.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.accounts.<id>.voice.tts.providers.<provider>. Run "openclaw doctor --fix".',
match: hasLegacyDiscordAccountTtsProviderKeys,
},
];

View File

@@ -78,31 +78,31 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "matrix"],
message:
"channels.matrix.allowPrivateNetwork is legacy; use channels.matrix.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.matrix.allowPrivateNetwork is legacy; use channels.matrix.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "matrix", "accounts"],
message:
"channels.matrix.accounts.<id>.allowPrivateNetwork is legacy; use channels.matrix.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.matrix.accounts.<id>.allowPrivateNetwork is legacy; use channels.matrix.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixAccountPrivateNetworkAliases,
},
{
path: ["channels", "matrix", "groups"],
message:
"channels.matrix.groups.<room>.allow is legacy; use channels.matrix.groups.<room>.enabled instead (auto-migrated on load).",
'channels.matrix.groups.<room>.allow is legacy; use channels.matrix.groups.<room>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixRoomMapAllowAliases,
},
{
path: ["channels", "matrix", "rooms"],
message:
"channels.matrix.rooms.<room>.allow is legacy; use channels.matrix.rooms.<room>.enabled instead (auto-migrated on load).",
'channels.matrix.rooms.<room>.allow is legacy; use channels.matrix.rooms.<room>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixRoomMapAllowAliases,
},
{
path: ["channels", "matrix", "accounts"],
message:
"channels.matrix.accounts.<id>.{groups,rooms}.<room>.allow is legacy; use channels.matrix.accounts.<id>.{groups,rooms}.<room>.enabled instead (auto-migrated on load).",
'channels.matrix.accounts.<id>.{groups,rooms}.<room>.allow is legacy; use channels.matrix.accounts.<id>.{groups,rooms}.<room>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixAccountRoomAllowAliases,
},
];

View File

@@ -195,31 +195,31 @@ const MATRIX_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "matrix"],
message:
"channels.matrix.allowPrivateNetwork is legacy; use channels.matrix.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.matrix.allowPrivateNetwork is legacy; use channels.matrix.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "matrix", "accounts"],
message:
"channels.matrix.accounts.<id>.allowPrivateNetwork is legacy; use channels.matrix.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.matrix.accounts.<id>.allowPrivateNetwork is legacy; use channels.matrix.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixAccountPrivateNetworkAliases,
},
{
path: ["channels", "matrix", "groups"],
message:
"channels.matrix.groups.<room>.allow is legacy; use channels.matrix.groups.<room>.enabled instead (auto-migrated on load).",
'channels.matrix.groups.<room>.allow is legacy; use channels.matrix.groups.<room>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixRoomMapAllowAliases,
},
{
path: ["channels", "matrix", "rooms"],
message:
"channels.matrix.rooms.<room>.allow is legacy; use channels.matrix.rooms.<room>.enabled instead (auto-migrated on load).",
'channels.matrix.rooms.<room>.allow is legacy; use channels.matrix.rooms.<room>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixRoomMapAllowAliases,
},
{
path: ["channels", "matrix", "accounts"],
message:
"channels.matrix.accounts.<id>.{groups,rooms}.<room>.allow is legacy; use channels.matrix.accounts.<id>.{groups,rooms}.<room>.enabled instead (auto-migrated on load).",
'channels.matrix.accounts.<id>.{groups,rooms}.<room>.allow is legacy; use channels.matrix.accounts.<id>.{groups,rooms}.<room>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixAccountRoomAllowAliases,
},
];

View File

@@ -16,9 +16,9 @@ function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
@@ -26,13 +26,13 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "mattermost"],
message:
"channels.mattermost.allowPrivateNetwork is legacy; use channels.mattermost.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.mattermost.allowPrivateNetwork is legacy; use channels.mattermost.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "mattermost", "accounts"],
message:
"channels.mattermost.accounts.<id>.allowPrivateNetwork is legacy; use channels.mattermost.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.mattermost.accounts.<id>.allowPrivateNetwork is legacy; use channels.mattermost.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];

View File

@@ -53,9 +53,9 @@ function hasLegacyMattermostAllowPrivateNetworkInAccounts(value: unknown): boole
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
@@ -63,18 +63,20 @@ export const MATTERMOST_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "mattermost"],
message:
"channels.mattermost.allowPrivateNetwork is legacy; use channels.mattermost.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.mattermost.allowPrivateNetwork is legacy; use channels.mattermost.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "mattermost", "accounts"],
message:
"channels.mattermost.accounts.<id>.allowPrivateNetwork is legacy; use channels.mattermost.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.mattermost.accounts.<id>.allowPrivateNetwork is legacy; use channels.mattermost.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyMattermostAllowPrivateNetworkInAccounts,
},
];
export function normalizeMattermostCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
export function normalizeMattermostCompatibilityConfig(
cfg: OpenClawConfig,
): ChannelDoctorConfigMutation {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
const mattermost = isRecord(channels?.mattermost) ? channels.mattermost : null;
if (!mattermost) {

View File

@@ -16,9 +16,9 @@ function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
@@ -26,13 +26,13 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "nextcloud-talk"],
message:
"channels.nextcloud-talk.allowPrivateNetwork is legacy; use channels.nextcloud-talk.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.nextcloud-talk.allowPrivateNetwork is legacy; use channels.nextcloud-talk.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "nextcloud-talk", "accounts"],
message:
"channels.nextcloud-talk.accounts.<id>.allowPrivateNetwork is legacy; use channels.nextcloud-talk.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.nextcloud-talk.accounts.<id>.allowPrivateNetwork is legacy; use channels.nextcloud-talk.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];
@@ -95,8 +95,9 @@ export function normalizeCompatibilityConfig({
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk":
updatedNextcloudTalk as NonNullable<OpenClawConfig["channels"]>["nextcloud-talk"],
"nextcloud-talk": updatedNextcloudTalk as NonNullable<
OpenClawConfig["channels"]
>["nextcloud-talk"],
},
},
changes,

View File

@@ -17,13 +17,15 @@ function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
function normalizeNextcloudTalkCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
function normalizeNextcloudTalkCompatibilityConfig(
cfg: OpenClawConfig,
): ChannelDoctorConfigMutation {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
const nextcloudTalk = isRecord(channels?.["nextcloud-talk"]) ? channels["nextcloud-talk"] : null;
if (!nextcloudTalk) {
@@ -77,8 +79,9 @@ function normalizeNextcloudTalkCompatibilityConfig(cfg: OpenClawConfig): Channel
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk":
updatedNextcloudTalk as NonNullable<OpenClawConfig["channels"]>["nextcloud-talk"],
"nextcloud-talk": updatedNextcloudTalk as NonNullable<
OpenClawConfig["channels"]
>["nextcloud-talk"],
},
},
changes,
@@ -89,13 +92,13 @@ const NEXTCLOUD_TALK_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "nextcloud-talk"],
message:
"channels.nextcloud-talk.allowPrivateNetwork is legacy; use channels.nextcloud-talk.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.nextcloud-talk.allowPrivateNetwork is legacy; use channels.nextcloud-talk.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "nextcloud-talk", "accounts"],
message:
"channels.nextcloud-talk.accounts.<id>.allowPrivateNetwork is legacy; use channels.nextcloud-talk.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.nextcloud-talk.accounts.<id>.allowPrivateNetwork is legacy; use channels.nextcloud-talk.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];

View File

@@ -94,7 +94,7 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "telegram", "groupMentionsOnly"],
message:
'channels.telegram.groupMentionsOnly was removed; use channels.telegram.groups."*".requireMention instead (auto-migrated on load).',
'channels.telegram.groupMentionsOnly was removed; use channels.telegram.groups."*".requireMention instead. Run "openclaw doctor --fix".',
},
{
path: ["channels", "telegram"],

View File

@@ -16,9 +16,9 @@ function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
@@ -26,13 +26,13 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "tlon"],
message:
"channels.tlon.allowPrivateNetwork is legacy; use channels.tlon.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.tlon.allowPrivateNetwork is legacy; use channels.tlon.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "tlon", "accounts"],
message:
"channels.tlon.accounts.<id>.allowPrivateNetwork is legacy; use channels.tlon.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.tlon.accounts.<id>.allowPrivateNetwork is legacy; use channels.tlon.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];

View File

@@ -17,9 +17,9 @@ function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
@@ -88,13 +88,13 @@ const TLON_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "tlon"],
message:
"channels.tlon.allowPrivateNetwork is legacy; use channels.tlon.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.tlon.allowPrivateNetwork is legacy; use channels.tlon.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "tlon", "accounts"],
message:
"channels.tlon.accounts.<id>.allowPrivateNetwork is legacy; use channels.tlon.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
'channels.tlon.accounts.<id>.allowPrivateNetwork is legacy; use channels.tlon.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];

View File

@@ -145,13 +145,13 @@ const ZALOUSER_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "zalouser", "groups"],
message:
"channels.zalouser.groups.<id>.allow is legacy; use channels.zalouser.groups.<id>.enabled instead (auto-migrated on load).",
'channels.zalouser.groups.<id>.allow is legacy; use channels.zalouser.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyZalouserGroupAllowAliases,
},
{
path: ["channels", "zalouser", "accounts"],
message:
"channels.zalouser.accounts.<id>.groups.<id>.allow is legacy; use channels.zalouser.accounts.<id>.groups.<id>.enabled instead (auto-migrated on load).",
'channels.zalouser.accounts.<id>.groups.<id>.allow is legacy; use channels.zalouser.accounts.<id>.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyZalouserAccountGroupAllowAliases,
},
];

View File

@@ -2,7 +2,6 @@ import { Type } from "@sinclair/typebox";
import { isRestartEnabled } from "../../config/commands.js";
import type { OpenClawConfig } from "../../config/config.js";
import { parseConfigJson5, resolveConfigSnapshotHash } from "../../config/io.js";
import { applyLegacyMigrations } from "../../config/legacy.js";
import { applyMergePatch } from "../../config/merge-patch.js";
import { extractDeliveryInfo } from "../../config/sessions.js";
import {
@@ -97,10 +96,8 @@ function assertGatewayConfigMutationAllowed(params: {
: (applyMergePatch(params.currentConfig, parsed, {
mergeObjectArraysById: true,
}) as Record<string, unknown>);
const migratedNextConfig = applyLegacyMigrations(nextConfig).next ?? nextConfig;
const changedProtectedPaths = PROTECTED_GATEWAY_CONFIG_PATHS.filter(
(path) =>
getValueAtPath(params.currentConfig, path) !== getValueAtPath(migratedNextConfig, path),
(path) => getValueAtPath(params.currentConfig, path) !== getValueAtPath(nextConfig, path),
);
if (changedProtectedPaths.length === 0) {
return;

View File

@@ -1,24 +1,6 @@
import type { LegacyConfigRule } from "../../config/legacy.shared.js";
import type { OpenClawConfig } from "../../config/types.js";
import { listBootstrapChannelPlugins } from "./bootstrap-registry.js";
export function collectChannelLegacyConfigRules(): LegacyConfigRule[] {
return listBootstrapChannelPlugins().flatMap((plugin) => plugin.doctor?.legacyConfigRules ?? []);
}
export function applyChannelDoctorCompatibilityMigrations(cfg: Record<string, unknown>): {
next: Record<string, unknown>;
changes: string[];
} {
let nextCfg = cfg as OpenClawConfig & Record<string, unknown>;
const changes: string[] = [];
for (const plugin of listBootstrapChannelPlugins()) {
const mutation = plugin.doctor?.normalizeCompatibilityConfig?.({ cfg: nextCfg });
if (!mutation || mutation.changes.length === 0) {
continue;
}
nextCfg = mutation.config as OpenClawConfig & Record<string, unknown>;
changes.push(...mutation.changes);
}
return { next: nextCfg, changes };
}

View File

@@ -0,0 +1,19 @@
import { listBootstrapChannelPlugins } from "../../../channels/plugins/bootstrap-registry.js";
import type { OpenClawConfig } from "../../../config/types.js";
export function applyChannelDoctorCompatibilityMigrations(cfg: Record<string, unknown>): {
next: Record<string, unknown>;
changes: string[];
} {
let nextCfg = cfg as OpenClawConfig & Record<string, unknown>;
const changes: string[] = [];
for (const plugin of listBootstrapChannelPlugins()) {
const mutation = plugin.doctor?.normalizeCompatibilityConfig?.({ cfg: nextCfg });
if (!mutation || mutation.changes.length === 0) {
continue;
}
nextCfg = mutation.config as OpenClawConfig & Record<string, unknown>;
changes.push(...mutation.changes);
}
return { next: nextCfg, changes };
}

View File

@@ -1,8 +1,8 @@
import { migrateLegacyConfig } from "../../../config/config.js";
import { formatConfigIssueLines } from "../../../config/issue-format.js";
import { stripUnknownConfigKeys } from "../../doctor-config-analysis.js";
import type { DoctorConfigPreflightResult } from "../../doctor-config-preflight.js";
import type { DoctorConfigMutationState } from "./config-mutation-state.js";
import { migrateLegacyConfig } from "./legacy-config-migrate.js";
export function applyLegacyCompatibilityStep(params: {
snapshot: DoctorConfigPreflightResult["snapshot"];

View File

@@ -0,0 +1,40 @@
import { LEGACY_CONFIG_MIGRATIONS } from "../../../config/legacy.migrations.js";
import type { OpenClawConfig } from "../../../config/types.js";
import { validateConfigObjectWithPlugins } from "../../../config/validation.js";
import { applyChannelDoctorCompatibilityMigrations } from "./channel-legacy-config-migrate.js";
export function applyLegacyDoctorMigrations(raw: unknown): {
next: Record<string, unknown> | null;
changes: string[];
} {
if (!raw || typeof raw !== "object") {
return { next: null, changes: [] };
}
const next = structuredClone(raw) as Record<string, unknown>;
const changes: string[] = [];
for (const migration of LEGACY_CONFIG_MIGRATIONS) {
migration.apply(next, changes);
}
const compat = applyChannelDoctorCompatibilityMigrations(next);
changes.push(...compat.changes);
if (changes.length === 0) {
return { next: null, changes: [] };
}
return { next: compat.next, changes };
}
export function migrateLegacyConfig(raw: unknown): {
config: OpenClawConfig | null;
changes: string[];
} {
const { next, changes } = applyLegacyDoctorMigrations(raw);
if (!next) {
return { config: null, changes: [] };
}
const validated = validateConfigObjectWithPlugins(next);
if (!validated.ok) {
changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
return { config: null, changes };
}
return { config: validated.config, changes };
}

View File

@@ -22,7 +22,6 @@ export {
writeConfigFile,
} from "./io.js";
export type { ConfigWriteNotification } from "./io.js";
export { migrateLegacyConfig } from "./legacy-migrate.js";
export { ConfigMutationConflictError, mutateConfigFile, replaceConfigFile } from "./mutate.js";
export * from "./paths.js";
export * from "./runtime-overrides.js";

View File

@@ -31,7 +31,6 @@ import {
readConfigIncludeFileWithGuards,
resolveConfigIncludes,
} from "./includes.js";
import { migrateLegacyConfig } from "./legacy-migrate.js";
import { findLegacyConfigIssues } from "./legacy.js";
import {
asResolvedSourceConfig,
@@ -1646,14 +1645,7 @@ function resolveLegacyConfigForRead(
sourceRaw,
listPluginDoctorLegacyConfigRules(),
);
if (sourceLegacyIssues.length === 0) {
return { effectiveConfigRaw: resolvedConfigRaw, sourceLegacyIssues };
}
const migrated = migrateLegacyConfig(resolvedConfigRaw);
return {
effectiveConfigRaw: migrated.config ?? resolvedConfigRaw,
sourceLegacyIssues,
};
return { effectiveConfigRaw: resolvedConfigRaw, sourceLegacyIssues };
}
type ReadConfigFileSnapshotInternalResult = {

View File

@@ -1,19 +0,0 @@
import { applyLegacyMigrations } from "./legacy.js";
import type { OpenClawConfig } from "./types.js";
import { validateConfigObjectWithPlugins } from "./validation.js";
export function migrateLegacyConfig(raw: unknown): {
config: OpenClawConfig | null;
changes: string[];
} {
const { next, changes } = applyLegacyMigrations(raw);
if (!next) {
return { config: null, changes: [] };
}
const validated = validateConfigObjectWithPlugins(next);
if (!validated.ok) {
changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
return { config: null, changes };
}
return { config: validated.config, changes };
}

View File

@@ -328,13 +328,13 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [
{
path: ["session", "threadBindings"],
message:
"session.threadBindings.ttlHours was renamed to session.threadBindings.idleHours (auto-migrated on load).",
'session.threadBindings.ttlHours was renamed to session.threadBindings.idleHours. Run "openclaw doctor --fix".',
match: (value) => hasLegacyThreadBindingTtl(value),
},
{
path: ["channels"],
message:
"channels.<id>.threadBindings.ttlHours was renamed to channels.<id>.threadBindings.idleHours (auto-migrated on load).",
'channels.<id>.threadBindings.ttlHours was renamed to channels.<id>.threadBindings.idleHours. Run "openclaw doctor --fix".',
match: (value) => hasLegacyThreadBindingTtlInAnyChannel(value),
},
];
@@ -343,37 +343,37 @@ const CHANNEL_STREAMING_RULES: LegacyConfigRule[] = [
{
path: ["channels", "telegram"],
message:
"channels.telegram.streamMode is legacy; use channels.telegram.streaming instead (auto-migrated on load).",
'channels.telegram.streamMode is legacy; use channels.telegram.streaming instead. Run "openclaw doctor --fix".',
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).",
'channels.telegram.accounts.<id>.streamMode is legacy; use channels.telegram.accounts.<id>.streaming instead. Run "openclaw doctor --fix".',
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).",
'channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming with enum values instead. Run "openclaw doctor --fix".',
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).",
'channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming with enum values instead. Run "openclaw doctor --fix".',
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).",
'channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming with enum values instead. Run "openclaw doctor --fix".',
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).",
'channels.slack.accounts.<id>.streamMode and boolean channels.slack.accounts.<id>.streaming are legacy; use channels.slack.accounts.<id>.streaming with enum values instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyKeysInAccounts(value, hasLegacySlackStreamingKeys),
},
];
@@ -382,37 +382,37 @@ 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).",
'channels.slack.channels.<id>.allow is legacy; use channels.slack.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
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).",
'channels.slack.accounts.<id>.channels.<id>.allow is legacy; use channels.slack.accounts.<id>.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
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).",
'channels.googlechat.groups.<id>.allow is legacy; use channels.googlechat.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
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).",
'channels.googlechat.accounts.<id>.groups.<id>.allow is legacy; use channels.googlechat.accounts.<id>.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
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).",
'channels.discord.guilds.<id>.channels.<id>.allow is legacy; use channels.discord.guilds.<id>.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
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).",
'channels.discord.accounts.<id>.guilds.<id>.channels.<id>.allow is legacy; use channels.discord.accounts.<id>.guilds.<id>.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyKeysInAccounts(value, hasLegacyDiscordGuildChannelAllowAlias),
},
];

View File

@@ -219,13 +219,13 @@ function migrateLegacyTtsConfig(
const MEMORY_SEARCH_RULE: LegacyConfigRule = {
path: ["memorySearch"],
message:
"top-level memorySearch was moved; use agents.defaults.memorySearch instead (auto-migrated on load).",
'top-level memorySearch was moved; use agents.defaults.memorySearch instead. Run "openclaw doctor --fix".',
};
const GATEWAY_BIND_RULE: LegacyConfigRule = {
path: ["gateway", "bind"],
message:
"gateway.bind host aliases (for example 0.0.0.0/localhost) are legacy; use bind modes (lan/loopback/custom/tailnet/auto) instead (auto-migrated on load).",
'gateway.bind host aliases (for example 0.0.0.0/localhost) are legacy; use bind modes (lan/loopback/custom/tailnet/auto) instead. Run "openclaw doctor --fix".',
match: (value) => isLegacyGatewayBindHostAlias(value),
requireSourceLiteral: true,
};
@@ -239,20 +239,20 @@ const HEARTBEAT_RULE: LegacyConfigRule = {
const X_SEARCH_RULE: LegacyConfigRule = {
path: ["tools", "web", "x_search", "apiKey"],
message:
"tools.web.x_search.apiKey moved to the xAI plugin; use plugins.entries.xai.config.webSearch.apiKey instead (auto-migrated on load).",
'tools.web.x_search.apiKey moved to the xAI plugin; use plugins.entries.xai.config.webSearch.apiKey instead. Run "openclaw doctor --fix".',
};
const LEGACY_TTS_RULES: LegacyConfigRule[] = [
{
path: ["messages", "tts"],
message:
"messages.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use messages.tts.providers.<provider> (auto-migrated on load).",
'messages.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use messages.tts.providers.<provider>. Run "openclaw doctor --fix".',
match: (value) => hasLegacyTtsProviderKeys(value),
},
{
path: ["plugins", "entries"],
message:
"plugins.entries.voice-call.config.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use plugins.entries.voice-call.config.tts.providers.<provider> (auto-migrated on load).",
'plugins.entries.voice-call.config.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use plugins.entries.voice-call.config.tts.providers.<provider>. Run "openclaw doctor --fix".',
match: (value) => hasLegacyPluginEntryTtsProviderKeys(value),
},
];
@@ -261,13 +261,13 @@ const LEGACY_SANDBOX_SCOPE_RULES: LegacyConfigRule[] = [
{
path: ["agents", "defaults", "sandbox"],
message:
"agents.defaults.sandbox.perSession is legacy; use agents.defaults.sandbox.scope instead (auto-migrated on load).",
'agents.defaults.sandbox.perSession is legacy; use agents.defaults.sandbox.scope instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacySandboxPerSession(value),
},
{
path: ["agents", "list"],
message:
"agents.list[].sandbox.perSession is legacy; use agents.list[].sandbox.scope instead (auto-migrated on load).",
'agents.list[].sandbox.perSession is legacy; use agents.list[].sandbox.scope instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyAgentListSandboxPerSession(value),
},
];
@@ -346,8 +346,7 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [
// to seed this for new installs, but existing bind=lan/bind=custom installs that upgrade
// crash-loop immediately on next startup with no recovery path (issue #29385).
//
// This migration runs on every gateway start via migrateLegacyConfig → applyLegacyMigrations
// and writes the seeded origins to disk before the startup guard fires, preventing the loop.
// Doctor-only migration path. Runtime now stops and points users to doctor before startup.
id: "gateway.controlUi.allowedOrigins-seed-for-non-loopback",
describe: "Seed gateway.controlUi.allowedOrigins for existing non-loopback gateway installs",
apply: (raw, changes) => {

View File

@@ -12,7 +12,7 @@ const LEGACY_WEB_SEARCH_RULES: LegacyConfigRule[] = [
{
path: ["tools", "web", "search"],
message:
"tools.web.search provider-owned config moved to plugins.entries.<plugin>.config.webSearch (auto-migrated on load).",
'tools.web.search provider-owned config moved to plugins.entries.<plugin>.config.webSearch. Run "openclaw doctor --fix".',
match: (_value, root) => listLegacyWebSearchConfigPaths(root).length > 0,
requireSourceLiteral: true,
},

View File

@@ -1,8 +1,4 @@
import {
applyChannelDoctorCompatibilityMigrations,
collectChannelLegacyConfigRules,
} from "../channels/plugins/legacy-config.js";
import { LEGACY_CONFIG_MIGRATIONS } from "./legacy.migrations.js";
import { collectChannelLegacyConfigRules } from "../channels/plugins/legacy-config.js";
import { LEGACY_CONFIG_RULES } from "./legacy.rules.js";
import type { LegacyConfigRule } from "./legacy.shared.js";
import type { LegacyConfigIssue } from "./types.js";
@@ -51,23 +47,3 @@ export function findLegacyConfigIssues(
}
return issues;
}
export function applyLegacyMigrations(raw: unknown): {
next: Record<string, unknown> | null;
changes: string[];
} {
if (!raw || typeof raw !== "object") {
return { next: null, changes: [] };
}
const next = structuredClone(raw) as Record<string, unknown>;
const changes: string[] = [];
for (const migration of LEGACY_CONFIG_MIGRATIONS) {
migration.apply(next, changes);
}
const compat = applyChannelDoctorCompatibilityMigrations(next);
changes.push(...compat.changes);
if (changes.length === 0) {
return { next: null, changes: [] };
}
return { next: compat.next, changes };
}

View File

@@ -28,7 +28,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 { applyLegacyMigrations, findLegacyConfigIssues } from "./legacy.js";
import { findLegacyConfigIssues } from "./legacy.js";
import { materializeRuntimeConfig } from "./materialize.js";
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
import { coerceSecretRef } from "./types.secrets.js";
@@ -546,13 +546,7 @@ function validateConfigObjectWithPluginsBase(
raw: unknown,
opts: { applyDefaults: boolean; env?: NodeJS.ProcessEnv },
): ValidateConfigWithPluginsResult {
// 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);
const base = opts.applyDefaults ? validateConfigObject(raw) : validateConfigObjectRaw(raw);
if (!base.ok) {
return { ok: false, issues: base.issues, warnings: [] };
}

View File

@@ -10,7 +10,6 @@ import {
writeConfigFile,
} from "../../config/config.js";
import { formatConfigIssueLines } from "../../config/issue-format.js";
import { applyLegacyMigrations } from "../../config/legacy.js";
import { applyMergePatch } from "../../config/merge-patch.js";
import {
redactConfigObject,
@@ -489,9 +488,7 @@ export const configHandlers: GatewayRequestHandlers = {
);
return;
}
const migrated = applyLegacyMigrations(restoredMerge.result);
const resolved = migrated.next ?? restoredMerge.result;
const validated = validateConfigObjectWithPlugins(resolved);
const validated = validateConfigObjectWithPlugins(restoredMerge.result);
if (!validated.ok) {
respond(
false,

View File

@@ -17,7 +17,6 @@ import {
getRuntimeConfig,
isNixMode,
loadConfig,
migrateLegacyConfig,
registerConfigWriteListener,
readConfigFileSnapshot,
writeConfigFile,
@@ -263,7 +262,7 @@ function assertValidGatewayStartupConfigSnapshot(
? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n")
: "Unknown validation issue.";
const doctorHint = options.includeDoctorHint
? `\nRun "${formatCliCommand("openclaw doctor")}" to repair, then retry.`
? `\nRun "${formatCliCommand("openclaw doctor --fix")}" to repair, then retry.`
: "";
throw new Error(`Invalid config at ${snapshot.path}.\n${issues}${doctorHint}`);
}
@@ -418,24 +417,7 @@ export async function startGatewayServer(
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
);
}
const { config: migrated, changes } = migrateLegacyConfig(configSnapshot.parsed);
if (!migrated) {
log.warn(
"gateway: legacy config entries detected but no auto-migration changes were produced; continuing with validation.",
);
} else {
await writeConfigFile(migrated);
if (changes.length > 0) {
log.info(
`gateway: migrated legacy config entries:\n${changes
.map((entry) => `- ${entry}`)
.join("\n")}`,
);
}
}
}
configSnapshot = await readConfigFileSnapshot();
if (configSnapshot.exists) {
assertValidGatewayStartupConfigSnapshot(configSnapshot, { includeDoctorHint: true });
}

View File

@@ -17,8 +17,6 @@ import {
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "../config/config.js";
import { migrateLegacyConfig } from "../config/legacy-migrate.js";
import { migrateLegacyXSearchConfig } from "../config/legacy-x-search.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { PluginOrigin } from "../plugins/types.js";
import { resolveUserPath } from "../utils.js";
@@ -175,10 +173,8 @@ export async function prepareSecretsRuntimeSnapshot(params: {
loadablePluginOrigins?: ReadonlyMap<string, PluginOrigin>;
}): Promise<PreparedSecretsRuntimeSnapshot> {
const runtimeEnv = mergeSecretsRuntimeEnv(params.env);
const migrated = migrateLegacyConfig(params.config);
const migratedConfig = migrated.config ?? migrateLegacyXSearchConfig(params.config).config;
const sourceConfig = structuredClone(migratedConfig);
const resolvedConfig = structuredClone(migratedConfig);
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(params.config);
const loadablePluginOrigins =
params.loadablePluginOrigins ??
resolveLoadablePluginOrigins({ config: sourceConfig, env: runtimeEnv });