fix: migrate legacy heartbeat config safely

This commit is contained in:
Gustavo Madeira Santana
2026-03-03 19:13:53 -05:00
parent b8d4888f3b
commit e842ec39fc
5 changed files with 133 additions and 10 deletions

View File

@@ -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.

View File

@@ -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({

View File

@@ -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",

View File

@@ -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")}`,
);
}
}
}

View File

@@ -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<ReturnType<typeof startGatewayServer>> | 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.");
});
});