diff --git a/src/config/legacy-migrate.test.ts b/src/config/legacy-migrate.test.ts index 1e19f15e550..4910c7f9488 100644 --- a/src/config/legacy-migrate.test.ts +++ b/src/config/legacy-migrate.test.ts @@ -209,6 +209,22 @@ describe("legacy migrate heartbeat config", () => { expect((res.config as { agent?: unknown } | null)?.agent).toBeUndefined(); }); + it("drops blocked prototype keys when migrating top-level heartbeat", () => { + const res = migrateLegacyConfig( + JSON.parse( + '{"heartbeat":{"every":"30m","__proto__":{"polluted":true},"showOk":true}}', + ) as Record, + ); + + const heartbeat = res.config?.agents?.defaults?.heartbeat as + | Record + | undefined; + expect(heartbeat?.every).toBe("30m"); + expect((heartbeat as { polluted?: unknown } | undefined)?.polluted).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(heartbeat ?? {}, "__proto__")).toBe(false); + expect(res.config?.channels?.defaults?.heartbeat).toEqual({ showOk: true }); + }); + it("records a migration change when removing empty top-level heartbeat", () => { const res = migrateLegacyConfig({ heartbeat: {}, diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts index db4d3a9c9f9..ccc07b4b99f 100644 --- a/src/config/legacy.migrations.part-3.ts +++ b/src/config/legacy.migrations.part-3.ts @@ -15,6 +15,7 @@ import { resolveDefaultAgentIdFromRaw, } from "./legacy.shared.js"; import { DEFAULT_GATEWAY_PORT } from "./paths.js"; +import { isBlockedObjectKey } from "./prototype-keys.js"; const AGENT_HEARTBEAT_KEYS = new Set([ "every", @@ -42,6 +43,9 @@ function splitLegacyHeartbeat(legacyHeartbeat: 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; @@ -61,6 +65,33 @@ function splitLegacyHeartbeat(legacyHeartbeat: Record): { }; } +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; +} + // NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed. // tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod). @@ -119,24 +150,16 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ return; } - const agents = ensureRecord(raw, "agents"); - const defaults = ensureRecord(agents, "defaults"); - const existing = getRecord(defaults.memorySearch); - if (!existing) { - defaults.memorySearch = legacyMemorySearch; - changes.push("Moved memorySearch → agents.defaults.memorySearch."); - } else { - // agents.defaults stays authoritative; legacy top-level config only fills gaps. - const merged = structuredClone(existing); - mergeMissing(merged, legacyMemorySearch); - defaults.memorySearch = merged; - changes.push( + 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).", - ); - } - - agents.defaults = defaults; - raw.agents = agents; + }); delete raw.memorySearch; }, }, @@ -302,45 +325,29 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ const { agentHeartbeat, channelHeartbeat } = splitLegacyHeartbeat(legacyHeartbeat); if (agentHeartbeat) { - const agents = ensureRecord(raw, "agents"); - const defaults = ensureRecord(agents, "defaults"); - const existing = getRecord(defaults.heartbeat); - if (!existing) { - defaults.heartbeat = agentHeartbeat; - changes.push("Moved heartbeat → agents.defaults.heartbeat."); - } else { - // agents.defaults stays authoritative; legacy top-level config only fills gaps. - const merged = structuredClone(existing); - mergeMissing(merged, agentHeartbeat); - defaults.heartbeat = merged; - changes.push( + 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).", - ); - } - - agents.defaults = defaults; - raw.agents = agents; + }); } if (channelHeartbeat) { - const channels = ensureRecord(raw, "channels"); - const defaults = ensureRecord(channels, "defaults"); - const existing = getRecord(defaults.heartbeat); - if (!existing) { - defaults.heartbeat = channelHeartbeat; - changes.push("Moved heartbeat visibility → channels.defaults.heartbeat."); - } else { - // channels.defaults stays authoritative; legacy top-level config only fills gaps. - const merged = structuredClone(existing); - mergeMissing(merged, channelHeartbeat); - defaults.heartbeat = merged; - changes.push( + 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).", - ); - } - - channels.defaults = defaults; - raw.channels = channels; + }); } if (!agentHeartbeat && !channelHeartbeat) {