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): { agentHeartbeat: Record | null; channelHeartbeat: Record | null; } { const agentHeartbeat: Record = {}; const channelHeartbeat: Record = {}; 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; rootKey: "agents" | "channels"; fieldKey: string; legacyValue: Record; 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): Record { const providers = getRecord(tts.providers) ?? {}; tts.providers = providers; return providers; } function mergeLegacyTtsProviderConfig( tts: Record, 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 | 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; }, }), ];