openclaw/src/config/legacy.migrations.runtime.ts

382 lines
12 KiB
TypeScript

import {
buildDefaultControlUiAllowedOrigins,
hasConfiguredControlUiAllowedOrigins,
isGatewayNonLoopbackBindMode,
resolveGatewayPortWithDefault,
} from "./gateway-control-ui-origins.js";
import {
defineLegacyConfigMigration,
ensureRecord,
getRecord,
mergeMissing,
type LegacyConfigMigrationSpec,
type LegacyConfigRule,
} from "./legacy.shared.js";
import { DEFAULT_GATEWAY_PORT } from "./paths.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
const AGENT_HEARTBEAT_KEYS = new Set([
"every",
"activeHours",
"model",
"session",
"includeReasoning",
"target",
"directPolicy",
"to",
"accountId",
"prompt",
"ackMaxChars",
"suppressToolErrorWarnings",
"lightContext",
"isolatedSession",
]);
const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]);
function isLegacyGatewayBindHostAlias(value: unknown): boolean {
if (typeof value !== "string") {
return false;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return false;
}
if (
normalized === "auto" ||
normalized === "loopback" ||
normalized === "lan" ||
normalized === "tailnet" ||
normalized === "custom"
) {
return false;
}
return (
normalized === "0.0.0.0" ||
normalized === "::" ||
normalized === "[::]" ||
normalized === "*" ||
normalized === "127.0.0.1" ||
normalized === "localhost" ||
normalized === "::1" ||
normalized === "[::1]"
);
}
function escapeControlForLog(value: string): string {
return value.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
}
function splitLegacyHeartbeat(legacyHeartbeat: Record<string, unknown>): {
agentHeartbeat: Record<string, unknown> | null;
channelHeartbeat: Record<string, unknown> | null;
} {
const agentHeartbeat: Record<string, unknown> = {};
const channelHeartbeat: Record<string, unknown> = {};
for (const [key, value] of Object.entries(legacyHeartbeat)) {
if (isBlockedObjectKey(key)) {
continue;
}
if (CHANNEL_HEARTBEAT_KEYS.has(key)) {
channelHeartbeat[key] = value;
continue;
}
if (AGENT_HEARTBEAT_KEYS.has(key)) {
agentHeartbeat[key] = value;
continue;
}
// Preserve unknown fields under the agent heartbeat namespace so validation
// still surfaces unsupported keys instead of silently dropping user input.
agentHeartbeat[key] = value;
}
return {
agentHeartbeat: Object.keys(agentHeartbeat).length > 0 ? agentHeartbeat : null,
channelHeartbeat: Object.keys(channelHeartbeat).length > 0 ? channelHeartbeat : null,
};
}
function mergeLegacyIntoDefaults(params: {
raw: Record<string, unknown>;
rootKey: "agents" | "channels";
fieldKey: string;
legacyValue: Record<string, unknown>;
changes: string[];
movedMessage: string;
mergedMessage: string;
}) {
const root = ensureRecord(params.raw, params.rootKey);
const defaults = ensureRecord(root, "defaults");
const existing = getRecord(defaults[params.fieldKey]);
if (!existing) {
defaults[params.fieldKey] = params.legacyValue;
params.changes.push(params.movedMessage);
} else {
// defaults stays authoritative; legacy top-level config only fills gaps.
const merged = structuredClone(existing);
mergeMissing(merged, params.legacyValue);
defaults[params.fieldKey] = merged;
params.changes.push(params.mergedMessage);
}
root.defaults = defaults;
params.raw[params.rootKey] = root;
}
function getOrCreateTtsProviders(tts: Record<string, unknown>): Record<string, unknown> {
const providers = getRecord(tts.providers) ?? {};
tts.providers = providers;
return providers;
}
function mergeLegacyTtsProviderConfig(
tts: Record<string, unknown>,
legacyKey: string,
providerId: string,
): boolean {
const legacyValue = getRecord(tts[legacyKey]);
if (!legacyValue) {
return false;
}
const providers = getOrCreateTtsProviders(tts);
const existing = getRecord(providers[providerId]) ?? {};
const merged = structuredClone(existing);
mergeMissing(merged, legacyValue);
providers[providerId] = merged;
delete tts[legacyKey];
return true;
}
function migrateLegacyTtsConfig(
tts: Record<string, unknown> | null | undefined,
pathLabel: string,
changes: string[],
): void {
if (!tts) {
return;
}
const movedOpenAI = mergeLegacyTtsProviderConfig(tts, "openai", "openai");
const movedElevenLabs = mergeLegacyTtsProviderConfig(tts, "elevenlabs", "elevenlabs");
const movedMicrosoft = mergeLegacyTtsProviderConfig(tts, "microsoft", "microsoft");
const movedEdge = mergeLegacyTtsProviderConfig(tts, "edge", "microsoft");
if (movedOpenAI) {
changes.push(`Moved ${pathLabel}.openai → ${pathLabel}.providers.openai.`);
}
if (movedElevenLabs) {
changes.push(`Moved ${pathLabel}.elevenlabs → ${pathLabel}.providers.elevenlabs.`);
}
if (movedMicrosoft) {
changes.push(`Moved ${pathLabel}.microsoft → ${pathLabel}.providers.microsoft.`);
}
if (movedEdge) {
changes.push(`Moved ${pathLabel}.edge → ${pathLabel}.providers.microsoft.`);
}
}
const MEMORY_SEARCH_RULE: LegacyConfigRule = {
path: ["memorySearch"],
message:
"top-level memorySearch was moved; use agents.defaults.memorySearch instead (auto-migrated on load).",
};
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).",
match: (value) => isLegacyGatewayBindHostAlias(value),
requireSourceLiteral: true,
};
const HEARTBEAT_RULE: LegacyConfigRule = {
path: ["heartbeat"],
message:
"top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
};
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [
defineLegacyConfigMigration({
// v2026.2.26 added a startup guard requiring gateway.controlUi.allowedOrigins (or the
// host-header fallback flag) for any non-loopback bind. The setup wizard was updated
// 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.
id: "gateway.controlUi.allowedOrigins-seed-for-non-loopback",
describe: "Seed gateway.controlUi.allowedOrigins for existing non-loopback gateway installs",
apply: (raw, changes) => {
const gateway = getRecord(raw.gateway);
if (!gateway) {
return;
}
const bind = gateway.bind;
if (!isGatewayNonLoopbackBindMode(bind)) {
return;
}
const controlUi = getRecord(gateway.controlUi) ?? {};
if (
hasConfiguredControlUiAllowedOrigins({
allowedOrigins: controlUi.allowedOrigins,
dangerouslyAllowHostHeaderOriginFallback:
controlUi.dangerouslyAllowHostHeaderOriginFallback,
})
) {
return;
}
const port = resolveGatewayPortWithDefault(gateway.port, DEFAULT_GATEWAY_PORT);
const origins = buildDefaultControlUiAllowedOrigins({
port,
bind,
customBindHost:
typeof gateway.customBindHost === "string" ? gateway.customBindHost : undefined,
});
gateway.controlUi = { ...controlUi, allowedOrigins: origins };
raw.gateway = gateway;
changes.push(
`Seeded gateway.controlUi.allowedOrigins ${JSON.stringify(origins)} for bind=${String(bind)}. ` +
"Required since v2026.2.26. Add other machine origins to gateway.controlUi.allowedOrigins if needed.",
);
},
}),
defineLegacyConfigMigration({
id: "memorySearch->agents.defaults.memorySearch",
describe: "Move top-level memorySearch to agents.defaults.memorySearch",
legacyRules: [MEMORY_SEARCH_RULE],
apply: (raw, changes) => {
const legacyMemorySearch = getRecord(raw.memorySearch);
if (!legacyMemorySearch) {
return;
}
mergeLegacyIntoDefaults({
raw,
rootKey: "agents",
fieldKey: "memorySearch",
legacyValue: legacyMemorySearch,
changes,
movedMessage: "Moved memorySearch → agents.defaults.memorySearch.",
mergedMessage:
"Merged memorySearch → agents.defaults.memorySearch (filled missing fields from legacy; kept explicit agents.defaults values).",
});
delete raw.memorySearch;
},
}),
defineLegacyConfigMigration({
id: "gateway.bind.host-alias->bind-mode",
describe: "Normalize gateway.bind host aliases to supported bind modes",
legacyRules: [GATEWAY_BIND_RULE],
apply: (raw, changes) => {
const gateway = getRecord(raw.gateway);
if (!gateway) {
return;
}
const bindRaw = gateway.bind;
if (typeof bindRaw !== "string") {
return;
}
const normalized = bindRaw.trim().toLowerCase();
let mapped: "lan" | "loopback" | undefined;
if (
normalized === "0.0.0.0" ||
normalized === "::" ||
normalized === "[::]" ||
normalized === "*"
) {
mapped = "lan";
} else if (
normalized === "127.0.0.1" ||
normalized === "localhost" ||
normalized === "::1" ||
normalized === "[::1]"
) {
mapped = "loopback";
}
if (!mapped || normalized === mapped) {
return;
}
gateway.bind = mapped;
raw.gateway = gateway;
changes.push(`Normalized gateway.bind "${escapeControlForLog(bindRaw)}" → "${mapped}".`);
},
}),
defineLegacyConfigMigration({
id: "tts.providers-generic-shape",
describe: "Move legacy bundled TTS config keys into messages.tts.providers",
apply: (raw, changes) => {
const messages = getRecord(raw.messages);
migrateLegacyTtsConfig(getRecord(messages?.tts), "messages.tts", changes);
const channels = getRecord(raw.channels);
const discord = getRecord(channels?.discord);
const discordVoice = getRecord(discord?.voice);
migrateLegacyTtsConfig(getRecord(discordVoice?.tts), "channels.discord.voice.tts", changes);
const discordAccounts = getRecord(discord?.accounts);
if (!discordAccounts) {
return;
}
for (const [accountId, accountValue] of Object.entries(discordAccounts)) {
if (isBlockedObjectKey(accountId)) {
continue;
}
const account = getRecord(accountValue);
const voice = getRecord(account?.voice);
migrateLegacyTtsConfig(
getRecord(voice?.tts),
`channels.discord.accounts.${accountId}.voice.tts`,
changes,
);
}
},
}),
defineLegacyConfigMigration({
id: "heartbeat->agents.defaults.heartbeat",
describe: "Move top-level heartbeat to agents.defaults.heartbeat/channels.defaults.heartbeat",
legacyRules: [HEARTBEAT_RULE],
apply: (raw, changes) => {
const legacyHeartbeat = getRecord(raw.heartbeat);
if (!legacyHeartbeat) {
return;
}
const { agentHeartbeat, channelHeartbeat } = splitLegacyHeartbeat(legacyHeartbeat);
if (agentHeartbeat) {
mergeLegacyIntoDefaults({
raw,
rootKey: "agents",
fieldKey: "heartbeat",
legacyValue: agentHeartbeat,
changes,
movedMessage: "Moved heartbeat → agents.defaults.heartbeat.",
mergedMessage:
"Merged heartbeat → agents.defaults.heartbeat (filled missing fields from legacy; kept explicit agents.defaults values).",
});
}
if (channelHeartbeat) {
mergeLegacyIntoDefaults({
raw,
rootKey: "channels",
fieldKey: "heartbeat",
legacyValue: channelHeartbeat,
changes,
movedMessage: "Moved heartbeat visibility → channels.defaults.heartbeat.",
mergedMessage:
"Merged heartbeat visibility → channels.defaults.heartbeat (filled missing fields from legacy; kept explicit channels.defaults values).",
});
}
if (!agentHeartbeat && !channelHeartbeat) {
changes.push("Removed empty top-level heartbeat.");
}
delete raw.heartbeat;
},
}),
];