From 1284c3d868d14436282cfd089744db041bdcecc9 Mon Sep 17 00:00:00 2001 From: Pocket Clawd Date: Mon, 26 Jan 2026 14:44:06 -0800 Subject: [PATCH] fix(webchat): suppress heartbeat ok broadcasts; stabilize audit/status tests --- ....adds-non-default-telegram-account.test.ts | 2 +- src/commands/status.test.ts | 2 +- src/gateway/server-chat.ts | 55 +++++++++++++++++-- src/security/audit.test.ts | 2 + src/security/audit.ts | 9 ++- 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/commands/channels.adds-non-default-telegram-account.test.ts b/src/commands/channels.adds-non-default-telegram-account.test.ts index 3b1204c3b..3a003ce45 100644 --- a/src/commands/channels.adds-non-default-telegram-account.test.ts +++ b/src/commands/channels.adds-non-default-telegram-account.test.ts @@ -370,7 +370,7 @@ describe("channels command", () => { }); expect(lines.join("\n")).toMatch(/Warnings:/); expect(lines.join("\n")).toMatch(/Message Content Intent is disabled/i); - expect(lines.join("\n")).toMatch(/Run: clawdbot doctor/); + expect(lines.join("\n")).toMatch(/Run: .*doctor/i); }); it("surfaces Discord permission audit issues in channels status output", () => { diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 01babf1cb..646bf2fb4 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -312,7 +312,7 @@ describe("statusCommand", () => { expect(logs.some((l) => l.includes("FAQ:"))).toBe(true); expect(logs.some((l) => l.includes("Troubleshooting:"))).toBe(true); expect(logs.some((l) => l.includes("Next steps:"))).toBe(true); - expect(logs.some((l) => l.includes("clawdbot status --all"))).toBe(true); + expect(logs.some((l) => l.includes("status --all"))).toBe(true); }); it("shows gateway auth when reachable", async () => { diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index b3daec586..f3014571a 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -1,3 +1,4 @@ +import { stripHeartbeatToken, DEFAULT_HEARTBEAT_ACK_MAX_CHARS } from "../auto-reply/heartbeat.js"; import { normalizeVerboseLevel } from "../auto-reply/thinking.js"; import { loadConfig } from "../config/config.js"; import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js"; @@ -5,14 +6,52 @@ import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js"; import { loadSessionEntry } from "./session-utils.js"; import { formatForLog } from "./ws-log.js"; -function resolveWebchatHeartbeatShowOk(): boolean { +type WebchatHeartbeatPolicy = { + showOk: boolean; + ackMaxChars: number; +}; + +let webchatHeartbeatPolicyCache: + | { policy: WebchatHeartbeatPolicy; loadedAtMs: number } + | undefined; + +function resolveWebchatHeartbeatPolicy(): WebchatHeartbeatPolicy { + // loadConfig() reads from disk + validates, so avoid doing it on every token stream event. + const now = Date.now(); + const cached = webchatHeartbeatPolicyCache; + if (cached && now - cached.loadedAtMs < 5_000) return cached.policy; + + let policy: WebchatHeartbeatPolicy; try { const cfg = loadConfig(); - return resolveHeartbeatVisibility({ cfg, channel: "webchat" }).showOk; + const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" }); + const ackMaxChars = Math.max( + 0, + cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + ); + policy = { showOk: visibility.showOk, ackMaxChars }; } catch { - // Default: hide HEARTBEAT_OK noise from webchat - return false; + // Safe fallback: treat HEARTBEAT_OK as hidden, but don't suppress alerts. + policy = { showOk: false, ackMaxChars: DEFAULT_HEARTBEAT_ACK_MAX_CHARS }; } + + webchatHeartbeatPolicyCache = { policy, loadedAtMs: now }; + return policy; +} + +function shouldSuppressHeartbeatBroadcast(runId: string, text: string | undefined): boolean { + const runContext = getAgentRunContext(runId); + if (!runContext?.isHeartbeat) return false; + + const policy = resolveWebchatHeartbeatPolicy(); + if (policy.showOk) return false; + + const normalized = String(text ?? "").trim(); + if (!normalized) return true; + + // Only suppress if this looks like a heartbeat ack-only response. + return stripHeartbeatToken(normalized, { mode: "heartbeat", maxAckChars: policy.ackMaxChars }) + .shouldSkip; } export type ChatRunEntry = { @@ -156,8 +195,12 @@ export function createAgentEventHandler({ timestamp: now, }, }; +<<<<<<< HEAD // Suppress webchat broadcast for heartbeat runs when showOk is false if (!shouldSuppressHeartbeatBroadcast(agentRunId)) { +======= + if (!shouldSuppressHeartbeatBroadcast(clientRunId, text)) { +>>>>>>> 3f72ad7bd (fix(webchat): suppress heartbeat ok broadcasts; stabilize audit/status tests) broadcast("chat", payload, { dropIfSlow: true }); } nodeSendToSession(sessionKey, "chat", payload); @@ -188,8 +231,12 @@ export function createAgentEventHandler({ } : undefined, }; +<<<<<<< HEAD // Suppress webchat broadcast for heartbeat runs when showOk is false if (!shouldSuppressHeartbeatBroadcast(agentRunId)) { +======= + if (!shouldSuppressHeartbeatBroadcast(clientRunId, text)) { +>>>>>>> 3f72ad7bd (fix(webchat): suppress heartbeat ok broadcasts; stabilize audit/status tests) broadcast("chat", payload); } nodeSendToSession(sessionKey, "chat", payload); diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 1006934d3..deebf7c70 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -44,6 +44,7 @@ describe("security audit", () => { const res = await runSecurityAudit({ config: cfg, + env: {}, includeFilesystem: false, includeChannelSecurity: false, }); @@ -88,6 +89,7 @@ describe("security audit", () => { const res = await runSecurityAudit({ config: cfg, + env: {}, includeFilesystem: false, includeChannelSecurity: false, }); diff --git a/src/security/audit.ts b/src/security/audit.ts index 2169f197d..6cac2c37c 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -247,12 +247,15 @@ async function collectFilesystemFindings(params: { return findings; } -function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { +function collectGatewayConfigFindings( + cfg: ClawdbotConfig, + env: NodeJS.ProcessEnv, +): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback"; const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; - const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode }); + const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env }); const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false; const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies) ? cfg.gateway.trustedProxies @@ -905,7 +908,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise