From e5481ac79fe33ffaf8a3c638244e159fc3a04894 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 5 Mar 2026 23:22:39 -0500 Subject: [PATCH] Doctor: warn on implicit heartbeat directPolicy (#36789) * Changelog: note heartbeat directPolicy doctor warning * Tests: cover heartbeat directPolicy doctor warning * Doctor: warn on implicit heartbeat directPolicy * Tests: cover per-agent heartbeat directPolicy warning * Update CHANGELOG.md --- CHANGELOG.md | 1 + src/commands/doctor-security.test.ts | 62 ++++++++++++++++++++++++++++ src/commands/doctor-security.ts | 41 ++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f5be40df55..fd7351d59d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -170,6 +170,7 @@ Docs: https://docs.openclaw.ai - Agents/failover cooldown classification: stop treating generic `cooling down` text as provider `rate_limit` so healthy models no longer show false global cooldown/rate-limit warnings while explicit `model_cooldown` markers still trigger failover. (#32972) thanks @stakeswky. - Agents/failover service-unavailable handling: stop treating bare proxy/CDN `service unavailable` errors as provider overload while keeping them retryable via the timeout/failover path, so transient outages no longer show false rate-limit warnings or block fallback. (#36646) thanks @jnMetaCode. +- Doctor/Heartbeat upgrade diagnostics: warn when heartbeat delivery is configured with an implicit `directPolicy` so upgrades pin direct/DM behavior explicitly instead of relying on the current default. (#36789) Thanks @vincentkoc. - Agents/current-time UTC anchor: append a machine-readable UTC suffix alongside local `Current time:` lines in shared cron-style prompt contexts so agents can compare UTC-stamped workspace timestamps without doing timezone math. (#32423) thanks @jriff. - TUI/webchat command-owner scope alignment: treat internal-channel gateway sessions with `operator.admin` as owner-authorized in command auth, restoring cron/gateway/connector tool access for affected TUI/webchat sessions while keeping external channels on identity-based owner checks. (from #35666, #35673, #35704) Thanks @Naylenv, @Octane0411, and @Sid-Qin. - Discord/inbound timeout isolation: separate inbound worker timeout tracking from listener timeout budgets so queued Discord replies are no longer dropped when listener watchdog windows expire mid-run. (#36602) Thanks @dutifulbob. diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index 064f3ce1f76..c91ed2087a4 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -135,4 +135,66 @@ describe("noteSecurityWarnings gateway exposure", () => { expect(message).toContain("exec-approvals.json"); expect(message).toContain("openclaw approvals get --gateway"); }); + + it("warns when heartbeat delivery relies on implicit directPolicy defaults", async () => { + const cfg = { + agents: { + defaults: { + heartbeat: { + target: "last", + }, + }, + }, + } as OpenClawConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).toContain("Heartbeat defaults"); + expect(message).toContain("agents.defaults.heartbeat.directPolicy"); + expect(message).toContain("direct/DM targets by default"); + }); + + it("warns when a per-agent heartbeat relies on implicit directPolicy", async () => { + const cfg = { + agents: { + list: [ + { + id: "ops", + heartbeat: { + target: "last", + }, + }, + ], + }, + } as OpenClawConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).toContain('Heartbeat agent "ops"'); + expect(message).toContain('heartbeat.directPolicy for agent "ops"'); + expect(message).toContain("direct/DM targets by default"); + }); + + it("skips heartbeat directPolicy warning when delivery is internal-only or explicit", async () => { + const cfg = { + agents: { + defaults: { + heartbeat: { + target: "none", + }, + }, + list: [ + { + id: "ops", + heartbeat: { + target: "last", + directPolicy: "block", + }, + }, + ], + }, + } as OpenClawConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).not.toContain("Heartbeat defaults"); + expect(message).not.toContain('Heartbeat agent "ops"'); + }); }); diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index ab1b4605608..5ba17c1c751 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -2,6 +2,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig, GatewayBindMode } from "../config/config.js"; +import type { AgentConfig } from "../config/types.agents.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; @@ -9,6 +10,44 @@ import { resolveDmAllowState } from "../security/dm-policy-shared.js"; import { note } from "../terminal/note.js"; import { resolveDefaultChannelAccountContext } from "./channel-account-context.js"; +function collectImplicitHeartbeatDirectPolicyWarnings(cfg: OpenClawConfig): string[] { + const warnings: string[] = []; + + const maybeWarn = (params: { + label: string; + heartbeat: AgentConfig["heartbeat"] | undefined; + pathHint: string; + }) => { + const heartbeat = params.heartbeat; + if (!heartbeat || heartbeat.target === undefined || heartbeat.target === "none") { + return; + } + if (heartbeat.directPolicy !== undefined) { + return; + } + warnings.push( + `- ${params.label}: heartbeat delivery is configured while ${params.pathHint} is unset.`, + ' Heartbeat now allows direct/DM targets by default. Set it explicitly to "allow" or "block" to pin upgrade behavior.', + ); + }; + + maybeWarn({ + label: "Heartbeat defaults", + heartbeat: cfg.agents?.defaults?.heartbeat, + pathHint: "agents.defaults.heartbeat.directPolicy", + }); + + for (const agent of cfg.agents?.list ?? []) { + maybeWarn({ + label: `Heartbeat agent "${agent.id}"`, + heartbeat: agent.heartbeat, + pathHint: `heartbeat.directPolicy for agent "${agent.id}"`, + }); + } + + return warnings; +} + export async function noteSecurityWarnings(cfg: OpenClawConfig) { const warnings: string[] = []; const auditHint = `- Run: ${formatCliCommand("openclaw security audit --deep")}`; @@ -21,6 +60,8 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { ); } + warnings.push(...collectImplicitHeartbeatDirectPolicyWarnings(cfg)); + // =========================================== // GATEWAY NETWORK EXPOSURE CHECK // ===========================================