diff --git a/CHANGELOG.md b/CHANGELOG.md index 06c04ecce..3d7059b51 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/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index ff4763987..64db401e7 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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, diff --git a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts index 87df61303..89632bbc5 100644 --- a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts +++ b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts @@ -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); diff --git a/src/config/legacy-migrate.test.ts b/src/config/legacy-migrate.test.ts index 63d971af0..1e19f15e5 100644 --- a/src/config/legacy-migrate.test.ts +++ b/src/config/legacy-migrate.test.ts @@ -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({ diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts index 3ce29ea63..db4d3a9c9 100644 --- a/src/config/legacy.migrations.part-3.ts +++ b/src/config/legacy.migrations.part-3.ts @@ -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): { + agentHeartbeat: Record | null; + channelHeartbeat: Record | null; +} { + const agentHeartbeat: Record = {}; + const channelHeartbeat: Record = {}; + + 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", diff --git a/src/config/legacy.rules.ts b/src/config/legacy.rules.ts index 9f4ef6098..420f6a468 100644 --- a/src/config/legacy.rules.ts +++ b/src/config/legacy.rules.ts @@ -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).", + }, ]; diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index d714ea61e..bd4ae5078 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 000000000..0522f8a85 --- /dev/null +++ b/src/gateway/server.legacy-migration.test.ts @@ -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> | 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> | 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."); + }); +});