diff --git a/src/config/legacy.migrations.part-2.ts b/src/config/legacy.migrations.audio.ts similarity index 90% rename from src/config/legacy.migrations.part-2.ts rename to src/config/legacy.migrations.audio.ts index fa3386bcf66..1375955ec19 100644 --- a/src/config/legacy.migrations.part-2.ts +++ b/src/config/legacy.migrations.audio.ts @@ -1,8 +1,9 @@ import { + defineLegacyConfigMigration, ensureRecord, getRecord, - type LegacyConfigMigration, mapLegacyAudioTranscription, + type LegacyConfigMigrationSpec, } from "./legacy.shared.js"; function applyLegacyAudioTranscriptionModel(params: { @@ -31,8 +32,8 @@ function applyLegacyAudioTranscriptionModel(params: { params.changes.push(params.alreadySetMessage); } -export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [ - { +export const LEGACY_CONFIG_MIGRATIONS_AUDIO: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ id: "audio.transcription-v2", describe: "Move audio.transcription to tools.media.audio.models", apply: (raw, changes) => { @@ -56,5 +57,5 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [ raw.audio = audio; } }, - }, + }), ]; diff --git a/src/config/legacy.migrations.part-1.ts b/src/config/legacy.migrations.channels.ts similarity index 80% rename from src/config/legacy.migrations.part-1.ts rename to src/config/legacy.migrations.channels.ts index 626d9737004..0849577e5b3 100644 --- a/src/config/legacy.migrations.part-1.ts +++ b/src/config/legacy.migrations.channels.ts @@ -6,14 +6,30 @@ import { resolveSlackStreamingMode, resolveTelegramPreviewStreamMode, } from "./discord-preview-streaming.js"; -import { getRecord, type LegacyConfigMigration } from "./legacy.shared.js"; +import { + defineLegacyConfigMigration, + getRecord, + type LegacyConfigMigrationSpec, + type LegacyConfigRule, +} from "./legacy.shared.js"; function hasOwnKey(target: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(target, key); } -function escapeControlForLog(value: string): string { - return value.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t"); +function hasLegacyThreadBindingTtl(value: unknown): boolean { + const threadBindings = getRecord(value); + return Boolean(threadBindings && hasOwnKey(threadBindings, "ttlHours")); +} + +function hasLegacyThreadBindingTtlInAccounts(value: unknown): boolean { + const accounts = getRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((entry) => + hasLegacyThreadBindingTtl(getRecord(entry)?.threadBindings), + ); } function migrateThreadBindingsTtlHoursForPath(params: { @@ -45,11 +61,33 @@ function migrateThreadBindingsTtlHoursForPath(params: { return true; } -export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ +const THREAD_BINDING_RULES: LegacyConfigRule[] = [ { + path: ["session", "threadBindings"], + message: + "session.threadBindings.ttlHours was renamed to session.threadBindings.idleHours (auto-migrated on load).", + match: (value) => hasLegacyThreadBindingTtl(value), + }, + { + path: ["channels", "discord", "threadBindings"], + message: + "channels.discord.threadBindings.ttlHours was renamed to channels.discord.threadBindings.idleHours (auto-migrated on load).", + match: (value) => hasLegacyThreadBindingTtl(value), + }, + { + path: ["channels", "discord", "accounts"], + message: + "channels.discord.accounts..threadBindings.ttlHours was renamed to channels.discord.accounts..threadBindings.idleHours (auto-migrated on load).", + match: (value) => hasLegacyThreadBindingTtlInAccounts(value), + }, +]; + +export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ id: "thread-bindings.ttlHours->idleHours", describe: "Move legacy threadBindings.ttlHours keys to threadBindings.idleHours (session + channels.discord)", + legacyRules: THREAD_BINDING_RULES, apply: (raw, changes) => { const session = getRecord(raw.session); if (session) { @@ -93,8 +131,8 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ channels.discord = discord; raw.channels = channels; }, - }, - { + }), + defineLegacyConfigMigration({ id: "channels.streaming-keys->channels.streaming", describe: "Normalize legacy streaming keys to channels..streaming (Telegram/Discord/Slack)", @@ -196,45 +234,5 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ migrateProvider("discord"); migrateProvider("slack"); }, - }, - { - id: "gateway.bind.host-alias->bind-mode", - describe: "Normalize gateway.bind host aliases to supported bind modes", - 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}".`); - }, - }, + }), ]; diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.runtime.ts similarity index 75% rename from src/config/legacy.migrations.part-3.ts rename to src/config/legacy.migrations.runtime.ts index aad2c948d58..b35a69caab7 100644 --- a/src/config/legacy.migrations.part-3.ts +++ b/src/config/legacy.migrations.runtime.ts @@ -5,10 +5,12 @@ import { resolveGatewayPortWithDefault, } from "./gateway-control-ui-origins.js"; import { + defineLegacyConfigMigration, ensureRecord, getRecord, - type LegacyConfigMigration, mergeMissing, + type LegacyConfigMigrationSpec, + type LegacyConfigRule, } from "./legacy.shared.js"; import { DEFAULT_GATEWAY_PORT } from "./paths.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; @@ -32,6 +34,39 @@ const AGENT_HEARTBEAT_KEYS = new Set([ 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; @@ -140,12 +175,28 @@ function migrateLegacyTtsConfig( } } -// NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed. +const MEMORY_SEARCH_RULE: LegacyConfigRule = { + path: ["memorySearch"], + message: + "top-level memorySearch was moved; use agents.defaults.memorySearch instead (auto-migrated on load).", +}; -// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod). +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, +}; -export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ - { +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 @@ -188,10 +239,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ "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) { @@ -210,8 +262,49 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ }); 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) => { @@ -240,10 +333,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ ); } }, - }, - { + }), + 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) { @@ -283,5 +377,5 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ } delete raw.heartbeat; }, - }, + }), ]; diff --git a/src/config/legacy.migrations.ts b/src/config/legacy.migrations.ts index 4e5cdf2a567..6e6ee7eb3f8 100644 --- a/src/config/legacy.migrations.ts +++ b/src/config/legacy.migrations.ts @@ -1,9 +1,17 @@ -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"; +import { LEGACY_CONFIG_MIGRATIONS_AUDIO } from "./legacy.migrations.audio.js"; +import { LEGACY_CONFIG_MIGRATIONS_CHANNELS } from "./legacy.migrations.channels.js"; +import { LEGACY_CONFIG_MIGRATIONS_RUNTIME } from "./legacy.migrations.runtime.js"; -export const LEGACY_CONFIG_MIGRATIONS = [ - ...LEGACY_CONFIG_MIGRATIONS_PART_1, - ...LEGACY_CONFIG_MIGRATIONS_PART_2, - ...LEGACY_CONFIG_MIGRATIONS_PART_3, +const LEGACY_CONFIG_MIGRATION_SPECS = [ + ...LEGACY_CONFIG_MIGRATIONS_CHANNELS, + ...LEGACY_CONFIG_MIGRATIONS_AUDIO, + ...LEGACY_CONFIG_MIGRATIONS_RUNTIME, ]; + +export const LEGACY_CONFIG_MIGRATIONS = LEGACY_CONFIG_MIGRATION_SPECS.map( + ({ legacyRules: _legacyRules, ...migration }) => migration, +); + +export const LEGACY_CONFIG_MIGRATION_RULES = LEGACY_CONFIG_MIGRATION_SPECS.flatMap( + (migration) => migration.legacyRules ?? [], +); diff --git a/src/config/legacy.rules.ts b/src/config/legacy.rules.ts index 3cecd1a67d3..f752e802747 100644 --- a/src/config/legacy.rules.ts +++ b/src/config/legacy.rules.ts @@ -1,85 +1 @@ -import type { LegacyConfigRule } from "./legacy.shared.js"; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function hasLegacyThreadBindingTtl(value: unknown): boolean { - return isRecord(value) && Object.prototype.hasOwnProperty.call(value, "ttlHours"); -} - -function hasLegacyThreadBindingTtlInAccounts(value: unknown): boolean { - if (!isRecord(value)) { - return false; - } - return Object.values(value).some((entry) => - hasLegacyThreadBindingTtl(isRecord(entry) ? entry.threadBindings : undefined), - ); -} - -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]" - ); -} - -export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ - { - path: ["session", "threadBindings"], - message: - "session.threadBindings.ttlHours was renamed to session.threadBindings.idleHours (auto-migrated on load).", - match: (value) => hasLegacyThreadBindingTtl(value), - }, - { - path: ["channels", "discord", "threadBindings"], - message: - "channels.discord.threadBindings.ttlHours was renamed to channels.discord.threadBindings.idleHours (auto-migrated on load).", - match: (value) => hasLegacyThreadBindingTtl(value), - }, - { - path: ["channels", "discord", "accounts"], - message: - "channels.discord.accounts..threadBindings.ttlHours was renamed to channels.discord.accounts..threadBindings.idleHours (auto-migrated on load).", - match: (value) => hasLegacyThreadBindingTtlInAccounts(value), - }, - { - path: ["memorySearch"], - message: - "top-level memorySearch was moved; use agents.defaults.memorySearch instead (auto-migrated on load).", - }, - { - 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, - }, - { - 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 { LEGACY_CONFIG_MIGRATION_RULES as LEGACY_CONFIG_RULES } from "./legacy.migrations.js"; diff --git a/src/config/legacy.shared.ts b/src/config/legacy.shared.ts index 3fed957d4fd..e97e2d4be70 100644 --- a/src/config/legacy.shared.ts +++ b/src/config/legacy.shared.ts @@ -13,6 +13,10 @@ export type LegacyConfigMigration = { apply: (raw: Record, changes: string[]) => void; }; +export type LegacyConfigMigrationSpec = LegacyConfigMigration & { + legacyRules?: LegacyConfigRule[]; +}; + import { isSafeExecutableValue } from "../infra/exec-safety.js"; import { isRecord } from "../utils.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; @@ -131,3 +135,7 @@ export const ensureAgentEntry = (list: unknown[], id: string): Record migration;