fix(config): detect top-level heartbeat as invalid config path (#30894) (#32706)

Merged via squash.

Prepared head SHA: 1714ffe6fc1e3ed6a8a120d01d074f1be83c62d3
Co-authored-by: xiwan <931632+xiwan@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
wan.xi
2026-03-04 09:27:04 +08:00
committed by GitHub
parent b7589b32a8
commit caa748b969
8 changed files with 398 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

@@ -600,6 +600,67 @@ describe("doctor config flow", () => {
expectGoogleChatDmAllowFromRepaired(result.cfg);
});
it("migrates top-level heartbeat into agents.defaults.heartbeat on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
heartbeat: {
model: "anthropic/claude-3-5-haiku-20241022",
every: "30m",
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
heartbeat?: unknown;
agents?: {
defaults?: {
heartbeat?: {
model?: string;
every?: string;
};
};
};
};
expect(cfg.heartbeat).toBeUndefined();
expect(cfg.agents?.defaults?.heartbeat).toMatchObject({
model: "anthropic/claude-3-5-haiku-20241022",
every: "30m",
});
});
it("migrates top-level heartbeat visibility into channels.defaults.heartbeat on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
heartbeat: {
showOk: true,
showAlerts: false,
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
heartbeat?: unknown;
channels?: {
defaults?: {
heartbeat?: {
showOk?: boolean;
showAlerts?: boolean;
useIndicator?: boolean;
};
};
};
};
expect(cfg.heartbeat).toBeUndefined();
expect(cfg.channels?.defaults?.heartbeat).toMatchObject({
showOk: true,
showAlerts: false,
});
});
it("repairs googlechat account dm.policy open by setting dm.allowFrom on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,

View File

@@ -274,6 +274,15 @@ describe("legacy config detection", () => {
},
);
});
it("flags top-level heartbeat as legacy in snapshot", async () => {
await withSnapshotForConfig(
{ heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m" } },
async (ctx) => {
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true);
},
);
});
it("flags legacy provider sections in snapshot", async () => {
await withSnapshotForConfig({ whatsapp: { allowFrom: ["+1555"] } }, async (ctx) => {
expect(ctx.snapshot.valid).toBe(false);

View File

@@ -96,6 +96,130 @@ 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("moves top-level heartbeat visibility into channels.defaults.heartbeat", () => {
const res = migrateLegacyConfig({
heartbeat: {
showOk: true,
showAlerts: false,
useIndicator: false,
},
});
expect(res.changes).toContain("Moved heartbeat visibility → channels.defaults.heartbeat.");
expect(res.config?.channels?.defaults?.heartbeat).toEqual({
showOk: true,
showAlerts: false,
useIndicator: false,
});
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();
});
it("keeps explicit channels.defaults.heartbeat values when merging top-level heartbeat visibility", () => {
const res = migrateLegacyConfig({
heartbeat: {
showOk: true,
showAlerts: true,
},
channels: {
defaults: {
heartbeat: {
showOk: false,
useIndicator: false,
},
},
},
});
expect(res.changes).toContain(
"Merged heartbeat visibility → channels.defaults.heartbeat (filled missing fields from legacy; kept explicit channels.defaults values).",
);
expect(res.config?.channels?.defaults?.heartbeat).toEqual({
showOk: false,
showAlerts: true,
useIndicator: false,
});
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
});
it("preserves agent.heartbeat precedence over top-level heartbeat legacy key", () => {
const res = migrateLegacyConfig({
agent: {
heartbeat: {
every: "1h",
target: "telegram",
},
},
heartbeat: {
every: "30m",
target: "discord",
model: "anthropic/claude-3-5-haiku-20241022",
},
});
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();
expect((res.config as { agent?: unknown } | null)?.agent).toBeUndefined();
});
it("records a migration change when removing empty top-level heartbeat", () => {
const res = migrateLegacyConfig({
heartbeat: {},
});
expect(res.changes).toContain("Removed empty top-level heartbeat.");
expect(res.config).not.toBeNull();
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

@@ -16,6 +16,51 @@ import {
} from "./legacy.shared.js";
import { DEFAULT_GATEWAY_PORT } from "./paths.js";
const AGENT_HEARTBEAT_KEYS = new Set([
"every",
"activeHours",
"model",
"session",
"includeReasoning",
"target",
"directPolicy",
"to",
"accountId",
"prompt",
"ackMaxChars",
"suppressToolErrorWarnings",
"lightContext",
]);
const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]);
function splitLegacyHeartbeat(legacyHeartbeat: Record<string, unknown>): {
agentHeartbeat: Record<string, unknown> | null;
channelHeartbeat: Record<string, unknown> | null;
} {
const agentHeartbeat: Record<string, unknown> = {};
const channelHeartbeat: Record<string, unknown> = {};
for (const [key, value] of Object.entries(legacyHeartbeat)) {
if (CHANNEL_HEARTBEAT_KEYS.has(key)) {
channelHeartbeat[key] = value;
continue;
}
if (AGENT_HEARTBEAT_KEYS.has(key)) {
agentHeartbeat[key] = value;
continue;
}
// Preserve unknown fields under the agent heartbeat namespace so validation
// still surfaces unsupported keys instead of silently dropping user input.
agentHeartbeat[key] = value;
}
return {
agentHeartbeat: Object.keys(agentHeartbeat).length > 0 ? agentHeartbeat : null,
channelHeartbeat: Object.keys(channelHeartbeat).length > 0 ? channelHeartbeat : null,
};
}
// NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed.
// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod).
@@ -245,6 +290,65 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
changes.push("Moved agent → agents.defaults.");
},
},
{
id: "heartbeat->agents.defaults.heartbeat",
describe: "Move top-level heartbeat to agents.defaults.heartbeat/channels.defaults.heartbeat",
apply: (raw, changes) => {
const legacyHeartbeat = getRecord(raw.heartbeat);
if (!legacyHeartbeat) {
return;
}
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(
"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(
"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) {
changes.push("Removed empty top-level heartbeat.");
}
delete raw.heartbeat;
},
},
{
id: "identity->agents.list",
describe: "Move identity to agents.list[].identity",

View File

@@ -204,4 +204,9 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
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).",
},
];

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,83 @@
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 (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
},
];
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 (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
);
expect(message).not.toContain("Legacy config entries detected but auto-migration failed.");
});
test("keeps detailed validation errors when heartbeat comes from include-resolved config", async () => {
testState.legacyIssues = [
{
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).",
},
];
// Simulate a parsed source that only contains include directives, while
// legacy heartbeat is surfaced from the resolved config.
testState.legacyParsed = {
$include: ["heartbeat.defaults.json"],
};
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 (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
);
expect(message).not.toContain("Legacy config entries detected but auto-migration failed.");
});
});