From e842ec39fcd3ca4125ef14ce1f29d9124e24013c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 3 Mar 2026 19:13:53 -0500 Subject: [PATCH] fix: migrate legacy heartbeat config safely --- CHANGELOG.md | 1 + src/config/legacy-migrate.test.ts | 45 ++++++++++++++++++++ src/config/legacy.migrations.part-3.ts | 30 ++++++++++++++ src/gateway/server.impl.ts | 21 +++++----- src/gateway/server.legacy-migration.test.ts | 46 +++++++++++++++++++++ 5 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 src/gateway/server.legacy-migration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 06c04eccee3..3d7059b5175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Gateway/security default response headers: add `Permissions-Policy: camera=(), microphone=(), geolocation=()` to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan. - Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into `openclaw/plugin-sdk/core` and `openclaw/plugin-sdk/telegram`, and preserve `api.runtime` reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy. - Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras. +- Config/heartbeat legacy-path handling: auto-migrate top-level `heartbeat` into `agents.defaults.heartbeat` (with merge semantics that preserve explicit defaults), and keep startup failures on non-migratable legacy entries in the detailed invalid-config path instead of generic migration-failed errors. (#32706) thanks @xiwan. - Security/auth labels: remove token and API-key snippets from user-facing auth status labels so `/status` and `/models` do not expose credential fragments. (#33262) thanks @cu1ch3n. - Security/audit denyCommands guidance: suggest likely exact node command IDs for unknown `gateway.nodes.denyCommands` entries so ineffective denylist entries are easier to correct. (#29713) thanks @liquidhorizon88-bot. - Docs/security hardening guidance: document Docker `DOCKER-USER` + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan. diff --git a/src/config/legacy-migrate.test.ts b/src/config/legacy-migrate.test.ts index 63d971af0d4..6e116772e54 100644 --- a/src/config/legacy-migrate.test.ts +++ b/src/config/legacy-migrate.test.ts @@ -96,6 +96,51 @@ describe("legacy migrate mention routing", () => { }); }); +describe("legacy migrate heartbeat config", () => { + it("moves top-level heartbeat into agents.defaults.heartbeat", () => { + const res = migrateLegacyConfig({ + heartbeat: { + model: "anthropic/claude-3-5-haiku-20241022", + every: "30m", + }, + }); + + expect(res.changes).toContain("Moved heartbeat → agents.defaults.heartbeat."); + expect(res.config?.agents?.defaults?.heartbeat).toEqual({ + model: "anthropic/claude-3-5-haiku-20241022", + every: "30m", + }); + expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined(); + }); + + it("keeps explicit agents.defaults.heartbeat values when merging top-level heartbeat", () => { + const res = migrateLegacyConfig({ + heartbeat: { + model: "anthropic/claude-3-5-haiku-20241022", + every: "30m", + }, + agents: { + defaults: { + heartbeat: { + every: "1h", + target: "telegram", + }, + }, + }, + }); + + expect(res.changes).toContain( + "Merged heartbeat → agents.defaults.heartbeat (filled missing fields from legacy; kept explicit agents.defaults values).", + ); + expect(res.config?.agents?.defaults?.heartbeat).toEqual({ + every: "1h", + target: "telegram", + model: "anthropic/claude-3-5-haiku-20241022", + }); + expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined(); + }); +}); + describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { it("seeds allowedOrigins for bind=lan with no existing controlUi config", () => { const res = migrateLegacyConfig({ diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts index 3ce29ea638b..2ac8fa20e73 100644 --- a/src/config/legacy.migrations.part-3.ts +++ b/src/config/legacy.migrations.part-3.ts @@ -95,6 +95,36 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ delete raw.memorySearch; }, }, + { + id: "heartbeat->agents.defaults.heartbeat", + describe: "Move top-level heartbeat to agents.defaults.heartbeat", + apply: (raw, changes) => { + const legacyHeartbeat = getRecord(raw.heartbeat); + if (!legacyHeartbeat) { + return; + } + + const agents = ensureRecord(raw, "agents"); + const defaults = ensureRecord(agents, "defaults"); + const existing = getRecord(defaults.heartbeat); + if (!existing) { + defaults.heartbeat = legacyHeartbeat; + 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, legacyHeartbeat); + defaults.heartbeat = merged; + changes.push( + "Merged heartbeat → agents.defaults.heartbeat (filled missing fields from legacy; kept explicit agents.defaults values).", + ); + } + + agents.defaults = defaults; + raw.agents = agents; + delete raw.heartbeat; + }, + }, { id: "auth.anthropic-claude-cli-mode-oauth", describe: "Switch anthropic:claude-cli auth profile mode to oauth", diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index d714ea61eeb..bd4ae507861 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -256,17 +256,18 @@ export async function startGatewayServer( } const { config: migrated, changes } = migrateLegacyConfig(configSnapshot.parsed); if (!migrated) { - throw new Error( - `Legacy config entries detected but auto-migration failed. Run "${formatCliCommand("openclaw doctor")}" to migrate.`, - ); - } - await writeConfigFile(migrated); - if (changes.length > 0) { - log.info( - `gateway: migrated legacy config entries:\n${changes - .map((entry) => `- ${entry}`) - .join("\n")}`, + log.warn( + "gateway: legacy config entries detected but no auto-migration changes were produced; continuing with validation.", ); + } else { + await writeConfigFile(migrated); + if (changes.length > 0) { + log.info( + `gateway: migrated legacy config entries:\n${changes + .map((entry) => `- ${entry}`) + .join("\n")}`, + ); + } } } diff --git a/src/gateway/server.legacy-migration.test.ts b/src/gateway/server.legacy-migration.test.ts new file mode 100644 index 00000000000..f24501ebfa1 --- /dev/null +++ b/src/gateway/server.legacy-migration.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; +import { + getFreePort, + installGatewayTestHooks, + startGatewayServer, + testState, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +describe("gateway startup legacy migration fallback", () => { + test("surfaces detailed validation errors when legacy entries have no migration output", async () => { + testState.legacyIssues = [ + { + path: "heartbeat", + message: + "top-level heartbeat is not a valid config path; use agents.defaults.heartbeat instead.", + }, + ]; + testState.legacyParsed = { + heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m" }, + }; + testState.migrationConfig = null; + testState.migrationChanges = []; + + let server: Awaited> | undefined; + let thrown: unknown; + try { + server = await startGatewayServer(await getFreePort()); + } catch (err) { + thrown = err; + } + + if (server) { + await server.close(); + } + + expect(thrown).toBeInstanceOf(Error); + const message = String((thrown as Error).message); + expect(message).toContain("Invalid config at"); + expect(message).toContain( + "heartbeat: top-level heartbeat is not a valid config path; use agents.defaults.heartbeat instead.", + ); + expect(message).not.toContain("Legacy config entries detected but auto-migration failed."); + }); +});