From 5323652cfd274b7dde39209daeefeccfe9312dd9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 14 Jan 2026 05:39:51 +0000 Subject: [PATCH] refactor(config): split legacy handling --- ...tection.accepts-imessage-dmpolicy.test.ts} | 0 ...tection.rejects-routing-allowfrom.test.ts} | 0 src/config/legacy.migrations.part-1.ts | 301 +++++ src/config/legacy.migrations.part-2.ts | 413 +++++++ src/config/legacy.migrations.part-3.ts | 143 +++ src/config/legacy.migrations.ts | 9 + src/config/legacy.rules.ts | 134 ++ src/config/legacy.shared.ts | 119 ++ src/config/legacy.ts | 1079 +---------------- 9 files changed, 1121 insertions(+), 1077 deletions(-) rename src/config/{config.legacy-config-detection.part-2.test.ts => config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts} (100%) rename src/config/{config.legacy-config-detection.part-1.test.ts => config.legacy-config-detection.rejects-routing-allowfrom.test.ts} (100%) create mode 100644 src/config/legacy.migrations.part-1.ts create mode 100644 src/config/legacy.migrations.part-2.ts create mode 100644 src/config/legacy.migrations.part-3.ts create mode 100644 src/config/legacy.migrations.ts create mode 100644 src/config/legacy.rules.ts create mode 100644 src/config/legacy.shared.ts diff --git a/src/config/config.legacy-config-detection.part-2.test.ts b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts similarity index 100% rename from src/config/config.legacy-config-detection.part-2.test.ts rename to src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts diff --git a/src/config/config.legacy-config-detection.part-1.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts similarity index 100% rename from src/config/config.legacy-config-detection.part-1.test.ts rename to src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts diff --git a/src/config/legacy.migrations.part-1.ts b/src/config/legacy.migrations.part-1.ts new file mode 100644 index 000000000..95a8cad41 --- /dev/null +++ b/src/config/legacy.migrations.part-1.ts @@ -0,0 +1,301 @@ +import { + ensureRecord, + getRecord, + isRecord, + type LegacyConfigMigration, + mergeMissing, +} from "./legacy.shared.js"; + +export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ + { + id: "bindings.match.provider->bindings.match.channel", + describe: "Move bindings[].match.provider to bindings[].match.channel", + apply: (raw, changes) => { + const bindings = Array.isArray(raw.bindings) ? raw.bindings : null; + if (!bindings) return; + + let touched = false; + for (const entry of bindings) { + if (!isRecord(entry)) continue; + const match = getRecord(entry.match); + if (!match) continue; + if (typeof match.channel === "string" && match.channel.trim()) continue; + const provider = + typeof match.provider === "string" ? match.provider.trim() : ""; + if (!provider) continue; + match.channel = provider; + delete match.provider; + entry.match = match; + touched = true; + } + + if (touched) { + raw.bindings = bindings; + changes.push( + "Moved bindings[].match.provider → bindings[].match.channel.", + ); + } + }, + }, + { + id: "session.sendPolicy.rules.match.provider->match.channel", + describe: "Move session.sendPolicy.rules[].match.provider to match.channel", + apply: (raw, changes) => { + const session = getRecord(raw.session); + if (!session) return; + const sendPolicy = getRecord(session.sendPolicy); + if (!sendPolicy) return; + const rules = Array.isArray(sendPolicy.rules) ? sendPolicy.rules : null; + if (!rules) return; + + let touched = false; + for (const rule of rules) { + if (!isRecord(rule)) continue; + const match = getRecord(rule.match); + if (!match) continue; + if (typeof match.channel === "string" && match.channel.trim()) continue; + const provider = + typeof match.provider === "string" ? match.provider.trim() : ""; + if (!provider) continue; + match.channel = provider; + delete match.provider; + rule.match = match; + touched = true; + } + + if (touched) { + sendPolicy.rules = rules; + session.sendPolicy = sendPolicy; + raw.session = session; + changes.push( + "Moved session.sendPolicy.rules[].match.provider → match.channel.", + ); + } + }, + }, + { + id: "messages.queue.byProvider->byChannel", + describe: "Move messages.queue.byProvider to messages.queue.byChannel", + apply: (raw, changes) => { + const messages = getRecord(raw.messages); + if (!messages) return; + const queue = getRecord(messages.queue); + if (!queue) return; + if (queue.byProvider === undefined) return; + if (queue.byChannel === undefined) { + queue.byChannel = queue.byProvider; + changes.push( + "Moved messages.queue.byProvider → messages.queue.byChannel.", + ); + } else { + changes.push( + "Removed messages.queue.byProvider (messages.queue.byChannel already set).", + ); + } + delete queue.byProvider; + messages.queue = queue; + raw.messages = messages; + }, + }, + { + id: "providers->channels", + describe: "Move provider config sections to channels.*", + apply: (raw, changes) => { + const legacyKeys = [ + "whatsapp", + "telegram", + "discord", + "slack", + "signal", + "imessage", + "msteams", + ]; + const legacyEntries = legacyKeys.filter((key) => isRecord(raw[key])); + if (legacyEntries.length === 0) return; + + const channels = ensureRecord(raw, "channels"); + for (const key of legacyEntries) { + const legacy = getRecord(raw[key]); + if (!legacy) continue; + const channelEntry = ensureRecord(channels, key); + const hadEntries = Object.keys(channelEntry).length > 0; + mergeMissing(channelEntry, legacy); + channels[key] = channelEntry; + delete raw[key]; + changes.push( + hadEntries + ? `Merged ${key} → channels.${key}.` + : `Moved ${key} → channels.${key}.`, + ); + } + raw.channels = channels; + }, + }, + { + id: "routing.allowFrom->channels.whatsapp.allowFrom", + describe: "Move routing.allowFrom to channels.whatsapp.allowFrom", + apply: (raw, changes) => { + const routing = raw.routing; + if (!routing || typeof routing !== "object") return; + const allowFrom = (routing as Record).allowFrom; + if (allowFrom === undefined) return; + + const channels = ensureRecord(raw, "channels"); + const whatsapp = + channels.whatsapp && typeof channels.whatsapp === "object" + ? (channels.whatsapp as Record) + : {}; + + if (whatsapp.allowFrom === undefined) { + whatsapp.allowFrom = allowFrom; + changes.push("Moved routing.allowFrom → channels.whatsapp.allowFrom."); + } else { + changes.push( + "Removed routing.allowFrom (channels.whatsapp.allowFrom already set).", + ); + } + + delete (routing as Record).allowFrom; + if (Object.keys(routing as Record).length === 0) { + delete raw.routing; + } + channels.whatsapp = whatsapp; + raw.channels = channels; + }, + }, + { + id: "routing.groupChat.requireMention->groups.*.requireMention", + describe: + "Move routing.groupChat.requireMention to channels.whatsapp/telegram/imessage groups", + apply: (raw, changes) => { + const routing = raw.routing; + if (!routing || typeof routing !== "object") return; + const groupChat = + (routing as Record).groupChat && + typeof (routing as Record).groupChat === "object" + ? ((routing as Record).groupChat as Record< + string, + unknown + >) + : null; + if (!groupChat) return; + const requireMention = groupChat.requireMention; + if (requireMention === undefined) return; + + const channels = ensureRecord(raw, "channels"); + const applyTo = (key: "whatsapp" | "telegram" | "imessage") => { + const section = + channels[key] && typeof channels[key] === "object" + ? (channels[key] as Record) + : {}; + const groups = + section.groups && typeof section.groups === "object" + ? (section.groups as Record) + : {}; + const defaultKey = "*"; + const entry = + groups[defaultKey] && typeof groups[defaultKey] === "object" + ? (groups[defaultKey] as Record) + : {}; + if (entry.requireMention === undefined) { + entry.requireMention = requireMention; + groups[defaultKey] = entry; + section.groups = groups; + channels[key] = section; + changes.push( + `Moved routing.groupChat.requireMention → channels.${key}.groups."*".requireMention.`, + ); + } else { + changes.push( + `Removed routing.groupChat.requireMention (channels.${key}.groups."*" already set).`, + ); + } + }; + + applyTo("whatsapp"); + applyTo("telegram"); + applyTo("imessage"); + + delete groupChat.requireMention; + if (Object.keys(groupChat).length === 0) { + delete (routing as Record).groupChat; + } + if (Object.keys(routing as Record).length === 0) { + delete raw.routing; + } + raw.channels = channels; + }, + }, + { + id: "gateway.token->gateway.auth.token", + describe: "Move gateway.token to gateway.auth.token", + apply: (raw, changes) => { + const gateway = raw.gateway; + if (!gateway || typeof gateway !== "object") return; + const token = (gateway as Record).token; + if (token === undefined) return; + + const gatewayObj = gateway as Record; + const auth = + gatewayObj.auth && typeof gatewayObj.auth === "object" + ? (gatewayObj.auth as Record) + : {}; + if (auth.token === undefined) { + auth.token = token; + if (!auth.mode) auth.mode = "token"; + changes.push("Moved gateway.token → gateway.auth.token."); + } else { + changes.push("Removed gateway.token (gateway.auth.token already set)."); + } + delete gatewayObj.token; + if (Object.keys(auth).length > 0) { + gatewayObj.auth = auth; + } + raw.gateway = gatewayObj; + }, + }, + { + id: "telegram.requireMention->channels.telegram.groups.*.requireMention", + describe: + "Move telegram.requireMention to channels.telegram.groups.*.requireMention", + apply: (raw, changes) => { + const channels = ensureRecord(raw, "channels"); + const telegram = channels.telegram; + if (!telegram || typeof telegram !== "object") return; + const requireMention = (telegram as Record) + .requireMention; + if (requireMention === undefined) return; + + const groups = + (telegram as Record).groups && + typeof (telegram as Record).groups === "object" + ? ((telegram as Record).groups as Record< + string, + unknown + >) + : {}; + const defaultKey = "*"; + const entry = + groups[defaultKey] && typeof groups[defaultKey] === "object" + ? (groups[defaultKey] as Record) + : {}; + + if (entry.requireMention === undefined) { + entry.requireMention = requireMention; + groups[defaultKey] = entry; + (telegram as Record).groups = groups; + changes.push( + 'Moved telegram.requireMention → channels.telegram.groups."*".requireMention.', + ); + } else { + changes.push( + 'Removed telegram.requireMention (channels.telegram.groups."*" already set).', + ); + } + + delete (telegram as Record).requireMention; + channels.telegram = telegram as Record; + raw.channels = channels; + }, + }, +]; diff --git a/src/config/legacy.migrations.part-2.ts b/src/config/legacy.migrations.part-2.ts new file mode 100644 index 000000000..3015c8841 --- /dev/null +++ b/src/config/legacy.migrations.part-2.ts @@ -0,0 +1,413 @@ +import { + ensureAgentEntry, + ensureRecord, + getAgentsList, + getRecord, + isRecord, + type LegacyConfigMigration, + mapLegacyAudioTranscription, + mergeMissing, +} from "./legacy.shared.js"; + +export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [ + { + id: "agent.model-config-v2", + describe: + "Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists", + apply: (raw, changes) => { + const agentRoot = getRecord(raw.agent); + const defaults = getRecord(getRecord(raw.agents)?.defaults); + const agent = agentRoot ?? defaults; + if (!agent) return; + const label = agentRoot ? "agent" : "agents.defaults"; + + const legacyModel = + typeof agent.model === "string" ? String(agent.model) : undefined; + const legacyImageModel = + typeof agent.imageModel === "string" + ? String(agent.imageModel) + : undefined; + const legacyAllowed = Array.isArray(agent.allowedModels) + ? (agent.allowedModels as unknown[]).map(String) + : []; + const legacyModelFallbacks = Array.isArray(agent.modelFallbacks) + ? (agent.modelFallbacks as unknown[]).map(String) + : []; + const legacyImageModelFallbacks = Array.isArray(agent.imageModelFallbacks) + ? (agent.imageModelFallbacks as unknown[]).map(String) + : []; + const legacyAliases = + agent.modelAliases && typeof agent.modelAliases === "object" + ? (agent.modelAliases as Record) + : {}; + + const hasLegacy = + legacyModel || + legacyImageModel || + legacyAllowed.length > 0 || + legacyModelFallbacks.length > 0 || + legacyImageModelFallbacks.length > 0 || + Object.keys(legacyAliases).length > 0; + if (!hasLegacy) return; + + const models = + agent.models && typeof agent.models === "object" + ? (agent.models as Record) + : {}; + + const ensureModel = (rawKey?: string) => { + if (typeof rawKey !== "string") return; + const key = rawKey.trim(); + if (!key) return; + if (!models[key]) models[key] = {}; + }; + + ensureModel(legacyModel); + ensureModel(legacyImageModel); + for (const key of legacyAllowed) ensureModel(key); + for (const key of legacyModelFallbacks) ensureModel(key); + for (const key of legacyImageModelFallbacks) ensureModel(key); + for (const target of Object.values(legacyAliases)) { + if (typeof target !== "string") continue; + ensureModel(target); + } + + for (const [alias, targetRaw] of Object.entries(legacyAliases)) { + if (typeof targetRaw !== "string") continue; + const target = targetRaw.trim(); + if (!target) continue; + const entry = + models[target] && typeof models[target] === "object" + ? (models[target] as Record) + : {}; + if (!("alias" in entry)) { + entry.alias = alias; + models[target] = entry; + } + } + + const currentModel = + agent.model && typeof agent.model === "object" + ? (agent.model as Record) + : null; + if (currentModel) { + if (!currentModel.primary && legacyModel) { + currentModel.primary = legacyModel; + } + if ( + legacyModelFallbacks.length > 0 && + (!Array.isArray(currentModel.fallbacks) || + currentModel.fallbacks.length === 0) + ) { + currentModel.fallbacks = legacyModelFallbacks; + } + agent.model = currentModel; + } else if (legacyModel || legacyModelFallbacks.length > 0) { + agent.model = { + primary: legacyModel, + fallbacks: legacyModelFallbacks.length ? legacyModelFallbacks : [], + }; + } + + const currentImageModel = + agent.imageModel && typeof agent.imageModel === "object" + ? (agent.imageModel as Record) + : null; + if (currentImageModel) { + if (!currentImageModel.primary && legacyImageModel) { + currentImageModel.primary = legacyImageModel; + } + if ( + legacyImageModelFallbacks.length > 0 && + (!Array.isArray(currentImageModel.fallbacks) || + currentImageModel.fallbacks.length === 0) + ) { + currentImageModel.fallbacks = legacyImageModelFallbacks; + } + agent.imageModel = currentImageModel; + } else if (legacyImageModel || legacyImageModelFallbacks.length > 0) { + agent.imageModel = { + primary: legacyImageModel, + fallbacks: legacyImageModelFallbacks.length + ? legacyImageModelFallbacks + : [], + }; + } + + agent.models = models; + + if (legacyModel !== undefined) { + changes.push( + `Migrated ${label}.model string → ${label}.model.primary.`, + ); + } + if (legacyModelFallbacks.length > 0) { + changes.push( + `Migrated ${label}.modelFallbacks → ${label}.model.fallbacks.`, + ); + } + if (legacyImageModel !== undefined) { + changes.push( + `Migrated ${label}.imageModel string → ${label}.imageModel.primary.`, + ); + } + if (legacyImageModelFallbacks.length > 0) { + changes.push( + `Migrated ${label}.imageModelFallbacks → ${label}.imageModel.fallbacks.`, + ); + } + if (legacyAllowed.length > 0) { + changes.push(`Migrated ${label}.allowedModels → ${label}.models.`); + } + if (Object.keys(legacyAliases).length > 0) { + changes.push( + `Migrated ${label}.modelAliases → ${label}.models.*.alias.`, + ); + } + + delete agent.allowedModels; + delete agent.modelAliases; + delete agent.modelFallbacks; + delete agent.imageModelFallbacks; + }, + }, + { + id: "routing.agents-v2", + describe: "Move routing.agents/defaultAgentId to agents.list", + apply: (raw, changes) => { + const routing = getRecord(raw.routing); + if (!routing) return; + + const routingAgents = getRecord(routing.agents); + const agents = ensureRecord(raw, "agents"); + const list = getAgentsList(agents); + + if (routingAgents) { + for (const [rawId, entryRaw] of Object.entries(routingAgents)) { + const agentId = String(rawId ?? "").trim(); + const entry = getRecord(entryRaw); + if (!agentId || !entry) continue; + + const target = ensureAgentEntry(list, agentId); + const entryCopy: Record = { ...entry }; + + if ("mentionPatterns" in entryCopy) { + const mentionPatterns = entryCopy.mentionPatterns; + const groupChat = ensureRecord(target, "groupChat"); + if (groupChat.mentionPatterns === undefined) { + groupChat.mentionPatterns = mentionPatterns; + changes.push( + `Moved routing.agents.${agentId}.mentionPatterns → agents.list (id "${agentId}").groupChat.mentionPatterns.`, + ); + } else { + changes.push( + `Removed routing.agents.${agentId}.mentionPatterns (agents.list groupChat mentionPatterns already set).`, + ); + } + delete entryCopy.mentionPatterns; + } + + const legacyGroupChat = getRecord(entryCopy.groupChat); + if (legacyGroupChat) { + const groupChat = ensureRecord(target, "groupChat"); + mergeMissing(groupChat, legacyGroupChat); + delete entryCopy.groupChat; + } + + const legacySandbox = getRecord(entryCopy.sandbox); + if (legacySandbox) { + const sandboxTools = getRecord(legacySandbox.tools); + if (sandboxTools) { + const tools = ensureRecord(target, "tools"); + const sandbox = ensureRecord(tools, "sandbox"); + const toolPolicy = ensureRecord(sandbox, "tools"); + mergeMissing(toolPolicy, sandboxTools); + delete legacySandbox.tools; + changes.push( + `Moved routing.agents.${agentId}.sandbox.tools → agents.list (id "${agentId}").tools.sandbox.tools.`, + ); + } + entryCopy.sandbox = legacySandbox; + } + + mergeMissing(target, entryCopy); + } + delete routing.agents; + changes.push("Moved routing.agents → agents.list."); + } + + const defaultAgentId = + typeof routing.defaultAgentId === "string" + ? routing.defaultAgentId.trim() + : ""; + if (defaultAgentId) { + const hasDefault = list.some( + (entry): entry is Record => + isRecord(entry) && entry.default === true, + ); + if (!hasDefault) { + const entry = ensureAgentEntry(list, defaultAgentId); + entry.default = true; + changes.push( + `Moved routing.defaultAgentId → agents.list (id "${defaultAgentId}").default.`, + ); + } else { + changes.push( + "Removed routing.defaultAgentId (agents.list default already set).", + ); + } + delete routing.defaultAgentId; + } + + if (list.length > 0) { + agents.list = list; + } + + if (Object.keys(routing).length === 0) { + delete raw.routing; + } + }, + }, + { + id: "routing.config-v2", + describe: + "Move routing bindings/groupChat/queue/agentToAgent/transcribeAudio", + apply: (raw, changes) => { + const routing = getRecord(raw.routing); + if (!routing) return; + + if (routing.bindings !== undefined) { + if (raw.bindings === undefined) { + raw.bindings = routing.bindings; + changes.push("Moved routing.bindings → bindings."); + } else { + changes.push("Removed routing.bindings (bindings already set)."); + } + delete routing.bindings; + } + + if (routing.agentToAgent !== undefined) { + const tools = ensureRecord(raw, "tools"); + if (tools.agentToAgent === undefined) { + tools.agentToAgent = routing.agentToAgent; + changes.push("Moved routing.agentToAgent → tools.agentToAgent."); + } else { + changes.push( + "Removed routing.agentToAgent (tools.agentToAgent already set).", + ); + } + delete routing.agentToAgent; + } + + if (routing.queue !== undefined) { + const messages = ensureRecord(raw, "messages"); + if (messages.queue === undefined) { + messages.queue = routing.queue; + changes.push("Moved routing.queue → messages.queue."); + } else { + changes.push("Removed routing.queue (messages.queue already set)."); + } + delete routing.queue; + } + + const groupChat = getRecord(routing.groupChat); + if (groupChat) { + const historyLimit = groupChat.historyLimit; + if (historyLimit !== undefined) { + const messages = ensureRecord(raw, "messages"); + const messagesGroup = ensureRecord(messages, "groupChat"); + if (messagesGroup.historyLimit === undefined) { + messagesGroup.historyLimit = historyLimit; + changes.push( + "Moved routing.groupChat.historyLimit → messages.groupChat.historyLimit.", + ); + } else { + changes.push( + "Removed routing.groupChat.historyLimit (messages.groupChat.historyLimit already set).", + ); + } + delete groupChat.historyLimit; + } + + const mentionPatterns = groupChat.mentionPatterns; + if (mentionPatterns !== undefined) { + const messages = ensureRecord(raw, "messages"); + const messagesGroup = ensureRecord(messages, "groupChat"); + if (messagesGroup.mentionPatterns === undefined) { + messagesGroup.mentionPatterns = mentionPatterns; + changes.push( + "Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.", + ); + } else { + changes.push( + "Removed routing.groupChat.mentionPatterns (messages.groupChat.mentionPatterns already set).", + ); + } + delete groupChat.mentionPatterns; + } + + if (Object.keys(groupChat).length === 0) { + delete routing.groupChat; + } else { + routing.groupChat = groupChat; + } + } + + if (routing.transcribeAudio !== undefined) { + const mapped = mapLegacyAudioTranscription(routing.transcribeAudio); + if (mapped) { + const tools = ensureRecord(raw, "tools"); + const toolsAudio = ensureRecord(tools, "audio"); + if (toolsAudio.transcription === undefined) { + toolsAudio.transcription = mapped; + changes.push( + "Moved routing.transcribeAudio → tools.audio.transcription.", + ); + } else { + changes.push( + "Removed routing.transcribeAudio (tools.audio.transcription already set).", + ); + } + } else { + changes.push( + "Removed routing.transcribeAudio (unsupported transcription CLI).", + ); + } + delete routing.transcribeAudio; + } + + const audio = getRecord(raw.audio); + if (audio?.transcription !== undefined) { + const mapped = mapLegacyAudioTranscription(audio.transcription); + if (mapped) { + const tools = ensureRecord(raw, "tools"); + const toolsAudio = ensureRecord(tools, "audio"); + if (toolsAudio.transcription === undefined) { + toolsAudio.transcription = mapped; + changes.push( + "Moved audio.transcription → tools.audio.transcription.", + ); + } else { + changes.push( + "Removed audio.transcription (tools.audio.transcription already set).", + ); + } + delete audio.transcription; + if (Object.keys(audio).length === 0) delete raw.audio; + else raw.audio = audio; + } else { + delete audio.transcription; + changes.push( + "Removed audio.transcription (unsupported transcription CLI).", + ); + if (Object.keys(audio).length === 0) delete raw.audio; + else raw.audio = audio; + } + } + + if (Object.keys(routing).length === 0) { + delete raw.routing; + } + }, + }, +]; diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts new file mode 100644 index 000000000..b68fe2ad7 --- /dev/null +++ b/src/config/legacy.migrations.part-3.ts @@ -0,0 +1,143 @@ +import { + ensureAgentEntry, + ensureRecord, + getAgentsList, + getRecord, + isRecord, + type LegacyConfigMigration, + mergeMissing, + resolveDefaultAgentIdFromRaw, +} from "./legacy.shared.js"; + +export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ + { + id: "agent.defaults-v2", + describe: "Move agent config to agents.defaults and tools", + apply: (raw, changes) => { + const agent = getRecord(raw.agent); + if (!agent) return; + + const agents = ensureRecord(raw, "agents"); + const defaults = getRecord(agents.defaults) ?? {}; + const tools = ensureRecord(raw, "tools"); + + const agentTools = getRecord(agent.tools); + if (agentTools) { + if (tools.allow === undefined && agentTools.allow !== undefined) { + tools.allow = agentTools.allow; + changes.push("Moved agent.tools.allow → tools.allow."); + } + if (tools.deny === undefined && agentTools.deny !== undefined) { + tools.deny = agentTools.deny; + changes.push("Moved agent.tools.deny → tools.deny."); + } + } + + const elevated = getRecord(agent.elevated); + if (elevated) { + if (tools.elevated === undefined) { + tools.elevated = elevated; + changes.push("Moved agent.elevated → tools.elevated."); + } else { + changes.push("Removed agent.elevated (tools.elevated already set)."); + } + } + + const bash = getRecord(agent.bash); + if (bash) { + if (tools.exec === undefined && tools.bash === undefined) { + tools.exec = bash; + changes.push("Moved agent.bash → tools.exec."); + } else if (tools.exec !== undefined) { + changes.push("Removed agent.bash (tools.exec already set)."); + } else { + changes.push("Removed agent.bash (tools.bash already set)."); + } + } + + const sandbox = getRecord(agent.sandbox); + if (sandbox) { + const sandboxTools = getRecord(sandbox.tools); + if (sandboxTools) { + const toolsSandbox = ensureRecord(tools, "sandbox"); + const toolPolicy = ensureRecord(toolsSandbox, "tools"); + mergeMissing(toolPolicy, sandboxTools); + delete sandbox.tools; + changes.push("Moved agent.sandbox.tools → tools.sandbox.tools."); + } + } + + const subagents = getRecord(agent.subagents); + if (subagents) { + const subagentTools = getRecord(subagents.tools); + if (subagentTools) { + const toolsSubagents = ensureRecord(tools, "subagents"); + const toolPolicy = ensureRecord(toolsSubagents, "tools"); + mergeMissing(toolPolicy, subagentTools); + delete subagents.tools; + changes.push("Moved agent.subagents.tools → tools.subagents.tools."); + } + } + + const agentCopy: Record = structuredClone(agent); + delete agentCopy.tools; + delete agentCopy.elevated; + delete agentCopy.bash; + if (isRecord(agentCopy.sandbox)) delete agentCopy.sandbox.tools; + if (isRecord(agentCopy.subagents)) delete agentCopy.subagents.tools; + + mergeMissing(defaults, agentCopy); + agents.defaults = defaults; + raw.agents = agents; + delete raw.agent; + changes.push("Moved agent → agents.defaults."); + }, + }, + { + id: "identity->agents.list", + describe: "Move identity to agents.list[].identity", + apply: (raw, changes) => { + const identity = getRecord(raw.identity); + if (!identity) return; + + const agents = ensureRecord(raw, "agents"); + const list = getAgentsList(agents); + const defaultId = resolveDefaultAgentIdFromRaw(raw); + const entry = ensureAgentEntry(list, defaultId); + if (entry.identity === undefined) { + entry.identity = identity; + changes.push( + `Moved identity → agents.list (id "${defaultId}").identity.`, + ); + } else { + changes.push("Removed identity (agents.list identity already set)."); + } + agents.list = list; + raw.agents = agents; + delete raw.identity; + }, + }, + { + id: "bind-tailnet->auto", + describe: "Remap gateway/bridge bind 'tailnet' to 'auto'", + apply: (raw, changes) => { + const migrateBind = ( + obj: Record | null | undefined, + key: string, + ) => { + if (!obj) return; + const bind = obj.bind; + if (bind === "tailnet") { + obj.bind = "auto"; + changes.push(`Migrated ${key}.bind from 'tailnet' to 'auto'.`); + } + }; + + const gateway = getRecord(raw.gateway); + migrateBind(gateway, "gateway"); + + const bridge = getRecord(raw.bridge); + migrateBind(bridge, "bridge"); + }, + }, +]; diff --git a/src/config/legacy.migrations.ts b/src/config/legacy.migrations.ts new file mode 100644 index 000000000..4e5cdf2a5 --- /dev/null +++ b/src/config/legacy.migrations.ts @@ -0,0 +1,9 @@ +import { LEGACY_CONFIG_MIGRATIONS_PART_1 } from "./legacy.migrations.part-1.js"; +import { LEGACY_CONFIG_MIGRATIONS_PART_2 } from "./legacy.migrations.part-2.js"; +import { LEGACY_CONFIG_MIGRATIONS_PART_3 } from "./legacy.migrations.part-3.js"; + +export const LEGACY_CONFIG_MIGRATIONS = [ + ...LEGACY_CONFIG_MIGRATIONS_PART_1, + ...LEGACY_CONFIG_MIGRATIONS_PART_2, + ...LEGACY_CONFIG_MIGRATIONS_PART_3, +]; diff --git a/src/config/legacy.rules.ts b/src/config/legacy.rules.ts new file mode 100644 index 000000000..f1488cf81 --- /dev/null +++ b/src/config/legacy.rules.ts @@ -0,0 +1,134 @@ +import type { LegacyConfigRule } from "./legacy.shared.js"; + +export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ + { + path: ["whatsapp"], + message: + "whatsapp config moved to channels.whatsapp (auto-migrated on load).", + }, + { + path: ["telegram"], + message: + "telegram config moved to channels.telegram (auto-migrated on load).", + }, + { + path: ["discord"], + message: + "discord config moved to channels.discord (auto-migrated on load).", + }, + { + path: ["slack"], + message: "slack config moved to channels.slack (auto-migrated on load).", + }, + { + path: ["signal"], + message: "signal config moved to channels.signal (auto-migrated on load).", + }, + { + path: ["imessage"], + message: + "imessage config moved to channels.imessage (auto-migrated on load).", + }, + { + path: ["msteams"], + message: + "msteams config moved to channels.msteams (auto-migrated on load).", + }, + { + path: ["routing", "allowFrom"], + message: + "routing.allowFrom was removed; use channels.whatsapp.allowFrom instead (auto-migrated on load).", + }, + { + path: ["routing", "bindings"], + message: + "routing.bindings was moved; use top-level bindings instead (auto-migrated on load).", + }, + { + path: ["routing", "agents"], + message: + "routing.agents was moved; use agents.list instead (auto-migrated on load).", + }, + { + path: ["routing", "defaultAgentId"], + message: + "routing.defaultAgentId was moved; use agents.list[].default instead (auto-migrated on load).", + }, + { + path: ["routing", "agentToAgent"], + message: + "routing.agentToAgent was moved; use tools.agentToAgent instead (auto-migrated on load).", + }, + { + path: ["routing", "groupChat", "requireMention"], + message: + 'routing.groupChat.requireMention was removed; use channels.whatsapp/telegram/imessage groups defaults (e.g. channels.whatsapp.groups."*".requireMention) instead (auto-migrated on load).', + }, + { + path: ["routing", "groupChat", "mentionPatterns"], + message: + "routing.groupChat.mentionPatterns was moved; use agents.list[].groupChat.mentionPatterns or messages.groupChat.mentionPatterns instead (auto-migrated on load).", + }, + { + path: ["routing", "queue"], + message: + "routing.queue was moved; use messages.queue instead (auto-migrated on load).", + }, + { + path: ["routing", "transcribeAudio"], + message: + "routing.transcribeAudio was moved; use tools.audio.transcription instead (auto-migrated on load).", + }, + { + path: ["telegram", "requireMention"], + message: + 'telegram.requireMention was removed; use channels.telegram.groups."*".requireMention instead (auto-migrated on load).', + }, + { + path: ["identity"], + message: + "identity was moved; use agents.list[].identity instead (auto-migrated on load).", + }, + { + path: ["agent"], + message: + "agent.* was moved; use agents.defaults (and tools.* for tool/elevated/exec settings) instead (auto-migrated on load).", + }, + { + path: ["agent", "model"], + message: + "agent.model string was replaced by agents.defaults.model.primary/fallbacks and agents.defaults.models (auto-migrated on load).", + match: (value) => typeof value === "string", + }, + { + path: ["agent", "imageModel"], + message: + "agent.imageModel string was replaced by agents.defaults.imageModel.primary/fallbacks (auto-migrated on load).", + match: (value) => typeof value === "string", + }, + { + path: ["agent", "allowedModels"], + message: + "agent.allowedModels was replaced by agents.defaults.models (auto-migrated on load).", + }, + { + path: ["agent", "modelAliases"], + message: + "agent.modelAliases was replaced by agents.defaults.models.*.alias (auto-migrated on load).", + }, + { + path: ["agent", "modelFallbacks"], + message: + "agent.modelFallbacks was replaced by agents.defaults.model.fallbacks (auto-migrated on load).", + }, + { + path: ["agent", "imageModelFallbacks"], + message: + "agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (auto-migrated on load).", + }, + { + path: ["gateway", "token"], + message: + "gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).", + }, +]; diff --git a/src/config/legacy.shared.ts b/src/config/legacy.shared.ts new file mode 100644 index 000000000..7e7dd5680 --- /dev/null +++ b/src/config/legacy.shared.ts @@ -0,0 +1,119 @@ +export type LegacyConfigRule = { + path: string[]; + message: string; + match?: (value: unknown, root: Record) => boolean; +}; + +export type LegacyConfigMigration = { + id: string; + describe: string; + apply: (raw: Record, changes: string[]) => void; +}; + +export const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +export const getRecord = (value: unknown): Record | null => + isRecord(value) ? value : null; + +export const ensureRecord = ( + root: Record, + key: string, +): Record => { + const existing = root[key]; + if (isRecord(existing)) return existing; + const next: Record = {}; + root[key] = next; + return next; +}; + +export const mergeMissing = ( + target: Record, + source: Record, +) => { + for (const [key, value] of Object.entries(source)) { + if (value === undefined) continue; + const existing = target[key]; + if (existing === undefined) { + target[key] = value; + continue; + } + if (isRecord(existing) && isRecord(value)) { + mergeMissing(existing, value); + } + } +}; + +const AUDIO_TRANSCRIPTION_CLI_ALLOWLIST = new Set(["whisper"]); + +export const mapLegacyAudioTranscription = ( + value: unknown, +): Record | null => { + const transcriber = getRecord(value); + const command = Array.isArray(transcriber?.command) + ? transcriber?.command + : null; + if (!command || command.length === 0) return null; + const rawExecutable = String(command[0] ?? "").trim(); + if (!rawExecutable) return null; + const executableName = rawExecutable.split(/[\\/]/).pop() ?? rawExecutable; + if (!AUDIO_TRANSCRIPTION_CLI_ALLOWLIST.has(executableName)) return null; + + const args = command.slice(1).map((part) => String(part)); + const timeoutSeconds = + typeof transcriber?.timeoutSeconds === "number" + ? transcriber?.timeoutSeconds + : undefined; + + const result: Record = {}; + if (args.length > 0) result.args = args; + if (timeoutSeconds !== undefined) result.timeoutSeconds = timeoutSeconds; + return result; +}; + +export const getAgentsList = (agents: Record | null) => { + const list = agents?.list; + return Array.isArray(list) ? list : []; +}; + +export const resolveDefaultAgentIdFromRaw = (raw: Record) => { + const agents = getRecord(raw.agents); + const list = getAgentsList(agents); + const defaultEntry = list.find( + (entry): entry is { id: string } => + isRecord(entry) && + entry.default === true && + typeof entry.id === "string" && + entry.id.trim() !== "", + ); + if (defaultEntry) return defaultEntry.id.trim(); + const routing = getRecord(raw.routing); + const routingDefault = + typeof routing?.defaultAgentId === "string" + ? routing.defaultAgentId.trim() + : ""; + if (routingDefault) return routingDefault; + const firstEntry = list.find( + (entry): entry is { id: string } => + isRecord(entry) && typeof entry.id === "string" && entry.id.trim() !== "", + ); + if (firstEntry) return firstEntry.id.trim(); + return "main"; +}; + +export const ensureAgentEntry = ( + list: unknown[], + id: string, +): Record => { + const normalized = id.trim(); + const existing = list.find( + (entry): entry is Record => + isRecord(entry) && + typeof entry.id === "string" && + entry.id.trim() === normalized, + ); + if (existing) return existing; + const created: Record = { id: normalized }; + list.push(created); + return created; +}; diff --git a/src/config/legacy.ts b/src/config/legacy.ts index cd28a4509..c918fed4f 100644 --- a/src/config/legacy.ts +++ b/src/config/legacy.ts @@ -1,1082 +1,7 @@ +import { LEGACY_CONFIG_MIGRATIONS } from "./legacy.migrations.js"; +import { LEGACY_CONFIG_RULES } from "./legacy.rules.js"; import type { LegacyConfigIssue } from "./types.js"; -type LegacyConfigRule = { - path: string[]; - message: string; - match?: (value: unknown, root: Record) => boolean; -}; - -type LegacyConfigMigration = { - id: string; - describe: string; - apply: (raw: Record, changes: string[]) => void; -}; - -const isRecord = (value: unknown): value is Record => - Boolean(value && typeof value === "object" && !Array.isArray(value)); - -const getRecord = (value: unknown): Record | null => - isRecord(value) ? value : null; - -const ensureRecord = ( - root: Record, - key: string, -): Record => { - const existing = root[key]; - if (isRecord(existing)) return existing; - const next: Record = {}; - root[key] = next; - return next; -}; - -const mergeMissing = ( - target: Record, - source: Record, -) => { - for (const [key, value] of Object.entries(source)) { - if (value === undefined) continue; - const existing = target[key]; - if (existing === undefined) { - target[key] = value; - continue; - } - if (isRecord(existing) && isRecord(value)) { - mergeMissing(existing, value); - } - } -}; - -const AUDIO_TRANSCRIPTION_CLI_ALLOWLIST = new Set(["whisper"]); - -const mapLegacyAudioTranscription = ( - value: unknown, -): Record | null => { - const transcriber = getRecord(value); - const command = Array.isArray(transcriber?.command) - ? transcriber?.command - : null; - if (!command || command.length === 0) return null; - const rawExecutable = String(command[0] ?? "").trim(); - if (!rawExecutable) return null; - const executableName = rawExecutable.split(/[\\/]/).pop() ?? rawExecutable; - if (!AUDIO_TRANSCRIPTION_CLI_ALLOWLIST.has(executableName)) return null; - - const args = command.slice(1).map((part) => String(part)); - const timeoutSeconds = - typeof transcriber?.timeoutSeconds === "number" - ? transcriber?.timeoutSeconds - : undefined; - - const result: Record = {}; - if (args.length > 0) result.args = args; - if (timeoutSeconds !== undefined) result.timeoutSeconds = timeoutSeconds; - return result; -}; - -const getAgentsList = (agents: Record | null) => { - const list = agents?.list; - return Array.isArray(list) ? list : []; -}; - -const resolveDefaultAgentIdFromRaw = (raw: Record) => { - const agents = getRecord(raw.agents); - const list = getAgentsList(agents); - const defaultEntry = list.find( - (entry): entry is { id: string } => - isRecord(entry) && - entry.default === true && - typeof entry.id === "string" && - entry.id.trim() !== "", - ); - if (defaultEntry) return defaultEntry.id.trim(); - const routing = getRecord(raw.routing); - const routingDefault = - typeof routing?.defaultAgentId === "string" - ? routing.defaultAgentId.trim() - : ""; - if (routingDefault) return routingDefault; - const firstEntry = list.find( - (entry): entry is { id: string } => - isRecord(entry) && typeof entry.id === "string" && entry.id.trim() !== "", - ); - if (firstEntry) return firstEntry.id.trim(); - return "main"; -}; - -const ensureAgentEntry = ( - list: unknown[], - id: string, -): Record => { - const normalized = id.trim(); - const existing = list.find( - (entry): entry is Record => - isRecord(entry) && - typeof entry.id === "string" && - entry.id.trim() === normalized, - ); - if (existing) return existing; - const created: Record = { id: normalized }; - list.push(created); - return created; -}; - -const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ - { - path: ["whatsapp"], - message: - "whatsapp config moved to channels.whatsapp (auto-migrated on load).", - }, - { - path: ["telegram"], - message: - "telegram config moved to channels.telegram (auto-migrated on load).", - }, - { - path: ["discord"], - message: - "discord config moved to channels.discord (auto-migrated on load).", - }, - { - path: ["slack"], - message: "slack config moved to channels.slack (auto-migrated on load).", - }, - { - path: ["signal"], - message: "signal config moved to channels.signal (auto-migrated on load).", - }, - { - path: ["imessage"], - message: - "imessage config moved to channels.imessage (auto-migrated on load).", - }, - { - path: ["msteams"], - message: - "msteams config moved to channels.msteams (auto-migrated on load).", - }, - { - path: ["routing", "allowFrom"], - message: - "routing.allowFrom was removed; use channels.whatsapp.allowFrom instead (auto-migrated on load).", - }, - { - path: ["routing", "bindings"], - message: - "routing.bindings was moved; use top-level bindings instead (auto-migrated on load).", - }, - { - path: ["routing", "agents"], - message: - "routing.agents was moved; use agents.list instead (auto-migrated on load).", - }, - { - path: ["routing", "defaultAgentId"], - message: - "routing.defaultAgentId was moved; use agents.list[].default instead (auto-migrated on load).", - }, - { - path: ["routing", "agentToAgent"], - message: - "routing.agentToAgent was moved; use tools.agentToAgent instead (auto-migrated on load).", - }, - { - path: ["routing", "groupChat", "requireMention"], - message: - 'routing.groupChat.requireMention was removed; use channels.whatsapp/telegram/imessage groups defaults (e.g. channels.whatsapp.groups."*".requireMention) instead (auto-migrated on load).', - }, - { - path: ["routing", "groupChat", "mentionPatterns"], - message: - "routing.groupChat.mentionPatterns was moved; use agents.list[].groupChat.mentionPatterns or messages.groupChat.mentionPatterns instead (auto-migrated on load).", - }, - { - path: ["routing", "queue"], - message: - "routing.queue was moved; use messages.queue instead (auto-migrated on load).", - }, - { - path: ["routing", "transcribeAudio"], - message: - "routing.transcribeAudio was moved; use tools.audio.transcription instead (auto-migrated on load).", - }, - { - path: ["telegram", "requireMention"], - message: - 'telegram.requireMention was removed; use channels.telegram.groups."*".requireMention instead (auto-migrated on load).', - }, - { - path: ["identity"], - message: - "identity was moved; use agents.list[].identity instead (auto-migrated on load).", - }, - { - path: ["agent"], - message: - "agent.* was moved; use agents.defaults (and tools.* for tool/elevated/exec settings) instead (auto-migrated on load).", - }, - { - path: ["agent", "model"], - message: - "agent.model string was replaced by agents.defaults.model.primary/fallbacks and agents.defaults.models (auto-migrated on load).", - match: (value) => typeof value === "string", - }, - { - path: ["agent", "imageModel"], - message: - "agent.imageModel string was replaced by agents.defaults.imageModel.primary/fallbacks (auto-migrated on load).", - match: (value) => typeof value === "string", - }, - { - path: ["agent", "allowedModels"], - message: - "agent.allowedModels was replaced by agents.defaults.models (auto-migrated on load).", - }, - { - path: ["agent", "modelAliases"], - message: - "agent.modelAliases was replaced by agents.defaults.models.*.alias (auto-migrated on load).", - }, - { - path: ["agent", "modelFallbacks"], - message: - "agent.modelFallbacks was replaced by agents.defaults.model.fallbacks (auto-migrated on load).", - }, - { - path: ["agent", "imageModelFallbacks"], - message: - "agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (auto-migrated on load).", - }, - { - path: ["gateway", "token"], - message: - "gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).", - }, -]; - -const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ - { - id: "bindings.match.provider->bindings.match.channel", - describe: "Move bindings[].match.provider to bindings[].match.channel", - apply: (raw, changes) => { - const bindings = Array.isArray(raw.bindings) ? raw.bindings : null; - if (!bindings) return; - - let touched = false; - for (const entry of bindings) { - if (!isRecord(entry)) continue; - const match = getRecord(entry.match); - if (!match) continue; - if (typeof match.channel === "string" && match.channel.trim()) continue; - const provider = - typeof match.provider === "string" ? match.provider.trim() : ""; - if (!provider) continue; - match.channel = provider; - delete match.provider; - entry.match = match; - touched = true; - } - - if (touched) { - raw.bindings = bindings; - changes.push( - "Moved bindings[].match.provider → bindings[].match.channel.", - ); - } - }, - }, - { - id: "session.sendPolicy.rules.match.provider->match.channel", - describe: "Move session.sendPolicy.rules[].match.provider to match.channel", - apply: (raw, changes) => { - const session = getRecord(raw.session); - if (!session) return; - const sendPolicy = getRecord(session.sendPolicy); - if (!sendPolicy) return; - const rules = Array.isArray(sendPolicy.rules) ? sendPolicy.rules : null; - if (!rules) return; - - let touched = false; - for (const rule of rules) { - if (!isRecord(rule)) continue; - const match = getRecord(rule.match); - if (!match) continue; - if (typeof match.channel === "string" && match.channel.trim()) continue; - const provider = - typeof match.provider === "string" ? match.provider.trim() : ""; - if (!provider) continue; - match.channel = provider; - delete match.provider; - rule.match = match; - touched = true; - } - - if (touched) { - sendPolicy.rules = rules; - session.sendPolicy = sendPolicy; - raw.session = session; - changes.push( - "Moved session.sendPolicy.rules[].match.provider → match.channel.", - ); - } - }, - }, - { - id: "messages.queue.byProvider->byChannel", - describe: "Move messages.queue.byProvider to messages.queue.byChannel", - apply: (raw, changes) => { - const messages = getRecord(raw.messages); - if (!messages) return; - const queue = getRecord(messages.queue); - if (!queue) return; - if (queue.byProvider === undefined) return; - if (queue.byChannel === undefined) { - queue.byChannel = queue.byProvider; - changes.push( - "Moved messages.queue.byProvider → messages.queue.byChannel.", - ); - } else { - changes.push( - "Removed messages.queue.byProvider (messages.queue.byChannel already set).", - ); - } - delete queue.byProvider; - messages.queue = queue; - raw.messages = messages; - }, - }, - { - id: "providers->channels", - describe: "Move provider config sections to channels.*", - apply: (raw, changes) => { - const legacyKeys = [ - "whatsapp", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "msteams", - ]; - const legacyEntries = legacyKeys.filter((key) => isRecord(raw[key])); - if (legacyEntries.length === 0) return; - - const channels = ensureRecord(raw, "channels"); - for (const key of legacyEntries) { - const legacy = getRecord(raw[key]); - if (!legacy) continue; - const channelEntry = ensureRecord(channels, key); - const hadEntries = Object.keys(channelEntry).length > 0; - mergeMissing(channelEntry, legacy); - channels[key] = channelEntry; - delete raw[key]; - changes.push( - hadEntries - ? `Merged ${key} → channels.${key}.` - : `Moved ${key} → channels.${key}.`, - ); - } - raw.channels = channels; - }, - }, - { - id: "routing.allowFrom->channels.whatsapp.allowFrom", - describe: "Move routing.allowFrom to channels.whatsapp.allowFrom", - apply: (raw, changes) => { - const routing = raw.routing; - if (!routing || typeof routing !== "object") return; - const allowFrom = (routing as Record).allowFrom; - if (allowFrom === undefined) return; - - const channels = ensureRecord(raw, "channels"); - const whatsapp = - channels.whatsapp && typeof channels.whatsapp === "object" - ? (channels.whatsapp as Record) - : {}; - - if (whatsapp.allowFrom === undefined) { - whatsapp.allowFrom = allowFrom; - changes.push("Moved routing.allowFrom → channels.whatsapp.allowFrom."); - } else { - changes.push( - "Removed routing.allowFrom (channels.whatsapp.allowFrom already set).", - ); - } - - delete (routing as Record).allowFrom; - if (Object.keys(routing as Record).length === 0) { - delete raw.routing; - } - channels.whatsapp = whatsapp; - raw.channels = channels; - }, - }, - { - id: "routing.groupChat.requireMention->groups.*.requireMention", - describe: - "Move routing.groupChat.requireMention to channels.whatsapp/telegram/imessage groups", - apply: (raw, changes) => { - const routing = raw.routing; - if (!routing || typeof routing !== "object") return; - const groupChat = - (routing as Record).groupChat && - typeof (routing as Record).groupChat === "object" - ? ((routing as Record).groupChat as Record< - string, - unknown - >) - : null; - if (!groupChat) return; - const requireMention = groupChat.requireMention; - if (requireMention === undefined) return; - - const channels = ensureRecord(raw, "channels"); - const applyTo = (key: "whatsapp" | "telegram" | "imessage") => { - const section = - channels[key] && typeof channels[key] === "object" - ? (channels[key] as Record) - : {}; - const groups = - section.groups && typeof section.groups === "object" - ? (section.groups as Record) - : {}; - const defaultKey = "*"; - const entry = - groups[defaultKey] && typeof groups[defaultKey] === "object" - ? (groups[defaultKey] as Record) - : {}; - if (entry.requireMention === undefined) { - entry.requireMention = requireMention; - groups[defaultKey] = entry; - section.groups = groups; - channels[key] = section; - changes.push( - `Moved routing.groupChat.requireMention → channels.${key}.groups."*".requireMention.`, - ); - } else { - changes.push( - `Removed routing.groupChat.requireMention (channels.${key}.groups."*" already set).`, - ); - } - }; - - applyTo("whatsapp"); - applyTo("telegram"); - applyTo("imessage"); - - delete groupChat.requireMention; - if (Object.keys(groupChat).length === 0) { - delete (routing as Record).groupChat; - } - if (Object.keys(routing as Record).length === 0) { - delete raw.routing; - } - raw.channels = channels; - }, - }, - { - id: "gateway.token->gateway.auth.token", - describe: "Move gateway.token to gateway.auth.token", - apply: (raw, changes) => { - const gateway = raw.gateway; - if (!gateway || typeof gateway !== "object") return; - const token = (gateway as Record).token; - if (token === undefined) return; - - const gatewayObj = gateway as Record; - const auth = - gatewayObj.auth && typeof gatewayObj.auth === "object" - ? (gatewayObj.auth as Record) - : {}; - if (auth.token === undefined) { - auth.token = token; - if (!auth.mode) auth.mode = "token"; - changes.push("Moved gateway.token → gateway.auth.token."); - } else { - changes.push("Removed gateway.token (gateway.auth.token already set)."); - } - delete gatewayObj.token; - if (Object.keys(auth).length > 0) { - gatewayObj.auth = auth; - } - raw.gateway = gatewayObj; - }, - }, - { - id: "telegram.requireMention->channels.telegram.groups.*.requireMention", - describe: - "Move telegram.requireMention to channels.telegram.groups.*.requireMention", - apply: (raw, changes) => { - const channels = ensureRecord(raw, "channels"); - const telegram = channels.telegram; - if (!telegram || typeof telegram !== "object") return; - const requireMention = (telegram as Record) - .requireMention; - if (requireMention === undefined) return; - - const groups = - (telegram as Record).groups && - typeof (telegram as Record).groups === "object" - ? ((telegram as Record).groups as Record< - string, - unknown - >) - : {}; - const defaultKey = "*"; - const entry = - groups[defaultKey] && typeof groups[defaultKey] === "object" - ? (groups[defaultKey] as Record) - : {}; - - if (entry.requireMention === undefined) { - entry.requireMention = requireMention; - groups[defaultKey] = entry; - (telegram as Record).groups = groups; - changes.push( - 'Moved telegram.requireMention → channels.telegram.groups."*".requireMention.', - ); - } else { - changes.push( - 'Removed telegram.requireMention (channels.telegram.groups."*" already set).', - ); - } - - delete (telegram as Record).requireMention; - channels.telegram = telegram as Record; - raw.channels = channels; - }, - }, - { - id: "agent.model-config-v2", - describe: - "Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists", - apply: (raw, changes) => { - const agentRoot = getRecord(raw.agent); - const defaults = getRecord(getRecord(raw.agents)?.defaults); - const agent = agentRoot ?? defaults; - if (!agent) return; - const label = agentRoot ? "agent" : "agents.defaults"; - - const legacyModel = - typeof agent.model === "string" ? String(agent.model) : undefined; - const legacyImageModel = - typeof agent.imageModel === "string" - ? String(agent.imageModel) - : undefined; - const legacyAllowed = Array.isArray(agent.allowedModels) - ? (agent.allowedModels as unknown[]).map(String) - : []; - const legacyModelFallbacks = Array.isArray(agent.modelFallbacks) - ? (agent.modelFallbacks as unknown[]).map(String) - : []; - const legacyImageModelFallbacks = Array.isArray(agent.imageModelFallbacks) - ? (agent.imageModelFallbacks as unknown[]).map(String) - : []; - const legacyAliases = - agent.modelAliases && typeof agent.modelAliases === "object" - ? (agent.modelAliases as Record) - : {}; - - const hasLegacy = - legacyModel || - legacyImageModel || - legacyAllowed.length > 0 || - legacyModelFallbacks.length > 0 || - legacyImageModelFallbacks.length > 0 || - Object.keys(legacyAliases).length > 0; - if (!hasLegacy) return; - - const models = - agent.models && typeof agent.models === "object" - ? (agent.models as Record) - : {}; - - const ensureModel = (rawKey?: string) => { - if (typeof rawKey !== "string") return; - const key = rawKey.trim(); - if (!key) return; - if (!models[key]) models[key] = {}; - }; - - ensureModel(legacyModel); - ensureModel(legacyImageModel); - for (const key of legacyAllowed) ensureModel(key); - for (const key of legacyModelFallbacks) ensureModel(key); - for (const key of legacyImageModelFallbacks) ensureModel(key); - for (const target of Object.values(legacyAliases)) { - if (typeof target !== "string") continue; - ensureModel(target); - } - - for (const [alias, targetRaw] of Object.entries(legacyAliases)) { - if (typeof targetRaw !== "string") continue; - const target = targetRaw.trim(); - if (!target) continue; - const entry = - models[target] && typeof models[target] === "object" - ? (models[target] as Record) - : {}; - if (!("alias" in entry)) { - entry.alias = alias; - models[target] = entry; - } - } - - const currentModel = - agent.model && typeof agent.model === "object" - ? (agent.model as Record) - : null; - if (currentModel) { - if (!currentModel.primary && legacyModel) { - currentModel.primary = legacyModel; - } - if ( - legacyModelFallbacks.length > 0 && - (!Array.isArray(currentModel.fallbacks) || - currentModel.fallbacks.length === 0) - ) { - currentModel.fallbacks = legacyModelFallbacks; - } - agent.model = currentModel; - } else if (legacyModel || legacyModelFallbacks.length > 0) { - agent.model = { - primary: legacyModel, - fallbacks: legacyModelFallbacks.length ? legacyModelFallbacks : [], - }; - } - - const currentImageModel = - agent.imageModel && typeof agent.imageModel === "object" - ? (agent.imageModel as Record) - : null; - if (currentImageModel) { - if (!currentImageModel.primary && legacyImageModel) { - currentImageModel.primary = legacyImageModel; - } - if ( - legacyImageModelFallbacks.length > 0 && - (!Array.isArray(currentImageModel.fallbacks) || - currentImageModel.fallbacks.length === 0) - ) { - currentImageModel.fallbacks = legacyImageModelFallbacks; - } - agent.imageModel = currentImageModel; - } else if (legacyImageModel || legacyImageModelFallbacks.length > 0) { - agent.imageModel = { - primary: legacyImageModel, - fallbacks: legacyImageModelFallbacks.length - ? legacyImageModelFallbacks - : [], - }; - } - - agent.models = models; - - if (legacyModel !== undefined) { - changes.push( - `Migrated ${label}.model string → ${label}.model.primary.`, - ); - } - if (legacyModelFallbacks.length > 0) { - changes.push( - `Migrated ${label}.modelFallbacks → ${label}.model.fallbacks.`, - ); - } - if (legacyImageModel !== undefined) { - changes.push( - `Migrated ${label}.imageModel string → ${label}.imageModel.primary.`, - ); - } - if (legacyImageModelFallbacks.length > 0) { - changes.push( - `Migrated ${label}.imageModelFallbacks → ${label}.imageModel.fallbacks.`, - ); - } - if (legacyAllowed.length > 0) { - changes.push(`Migrated ${label}.allowedModels → ${label}.models.`); - } - if (Object.keys(legacyAliases).length > 0) { - changes.push( - `Migrated ${label}.modelAliases → ${label}.models.*.alias.`, - ); - } - - delete agent.allowedModels; - delete agent.modelAliases; - delete agent.modelFallbacks; - delete agent.imageModelFallbacks; - }, - }, - { - id: "routing.agents-v2", - describe: "Move routing.agents/defaultAgentId to agents.list", - apply: (raw, changes) => { - const routing = getRecord(raw.routing); - if (!routing) return; - - const routingAgents = getRecord(routing.agents); - const agents = ensureRecord(raw, "agents"); - const list = getAgentsList(agents); - - if (routingAgents) { - for (const [rawId, entryRaw] of Object.entries(routingAgents)) { - const agentId = String(rawId ?? "").trim(); - const entry = getRecord(entryRaw); - if (!agentId || !entry) continue; - - const target = ensureAgentEntry(list, agentId); - const entryCopy: Record = { ...entry }; - - if ("mentionPatterns" in entryCopy) { - const mentionPatterns = entryCopy.mentionPatterns; - const groupChat = ensureRecord(target, "groupChat"); - if (groupChat.mentionPatterns === undefined) { - groupChat.mentionPatterns = mentionPatterns; - changes.push( - `Moved routing.agents.${agentId}.mentionPatterns → agents.list (id "${agentId}").groupChat.mentionPatterns.`, - ); - } else { - changes.push( - `Removed routing.agents.${agentId}.mentionPatterns (agents.list groupChat mentionPatterns already set).`, - ); - } - delete entryCopy.mentionPatterns; - } - - const legacyGroupChat = getRecord(entryCopy.groupChat); - if (legacyGroupChat) { - const groupChat = ensureRecord(target, "groupChat"); - mergeMissing(groupChat, legacyGroupChat); - delete entryCopy.groupChat; - } - - const legacySandbox = getRecord(entryCopy.sandbox); - if (legacySandbox) { - const sandboxTools = getRecord(legacySandbox.tools); - if (sandboxTools) { - const tools = ensureRecord(target, "tools"); - const sandbox = ensureRecord(tools, "sandbox"); - const toolPolicy = ensureRecord(sandbox, "tools"); - mergeMissing(toolPolicy, sandboxTools); - delete legacySandbox.tools; - changes.push( - `Moved routing.agents.${agentId}.sandbox.tools → agents.list (id "${agentId}").tools.sandbox.tools.`, - ); - } - entryCopy.sandbox = legacySandbox; - } - - mergeMissing(target, entryCopy); - } - delete routing.agents; - changes.push("Moved routing.agents → agents.list."); - } - - const defaultAgentId = - typeof routing.defaultAgentId === "string" - ? routing.defaultAgentId.trim() - : ""; - if (defaultAgentId) { - const hasDefault = list.some( - (entry): entry is Record => - isRecord(entry) && entry.default === true, - ); - if (!hasDefault) { - const entry = ensureAgentEntry(list, defaultAgentId); - entry.default = true; - changes.push( - `Moved routing.defaultAgentId → agents.list (id "${defaultAgentId}").default.`, - ); - } else { - changes.push( - "Removed routing.defaultAgentId (agents.list default already set).", - ); - } - delete routing.defaultAgentId; - } - - if (list.length > 0) { - agents.list = list; - } - - if (Object.keys(routing).length === 0) { - delete raw.routing; - } - }, - }, - { - id: "routing.config-v2", - describe: - "Move routing bindings/groupChat/queue/agentToAgent/transcribeAudio", - apply: (raw, changes) => { - const routing = getRecord(raw.routing); - if (!routing) return; - - if (routing.bindings !== undefined) { - if (raw.bindings === undefined) { - raw.bindings = routing.bindings; - changes.push("Moved routing.bindings → bindings."); - } else { - changes.push("Removed routing.bindings (bindings already set)."); - } - delete routing.bindings; - } - - if (routing.agentToAgent !== undefined) { - const tools = ensureRecord(raw, "tools"); - if (tools.agentToAgent === undefined) { - tools.agentToAgent = routing.agentToAgent; - changes.push("Moved routing.agentToAgent → tools.agentToAgent."); - } else { - changes.push( - "Removed routing.agentToAgent (tools.agentToAgent already set).", - ); - } - delete routing.agentToAgent; - } - - if (routing.queue !== undefined) { - const messages = ensureRecord(raw, "messages"); - if (messages.queue === undefined) { - messages.queue = routing.queue; - changes.push("Moved routing.queue → messages.queue."); - } else { - changes.push("Removed routing.queue (messages.queue already set)."); - } - delete routing.queue; - } - - const groupChat = getRecord(routing.groupChat); - if (groupChat) { - const historyLimit = groupChat.historyLimit; - if (historyLimit !== undefined) { - const messages = ensureRecord(raw, "messages"); - const messagesGroup = ensureRecord(messages, "groupChat"); - if (messagesGroup.historyLimit === undefined) { - messagesGroup.historyLimit = historyLimit; - changes.push( - "Moved routing.groupChat.historyLimit → messages.groupChat.historyLimit.", - ); - } else { - changes.push( - "Removed routing.groupChat.historyLimit (messages.groupChat.historyLimit already set).", - ); - } - delete groupChat.historyLimit; - } - - const mentionPatterns = groupChat.mentionPatterns; - if (mentionPatterns !== undefined) { - const messages = ensureRecord(raw, "messages"); - const messagesGroup = ensureRecord(messages, "groupChat"); - if (messagesGroup.mentionPatterns === undefined) { - messagesGroup.mentionPatterns = mentionPatterns; - changes.push( - "Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.", - ); - } else { - changes.push( - "Removed routing.groupChat.mentionPatterns (messages.groupChat.mentionPatterns already set).", - ); - } - delete groupChat.mentionPatterns; - } - - if (Object.keys(groupChat).length === 0) { - delete routing.groupChat; - } else { - routing.groupChat = groupChat; - } - } - - if (routing.transcribeAudio !== undefined) { - const mapped = mapLegacyAudioTranscription(routing.transcribeAudio); - if (mapped) { - const tools = ensureRecord(raw, "tools"); - const toolsAudio = ensureRecord(tools, "audio"); - if (toolsAudio.transcription === undefined) { - toolsAudio.transcription = mapped; - changes.push( - "Moved routing.transcribeAudio → tools.audio.transcription.", - ); - } else { - changes.push( - "Removed routing.transcribeAudio (tools.audio.transcription already set).", - ); - } - } else { - changes.push( - "Removed routing.transcribeAudio (unsupported transcription CLI).", - ); - } - delete routing.transcribeAudio; - } - - const audio = getRecord(raw.audio); - if (audio?.transcription !== undefined) { - const mapped = mapLegacyAudioTranscription(audio.transcription); - if (mapped) { - const tools = ensureRecord(raw, "tools"); - const toolsAudio = ensureRecord(tools, "audio"); - if (toolsAudio.transcription === undefined) { - toolsAudio.transcription = mapped; - changes.push( - "Moved audio.transcription → tools.audio.transcription.", - ); - } else { - changes.push( - "Removed audio.transcription (tools.audio.transcription already set).", - ); - } - delete audio.transcription; - if (Object.keys(audio).length === 0) delete raw.audio; - else raw.audio = audio; - } else { - delete audio.transcription; - changes.push( - "Removed audio.transcription (unsupported transcription CLI).", - ); - if (Object.keys(audio).length === 0) delete raw.audio; - else raw.audio = audio; - } - } - - if (Object.keys(routing).length === 0) { - delete raw.routing; - } - }, - }, - { - id: "agent.defaults-v2", - describe: "Move agent config to agents.defaults and tools", - apply: (raw, changes) => { - const agent = getRecord(raw.agent); - if (!agent) return; - - const agents = ensureRecord(raw, "agents"); - const defaults = getRecord(agents.defaults) ?? {}; - const tools = ensureRecord(raw, "tools"); - - const agentTools = getRecord(agent.tools); - if (agentTools) { - if (tools.allow === undefined && agentTools.allow !== undefined) { - tools.allow = agentTools.allow; - changes.push("Moved agent.tools.allow → tools.allow."); - } - if (tools.deny === undefined && agentTools.deny !== undefined) { - tools.deny = agentTools.deny; - changes.push("Moved agent.tools.deny → tools.deny."); - } - } - - const elevated = getRecord(agent.elevated); - if (elevated) { - if (tools.elevated === undefined) { - tools.elevated = elevated; - changes.push("Moved agent.elevated → tools.elevated."); - } else { - changes.push("Removed agent.elevated (tools.elevated already set)."); - } - } - - const bash = getRecord(agent.bash); - if (bash) { - if (tools.exec === undefined && tools.bash === undefined) { - tools.exec = bash; - changes.push("Moved agent.bash → tools.exec."); - } else if (tools.exec !== undefined) { - changes.push("Removed agent.bash (tools.exec already set)."); - } else { - changes.push("Removed agent.bash (tools.bash already set)."); - } - } - - const sandbox = getRecord(agent.sandbox); - if (sandbox) { - const sandboxTools = getRecord(sandbox.tools); - if (sandboxTools) { - const toolsSandbox = ensureRecord(tools, "sandbox"); - const toolPolicy = ensureRecord(toolsSandbox, "tools"); - mergeMissing(toolPolicy, sandboxTools); - delete sandbox.tools; - changes.push("Moved agent.sandbox.tools → tools.sandbox.tools."); - } - } - - const subagents = getRecord(agent.subagents); - if (subagents) { - const subagentTools = getRecord(subagents.tools); - if (subagentTools) { - const toolsSubagents = ensureRecord(tools, "subagents"); - const toolPolicy = ensureRecord(toolsSubagents, "tools"); - mergeMissing(toolPolicy, subagentTools); - delete subagents.tools; - changes.push("Moved agent.subagents.tools → tools.subagents.tools."); - } - } - - const agentCopy: Record = structuredClone(agent); - delete agentCopy.tools; - delete agentCopy.elevated; - delete agentCopy.bash; - if (isRecord(agentCopy.sandbox)) delete agentCopy.sandbox.tools; - if (isRecord(agentCopy.subagents)) delete agentCopy.subagents.tools; - - mergeMissing(defaults, agentCopy); - agents.defaults = defaults; - raw.agents = agents; - delete raw.agent; - changes.push("Moved agent → agents.defaults."); - }, - }, - { - id: "identity->agents.list", - describe: "Move identity to agents.list[].identity", - apply: (raw, changes) => { - const identity = getRecord(raw.identity); - if (!identity) return; - - const agents = ensureRecord(raw, "agents"); - const list = getAgentsList(agents); - const defaultId = resolveDefaultAgentIdFromRaw(raw); - const entry = ensureAgentEntry(list, defaultId); - if (entry.identity === undefined) { - entry.identity = identity; - changes.push( - `Moved identity → agents.list (id "${defaultId}").identity.`, - ); - } else { - changes.push("Removed identity (agents.list identity already set)."); - } - agents.list = list; - raw.agents = agents; - delete raw.identity; - }, - }, - { - id: "bind-tailnet->auto", - describe: "Remap gateway/bridge bind 'tailnet' to 'auto'", - apply: (raw, changes) => { - const migrateBind = ( - obj: Record | null | undefined, - key: string, - ) => { - if (!obj) return; - const bind = obj.bind; - if (bind === "tailnet") { - obj.bind = "auto"; - changes.push(`Migrated ${key}.bind from 'tailnet' to 'auto'.`); - } - }; - - const gateway = getRecord(raw.gateway); - migrateBind(gateway, "gateway"); - - const bridge = getRecord(raw.bridge); - migrateBind(bridge, "bridge"); - }, - }, -]; - export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { if (!raw || typeof raw !== "object") return []; const root = raw as Record;