From 48fc16c1a4b767d8a0b17db43473d4e943c1278a Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 16 Jul 2026 16:02:51 +0530 Subject: [PATCH 01/17] fix(whatsapp): report the paired in-process bridge in channels status and refactor Signed-off-by: Hung Le --- .../actions/sandbox/channel-status-config.ts | 2 +- src/lib/actions/sandbox/channel-status.ts | 312 +------------- src/lib/messaging/channels/manifests.test.ts | 3 + .../channels/whatsapp/hooks/index.ts | 26 ++ .../hooks/status-health-eval.test.ts} | 2 +- .../whatsapp/hooks/status-health-eval.ts} | 47 +-- .../whatsapp/hooks/status-health.test.ts | 274 +++++++++++++ .../channels/whatsapp/hooks/status-health.ts | 386 ++++++++++++++++++ .../messaging/channels/whatsapp/manifest.ts | 15 +- src/lib/messaging/hooks/builtins.ts | 8 + src/lib/messaging/hooks/hook-runner.test.ts | 1 + 11 files changed, 756 insertions(+), 320 deletions(-) create mode 100644 src/lib/messaging/channels/whatsapp/hooks/index.ts rename src/lib/{sandbox/whatsapp-diagnostics.test.ts => messaging/channels/whatsapp/hooks/status-health-eval.test.ts} (99%) rename src/lib/{sandbox/whatsapp-diagnostics.ts => messaging/channels/whatsapp/hooks/status-health-eval.ts} (94%) create mode 100644 src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts create mode 100644 src/lib/messaging/channels/whatsapp/hooks/status-health.ts diff --git a/src/lib/actions/sandbox/channel-status-config.ts b/src/lib/actions/sandbox/channel-status-config.ts index 779f8c74a1a..ef6af42f432 100644 --- a/src/lib/actions/sandbox/channel-status-config.ts +++ b/src/lib/actions/sandbox/channel-status-config.ts @@ -16,13 +16,13 @@ import { getBuiltInRenderedConfigParser, tryGetMessagingAgentId, } from "../../messaging"; +import type { DiagnosticSignal } from "../../messaging/channels/channel-health"; import type { ChannelConfigInputSpec, MessagingAgentId, MessagingSerializableValue, SandboxMessagingInputReference, } from "../../messaging/manifest"; -import type { DiagnosticSignal } from "../../sandbox/whatsapp-diagnostics"; import * as registry from "../../state/registry"; import { booleanConfigValue, diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index 8e063a0dae9..2c89a955ce7 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -14,7 +14,6 @@ import { type AgentDefinition, loadAgent } from "../../agent/defs"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; import { B, D, G, R, RD, YW } from "../../cli/terminal-style"; -import { shellQuote as quotePath } from "../../core/shell-quote"; import { createBuiltInChannelManifestRegistry, getMessagingManifestAvailabilityContext, @@ -22,7 +21,10 @@ import { import { type ChannelHealthReport, channelHealthProbeInputs, + type DiagnosticSeverity, + type DiagnosticSignal, } from "../../messaging/channels/channel-health"; +import type { WhatsappDiagnosticReport } from "../../messaging/channels/whatsapp/hooks/status-health-eval"; import { collectBuiltInMessagingChannelDiagnostics, type MessagingChannelDiagnosticSpec, @@ -33,16 +35,6 @@ import { runMessagingStatusHooks, } from "../../messaging/hooks/status-runner"; import * as policies from "../../policy"; -import { - type DiagnosticSeverity, - type DiagnosticSignal, - evaluateWhatsappDiagnostics, - parseWhatsappHeartbeat, - summarizeWhatsappLogLines, - type WhatsappDiagnosticReport, - type WhatsappHeartbeat, - type WhatsappProbeInput, -} from "../../sandbox/whatsapp-diagnostics"; import * as registry from "../../state/registry"; import { buildConfigStatusSignals } from "./channel-status-config"; @@ -108,21 +100,9 @@ export type ChannelStatusReport = channels: ChannelStatusSingleReport[]; }; -// Bound how long we are willing to block inside an `openshell sandbox exec` -// for the inline diagnostic snippet. WhatsApp's bridge sometimes goes -// unresponsive when the Noise WebSocket is stuck; a fast hard cap keeps -// channels status from inheriting that hang. -const WHATSAPP_PROBE_TIMEOUT_MS = 8_000; const CHANNEL_STATUS_DIAGNOSTICS = collectBuiltInMessagingChannelDiagnostics(); const channelManifestRegistry = createBuiltInChannelManifestRegistry(); -const SHELL_OK = "NEMOCLAW_WA_DIAG_OK"; -const HEARTBEAT_BEGIN = "NEMOCLAW_WA_HEARTBEAT_BEGIN"; -const HEARTBEAT_END = "NEMOCLAW_WA_HEARTBEAT_END"; -const LOG_BEGIN = "NEMOCLAW_WA_LOG_BEGIN"; -const LOG_END = "NEMOCLAW_WA_LOG_END"; -const PROC_DONE = "NEMOCLAW_WA_PROC_DONE"; - function severityLabel(severity: DiagnosticSeverity): string { switch (severity) { case "ok": @@ -167,256 +147,6 @@ function diagnosticChannelNames(): string[] { return CHANNEL_STATUS_DIAGNOSTICS.map((diagnostic) => diagnostic.channelId); } -function resolveStateDirs(agent: AgentDefinition): string[] { - const configDir = agent.configPaths?.dir; - if (!configDir) return []; - const stateDirs = new Set(agent.stateDirs ?? []); - // The two known WhatsApp bridge layouts: - // OpenClaw: /whatsapp - // Hermes: /platforms/whatsapp/session - // We probe the session subdirectory for Hermes because the agent manifest - // pre-creates the parent `platforms/whatsapp` directory at provisioning - // time so the state_dirs backup can preserve it across rebuilds. A fresh - // unpaired sandbox therefore already has a non-empty `platforms/whatsapp` - // directory — only the `session` subdir is created after a successful - // QR pairing. - const candidates: string[] = []; - if (stateDirs.has("whatsapp")) candidates.push(`${configDir}/whatsapp`); - if (stateDirs.has("platforms")) candidates.push(`${configDir}/platforms/whatsapp/session`); - if (candidates.length === 0) { - // Fallback: probe both shapes even when the manifest does not declare - // the dir — best-effort but safe because non-existent paths just yield - // "missing" probe output. - candidates.push(`${configDir}/whatsapp`, `${configDir}/platforms/whatsapp/session`); - } - return Array.from(new Set(candidates)); -} - -function buildProbeScript(stateDirs: readonly string[]): string { - // The script: - // 1. Marks success with SHELL_OK so we can disambiguate "exec failed" from - // "exec succeeded but produced nothing". - // 2. Lists each candidate state directory and emits a single "POPULATED" - // or "EMPTY" / "MISSING" line per dir. - // 3. Cats the first heartbeat-shaped file it finds, wrapped in begin/end - // markers so the parser can extract it without parsing find output. - // 4. Tails up to 200 lines of bridge log files and forwards only short - // lines that match the diagnostic regex set. The host parser further - // filters to summary phrases. - // 5. Runs pgrep for known bridge process names, then filters out the probe - // shell itself and the pgrep call so the diagnostic does not report a - // bridge as "running" when the only match is our own command line. - // The script is joined with newlines so the embedded `for` / `if` - // constructs parse as compound statements. Joining the whole thing with - // ` && ` corrupts the grammar (e.g. `do && if`), which `/bin/sh` rejects - // before the SHELL_OK marker prints and every live probe gets misread as - // unreachable. The leading `set +e` makes the probe survive missing log - // files and empty pgrep matches without aborting at the first non-zero - // exit. - const quotedDirs = stateDirs.map(quotePath).join(" "); - return [ - `set +e`, - `printf '%s\\n' ${quotePath(SHELL_OK)}`, - `for dir in ${quotedDirs}; do`, - ` if [ ! -d "$dir" ]; then printf 'DIR %s MISSING\\n' "$dir"; continue; fi`, - ` if [ -z "$(ls -A "$dir" 2>/dev/null)" ]; then`, - ` printf 'DIR %s EMPTY\\n' "$dir"`, - ` else`, - ` printf 'DIR %s POPULATED\\n' "$dir"`, - ` fi`, - `done`, - `for dir in ${quotedDirs}; do`, - ` for candidate in heartbeat.json status.json health.json bridge-status.json; do`, - ` if [ -f "$dir/$candidate" ]; then`, - ` printf '%s\\n' ${quotePath(HEARTBEAT_BEGIN)}`, - ` cat "$dir/$candidate" 2>/dev/null | head -c 8192`, - ` printf '\\n%s\\n' ${quotePath(HEARTBEAT_END)}`, - ` break 2`, - ` fi`, - ` done`, - `done`, - `printf '%s\\n' ${quotePath(LOG_BEGIN)}`, - `for dir in ${quotedDirs}; do`, - ` for log in "$dir"/*.log "$dir"/logs/*.log; do`, - ` [ -f "$log" ] || continue`, - ` tail -n 200 "$log" 2>/dev/null | grep -E 'connection\\.(open|close|update|update.*restart)|ws (open|close)|401|unauthorized|qr.*(expired|timeout)|restartRequired|loggedOut|logged out|getMessage' | tail -n 20`, - ` done`, - `done`, - `printf '%s\\n' ${quotePath(LOG_END)}`, - `__nemoclaw_wa_self_pid=$$`, - // Match both process-name-with-whatsapp and processes whose argv - // mentions the WhatsApp state directory or known plugin paths. A - // bridge that runs inside the parent agent process (e.g. an OpenClaw - // plugin loaded via a generic `node` entry point) usually carries the - // platforms/whatsapp path on its command line via `--state-dir` or - // similar. - `pgrep -fa 'whatsapp|baileys|platforms/whatsapp|openclaw-whatsapp|hermes.*whatsapp' 2>/dev/null | awk -v self="$__nemoclaw_wa_self_pid" '$1 != self && $0 !~ /pgrep -fa/ && $0 !~ /NEMOCLAW_WA_DIAG_OK/ { print "PROC " $0 }' | head -n 5`, - // Always emit PROC_DONE after the pgrep pipeline so the parser can tell - // apart "pgrep completed with no matches" (the bridge runs under a - // process name that does not contain `whatsapp` or `baileys`, or has - // crashed) from "the probe never reached pgrep" (script aborted - // mid-flight). Without this marker both cases collapse to `null`. - `printf '%s\\n' ${quotePath(PROC_DONE)}`, - ].join("\n"); -} - -type ParsedProbe = { - reachable: boolean; - stateDirPopulated: boolean | null; - heartbeatRaw: string | null; - logLines: string[]; - bridgeProcessAlive: boolean | null; -}; - -function parseProbeOutput(stdout: string): ParsedProbe { - const lines = stdout.split(/\r?\n/); - if (!lines.includes(SHELL_OK)) { - return { - reachable: false, - stateDirPopulated: null, - heartbeatRaw: null, - logLines: [], - bridgeProcessAlive: null, - }; - } - let stateDirPopulated: boolean | null = false; - let sawAnyDir = false; - let heartbeatRaw: string | null = null; - let inHeartbeat = false; - let inLogs = false; - const heartbeatBuf: string[] = []; - const logLines: string[] = []; - let sawProcMatch = false; - let sawProcDone = false; - - for (const line of lines) { - if (line === HEARTBEAT_BEGIN) { - inHeartbeat = true; - continue; - } - if (line === HEARTBEAT_END) { - inHeartbeat = false; - heartbeatRaw = heartbeatBuf.join("\n").trim(); - continue; - } - if (line === LOG_BEGIN) { - inLogs = true; - continue; - } - if (line === LOG_END) { - inLogs = false; - continue; - } - if (inHeartbeat) { - heartbeatBuf.push(line); - continue; - } - if (inLogs) { - const trimmed = line.trim(); - if (trimmed.length > 0) logLines.push(trimmed); - continue; - } - const dirMatch = line.match(/^DIR\s+\S+\s+(MISSING|EMPTY|POPULATED)$/); - if (dirMatch) { - sawAnyDir = true; - if (dirMatch[1] === "POPULATED") stateDirPopulated = true; - continue; - } - if (line.startsWith("PROC ")) { - sawProcMatch = true; - continue; - } - if (line === PROC_DONE) { - sawProcDone = true; - continue; - } - } - // Three states: - // true → pgrep printed at least one matching process - // false → pgrep completed with no matches; either the bridge is dead - // OR it runs inside the parent agent process under a name that - // does not contain `whatsapp`/`baileys`. The evaluator resolves - // that ambiguity using heartbeat freshness. - // null → the probe aborted before reaching pgrep (timeout, exec - // failure); we cannot infer anything about the bridge state. - let bridgeProcessAliveOut: boolean | null; - if (sawProcMatch) { - bridgeProcessAliveOut = true; - } else if (sawProcDone) { - bridgeProcessAliveOut = false; - } else { - bridgeProcessAliveOut = null; - } - return { - reachable: true, - stateDirPopulated: sawAnyDir ? stateDirPopulated : null, - heartbeatRaw, - logLines, - bridgeProcessAlive: bridgeProcessAliveOut, - }; -} - -function buildWhatsappProbeInput( - sandboxName: string, - agent: AgentDefinition, - deps: Required, -): WhatsappProbeInput { - const stateDirs = resolveStateDirs(agent); - const script = buildProbeScript(stateDirs); - const probedAt = deps.now().toISOString(); - const exec = deps.execSandbox(sandboxName, script, WHATSAPP_PROBE_TIMEOUT_MS); - const parsed = exec - ? parseProbeOutput(exec.stdout) - : { - reachable: false, - stateDirPopulated: null, - heartbeatRaw: null, - logLines: [], - bridgeProcessAlive: null, - }; - - let heartbeat: WhatsappHeartbeat | null = null; - let heartbeatParseError: string | null = null; - if (parsed.heartbeatRaw) { - const parseResult = parseWhatsappHeartbeat(parsed.heartbeatRaw); - if ("heartbeat" in parseResult) { - heartbeat = parseResult.heartbeat; - } else { - heartbeatParseError = parseResult.parseError; - } - } - - const entry = deps.getSandbox(sandboxName); - const channelEnabledInRegistry = registry - .getConfiguredMessagingChannelsFromEntry(entry) - .includes("whatsapp"); - - const appliedPresets = deps.getAppliedPresets(sandboxName); - const presetInRegistry = appliedPresets.includes("whatsapp"); - let presetOnGateway: boolean | null = null; - try { - const gatewayPresets = deps.getGatewayPresets(sandboxName); - presetOnGateway = gatewayPresets === null ? null : gatewayPresets.includes("whatsapp"); - } catch { - presetOnGateway = null; - } - - return { - agent: agent.name, - stateDirs, - stateDirPopulated: parsed.stateDirPopulated, - heartbeat, - heartbeatParseError, - bridgeProcessAlive: parsed.bridgeProcessAlive, - recentLogSignals: summarizeWhatsappLogLines(parsed.logLines), - probeReachable: parsed.reachable, - probedAt, - presetInRegistry, - presetOnGateway, - channelEnabledInRegistry, - }; -} - function renderReport( report: ChannelStatusReport, asJson: boolean, @@ -607,13 +337,14 @@ function channelSupportedByAgent(channelName: string, agent: AgentDefinition): b .some((manifest) => manifest.id === channelName); } -// Runs a `log-tail` deep-probe channel's `phase:"status"` health hook through -// the generic status-hook runner and returns its channel-health report. All +// Runs a deep-probe channel's `phase:"status"` health hook through the +// generic status-hook runner and returns its channel-health report. All // channel-specific probing + classification lives in the channel's own hook -// (e.g. channels/telegram/hooks/status-health.ts); this stays channel-agnostic. -// The hook's own `agents` gate skips channels with no breadcrumb producer for -// the requested agent (e.g. Hermes), so the caller falls back to the basic -// report when no health output is returned. +// (e.g. channels/telegram/hooks/status-health.ts, channels/whatsapp/hooks); +// this stays channel-agnostic. The hook's own `agents` gate skips channels +// with no breadcrumb producer for the requested agent (e.g. Hermes +// telegram), so the caller falls back to the basic report when no health +// output is returned. function runChannelHealthHook( sandboxName: string, channelName: string, @@ -733,25 +464,18 @@ export async function showSandboxChannelStatus( const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(entry)); const channelIsPaused = disabledChannels.has(channelName); - // A `log-tail` deep-probe channel runs its `phase:"status"` health hook via - // the generic status-hook runner (the hook lives in the channel folder). The - // hook's `agents` gate skips channels with no breadcrumb producer for this - // agent (e.g. Hermes telegram), so those fall back to the basic config report. + // A deep-probe channel (log-tail or in-sandbox-qr) runs its `phase:"status"` + // health hook via the generic status-hook runner (the hook lives in the + // channel folder). The hook's `agents` gate skips channels with no + // breadcrumb producer for this agent (e.g. Hermes telegram), so those fall + // back to the basic config report. const healthReport = - diagnostic.deepProbe === "log-tail" && !channelIsPaused + (diagnostic.deepProbe === "log-tail" || diagnostic.deepProbe === "in-sandbox-qr") && + !channelIsPaused ? runChannelHealthHook(sandboxName, channelName, agent, deps, diagnostic) : undefined; let report: ChannelStatusReport; - if (diagnostic.deepProbe === "in-sandbox-qr" && !channelIsPaused) { - const input = buildWhatsappProbeInput(sandboxName, agent, deps); - const whatsappReport = evaluateWhatsappDiagnostics(input); - report = { - schemaVersion: 1, - sandbox: sandboxName, - channel: channelName, - report: whatsappReport, - }; - } else if (healthReport) { + if (healthReport) { // Append the config-value signals (#5691/#5695: group policy, mention mode, // allowed IDs) the basic report shows, so `--channel ` reports both the // channel config and live runtime health. diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index f1c1b61b4f9..a8d9d0eed1e 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -86,6 +86,9 @@ describe("built-in channel manifests", () => { "src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.ts", "src/lib/messaging/channels/slack/hooks/validate-credentials.ts", "src/lib/messaging/channels/whatsapp/manifest.ts", + "src/lib/messaging/channels/whatsapp/hooks/index.ts", + "src/lib/messaging/channels/whatsapp/hooks/status-health.ts", + "src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts", "src/lib/messaging/channels/teams/manifest.ts", "src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts", "src/lib/messaging/hooks/common/config-prompt.ts", diff --git a/src/lib/messaging/channels/whatsapp/hooks/index.ts b/src/lib/messaging/channels/whatsapp/hooks/index.ts new file mode 100644 index 00000000000..374216d93ee --- /dev/null +++ b/src/lib/messaging/channels/whatsapp/hooks/index.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookRegistration } from "../../../hooks/types"; +import { + createWhatsappStatusHealthHookRegistration, + type WhatsappStatusHealthHookOptions, +} from "./status-health"; + +export * from "./status-health"; +export * from "./status-health-eval"; + +/** + * Aggregate options for all WhatsApp channel hooks. Kept as an interface so + * additional hooks (e.g. an enrollment or reachability hook) can be added + * later without changing every caller. + */ +export interface WhatsappHookOptions { + readonly statusHealth?: WhatsappStatusHealthHookOptions; +} + +export function createWhatsappHookRegistrations( + options: WhatsappHookOptions = {}, +): readonly MessagingHookRegistration[] { + return [createWhatsappStatusHealthHookRegistration(options.statusHealth)]; +} diff --git a/src/lib/sandbox/whatsapp-diagnostics.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts similarity index 99% rename from src/lib/sandbox/whatsapp-diagnostics.test.ts rename to src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts index 34b58a51262..9276111774d 100644 --- a/src/lib/sandbox/whatsapp-diagnostics.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts @@ -8,7 +8,7 @@ import { parseWhatsappHeartbeat, summarizeWhatsappLogLines, type WhatsappProbeInput, -} from "./whatsapp-diagnostics"; +} from "./status-health-eval"; const PROBED_AT = "2026-05-28T04:00:00.000Z"; diff --git a/src/lib/sandbox/whatsapp-diagnostics.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts similarity index 94% rename from src/lib/sandbox/whatsapp-diagnostics.ts rename to src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts index a509cec3c4b..b5c47880707 100644 --- a/src/lib/sandbox/whatsapp-diagnostics.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts @@ -5,24 +5,23 @@ * Pure helpers that translate raw probe evidence collected from inside a * sandbox into a structured WhatsApp channel diagnostic. * - * The probes themselves live in `actions/sandbox/channel-status.ts`; this - * module never touches the filesystem, child processes, or the clock so the - * evaluation can be exercised hermetically from fixtures. Issue #4386 reported - * a paired-looking WhatsApp channel where the Noise WebSocket was alive but no - * inbound events arrived, and the existing CLI surface silently rendered - * "healthy". The diagnostic below separates QR/session state, WebSocket state, - * inbound-event delivery, and policy/config coverage so a paired-but-idle - * channel cannot be mistaken for working. + * Consumed by the `whatsapp.statusHealth` status hook (see `status-health.ts`); + * this module never touches the filesystem, child processes, or the clock so + * the evaluation can be exercised hermetically from fixtures. Issue #4386 + * reported a paired-looking WhatsApp channel where the Noise WebSocket was + * alive but no inbound events arrived, and the existing CLI surface silently + * rendered "healthy". The diagnostic below separates QR/session state, + * WebSocket state, inbound-event delivery, and policy/config coverage so a + * paired-but-idle channel cannot be mistaken for working. */ -export type DiagnosticSeverity = "ok" | "warn" | "fail" | "info"; +import type { + ChannelHealthReport, + DiagnosticSeverity, + DiagnosticSignal, +} from "../../channel-health"; -export type DiagnosticSignal = { - label: string; - severity: DiagnosticSeverity; - detail: string; - hint?: string; -}; +export type { DiagnosticSeverity, DiagnosticSignal } from "../../channel-health"; export type WhatsappVerdict = | "healthy" @@ -58,8 +57,8 @@ export type WhatsappHeartbeat = { export type WhatsappProbeInput = { // Agent owning the sandbox: "openclaw", "hermes", etc. Used for hint text. agent: string; - // State directories inspected inside the sandbox. Discovered from the agent - // manifest in the orchestrator. + // State directories inspected inside the sandbox. Discovered from the + // agent-scoped path convention by the hook that builds this input. stateDirs: readonly string[]; // True when the bridge state directory exists inside the sandbox and is // non-empty. False when the directory is missing or empty. Null when the @@ -91,15 +90,17 @@ export type WhatsappProbeInput = { channelEnabledInRegistry: boolean; }; -export type WhatsappDiagnosticReport = { - schemaVersion: 1; +/** + * WhatsApp extends the generic {@link ChannelHealthReport} with a `heartbeat` + * field so the renderer can still surface the parsed heartbeat block that + * predates the generic status-hook contract. The base guard used by + * `readChannelHealthOutputs` only checks the base fields, so this extra + * property survives round-tripping through the status-runner. + */ +export type WhatsappDiagnosticReport = ChannelHealthReport & { channel: "whatsapp"; - agent: string; verdict: WhatsappVerdict; - probedAt: string; - signals: DiagnosticSignal[]; heartbeat: WhatsappHeartbeat | null; - hints: string[]; }; // Bridges flush their session blob immediately after a successful QR pair; diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts new file mode 100644 index 00000000000..7c5e36a64b1 --- /dev/null +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -0,0 +1,274 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; +import type { MessagingHookContext, MessagingHookResult } from "../../../hooks/types"; +import type { ChannelHealthReport } from "../../channel-health"; +import { createWhatsappStatusHealthHook } from "./status-health"; +import type { WhatsappDiagnosticReport } from "./status-health-eval"; + +const BASE_INPUTS = { + currentSandbox: "alpha", + agent: "openclaw", + probedAt: "2026-07-14T00:00:00.000Z", + channelEnabledInRegistry: true, + presetInRegistry: true, + presetOnGateway: true, +}; + +function context( + inputs: Record = BASE_INPUTS, + channelId = "whatsapp", +): MessagingHookContext { + return { + channelId, + hookId: "whatsapp-status-health", + phase: "status", + inputs, + } as unknown as MessagingHookContext; +} + +type ExecResult = { status: number; stdout: string; stderr: string } | null; + +function makeExec(result: ExecResult) { + return vi.fn((_sandbox: string, _command: string, _timeout: number): ExecResult => result); +} + +// The hook is synchronous; the handler type is a sync|Promise union, so narrow. +function reportOf( + result: MessagingHookResult | Promise, +): WhatsappDiagnosticReport | undefined { + const value = (result as MessagingHookResult).outputs?.channelHealth?.value as unknown as + | { report?: WhatsappDiagnosticReport } + | undefined; + return value?.report; +} + +function baseReportOf( + result: MessagingHookResult | Promise, +): ChannelHealthReport | undefined { + return reportOf(result); +} + +function outputsOf(result: MessagingHookResult | Promise) { + return (result as MessagingHookResult).outputs; +} + +function makeProbeStdout( + parts: { + reachable?: boolean; + dirs?: readonly { path: string; state: "MISSING" | "EMPTY" | "POPULATED" }[]; + heartbeat?: string | null; + logLines?: readonly string[]; + procLines?: readonly string[]; + procDone?: boolean; + gwAlive?: boolean; + gwLastInbound?: string | null; + } = {}, +): string { + const shellOk = parts.reachable === false ? [] : ["NEMOCLAW_WA_DIAG_OK"]; + const dirLines = (parts.dirs ?? []).map((dir) => `DIR ${dir.path} ${dir.state}`); + const heartbeatBlock = + parts.heartbeat == null + ? [] + : ["NEMOCLAW_WA_HEARTBEAT_BEGIN", parts.heartbeat, "NEMOCLAW_WA_HEARTBEAT_END"]; + const logBlock = ["NEMOCLAW_WA_LOG_BEGIN", ...(parts.logLines ?? []), "NEMOCLAW_WA_LOG_END"]; + const gwAliveLine = parts.gwAlive ? ["NEMOCLAW_WA_GW_ALIVE"] : []; + const gwInboundLine = parts.gwLastInbound + ? [`NEMOCLAW_WA_GW_LAST_INBOUND ${parts.gwLastInbound}`] + : []; + const procBlock = parts.procLines ?? []; + const procDoneLine = parts.procDone === false ? [] : ["NEMOCLAW_WA_PROC_DONE"]; + return [ + ...shellOk, + ...dirLines, + ...heartbeatBlock, + ...logBlock, + ...gwAliveLine, + ...gwInboundLine, + ...procBlock, + ...procDoneLine, + ].join("\n"); +} + +describe("whatsapp.statusHealth hook", () => { + it("probes the state dirs and reports healthy when heartbeat shows recent inbound (#4386)", () => { + const heartbeat = JSON.stringify({ + lastInboundAt: "2026-07-13T23:59:30.000Z", + messagesHandled: 4, + connectionState: "open", + }); + const exec = makeExec({ + status: 0, + stdout: makeProbeStdout({ + dirs: [{ path: "/sandbox/.openclaw/whatsapp", state: "POPULATED" }], + heartbeat, + procLines: ["PROC 1234 openclaw-whatsapp"], + }), + stderr: "", + }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); + expect(reportOf(result)?.verdict).toBe("healthy"); + // Extension type: the base guard should still classify this as a health + // report even with the extra heartbeat field. + expect(baseReportOf(result)?.channel).toBe("whatsapp"); + }); + + it("probes the credentials/whatsapp path (OpenClaw 2026.6.10+) as populated evidence", () => { + // Regression guard: OpenClaw 2026.6.10+ stores the paired Baileys session + // under `credentials/whatsapp//creds.json`. When only that dir is + // POPULATED (and the legacy `whatsapp/` path is MISSING) the diagnostic + // must still see the sandbox as paired. + const exec = makeExec({ + status: 0, + stdout: makeProbeStdout({ + dirs: [ + { path: "/sandbox/.openclaw/whatsapp", state: "MISSING" }, + { path: "/sandbox/.openclaw/credentials/whatsapp", state: "POPULATED" }, + ], + heartbeat: JSON.stringify({ + lastInboundAt: "2026-07-13T23:59:30.000Z", + messagesHandled: 2, + connectionState: "open", + }), + procLines: ["PROC 1234 openclaw-whatsapp"], + }), + stderr: "", + }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); + const report = reportOf(result); + expect(report?.verdict).toBe("healthy"); + const pairing = report?.signals.find((s) => s.label === "Pairing / session"); + expect(pairing?.severity).toBe("ok"); + }); + + it("synthesizes a heartbeat from GW_ALIVE + GW_LAST_INBOUND when no heartbeat file exists", () => { + // Part 2 (gateway-log liveness): the in-process bridge does not publish a + // heartbeat file, but its provider-ready + inbound breadcrumbs are in the + // gateway log. The hook must synthesize a heartbeat + alive bridge so the + // "paired but no inbound observed" warning does not fire for a healthy + // in-process bridge. + const exec = makeExec({ + status: 0, + stdout: makeProbeStdout({ + dirs: [{ path: "/sandbox/.openclaw/whatsapp", state: "POPULATED" }], + heartbeat: null, + gwAlive: true, + gwLastInbound: "2026-07-13T23:59:30.000Z", + procDone: true, + }), + stderr: "", + }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); + const report = reportOf(result); + expect(report?.verdict).toBe("healthy"); + expect(report?.heartbeat?.connectionState).toBe("open"); + expect(report?.heartbeat?.lastInboundAt).toBe("2026-07-13T23:59:30.000Z"); + const proc = report?.signals.find((s) => s.label === "Bridge process"); + expect(proc?.severity).toBe("ok"); + }); + + it("emits a syntactically valid /bin/sh probe script", () => { + // The probe is a multiline sh script (for/if/grep pipelines, marker + // sequencing, and the gateway-log block). A shell syntax regression would + // fail every real probe while mocked-stdout tests stay green; validate the + // generated command with `sh -n`. + const exec = makeExec({ status: 0, stdout: makeProbeStdout(), stderr: "" }); + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); + const command = exec.mock.calls[0]?.[1] ?? ""; + const validation = spawnSync("sh", ["-n", "-c", command], { encoding: "utf-8" }); + expect(validation.status, validation.stderr || validation.stdout).toBe(0); + // The probe must filter its own pgrep line out of the process results. + expect(command).toMatch(/__nemoclaw_wa_self_pid/); + expect(command).toMatch(/pgrep -fa/); + // Part 2: gateway-log block scoped to whatsapp lines only. The bracket + // form appears in the emitted shell as `\[whatsapp\]` (grep -E literal). + expect(command).toMatch(/channels\/whatsapp/); + expect(command).toMatch(/\\\[whatsapp\\\]/); + expect(command).toMatch(/NEMOCLAW_WA_GW_ALIVE/); + expect(command).toMatch(/NEMOCLAW_WA_GW_LAST_INBOUND/); + }); + + it("selects the hermes state-dir path when the agent is hermes", () => { + const exec = makeExec({ status: 0, stdout: makeProbeStdout(), stderr: "" }); + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( + context({ ...BASE_INPUTS, agent: "hermes" }), + ); + const command = exec.mock.calls[0]?.[1] ?? ""; + expect(command).toContain("/sandbox/.hermes/platforms/whatsapp/session"); + expect(command).not.toContain("/sandbox/.openclaw/"); + }); + + it("selects the openclaw state-dir paths by default (both whatsapp and credentials/whatsapp)", () => { + const exec = makeExec({ status: 0, stdout: makeProbeStdout(), stderr: "" }); + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); + const command = exec.mock.calls[0]?.[1] ?? ""; + expect(command).toContain("/sandbox/.openclaw/whatsapp"); + expect(command).toContain("/sandbox/.openclaw/credentials/whatsapp"); + }); + + it("reports probe_failed when the sandbox exec fails (null result)", () => { + const exec = makeExec(null); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); + expect(reportOf(result)?.verdict).toBe("probe_failed"); + }); + + it("reports probe_failed when stdout omits the shell-OK marker", () => { + const exec = makeExec({ status: 0, stdout: "", stderr: "" }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); + expect(reportOf(result)?.verdict).toBe("probe_failed"); + }); + + it("reports probe_failed on a non-zero exec even when partial stdout carries healthy markers", () => { + // A timed-out/killed exec can flush partial stdout that already contains + // SHELL_OK (printed first) plus a populated dir and heartbeat. Without the + // `exec.status === 0` guard this would read as `healthy` off partial data; + // the clean-exit requirement keeps it classified as probe_failed. + const exec = makeExec({ + status: 124, + stdout: makeProbeStdout({ + dirs: [{ path: "/sandbox/.openclaw/whatsapp", state: "POPULATED" }], + heartbeat: JSON.stringify({ + lastInboundAt: "2026-07-13T23:59:30.000Z", + messagesHandled: 4, + connectionState: "open", + }), + procLines: ["PROC 1234 openclaw-whatsapp"], + }), + stderr: "timed out", + }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); + expect(reportOf(result)?.verdict).toBe("probe_failed"); + }); + + it("derives config_gap / policy_gap from the host-fact inputs", () => { + const exec = makeExec({ + status: 0, + stdout: makeProbeStdout({ + dirs: [{ path: "/sandbox/.openclaw/whatsapp", state: "POPULATED" }], + }), + stderr: "", + }); + const hook = createWhatsappStatusHealthHook({ executeSandboxCommand: exec }); + expect( + reportOf(hook(context({ ...BASE_INPUTS, channelEnabledInRegistry: false })))?.verdict, + ).toBe("config_gap"); + expect(reportOf(hook(context({ ...BASE_INPUTS, presetInRegistry: false })))?.verdict).toBe( + "policy_gap", + ); + }); + + it("no-ops for a non-whatsapp channel or without an exec runner", () => { + const exec = makeExec({ status: 0, stdout: makeProbeStdout(), stderr: "" }); + expect( + outputsOf( + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( + context(BASE_INPUTS, "slack"), + ), + ), + ).toBeUndefined(); + expect(outputsOf(createWhatsappStatusHealthHook({})(context()))).toBeUndefined(); + expect(exec).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts new file mode 100644 index 00000000000..d2b01239496 --- /dev/null +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -0,0 +1,386 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * `whatsapp.statusHealth` — a `phase: "status"` hook that probes the live + * WhatsApp bridge state from inside the sandbox and emits a + * `messaging-channel-health` status output. Run by the generic channels-status + * command via the status-hook runner, so no whatsapp-specific code lives in + * the generic status orchestrator. + * + * WhatsApp has two supported bridge shapes: + * + * 1. OpenClaw: a Baileys session under either `/whatsapp` or the + * newer `/credentials/whatsapp` layout (OpenClaw 2026.6.10+ + * stores the paired session there). The bridge may either run inside the + * gateway process (in which case the pgrep probe cannot enumerate it, + * and gateway-log breadcrumbs become the liveness signal) or as a + * dedicated `openclaw-whatsapp` process with its own heartbeat file. + * + * 2. Hermes: a session under `/platforms/whatsapp/session`. + * + * The probe inspects both layouts, the heartbeat file, a bounded slice of + * bridge logs, running processes, and the OpenClaw gateway log (which + * whatsapp lines are scoped to via `channels/whatsapp` and `[whatsapp]`). + * The gateway-log liveness markers synthesize a heartbeat when the in-process + * bridge is up and has recorded inbound traffic without publishing a + * heartbeat file — this closes the "paired-looking with no observable + * inbound" gap reported in issue #4386 for the current OpenClaw in-process + * bridge. + */ + +import { shellQuote as quotePath } from "../../../../core/shell-quote"; +import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; +import type { MessagingSerializableValue } from "../../../manifest"; +import { + type ChannelStatusHealthHookOptions, + MESSAGING_CHANNEL_HEALTH_OUTPUT_TYPE, +} from "../../channel-health"; +import { + evaluateWhatsappDiagnostics, + parseWhatsappHeartbeat, + summarizeWhatsappLogLines, + type WhatsappHeartbeat, + type WhatsappProbeInput, +} from "./status-health-eval"; + +export const WHATSAPP_STATUS_HEALTH_HOOK_HANDLER_ID = "whatsapp.statusHealth"; + +// Bound how long we are willing to block inside an `openshell sandbox exec` +// for the inline diagnostic snippet. WhatsApp's bridge sometimes goes +// unresponsive when the Noise WebSocket is stuck; a fast hard cap keeps +// channels status from inheriting that hang. +const DEFAULT_TIMEOUT_MS = 8_000; + +const SHELL_OK = "NEMOCLAW_WA_DIAG_OK"; +const HEARTBEAT_BEGIN = "NEMOCLAW_WA_HEARTBEAT_BEGIN"; +const HEARTBEAT_END = "NEMOCLAW_WA_HEARTBEAT_END"; +const LOG_BEGIN = "NEMOCLAW_WA_LOG_BEGIN"; +const LOG_END = "NEMOCLAW_WA_LOG_END"; +const PROC_DONE = "NEMOCLAW_WA_PROC_DONE"; +// Part 2 (gateway-log liveness for the in-process bridge): the probe emits +// only these two markers plus the extracted ISO timestamp — never a raw log +// line, so phone numbers embedded in an "Inbound message …" line cannot +// escape the sandbox. Scoped to whatsapp lines via `channels/whatsapp` and +// `[whatsapp]` so telegram breadcrumbs never get miscounted as WA liveness. +const GW_ALIVE = "NEMOCLAW_WA_GW_ALIVE"; +const GW_LAST_INBOUND = "NEMOCLAW_WA_GW_LAST_INBOUND"; + +/** WhatsApp uses the generic channel-health hook options unchanged. */ +export type WhatsappStatusHealthHookOptions = ChannelStatusHealthHookOptions; + +export function createWhatsappStatusHealthHook( + options: WhatsappStatusHealthHookOptions = {}, +): MessagingHookHandler { + return (context) => { + if (context.channelId !== "whatsapp") return {}; + const execute = options.executeSandboxCommand; + const sandboxName = normalizeString(context.inputs?.currentSandbox); + // Without a sandbox target or an exec runner there is nothing to probe + // (e.g. the top-level status runner does not thread an exec runner into + // this hook). + if (!execute || !sandboxName) return {}; + + const agent = normalizeString(context.inputs?.agent) ?? "openclaw"; + const stateDirs = resolveWhatsappStateDirs(agent); + const timeoutMs = normalizeTimeoutMs(options.timeoutMs); + const script = buildWhatsappProbeScript(stateDirs); + const exec = execute(sandboxName, script, timeoutMs); + const parsed = parseProbeOutput(String(exec?.stdout ?? "")); + // A non-zero exec (timeout/kill/unhealthy sandbox) can still carry partial + // stdout that already contains the SHELL_OK marker (it is printed first), + // so require a clean exit before trusting the probe. Otherwise a stalled + // probe reads a verdict off partial data instead of classifying as + // probe_failed. Mirrors the telegram status-health hook. + const reachable = parsed.reachable && exec?.status === 0; + + let heartbeat: WhatsappHeartbeat | null = null; + let heartbeatParseError: string | null = null; + if (parsed.heartbeatRaw) { + const parseResult = parseWhatsappHeartbeat(parsed.heartbeatRaw); + if ("heartbeat" in parseResult) { + heartbeat = parseResult.heartbeat; + } else { + heartbeatParseError = parseResult.parseError; + } + } + + // Part 2 (gateway-log liveness): when the probe found the whatsapp + // provider listening in the gateway log, treat that as bridge liveness + // — the in-process bridge does not show under pgrep. When there is no + // heartbeat file but the gateway log shows recent inbound, synthesize a + // minimal heartbeat so the "paired but no inbound observed" warning is + // replaced with the actual last-inbound timestamp. + let bridgeProcessAlive = parsed.bridgeProcessAlive; + if (parsed.gatewayProviderAlive) { + bridgeProcessAlive = true; + } + if (!heartbeat && parsed.gatewayProviderAlive) { + heartbeat = { + connectionState: "open", + lastInboundAt: parsed.gatewayLastInboundAt, + messagesHandled: null, + noteCategory: null, + }; + } + + const input: WhatsappProbeInput = { + agent, + stateDirs, + stateDirPopulated: parsed.stateDirPopulated, + heartbeat, + heartbeatParseError, + bridgeProcessAlive, + recentLogSignals: summarizeWhatsappLogLines(parsed.logLines), + probeReachable: reachable, + probedAt: normalizeString(context.inputs?.probedAt) ?? "", + presetInRegistry: Boolean(context.inputs?.presetInRegistry), + presetOnGateway: normalizeTristate(context.inputs?.presetOnGateway), + channelEnabledInRegistry: Boolean(context.inputs?.channelEnabledInRegistry), + }; + const report = evaluateWhatsappDiagnostics(input); + return { + outputs: { + channelHealth: { + kind: "status", + value: { + type: MESSAGING_CHANNEL_HEALTH_OUTPUT_TYPE, + report, + } as unknown as MessagingSerializableValue, + }, + }, + }; + }; +} + +export function createWhatsappStatusHealthHookRegistration( + options: WhatsappStatusHealthHookOptions = {}, +): MessagingHookRegistration { + return { + id: WHATSAPP_STATUS_HEALTH_HOOK_HANDLER_ID, + handler: createWhatsappStatusHealthHook(options), + }; +} + +/** + * The two known WhatsApp bridge state layouts, keyed by agent name. The hook + * has no AgentDefinition — the parent runner threads only serializable + * facts through the manifest hook contract — so paths are derived from the + * agent string and the fixed in-sandbox config dir convention. Non-existent + * candidates simply yield "MISSING" in the probe output. + */ +export function resolveWhatsappStateDirs(agent: string): string[] { + if (agent === "hermes") { + return ["/sandbox/.hermes/platforms/whatsapp/session"]; + } + // Default to the OpenClaw layout. OpenClaw 2026.6.10+ writes the paired + // Baileys session under `credentials/whatsapp//creds.json`, not + // `/whatsapp`, so probe both shapes (Part 1 fix). + return ["/sandbox/.openclaw/whatsapp", "/sandbox/.openclaw/credentials/whatsapp"]; +} + +function buildWhatsappProbeScript(stateDirs: readonly string[]): string { + // The script: + // 1. Marks success with SHELL_OK so we can disambiguate "exec failed" from + // "exec succeeded but produced nothing". + // 2. Lists each candidate state directory and emits a single "POPULATED" + // or "EMPTY" / "MISSING" line per dir. + // 3. Cats the first heartbeat-shaped file it finds, wrapped in begin/end + // markers so the parser can extract it without parsing find output. + // 4. Tails up to 200 lines of bridge log files and forwards only short + // lines that match the diagnostic regex set. The host parser further + // filters to summary phrases. + // 5. Scans the OpenClaw gateway log for whatsapp-scoped liveness lines + // (Part 2) — provider-ready plus the newest inbound timestamp — and + // emits only the fixed markers + the parsed ISO string. Never a raw + // log line: gateway inbound lines can carry phone numbers. + // 6. Runs pgrep for known bridge process names, then filters out the probe + // shell itself and the pgrep call so the diagnostic does not report a + // bridge as "running" when the only match is our own command line. + // The script is joined with newlines so the embedded `for` / `if` + // constructs parse as compound statements. Joining the whole thing with + // ` && ` corrupts the grammar (e.g. `do && if`), which `/bin/sh` rejects + // before the SHELL_OK marker prints and every live probe gets misread as + // unreachable. The leading `set +e` makes the probe survive missing log + // files and empty pgrep matches without aborting at the first non-zero + // exit. + const quotedDirs = stateDirs.map(quotePath).join(" "); + return [ + `set +e`, + `printf '%s\\n' ${quotePath(SHELL_OK)}`, + `for dir in ${quotedDirs}; do`, + ` if [ ! -d "$dir" ]; then printf 'DIR %s MISSING\\n' "$dir"; continue; fi`, + ` if [ -z "$(ls -A "$dir" 2>/dev/null)" ]; then`, + ` printf 'DIR %s EMPTY\\n' "$dir"`, + ` else`, + ` printf 'DIR %s POPULATED\\n' "$dir"`, + ` fi`, + `done`, + `for dir in ${quotedDirs}; do`, + ` for candidate in heartbeat.json status.json health.json bridge-status.json; do`, + ` if [ -f "$dir/$candidate" ]; then`, + ` printf '%s\\n' ${quotePath(HEARTBEAT_BEGIN)}`, + ` cat "$dir/$candidate" 2>/dev/null | head -c 8192`, + ` printf '\\n%s\\n' ${quotePath(HEARTBEAT_END)}`, + ` break 2`, + ` fi`, + ` done`, + `done`, + `printf '%s\\n' ${quotePath(LOG_BEGIN)}`, + `for dir in ${quotedDirs}; do`, + ` for log in "$dir"/*.log "$dir"/logs/*.log; do`, + ` [ -f "$log" ] || continue`, + ` tail -n 200 "$log" 2>/dev/null | grep -E 'connection\\.(open|close|update|update.*restart)|ws (open|close)|401|unauthorized|qr.*(expired|timeout)|restartRequired|loggedOut|logged out|getMessage' | tail -n 20`, + ` done`, + `done`, + `printf '%s\\n' ${quotePath(LOG_END)}`, + // Part 2 (gateway-log liveness). The OpenClaw gateway log has whatsapp + // lines like `... channels/whatsapp ... starting provider` and + // `... [whatsapp] ... Inbound message from ...`. Scope grep to whatsapp + // so telegram lines are not miscounted, and emit ONLY the markers + + // the ISO timestamp — never a raw log line. For hermes runs the glob + // finds no files and the block is a no-op. + `for gwlog in /tmp/openclaw-*/openclaw-*.log; do`, + ` [ -f "$gwlog" ] || continue`, + ` __wa_scoped=$(tail -n 500 "$gwlog" 2>/dev/null | grep -E 'channels/whatsapp|\\[whatsapp\\]')`, + ` printf '%s' "$__wa_scoped" | grep -qE 'starting provider|Listening for WhatsApp inbound' && printf '%s\\n' ${quotePath(GW_ALIVE)}`, + ` __wa_last=$(printf '%s' "$__wa_scoped" | grep -E 'Inbound message' | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+([+-][0-9:]+|Z)?' | tail -n 1)`, + ` [ -n "$__wa_last" ] && printf '%s %s\\n' ${quotePath(GW_LAST_INBOUND)} "$__wa_last"`, + `done`, + `__nemoclaw_wa_self_pid=$$`, + // Match both process-name-with-whatsapp and processes whose argv + // mentions the WhatsApp state directory or known plugin paths. A + // bridge that runs inside the parent agent process (e.g. an OpenClaw + // plugin loaded via a generic `node` entry point) usually carries the + // platforms/whatsapp path on its command line via `--state-dir` or + // similar. + `pgrep -fa 'whatsapp|baileys|platforms/whatsapp|openclaw-whatsapp|hermes.*whatsapp' 2>/dev/null | awk -v self="$__nemoclaw_wa_self_pid" '$1 != self && $0 !~ /pgrep -fa/ && $0 !~ /NEMOCLAW_WA_DIAG_OK/ { print "PROC " $0 }' | head -n 5`, + // Always emit PROC_DONE after the pgrep pipeline so the parser can tell + // apart "pgrep completed with no matches" (the bridge runs under a + // process name that does not contain `whatsapp` or `baileys`, or has + // crashed) from "the probe never reached pgrep" (script aborted + // mid-flight). Without this marker both cases collapse to `null`. + `printf '%s\\n' ${quotePath(PROC_DONE)}`, + ].join("\n"); +} + +type ParsedProbe = { + reachable: boolean; + stateDirPopulated: boolean | null; + heartbeatRaw: string | null; + logLines: string[]; + bridgeProcessAlive: boolean | null; + gatewayProviderAlive: boolean; + gatewayLastInboundAt: string | null; +}; + +function parseProbeOutput(stdout: string): ParsedProbe { + const lines = stdout.split(/\r?\n/); + if (!lines.includes(SHELL_OK)) { + return { + reachable: false, + stateDirPopulated: null, + heartbeatRaw: null, + logLines: [], + bridgeProcessAlive: null, + gatewayProviderAlive: false, + gatewayLastInboundAt: null, + }; + } + let stateDirPopulated: boolean | null = false; + let sawAnyDir = false; + let heartbeatRaw: string | null = null; + let inHeartbeat = false; + let inLogs = false; + const heartbeatBuf: string[] = []; + const logLines: string[] = []; + let sawProcMatch = false; + let sawProcDone = false; + let gatewayProviderAlive = false; + let gatewayLastInboundAt: string | null = null; + + for (const line of lines) { + if (line === HEARTBEAT_BEGIN) { + inHeartbeat = true; + continue; + } + if (line === HEARTBEAT_END) { + inHeartbeat = false; + heartbeatRaw = heartbeatBuf.join("\n").trim(); + continue; + } + if (line === LOG_BEGIN) { + inLogs = true; + continue; + } + if (line === LOG_END) { + inLogs = false; + continue; + } + if (inHeartbeat) { + heartbeatBuf.push(line); + continue; + } + if (inLogs) { + const trimmed = line.trim(); + if (trimmed.length > 0) logLines.push(trimmed); + continue; + } + const dirMatch = line.match(/^DIR\s+\S+\s+(MISSING|EMPTY|POPULATED)$/); + if (dirMatch) { + sawAnyDir = true; + if (dirMatch[1] === "POPULATED") stateDirPopulated = true; + continue; + } + if (line.startsWith("PROC ")) { + sawProcMatch = true; + continue; + } + if (line === PROC_DONE) { + sawProcDone = true; + continue; + } + if (line === GW_ALIVE) { + gatewayProviderAlive = true; + continue; + } + if (line.startsWith(`${GW_LAST_INBOUND} `)) { + gatewayLastInboundAt = line.slice(GW_LAST_INBOUND.length + 1).trim() || null; + continue; + } + } + // Three states: + // true → pgrep printed at least one matching process + // false → pgrep completed with no matches; either the bridge is dead + // OR it runs inside the parent agent process under a name that + // does not contain `whatsapp`/`baileys`. The evaluator resolves + // that ambiguity using heartbeat freshness. + // null → the probe aborted before reaching pgrep (timeout, exec + // failure); we cannot infer anything about the bridge state. + const bridgeProcessAliveOut = sawProcMatch ? true : sawProcDone ? false : null; + return { + reachable: true, + stateDirPopulated: sawAnyDir ? stateDirPopulated : null, + heartbeatRaw, + logLines, + bridgeProcessAlive: bridgeProcessAliveOut, + gatewayProviderAlive, + gatewayLastInboundAt, + }; +} + +function normalizeString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function normalizeTristate(value: unknown): boolean | null { + if (value === true) return true; + if (value === false) return false; + return null; +} + +function normalizeTimeoutMs(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : DEFAULT_TIMEOUT_MS; +} diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index c4e89e61e21..6f6c25492e1 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -120,5 +120,18 @@ export const whatsappManifest = { required: true, }, ], - hooks: [], + hooks: [ + { + id: "whatsapp-status-health", + phase: "status", + handler: "whatsapp.statusHealth", + agents: ["openclaw", "hermes"], + outputs: [ + { + id: "channelHealth", + kind: "status", + }, + ], + }, + ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/hooks/builtins.ts b/src/lib/messaging/hooks/builtins.ts index ade5940b2b4..2c5d63b01db 100644 --- a/src/lib/messaging/hooks/builtins.ts +++ b/src/lib/messaging/hooks/builtins.ts @@ -11,6 +11,10 @@ import { type TelegramHookOptions, } from "../channels/telegram/hooks"; import { createWechatHookRegistrations, type WechatHookOptions } from "../channels/wechat/hooks"; +import { + createWhatsappHookRegistrations, + type WhatsappHookOptions, +} from "../channels/whatsapp/hooks"; import { type CommonHookOptions, createCommonHookRegistrations } from "./common"; import { MessagingHookRegistry } from "./registry"; import type { MessagingHookRegistration } from "./types"; @@ -23,6 +27,7 @@ export interface BuiltInMessagingHookOptions { readonly teams?: TeamsHookOptions; readonly telegram?: TelegramHookOptions; readonly wechat?: WechatHookOptions; + readonly whatsapp?: WhatsappHookOptions; // Host capability threaded into every channel's `phase:"status"` health hook, // so a status caller enables live probing without naming a specific channel. readonly statusHealth?: ChannelStatusHealthHookOptions; @@ -47,6 +52,9 @@ export function createBuiltInMessagingHookRegistrations( ), ), ...createWechatHookRegistrations(options.wechat), + ...createWhatsappHookRegistrations( + withStatusHealthOptions(options.whatsapp, options.statusHealth), + ), ]; } diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index 47c0ef437a6..cc4b0f47242 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -52,6 +52,7 @@ describe("MessagingHookRegistry", () => { "wechat.ilinkLogin", "wechat.seedOpenClawAccount", "wechat.healthCheck", + "whatsapp.statusHealth", ]); }); From ede4ffefe52592f05e6baca718dc5ee3579ce12d Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 16 Jul 2026 16:30:32 +0530 Subject: [PATCH 02/17] fix(whatsapp): read the canonical /tmp/gateway.log for in-process bridge liveness Signed-off-by: Hung Le --- .../whatsapp/hooks/status-health.test.ts | 6 ++++ .../channels/whatsapp/hooks/status-health.ts | 29 ++++++++++--------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index 7c5e36a64b1..963cb550665 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -188,6 +188,12 @@ describe("whatsapp.statusHealth hook", () => { expect(command).toMatch(/\\\[whatsapp\\\]/); expect(command).toMatch(/NEMOCLAW_WA_GW_ALIVE/); expect(command).toMatch(/NEMOCLAW_WA_GW_LAST_INBOUND/); + // Part 2 must read the canonical in-sandbox gateway log (where NemoClaw + // redirects gateway stdout, same as the telegram hook), NOT the OpenClaw + // internal dated log — the latter is not guaranteed to exist on every + // sandbox and would silently disable in-process bridge liveness detection. + expect(command).toContain("/tmp/gateway.log"); + expect(command).not.toContain("/tmp/openclaw-"); }); it("selects the hermes state-dir path when the agent is hermes", () => { diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index d2b01239496..ad9663d063f 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -65,6 +65,12 @@ const PROC_DONE = "NEMOCLAW_WA_PROC_DONE"; // `[whatsapp]` so telegram breadcrumbs never get miscounted as WA liveness. const GW_ALIVE = "NEMOCLAW_WA_GW_ALIVE"; const GW_LAST_INBOUND = "NEMOCLAW_WA_GW_LAST_INBOUND"; +// The canonical in-sandbox gateway log. NemoClaw launches the OpenClaw gateway +// with stdout+stderr redirected here (see agent/gateway-script-shared.ts), so +// every `[whatsapp] …` breadcrumb lands in this one file — the same log the +// telegram status hook reads. The OpenClaw-internal dated log under +// /tmp/openclaw-/ is not guaranteed to exist or to hold these lines. +const OPENCLAW_GATEWAY_LOG_FILE = "/tmp/gateway.log"; /** WhatsApp uses the generic channel-health hook options unchanged. */ export type WhatsappStatusHealthHookOptions = ChannelStatusHealthHookOptions; @@ -234,19 +240,16 @@ function buildWhatsappProbeScript(stateDirs: readonly string[]): string { ` done`, `done`, `printf '%s\\n' ${quotePath(LOG_END)}`, - // Part 2 (gateway-log liveness). The OpenClaw gateway log has whatsapp - // lines like `... channels/whatsapp ... starting provider` and - // `... [whatsapp] ... Inbound message from ...`. Scope grep to whatsapp - // so telegram lines are not miscounted, and emit ONLY the markers + - // the ISO timestamp — never a raw log line. For hermes runs the glob - // finds no files and the block is a no-op. - `for gwlog in /tmp/openclaw-*/openclaw-*.log; do`, - ` [ -f "$gwlog" ] || continue`, - ` __wa_scoped=$(tail -n 500 "$gwlog" 2>/dev/null | grep -E 'channels/whatsapp|\\[whatsapp\\]')`, - ` printf '%s' "$__wa_scoped" | grep -qE 'starting provider|Listening for WhatsApp inbound' && printf '%s\\n' ${quotePath(GW_ALIVE)}`, - ` __wa_last=$(printf '%s' "$__wa_scoped" | grep -E 'Inbound message' | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+([+-][0-9:]+|Z)?' | tail -n 1)`, - ` [ -n "$__wa_last" ] && printf '%s %s\\n' ${quotePath(GW_LAST_INBOUND)} "$__wa_last"`, - `done`, + // Part 2 (gateway-log liveness). The canonical gateway log holds plaintext + // `[whatsapp] … starting provider` / `[whatsapp] … Listening for WhatsApp + // inbound` / `[whatsapp] … Inbound message …` breadcrumbs. Scope grep to + // whatsapp so telegram lines are not miscounted, and emit ONLY the markers + // + the ISO timestamp — never a raw log line (an inbound line carries a + // phone number). Absent for a hermes runtime → the block is a no-op. + `__wa_scoped=$(tail -n 500 ${quotePath(OPENCLAW_GATEWAY_LOG_FILE)} 2>/dev/null | grep -aE 'channels/whatsapp|\\[whatsapp\\]')`, + `printf '%s' "$__wa_scoped" | grep -qE 'starting provider|Listening for WhatsApp inbound' && printf '%s\\n' ${quotePath(GW_ALIVE)}`, + `__wa_last=$(printf '%s' "$__wa_scoped" | grep -E 'Inbound message' | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+([+-][0-9:]+|Z)?' | tail -n 1)`, + `[ -n "$__wa_last" ] && printf '%s %s\\n' ${quotePath(GW_LAST_INBOUND)} "$__wa_last"`, `__nemoclaw_wa_self_pid=$$`, // Match both process-name-with-whatsapp and processes whose argv // mentions the WhatsApp state directory or known plugin paths. A From 1acc4551f9dc1fb1e20a92ef73bb3c07ea7bdab6 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Fri, 17 Jul 2026 11:28:40 +0530 Subject: [PATCH 03/17] fix(whatsapp): probe live gateway status JSON, not log scraping Signed-off-by: Hung Le --- .../actions/sandbox/channel-status.test.ts | 210 +++---- src/lib/actions/sandbox/channel-status.ts | 34 +- .../whatsapp/hooks/status-health.test.ts | 434 ++++++++------- .../channels/whatsapp/hooks/status-health.ts | 512 +++++++++--------- 4 files changed, 587 insertions(+), 603 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 063595a7510..7463b95e282 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -9,26 +9,23 @@ import { showSandboxChannelStatus, } from "./channel-status.test-helpers"; +// The whatsapp status hook now reads OpenClaw's authoritative live status JSON +// (`openclaw channels status --channel whatsapp --json`) instead of scraping +// shell markers, so these integration tests feed that JSON shape through the +// mocked sandbox exec. `wa` is the per-channel object under `channels.whatsapp`. +function waStatusJson(wa: Record): string { + return JSON.stringify({ channels: { whatsapp: wa } }); +} + describe("showSandboxChannelStatus (whatsapp)", () => { it("returns idle verdict and exit code 1 when paired but no inbound observed", async () => { - const heartbeat = JSON.stringify({ + const stdout = waStatusJson({ + linked: true, + running: true, + connected: true, + healthState: "healthy", lastInboundAt: null, - messagesHandled: 0, - connectionState: "open", }); - const stdout = [ - "NEMOCLAW_WA_DIAG_OK", - "DIR /sandbox/.openclaw/whatsapp POPULATED", - "DIR /sandbox/.openclaw/platforms/whatsapp MISSING", - "NEMOCLAW_WA_HEARTBEAT_BEGIN", - heartbeat, - "NEMOCLAW_WA_HEARTBEAT_END", - "NEMOCLAW_WA_LOG_BEGIN", - "2026-05-28 connection.open", - "NEMOCLAW_WA_LOG_END", - "PROC 1234 baileys-runtime", - "NEMOCLAW_WA_PROC_DONE", - ].join("\n"); const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`process.exit(${code})`); }) as never); @@ -52,22 +49,13 @@ describe("showSandboxChannelStatus (whatsapp)", () => { }); it("renders an idle verdict in the text report and exits non-zero", async () => { - const heartbeat = JSON.stringify({ + const stdout = waStatusJson({ + linked: true, + running: true, + connected: true, + healthState: "healthy", lastInboundAt: null, - messagesHandled: 0, - connectionState: "open", }); - const stdout = [ - "NEMOCLAW_WA_DIAG_OK", - "DIR /sandbox/.openclaw/whatsapp POPULATED", - "NEMOCLAW_WA_HEARTBEAT_BEGIN", - heartbeat, - "NEMOCLAW_WA_HEARTBEAT_END", - "NEMOCLAW_WA_LOG_BEGIN", - "NEMOCLAW_WA_LOG_END", - "PROC 1234 openclaw-whatsapp", - "NEMOCLAW_WA_PROC_DONE", - ].join("\n"); const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`process.exit(${code})`); }) as never); @@ -90,22 +78,13 @@ describe("showSandboxChannelStatus (whatsapp)", () => { }); it("returns healthy verdict when paired and a recent inbound was observed", async () => { - const heartbeat = JSON.stringify({ - lastInboundAt: "2026-05-28T03:59:30.000Z", - messagesHandled: 4, - connectionState: "open", + const stdout = waStatusJson({ + linked: true, + running: true, + connected: true, + healthState: "healthy", + lastInboundAt: 1748404770000, }); - const stdout = [ - "NEMOCLAW_WA_DIAG_OK", - "DIR /sandbox/.openclaw/whatsapp POPULATED", - "NEMOCLAW_WA_HEARTBEAT_BEGIN", - heartbeat, - "NEMOCLAW_WA_HEARTBEAT_END", - "NEMOCLAW_WA_LOG_BEGIN", - "NEMOCLAW_WA_LOG_END", - "PROC 1234 openclaw-whatsapp", - "NEMOCLAW_WA_PROC_DONE", - ].join("\n"); const { deps, out_lines } = makeDeps({ exec: () => ({ status: 0, stdout, stderr: "" }), }); @@ -115,7 +94,40 @@ describe("showSandboxChannelStatus (whatsapp)", () => { expect(dump).toMatch(/Verdict:.*healthy/); }); - it("returns probe_failed when openshell exec produces no marker", async () => { + it("reports a stopped in-process bridge as not healthy even with a recent last inbound (#7016)", async () => { + // Regression for the append-only-log false positive (PRA-1 / CodeRabbit): + // a bridge that has stopped still leaves a recent `lastInboundAt` behind, + // but the authoritative `running: false` / `healthState: "stopped"` must + // win so the operator is not told a torn-down bridge is healthy. + const stdout = waStatusJson({ + linked: true, + running: false, + connected: false, + healthState: "stopped", + lastStopAt: 1748404800000, + lastInboundAt: 1748404770000, + }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout, stderr: "" }), + }); + let threw: Error | null = null; + try { + await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); + } catch (err) { + threw = err as Error; + } finally { + exitSpy.mockRestore(); + } + expect(threw?.message).toBe("process.exit(1)"); + const dump = out_lines.join("\n"); + expect(dump).not.toMatch(/Verdict:.*healthy/); + expect(dump).toMatch(/Bridge process: no WhatsApp bridge process observed/); + }); + + it("returns probe_failed when the openclaw status command exits non-zero", async () => { const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`process.exit(${code})`); }) as never); @@ -154,12 +166,13 @@ describe("showSandboxChannelStatus (whatsapp)", () => { }); it("returns config_gap when the sandbox has whatsapp neither registered nor enabled", async () => { - const stdout = [ - "NEMOCLAW_WA_DIAG_OK", - "DIR /sandbox/.openclaw/whatsapp MISSING", - "NEMOCLAW_WA_LOG_BEGIN", - "NEMOCLAW_WA_LOG_END", - ].join("\n"); + const stdout = waStatusJson({ + linked: true, + running: true, + connected: true, + healthState: "healthy", + lastInboundAt: 1748404770000, + }); const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`process.exit(${code})`); }) as never); @@ -180,13 +193,10 @@ describe("showSandboxChannelStatus (whatsapp)", () => { expect(threw?.message).toBe("process.exit(1)"); }); - it("uses the hermes pairing hint when the agent is hermes", async () => { - const stdout = [ - "NEMOCLAW_WA_DIAG_OK", - "DIR /sandbox/.hermes/platforms/whatsapp/session MISSING", - "NEMOCLAW_WA_LOG_BEGIN", - "NEMOCLAW_WA_LOG_END", - ].join("\n"); + it("uses the hermes pairing hint when the agent is hermes and no session file exists", async () => { + // Hermes has no `openclaw` CLI in the sandbox; the probe stats for the + // Baileys `creds.json` session artifact and emits a present/absent marker. + const stdout = "NEMOCLAW_WA_HERMES_SESSION_ABSENT\n"; const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`process.exit(${code})`); }) as never); @@ -206,88 +216,23 @@ describe("showSandboxChannelStatus (whatsapp)", () => { expect(dump).toMatch(/Verdict:.*unpaired/); }); - it("distinguishes 'pgrep completed with no matches' from 'probe never reached pgrep'", async () => { - // With the PROC_DONE marker, the orchestrator reports - // bridgeProcessAlive: false when pgrep ran cleanly with no matches - // (so the diagnostic can route to fail/idle) and null only when the - // probe aborted before reaching pgrep (so the diagnostic stays info - // and a healthy heartbeat is not penalized by an unrelated probe - // failure). - const stdoutNoMatch = [ - "NEMOCLAW_WA_DIAG_OK", - "DIR /sandbox/.openclaw/whatsapp POPULATED", - "NEMOCLAW_WA_HEARTBEAT_BEGIN", - JSON.stringify({ - lastInboundAt: "2026-05-27T00:00:00.000Z", - messagesHandled: 1, - connectionState: "open", - }), - "NEMOCLAW_WA_HEARTBEAT_END", - "NEMOCLAW_WA_LOG_BEGIN", - "NEMOCLAW_WA_LOG_END", - "NEMOCLAW_WA_PROC_DONE", - ].join("\n"); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`process.exit(${code})`); - }) as never); - try { - const { deps: depsNoMatch, out_lines: linesNoMatch } = makeDeps({ - exec: () => ({ status: 0, stdout: stdoutNoMatch, stderr: "" }), - }); - try { - await showSandboxChannelStatus("alpha", { deps: depsNoMatch, channel: "whatsapp" }); - } catch { - /* expected exit(1) for stale-heartbeat + no bridge */ - } - const dumpNoMatch = linesNoMatch.join("\n"); - expect(dumpNoMatch).toMatch(/Bridge process: no WhatsApp bridge process observed/); - expect(dumpNoMatch).toMatch(/Verdict:.*idle/); - - const stdoutTimeout = [ - "NEMOCLAW_WA_DIAG_OK", - "DIR /sandbox/.openclaw/whatsapp POPULATED", - "NEMOCLAW_WA_HEARTBEAT_BEGIN", - JSON.stringify({ - lastInboundAt: "2026-05-28T03:59:30.000Z", - messagesHandled: 1, - connectionState: "open", - }), - "NEMOCLAW_WA_HEARTBEAT_END", - "NEMOCLAW_WA_LOG_BEGIN", - "NEMOCLAW_WA_LOG_END", - // No PROC_DONE — simulating a probe that aborted before reaching - // the pgrep stage. - ].join("\n"); - const { deps: depsTimeout, out_lines: linesTimeout } = makeDeps({ - exec: () => ({ status: 0, stdout: stdoutTimeout, stderr: "" }), - }); - await showSandboxChannelStatus("alpha", { deps: depsTimeout, channel: "whatsapp" }); - const dumpTimeout = linesTimeout.join("\n"); - expect(dumpTimeout).toMatch(/Bridge process: could not enumerate sandbox processes/); - expect(dumpTimeout).toMatch(/Verdict:.*healthy/); - } finally { - exitSpy.mockRestore(); - } - }); - - it("captures the probe script as a syntactically valid /bin/sh program", async () => { - // Regression guard: an earlier version joined the multi-line script with - // ` && ` which produced `do && if` and other invalid constructs, - // causing every real probe to look like exec failure. Validate the - // emitted script with `sh -n` before declaring the diagnostic working. + it("emits a syntactically valid /bin/sh program for the hermes session probe", async () => { + // Regression guard: the hermes branch is the only remaining probe that + // builds a multi-line shell script. Validate it with `sh -n` so a future + // edit cannot ship a script that fails to parse and reads as exec failure. let capturedCmd: string | null = null; const exec = (_sb: string, cmd: string): ExecResult | null => { capturedCmd = cmd; return { status: 0, - stdout: "NEMOCLAW_WA_DIAG_OK\nDIR /sandbox/.openclaw/whatsapp MISSING\n", + stdout: "NEMOCLAW_WA_HERMES_SESSION_ABSENT\n", stderr: "", }; }; const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`process.exit(${code})`); }) as never); - const { deps } = makeDeps({ exec }); + const { deps } = makeDeps({ exec, agentName: "hermes" }); try { await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); } catch { @@ -301,9 +246,8 @@ describe("showSandboxChannelStatus (whatsapp)", () => { encoding: "utf-8", }); expect(validation.status, validation.stderr || validation.stdout).toBe(0); - // The probe must also filter its own command line out of the pgrep results. - expect(capturedCmd as unknown as string).toMatch(/__nemoclaw_wa_self_pid/); - expect(capturedCmd as unknown as string).toMatch(/pgrep -fa/); + // The hermes probe stats the authoritative Baileys credentials artifact. + expect(capturedCmd as unknown as string).toMatch(/creds\.json/); }); it("skips the deep probe and reports paused state when WhatsApp is in disabledChannels", async () => { diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index 2c89a955ce7..0edfb8e69d6 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -24,7 +24,6 @@ import { type DiagnosticSeverity, type DiagnosticSignal, } from "../../messaging/channels/channel-health"; -import type { WhatsappDiagnosticReport } from "../../messaging/channels/whatsapp/hooks/status-health-eval"; import { collectBuiltInMessagingChannelDiagnostics, type MessagingChannelDiagnosticSpec, @@ -82,7 +81,7 @@ type ChannelStatusSingleReport = schemaVersion: 1; sandbox: string; channel: string; - report: WhatsappDiagnosticReport | ChannelHealthReport; + report: ChannelHealthReport; } | { schemaVersion: 1; @@ -337,6 +336,21 @@ function channelSupportedByAgent(channelName: string, agent: AgentDefinition): b .some((manifest) => manifest.id === channelName); } +// Manifest-first gate for `runChannelHealthHook`: returns true when the +// channel declares a `phase: "status"` hook that emits a `channelHealth` +// output. That is the output id `readChannelHealthOutputs` looks for, so +// keying the gate off it keeps orchestration + hook wiring in sync without +// hard-coding channel names or `deepProbe` strings. +function channelHasChannelHealthStatusHook(channelName: string): boolean { + const manifest = channelManifestRegistry.get(channelName); + if (!manifest) return false; + return manifest.hooks.some( + (hook) => + hook.phase === "status" && + hook.outputs?.some((output) => output.id === "channelHealth") === true, + ); +} + // Runs a deep-probe channel's `phase:"status"` health hook through the // generic status-hook runner and returns its channel-health report. All // channel-specific probing + classification lives in the channel's own hook @@ -464,14 +478,16 @@ export async function showSandboxChannelStatus( const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(entry)); const channelIsPaused = disabledChannels.has(channelName); - // A deep-probe channel (log-tail or in-sandbox-qr) runs its `phase:"status"` - // health hook via the generic status-hook runner (the hook lives in the - // channel folder). The hook's `agents` gate skips channels with no - // breadcrumb producer for this agent (e.g. Hermes telegram), so those fall - // back to the basic config report. + // Manifest-first gating: a channel opts into a deep runtime probe by + // declaring a `phase: "status"` hook whose output includes a + // `channelHealth`-shaped status. The generic status-hook runner then owns + // dispatch, and this orchestrator stays channel-agnostic. Keeping the + // check tied to the `channelHealth` output id (rather than "any status + // hook") preserves the existing target set — whatsapp and telegram — so + // slack/teams status hooks that produce different output kinds do not get + // pulled in here. const healthReport = - (diagnostic.deepProbe === "log-tail" || diagnostic.deepProbe === "in-sandbox-qr") && - !channelIsPaused + channelHasChannelHealthStatusHook(channelName) && !channelIsPaused ? runChannelHealthHook(sandboxName, channelName, agent, deps, diagnostic) : undefined; let report: ChannelStatusReport; diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index 963cb550665..8ead67be596 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -1,12 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; import { describe, expect, it, vi } from "vitest"; import type { MessagingHookContext, MessagingHookResult } from "../../../hooks/types"; import type { ChannelHealthReport } from "../../channel-health"; import { createWhatsappStatusHealthHook } from "./status-health"; -import type { WhatsappDiagnosticReport } from "./status-health-eval"; const BASE_INPUTS = { currentSandbox: "alpha", @@ -17,6 +15,10 @@ const BASE_INPUTS = { presetOnGateway: true, }; +// A parseable phone number the redaction assertions treat as sentinel PII. +// The hook must never propagate it out of the sandbox JSON to the report. +const REDACTION_PHONE = "+14155551212"; + function context( inputs: Record = BASE_INPUTS, channelId = "whatsapp", @@ -35,238 +37,273 @@ function makeExec(result: ExecResult) { return vi.fn((_sandbox: string, _command: string, _timeout: number): ExecResult => result); } -// The hook is synchronous; the handler type is a sync|Promise union, so narrow. +// Sequential mock: each invocation of the hook only issues one exec call, but +// some tests want to exercise multiple hook invocations against a scripted +// list of responses. +function makeSequentialExec(results: readonly ExecResult[]) { + let call = 0; + return vi.fn((_sandbox: string, _command: string, _timeout: number): ExecResult => { + const value = results[call] ?? results[results.length - 1] ?? null; + call += 1; + return value; + }); +} + function reportOf( result: MessagingHookResult | Promise, -): WhatsappDiagnosticReport | undefined { +): ChannelHealthReport | undefined { const value = (result as MessagingHookResult).outputs?.channelHealth?.value as unknown as - | { report?: WhatsappDiagnosticReport } + | { report?: ChannelHealthReport } | undefined; return value?.report; } -function baseReportOf( - result: MessagingHookResult | Promise, -): ChannelHealthReport | undefined { - return reportOf(result); -} - function outputsOf(result: MessagingHookResult | Promise) { return (result as MessagingHookResult).outputs; } -function makeProbeStdout( - parts: { - reachable?: boolean; - dirs?: readonly { path: string; state: "MISSING" | "EMPTY" | "POPULATED" }[]; - heartbeat?: string | null; - logLines?: readonly string[]; - procLines?: readonly string[]; - procDone?: boolean; - gwAlive?: boolean; - gwLastInbound?: string | null; - } = {}, -): string { - const shellOk = parts.reachable === false ? [] : ["NEMOCLAW_WA_DIAG_OK"]; - const dirLines = (parts.dirs ?? []).map((dir) => `DIR ${dir.path} ${dir.state}`); - const heartbeatBlock = - parts.heartbeat == null - ? [] - : ["NEMOCLAW_WA_HEARTBEAT_BEGIN", parts.heartbeat, "NEMOCLAW_WA_HEARTBEAT_END"]; - const logBlock = ["NEMOCLAW_WA_LOG_BEGIN", ...(parts.logLines ?? []), "NEMOCLAW_WA_LOG_END"]; - const gwAliveLine = parts.gwAlive ? ["NEMOCLAW_WA_GW_ALIVE"] : []; - const gwInboundLine = parts.gwLastInbound - ? [`NEMOCLAW_WA_GW_LAST_INBOUND ${parts.gwLastInbound}`] - : []; - const procBlock = parts.procLines ?? []; - const procDoneLine = parts.procDone === false ? [] : ["NEMOCLAW_WA_PROC_DONE"]; - return [ - ...shellOk, - ...dirLines, - ...heartbeatBlock, - ...logBlock, - ...gwAliveLine, - ...gwInboundLine, - ...procBlock, - ...procDoneLine, - ].join("\n"); +function stringifyReport(result: MessagingHookResult | Promise): string { + return JSON.stringify(reportOf(result)); } -describe("whatsapp.statusHealth hook", () => { - it("probes the state dirs and reports healthy when heartbeat shows recent inbound (#4386)", () => { - const heartbeat = JSON.stringify({ - lastInboundAt: "2026-07-13T23:59:30.000Z", - messagesHandled: 4, - connectionState: "open", - }); - const exec = makeExec({ - status: 0, - stdout: makeProbeStdout({ - dirs: [{ path: "/sandbox/.openclaw/whatsapp", state: "POPULATED" }], - heartbeat, - procLines: ["PROC 1234 openclaw-whatsapp"], - }), - stderr: "", - }); - const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); - expect(reportOf(result)?.verdict).toBe("healthy"); - // Extension type: the base guard should still classify this as a health - // report even with the extra heartbeat field. - expect(baseReportOf(result)?.channel).toBe("whatsapp"); - }); +// Canonical stdout builder for the openclaw CLI. The runtime `wa` object +// carries redaction-sensitive `self.*` and `lastError` keys so tests can +// assert none of that leaks into the diagnostic. +type WaFixture = { + readonly configured?: boolean; + readonly statusState?: string; + readonly linked?: boolean; + readonly running?: boolean; + readonly connected?: boolean; + readonly healthState?: string; + readonly lastInboundAt?: number | null; + readonly lastStopAt?: number | null; + readonly reconnectAttempts?: number; + readonly self?: Record; + readonly lastError?: string | null; +}; - it("probes the credentials/whatsapp path (OpenClaw 2026.6.10+) as populated evidence", () => { - // Regression guard: OpenClaw 2026.6.10+ stores the paired Baileys session - // under `credentials/whatsapp//creds.json`. When only that dir is - // POPULATED (and the legacy `whatsapp/` path is MISSING) the diagnostic - // must still see the sandbox as paired. - const exec = makeExec({ - status: 0, - stdout: makeProbeStdout({ - dirs: [ - { path: "/sandbox/.openclaw/whatsapp", state: "MISSING" }, - { path: "/sandbox/.openclaw/credentials/whatsapp", state: "POPULATED" }, - ], - heartbeat: JSON.stringify({ - lastInboundAt: "2026-07-13T23:59:30.000Z", - messagesHandled: 2, - connectionState: "open", - }), - procLines: ["PROC 1234 openclaw-whatsapp"], - }), - stderr: "", - }); +function openclawJson(wa: WaFixture | null): string { + const payload = + wa === null + ? { gatewayReachable: false, error: "unknown channel: whatsapp", configOnly: true } + : { channels: { whatsapp: wa } }; + return JSON.stringify(payload); +} + +const HEALTHY_WA: WaFixture = { + configured: true, + statusState: "linked", + linked: true, + running: true, + connected: true, + healthState: "healthy", + lastInboundAt: Date.parse("2026-07-13T23:59:30.000Z"), + reconnectAttempts: 0, + self: { e164: REDACTION_PHONE, jid: `${REDACTION_PHONE}@s.whatsapp.net`, lid: "1@lid" }, + lastError: null, +}; + +// The exact PRA-1 / CR3 shape: the bridge went from linked+running to +// linked+stopped. `lastInboundAt` is still recent (last delivered message +// before the stop) but every liveness bit says "not running". This must not +// render as healthy. +const STOPPED_WA: WaFixture = { + configured: true, + statusState: "linked", + linked: true, + running: false, + connected: false, + healthState: "stopped", + lastInboundAt: Date.parse("2026-07-13T23:59:30.000Z"), + lastStopAt: Date.parse("2026-07-13T23:59:45.000Z"), + reconnectAttempts: 0, + self: { e164: REDACTION_PHONE, jid: `${REDACTION_PHONE}@s.whatsapp.net`, lid: "1@lid" }, + lastError: `disconnect from ${REDACTION_PHONE}`, +}; + +const UNPAIRED_WA: WaFixture = { + configured: true, + statusState: "unpaired", + linked: false, + running: false, + connected: false, + healthState: "stopped", + lastInboundAt: null, + reconnectAttempts: 0, +}; + +describe("whatsapp.statusHealth openclaw CLI probe", () => { + it.each([ + // Verdict, wa fixture, and a short label. Table-driven so branching is + // pushed into it.each iteration rather than test-body control flow. + { + label: "healthy: linked+running+connected+recent inbound", + wa: HEALTHY_WA, + verdict: "healthy", + }, + { + label: "stopped: linked+lastInboundAt fresh BUT running=false (PRA-1 / CR3 regression)", + wa: STOPPED_WA, + verdict: "idle", + }, + { label: "unpaired: linked=false", wa: UNPAIRED_WA, verdict: "unpaired" }, + ] as const)("reports verdict $verdict for $label", ({ wa, verdict }) => { + const exec = makeExec({ status: 0, stdout: openclawJson(wa), stderr: "" }); const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); - const report = reportOf(result); - expect(report?.verdict).toBe("healthy"); - const pairing = report?.signals.find((s) => s.label === "Pairing / session"); - expect(pairing?.severity).toBe("ok"); + expect(reportOf(result)?.verdict).toBe(verdict); + // The healthy path is the only case that must produce `healthy` — every + // non-healthy fixture must render as something else so the PRA-1 root + // cause (false-positive healthy on stopped) cannot regress. + expect(reportOf(result)?.verdict === "healthy").toBe(verdict === "healthy"); }); - it("synthesizes a heartbeat from GW_ALIVE + GW_LAST_INBOUND when no heartbeat file exists", () => { - // Part 2 (gateway-log liveness): the in-process bridge does not publish a - // heartbeat file, but its provider-ready + inbound breadcrumbs are in the - // gateway log. The hook must synthesize a heartbeat + alive bridge so the - // "paired but no inbound observed" warning does not fire for a healthy - // in-process bridge. - const exec = makeExec({ - status: 0, - stdout: makeProbeStdout({ - dirs: [{ path: "/sandbox/.openclaw/whatsapp", state: "POPULATED" }], - heartbeat: null, - gwAlive: true, - gwLastInbound: "2026-07-13T23:59:30.000Z", - procDone: true, - }), - stderr: "", - }); - const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); - const report = reportOf(result); - expect(report?.verdict).toBe("healthy"); - expect(report?.heartbeat?.connectionState).toBe("open"); - expect(report?.heartbeat?.lastInboundAt).toBe("2026-07-13T23:59:30.000Z"); - const proc = report?.signals.find((s) => s.label === "Bridge process"); - expect(proc?.severity).toBe("ok"); + it("stopped bridge never emits verdict=healthy (PRA-1 explicit guard)", () => { + const exec = makeExec({ status: 0, stdout: openclawJson(STOPPED_WA), stderr: "" }); + const report = reportOf( + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()), + ); + expect(report?.verdict).not.toBe("healthy"); + const bridge = report?.signals.find((s) => s.label === "Bridge process"); + expect(bridge?.severity).toBe("fail"); }); - it("emits a syntactically valid /bin/sh probe script", () => { - // The probe is a multiline sh script (for/if/grep pipelines, marker - // sequencing, and the gateway-log block). A shell syntax regression would - // fail every real probe while mocked-stdout tests stay green; validate the - // generated command with `sh -n`. - const exec = makeExec({ status: 0, stdout: makeProbeStdout(), stderr: "" }); - createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); - const command = exec.mock.calls[0]?.[1] ?? ""; - const validation = spawnSync("sh", ["-n", "-c", command], { encoding: "utf-8" }); - expect(validation.status, validation.stderr || validation.stdout).toBe(0); - // The probe must filter its own pgrep line out of the process results. - expect(command).toMatch(/__nemoclaw_wa_self_pid/); - expect(command).toMatch(/pgrep -fa/); - // Part 2: gateway-log block scoped to whatsapp lines only. The bracket - // form appears in the emitted shell as `\[whatsapp\]` (grep -E literal). - expect(command).toMatch(/channels\/whatsapp/); - expect(command).toMatch(/\\\[whatsapp\\\]/); - expect(command).toMatch(/NEMOCLAW_WA_GW_ALIVE/); - expect(command).toMatch(/NEMOCLAW_WA_GW_LAST_INBOUND/); - // Part 2 must read the canonical in-sandbox gateway log (where NemoClaw - // redirects gateway stdout, same as the telegram hook), NOT the OpenClaw - // internal dated log — the latter is not guaranteed to exist on every - // sandbox and would silently disable in-process bridge liveness detection. - expect(command).toContain("/tmp/gateway.log"); - expect(command).not.toContain("/tmp/openclaw-"); + it.each([ + // Redaction guard: none of these known-PII bytes may appear in the + // emitted JSON, regardless of whether the fixture is healthy or stopped. + { fixture: HEALTHY_WA, label: "healthy" }, + { fixture: STOPPED_WA, label: "stopped" }, + ])("never propagates self.* or lastError values ($label)", ({ fixture }) => { + const exec = makeExec({ status: 0, stdout: openclawJson(fixture), stderr: "" }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); + const serialized = stringifyReport(result); + expect(serialized).not.toContain(REDACTION_PHONE); + expect(serialized).not.toContain("@s.whatsapp.net"); + expect(serialized).not.toContain("@lid"); + expect(serialized).not.toContain("disconnect from"); }); - it("selects the hermes state-dir path when the agent is hermes", () => { - const exec = makeExec({ status: 0, stdout: makeProbeStdout(), stderr: "" }); - createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( - context({ ...BASE_INPUTS, agent: "hermes" }), + it("gateway-unreachable JSON yields a non-healthy verdict without crashing", () => { + const exec = makeExec({ status: 0, stdout: openclawJson(null), stderr: "" }); + const report = reportOf( + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()), ); - const command = exec.mock.calls[0]?.[1] ?? ""; - expect(command).toContain("/sandbox/.hermes/platforms/whatsapp/session"); - expect(command).not.toContain("/sandbox/.openclaw/"); + expect(report?.verdict).not.toBe("healthy"); + // stateDirPopulated stays null in this branch, so the evaluator lands on + // "unknown" (a non-fail, non-idle verdict) — an honest report that the + // gateway didn't answer rather than a fabricated healthy. + expect(report?.verdict).toBe("unknown"); + const logSignal = report?.signals.find((s) => s.label === "Recent log signals"); + expect(logSignal?.detail).toMatch(/gateway not reachable/); }); - it("selects the openclaw state-dir paths by default (both whatsapp and credentials/whatsapp)", () => { - const exec = makeExec({ status: 0, stdout: makeProbeStdout(), stderr: "" }); - createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); - const command = exec.mock.calls[0]?.[1] ?? ""; - expect(command).toContain("/sandbox/.openclaw/whatsapp"); - expect(command).toContain("/sandbox/.openclaw/credentials/whatsapp"); + it.each([ + { + label: "non-zero exec", + exec: { status: 124, stdout: openclawJson(HEALTHY_WA), stderr: "timed out" }, + }, + { label: "null exec (sandbox down)", exec: null as ExecResult }, + { label: "empty stdout", exec: { status: 0, stdout: "", stderr: "" } }, + { label: "non-JSON stdout", exec: { status: 0, stdout: "not json at all", stderr: "" } }, + { label: "JSON but not an object", exec: { status: 0, stdout: '"hello"', stderr: "" } }, + ] as const)("verdict=probe_failed when $label", ({ exec }) => { + const runner = makeExec(exec); + const report = reportOf( + createWhatsappStatusHealthHook({ executeSandboxCommand: runner })(context()), + ); + expect(report?.verdict).toBe("probe_failed"); }); - it("reports probe_failed when the sandbox exec fails (null result)", () => { - const exec = makeExec(null); - const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); - expect(reportOf(result)?.verdict).toBe("probe_failed"); + it("invokes the openclaw CLI with the JSON + timeout flags", () => { + const exec = makeExec({ status: 0, stdout: openclawJson(HEALTHY_WA), stderr: "" }); + createWhatsappStatusHealthHook({ executeSandboxCommand: exec, timeoutMs: 4500 })(context()); + const command = String(exec.mock.calls[0]?.[1] ?? ""); + expect(command).toContain("openclaw channels status --channel whatsapp --json"); + expect(command).toContain("--timeout 4500"); + // The hook must not fall back to the old log-scraping / pgrep / dir-listing + // probe: the new implementation never issues those commands. + expect(command).not.toContain("/tmp/gateway.log"); + expect(command).not.toMatch(/pgrep/); + expect(command).not.toMatch(/DIR .* POPULATED/); }); - it("reports probe_failed when stdout omits the shell-OK marker", () => { - const exec = makeExec({ status: 0, stdout: "", stderr: "" }); - const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); - expect(reportOf(result)?.verdict).toBe("probe_failed"); + it("healthState surfaces neutral state signal only when non-healthy", () => { + const stale: WaFixture = { + ...HEALTHY_WA, + healthState: "stale", + reconnectAttempts: 3, + connected: false, + }; + const exec = makeSequentialExec([ + { status: 0, stdout: openclawJson(HEALTHY_WA), stderr: "" }, + { status: 0, stdout: openclawJson(stale), stderr: "" }, + ]); + const hook = createWhatsappStatusHealthHook({ executeSandboxCommand: exec }); + const healthyReport = reportOf(hook(context())); + // Healthy runs stay clean (no "Recent log signals" row from healthState). + expect(healthyReport?.signals.some((s) => s.label === "Recent log signals")).toBe(false); + const staleReport = reportOf(hook(context())); + const staleLogs = staleReport?.signals.find((s) => s.label === "Recent log signals"); + expect(staleLogs?.detail).toMatch(/healthState=stale/); + expect(staleLogs?.detail).toMatch(/reconnectAttempts=3/); }); +}); - it("reports probe_failed on a non-zero exec even when partial stdout carries healthy markers", () => { - // A timed-out/killed exec can flush partial stdout that already contains - // SHELL_OK (printed first) plus a populated dir and heartbeat. Without the - // `exec.status === 0` guard this would read as `healthy` off partial data; - // the clean-exit requirement keeps it classified as probe_failed. - const exec = makeExec({ - status: 124, - stdout: makeProbeStdout({ - dirs: [{ path: "/sandbox/.openclaw/whatsapp", state: "POPULATED" }], - heartbeat: JSON.stringify({ - lastInboundAt: "2026-07-13T23:59:30.000Z", - messagesHandled: 4, - connectionState: "open", - }), - procLines: ["PROC 1234 openclaw-whatsapp"], - }), - stderr: "timed out", - }); - const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); - expect(reportOf(result)?.verdict).toBe("probe_failed"); +describe("whatsapp.statusHealth hermes credentials probe", () => { + it.each([ + { + label: "session creds present", + stdout: "NEMOCLAW_WA_HERMES_SESSION_PRESENT\n", + populated: true, + }, + { + label: "session creds absent", + stdout: "NEMOCLAW_WA_HERMES_SESSION_ABSENT\n", + populated: false, + }, + ] as const)("reports stateDirPopulated=$populated for $label", ({ stdout, populated }) => { + const exec = makeExec({ status: 0, stdout, stderr: "" }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( + context({ ...BASE_INPUTS, agent: "hermes" }), + ); + const pairing = reportOf(result)?.signals.find((s) => s.label === "Pairing / session"); + expect(pairing?.severity).toBe(populated ? "ok" : "warn"); }); - it("derives config_gap / policy_gap from the host-fact inputs", () => { + it("targets the authoritative session credentials file, not a dir listing", () => { const exec = makeExec({ status: 0, - stdout: makeProbeStdout({ - dirs: [{ path: "/sandbox/.openclaw/whatsapp", state: "POPULATED" }], - }), + stdout: "NEMOCLAW_WA_HERMES_SESSION_PRESENT\n", stderr: "", }); - const hook = createWhatsappStatusHealthHook({ executeSandboxCommand: exec }); - expect( - reportOf(hook(context({ ...BASE_INPUTS, channelEnabledInRegistry: false })))?.verdict, - ).toBe("config_gap"); - expect(reportOf(hook(context({ ...BASE_INPUTS, presetInRegistry: false })))?.verdict).toBe( - "policy_gap", + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( + context({ ...BASE_INPUTS, agent: "hermes" }), + ); + const command = String(exec.mock.calls[0]?.[1] ?? ""); + expect(command).toContain("/sandbox/.hermes/platforms/whatsapp/session/creds.json"); + // A missing creds file must be authoritative — the old dir-listing logic + // that treated any non-empty dir as "populated" is gone. + expect(command).not.toMatch(/ls -A/); + // Hermes does not carry the openclaw CLI. + expect(command).not.toContain("openclaw channels status"); + }); + + it("hermes probe with neither marker present classifies as probe_failed", () => { + // A malformed exec (e.g. `sh` errors before either branch fires) must not + // be read as a fabricated verdict; require one of the two literal markers. + const exec = makeExec({ status: 0, stdout: "", stderr: "" }); + const report = reportOf( + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( + context({ ...BASE_INPUTS, agent: "hermes" }), + ), ); + expect(report?.verdict).toBe("probe_failed"); }); +}); +describe("whatsapp.statusHealth wiring guards", () => { it("no-ops for a non-whatsapp channel or without an exec runner", () => { - const exec = makeExec({ status: 0, stdout: makeProbeStdout(), stderr: "" }); + const exec = makeExec({ status: 0, stdout: openclawJson(HEALTHY_WA), stderr: "" }); expect( outputsOf( createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( @@ -277,4 +314,13 @@ describe("whatsapp.statusHealth hook", () => { expect(outputsOf(createWhatsappStatusHealthHook({})(context()))).toBeUndefined(); expect(exec).not.toHaveBeenCalled(); }); + + it("derives config_gap / policy_gap from the host-fact inputs", () => { + const exec = makeExec({ status: 0, stdout: openclawJson(HEALTHY_WA), stderr: "" }); + const hook = createWhatsappStatusHealthHook({ executeSandboxCommand: exec }); + const configGap = reportOf(hook(context({ ...BASE_INPUTS, channelEnabledInRegistry: false }))); + expect(configGap?.verdict).toBe("config_gap"); + const policyGap = reportOf(hook(context({ ...BASE_INPUTS, presetInRegistry: false }))); + expect(policyGap?.verdict).toBe("policy_gap"); + }); }); diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index ad9663d063f..31fe80b38ba 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -8,25 +8,35 @@ * command via the status-hook runner, so no whatsapp-specific code lives in * the generic status orchestrator. * - * WhatsApp has two supported bridge shapes: + * The probe reads OpenClaw's authoritative live status JSON: * - * 1. OpenClaw: a Baileys session under either `/whatsapp` or the - * newer `/credentials/whatsapp` layout (OpenClaw 2026.6.10+ - * stores the paired session there). The bridge may either run inside the - * gateway process (in which case the pgrep probe cannot enumerate it, - * and gateway-log breadcrumbs become the liveness signal) or as a - * dedicated `openclaw-whatsapp` process with its own heartbeat file. + * openclaw channels status --channel whatsapp --json --timeout * - * 2. Hermes: a session under `/platforms/whatsapp/session`. + * That JSON already reflects the live `linked`/`running`/`connected` / + * `healthState` state kept by the in-process bridge, so the probe never + * needs to scrape gateway-log breadcrumbs, list a credentials directory, + * or grep for a bridge process — all three signals were misleading in + * different real cases: * - * The probe inspects both layouts, the heartbeat file, a bounded slice of - * bridge logs, running processes, and the OpenClaw gateway log (which - * whatsapp lines are scoped to via `channels/whatsapp` and `[whatsapp]`). - * The gateway-log liveness markers synthesize a heartbeat when the in-process - * bridge is up and has recorded inbound traffic without publishing a - * heartbeat file — this closes the "paired-looking with no observable - * inbound" gap reported in issue #4386 for the current OpenClaw in-process - * bridge. + * - Append-only `starting provider` breadcrumbs in `/tmp/gateway.log` + * survive across restarts, so a stopped bridge would still read + * "provider ready" (false-positive healthy). + * - A non-empty `credentials/whatsapp` dir does not imply a valid paired + * session — half-written state or credentials from a prior tenant + * read as "populated" without actually pairing. + * - The bridge runs inside the OpenClaw gateway process, so `pgrep` + * could not enumerate it and the probe would report "unpaired" for + * a working bridge. + * + * For Hermes (secondary), which has no `openclaw` CLI in the sandbox, the + * probe checks for an authoritative session credentials file (Baileys + * `creds.json`) under `/platforms/whatsapp/session` instead of a + * loose directory-listing check. + * + * Redaction contract: this probe never reads, stores, logs, or emits the + * self.e164 / self.jid / self.lid values or the raw `lastError` string + * from the OpenClaw JSON — those can carry phone numbers. Only booleans, + * state-string enums, and epoch timestamps make it into the report. */ import { shellQuote as quotePath } from "../../../../core/shell-quote"; @@ -38,8 +48,6 @@ import { } from "../../channel-health"; import { evaluateWhatsappDiagnostics, - parseWhatsappHeartbeat, - summarizeWhatsappLogLines, type WhatsappHeartbeat, type WhatsappProbeInput, } from "./status-health-eval"; @@ -47,30 +55,19 @@ import { export const WHATSAPP_STATUS_HEALTH_HOOK_HANDLER_ID = "whatsapp.statusHealth"; // Bound how long we are willing to block inside an `openshell sandbox exec` -// for the inline diagnostic snippet. WhatsApp's bridge sometimes goes -// unresponsive when the Noise WebSocket is stuck; a fast hard cap keeps -// channels status from inheriting that hang. +// for the diagnostic. WhatsApp's in-process bridge can go unresponsive when +// the Noise WebSocket is stuck; a fast hard cap keeps channels status from +// inheriting that hang. const DEFAULT_TIMEOUT_MS = 8_000; - -const SHELL_OK = "NEMOCLAW_WA_DIAG_OK"; -const HEARTBEAT_BEGIN = "NEMOCLAW_WA_HEARTBEAT_BEGIN"; -const HEARTBEAT_END = "NEMOCLAW_WA_HEARTBEAT_END"; -const LOG_BEGIN = "NEMOCLAW_WA_LOG_BEGIN"; -const LOG_END = "NEMOCLAW_WA_LOG_END"; -const PROC_DONE = "NEMOCLAW_WA_PROC_DONE"; -// Part 2 (gateway-log liveness for the in-process bridge): the probe emits -// only these two markers plus the extracted ISO timestamp — never a raw log -// line, so phone numbers embedded in an "Inbound message …" line cannot -// escape the sandbox. Scoped to whatsapp lines via `channels/whatsapp` and -// `[whatsapp]` so telegram breadcrumbs never get miscounted as WA liveness. -const GW_ALIVE = "NEMOCLAW_WA_GW_ALIVE"; -const GW_LAST_INBOUND = "NEMOCLAW_WA_GW_LAST_INBOUND"; -// The canonical in-sandbox gateway log. NemoClaw launches the OpenClaw gateway -// with stdout+stderr redirected here (see agent/gateway-script-shared.ts), so -// every `[whatsapp] …` breadcrumb lands in this one file — the same log the -// telegram status hook reads. The OpenClaw-internal dated log under -// /tmp/openclaw-/ is not guaranteed to exist or to hold these lines. -const OPENCLAW_GATEWAY_LOG_FILE = "/tmp/gateway.log"; +// The Hermes credentials probe emits one of these two literal markers so the +// host parser can tell "session file present" from "session file missing" or +// "probe failed". Absent stdout is treated as probe failure. +const HERMES_SESSION_PRESENT = "NEMOCLAW_WA_HERMES_SESSION_PRESENT"; +const HERMES_SESSION_ABSENT = "NEMOCLAW_WA_HERMES_SESSION_ABSENT"; +// Baileys writes the paired session under `/creds.json`. Hermes' +// WhatsApp adapter follows that convention, so a present `creds.json` is the +// authoritative pairing signal — a non-empty session dir alone is not. +const HERMES_SESSION_CREDS_FILE = "/sandbox/.hermes/platforms/whatsapp/session/creds.json"; /** WhatsApp uses the generic channel-health hook options unchanged. */ export type WhatsappStatusHealthHookOptions = ChannelStatusHealthHookOptions; @@ -90,55 +87,20 @@ export function createWhatsappStatusHealthHook( const agent = normalizeString(context.inputs?.agent) ?? "openclaw"; const stateDirs = resolveWhatsappStateDirs(agent); const timeoutMs = normalizeTimeoutMs(options.timeoutMs); - const script = buildWhatsappProbeScript(stateDirs); - const exec = execute(sandboxName, script, timeoutMs); - const parsed = parseProbeOutput(String(exec?.stdout ?? "")); - // A non-zero exec (timeout/kill/unhealthy sandbox) can still carry partial - // stdout that already contains the SHELL_OK marker (it is printed first), - // so require a clean exit before trusting the probe. Otherwise a stalled - // probe reads a verdict off partial data instead of classifying as - // probe_failed. Mirrors the telegram status-health hook. - const reachable = parsed.reachable && exec?.status === 0; - - let heartbeat: WhatsappHeartbeat | null = null; - let heartbeatParseError: string | null = null; - if (parsed.heartbeatRaw) { - const parseResult = parseWhatsappHeartbeat(parsed.heartbeatRaw); - if ("heartbeat" in parseResult) { - heartbeat = parseResult.heartbeat; - } else { - heartbeatParseError = parseResult.parseError; - } - } - - // Part 2 (gateway-log liveness): when the probe found the whatsapp - // provider listening in the gateway log, treat that as bridge liveness - // — the in-process bridge does not show under pgrep. When there is no - // heartbeat file but the gateway log shows recent inbound, synthesize a - // minimal heartbeat so the "paired but no inbound observed" warning is - // replaced with the actual last-inbound timestamp. - let bridgeProcessAlive = parsed.bridgeProcessAlive; - if (parsed.gatewayProviderAlive) { - bridgeProcessAlive = true; - } - if (!heartbeat && parsed.gatewayProviderAlive) { - heartbeat = { - connectionState: "open", - lastInboundAt: parsed.gatewayLastInboundAt, - messagesHandled: null, - noteCategory: null, - }; - } + const probe = + agent === "hermes" + ? runHermesSessionProbe(execute, sandboxName, timeoutMs) + : runOpenclawStatusProbe(execute, sandboxName, timeoutMs); const input: WhatsappProbeInput = { agent, stateDirs, - stateDirPopulated: parsed.stateDirPopulated, - heartbeat, - heartbeatParseError, - bridgeProcessAlive, - recentLogSignals: summarizeWhatsappLogLines(parsed.logLines), - probeReachable: reachable, + stateDirPopulated: probe.stateDirPopulated, + heartbeat: probe.heartbeat, + heartbeatParseError: null, + bridgeProcessAlive: probe.bridgeProcessAlive, + recentLogSignals: probe.recentLogSignals, + probeReachable: probe.probeReachable, probedAt: normalizeString(context.inputs?.probedAt) ?? "", presetInRegistry: Boolean(context.inputs?.presetInRegistry), presetOnGateway: normalizeTristate(context.inputs?.presetOnGateway), @@ -169,209 +131,225 @@ export function createWhatsappStatusHealthHookRegistration( } /** - * The two known WhatsApp bridge state layouts, keyed by agent name. The hook - * has no AgentDefinition — the parent runner threads only serializable - * facts through the manifest hook contract — so paths are derived from the - * agent string and the fixed in-sandbox config dir convention. Non-existent - * candidates simply yield "MISSING" in the probe output. + * The two known WhatsApp bridge state layouts, keyed by agent name. The + * OpenClaw entries are informational — the openclaw probe no longer inspects + * these directories (it reads the CLI's live JSON instead) but the paths are + * still surfaced in the "Pairing / session" signal detail so the operator + * knows where the session material lives if they need to intervene. The + * Hermes entry is the actual credentials-file parent the hermes probe stats. */ export function resolveWhatsappStateDirs(agent: string): string[] { if (agent === "hermes") { return ["/sandbox/.hermes/platforms/whatsapp/session"]; } - // Default to the OpenClaw layout. OpenClaw 2026.6.10+ writes the paired - // Baileys session under `credentials/whatsapp//creds.json`, not - // `/whatsapp`, so probe both shapes (Part 1 fix). + // OpenClaw 2026.6.10+ writes the paired Baileys session under + // `credentials/whatsapp//creds.json`, not `/whatsapp`. return ["/sandbox/.openclaw/whatsapp", "/sandbox/.openclaw/credentials/whatsapp"]; } -function buildWhatsappProbeScript(stateDirs: readonly string[]): string { - // The script: - // 1. Marks success with SHELL_OK so we can disambiguate "exec failed" from - // "exec succeeded but produced nothing". - // 2. Lists each candidate state directory and emits a single "POPULATED" - // or "EMPTY" / "MISSING" line per dir. - // 3. Cats the first heartbeat-shaped file it finds, wrapped in begin/end - // markers so the parser can extract it without parsing find output. - // 4. Tails up to 200 lines of bridge log files and forwards only short - // lines that match the diagnostic regex set. The host parser further - // filters to summary phrases. - // 5. Scans the OpenClaw gateway log for whatsapp-scoped liveness lines - // (Part 2) — provider-ready plus the newest inbound timestamp — and - // emits only the fixed markers + the parsed ISO string. Never a raw - // log line: gateway inbound lines can carry phone numbers. - // 6. Runs pgrep for known bridge process names, then filters out the probe - // shell itself and the pgrep call so the diagnostic does not report a - // bridge as "running" when the only match is our own command line. - // The script is joined with newlines so the embedded `for` / `if` - // constructs parse as compound statements. Joining the whole thing with - // ` && ` corrupts the grammar (e.g. `do && if`), which `/bin/sh` rejects - // before the SHELL_OK marker prints and every live probe gets misread as - // unreachable. The leading `set +e` makes the probe survive missing log - // files and empty pgrep matches without aborting at the first non-zero - // exit. - const quotedDirs = stateDirs.map(quotePath).join(" "); - return [ - `set +e`, - `printf '%s\\n' ${quotePath(SHELL_OK)}`, - `for dir in ${quotedDirs}; do`, - ` if [ ! -d "$dir" ]; then printf 'DIR %s MISSING\\n' "$dir"; continue; fi`, - ` if [ -z "$(ls -A "$dir" 2>/dev/null)" ]; then`, - ` printf 'DIR %s EMPTY\\n' "$dir"`, - ` else`, - ` printf 'DIR %s POPULATED\\n' "$dir"`, - ` fi`, - `done`, - `for dir in ${quotedDirs}; do`, - ` for candidate in heartbeat.json status.json health.json bridge-status.json; do`, - ` if [ -f "$dir/$candidate" ]; then`, - ` printf '%s\\n' ${quotePath(HEARTBEAT_BEGIN)}`, - ` cat "$dir/$candidate" 2>/dev/null | head -c 8192`, - ` printf '\\n%s\\n' ${quotePath(HEARTBEAT_END)}`, - ` break 2`, - ` fi`, - ` done`, - `done`, - `printf '%s\\n' ${quotePath(LOG_BEGIN)}`, - `for dir in ${quotedDirs}; do`, - ` for log in "$dir"/*.log "$dir"/logs/*.log; do`, - ` [ -f "$log" ] || continue`, - ` tail -n 200 "$log" 2>/dev/null | grep -E 'connection\\.(open|close|update|update.*restart)|ws (open|close)|401|unauthorized|qr.*(expired|timeout)|restartRequired|loggedOut|logged out|getMessage' | tail -n 20`, - ` done`, - `done`, - `printf '%s\\n' ${quotePath(LOG_END)}`, - // Part 2 (gateway-log liveness). The canonical gateway log holds plaintext - // `[whatsapp] … starting provider` / `[whatsapp] … Listening for WhatsApp - // inbound` / `[whatsapp] … Inbound message …` breadcrumbs. Scope grep to - // whatsapp so telegram lines are not miscounted, and emit ONLY the markers - // + the ISO timestamp — never a raw log line (an inbound line carries a - // phone number). Absent for a hermes runtime → the block is a no-op. - `__wa_scoped=$(tail -n 500 ${quotePath(OPENCLAW_GATEWAY_LOG_FILE)} 2>/dev/null | grep -aE 'channels/whatsapp|\\[whatsapp\\]')`, - `printf '%s' "$__wa_scoped" | grep -qE 'starting provider|Listening for WhatsApp inbound' && printf '%s\\n' ${quotePath(GW_ALIVE)}`, - `__wa_last=$(printf '%s' "$__wa_scoped" | grep -E 'Inbound message' | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+([+-][0-9:]+|Z)?' | tail -n 1)`, - `[ -n "$__wa_last" ] && printf '%s %s\\n' ${quotePath(GW_LAST_INBOUND)} "$__wa_last"`, - `__nemoclaw_wa_self_pid=$$`, - // Match both process-name-with-whatsapp and processes whose argv - // mentions the WhatsApp state directory or known plugin paths. A - // bridge that runs inside the parent agent process (e.g. an OpenClaw - // plugin loaded via a generic `node` entry point) usually carries the - // platforms/whatsapp path on its command line via `--state-dir` or - // similar. - `pgrep -fa 'whatsapp|baileys|platforms/whatsapp|openclaw-whatsapp|hermes.*whatsapp' 2>/dev/null | awk -v self="$__nemoclaw_wa_self_pid" '$1 != self && $0 !~ /pgrep -fa/ && $0 !~ /NEMOCLAW_WA_DIAG_OK/ { print "PROC " $0 }' | head -n 5`, - // Always emit PROC_DONE after the pgrep pipeline so the parser can tell - // apart "pgrep completed with no matches" (the bridge runs under a - // process name that does not contain `whatsapp` or `baileys`, or has - // crashed) from "the probe never reached pgrep" (script aborted - // mid-flight). Without this marker both cases collapse to `null`. - `printf '%s\\n' ${quotePath(PROC_DONE)}`, - ].join("\n"); -} +type OpenclawWhatsappState = { + readonly configured?: unknown; + readonly statusState?: unknown; + readonly linked?: unknown; + readonly running?: unknown; + readonly connected?: unknown; + readonly healthState?: unknown; + readonly lastInboundAt?: unknown; + readonly lastStopAt?: unknown; + readonly lastDisconnect?: unknown; + readonly reconnectAttempts?: unknown; +}; -type ParsedProbe = { - reachable: boolean; - stateDirPopulated: boolean | null; - heartbeatRaw: string | null; - logLines: string[]; - bridgeProcessAlive: boolean | null; - gatewayProviderAlive: boolean; - gatewayLastInboundAt: string | null; +type ProbeResult = { + readonly probeReachable: boolean; + readonly stateDirPopulated: boolean | null; + readonly bridgeProcessAlive: boolean | null; + readonly heartbeat: WhatsappHeartbeat | null; + readonly recentLogSignals: readonly string[]; }; -function parseProbeOutput(stdout: string): ParsedProbe { - const lines = stdout.split(/\r?\n/); - if (!lines.includes(SHELL_OK)) { +const PROBE_UNREACHABLE: ProbeResult = { + probeReachable: false, + stateDirPopulated: null, + bridgeProcessAlive: null, + heartbeat: null, + recentLogSignals: [], +}; + +/** + * OpenClaw branch. Runs `openclaw channels status --channel whatsapp --json` + * inside the sandbox and translates the authoritative response into the + * evaluator's probe-input shape. The CLI shells out to the gateway, which + * reflects the in-process bridge's current state, so this replaces the old + * log-scraping + pgrep + dir-listing signals with a single trusted source. + */ +function runOpenclawStatusProbe( + execute: NonNullable, + sandboxName: string, + timeoutMs: number, +): ProbeResult { + const command = `openclaw channels status --channel whatsapp --json --timeout ${timeoutMs}`; + const exec = execute(sandboxName, command, timeoutMs); + // A non-zero exec (timeout/kill/unhealthy sandbox) can still carry partial + // stdout; require a clean exit before trusting the probe. Otherwise a + // stalled openclaw invocation could yield unparseable JSON that reads as a + // fabricated verdict instead of classifying as probe_failed. + if (!exec || exec.status !== 0) return PROBE_UNREACHABLE; + const json = parseOpenclawJson(String(exec.stdout ?? "")); + if (!json) return PROBE_UNREACHABLE; + + const channels = readObject(json.channels); + const wa = channels ? readObject(channels.whatsapp) : null; + if (!wa) { + // No `channels.whatsapp` — the CLI is reporting the gateway is + // unreachable or the channel is not on the gateway yet + // (`{ gatewayReachable: false, error: "unknown channel: ...", ... }`). + // Signal probe-reachable (we did get a clean exit and valid JSON) but + // leave the runtime fields null so the evaluator lands on "unknown". return { - reachable: false, + probeReachable: true, stateDirPopulated: null, - heartbeatRaw: null, - logLines: [], bridgeProcessAlive: null, - gatewayProviderAlive: false, - gatewayLastInboundAt: null, + heartbeat: null, + recentLogSignals: ["gateway not reachable — live WhatsApp health unavailable"], }; } - let stateDirPopulated: boolean | null = false; - let sawAnyDir = false; - let heartbeatRaw: string | null = null; - let inHeartbeat = false; - let inLogs = false; - const heartbeatBuf: string[] = []; - const logLines: string[] = []; - let sawProcMatch = false; - let sawProcDone = false; - let gatewayProviderAlive = false; - let gatewayLastInboundAt: string | null = null; + return mapOpenclawWaState(wa); +} - for (const line of lines) { - if (line === HEARTBEAT_BEGIN) { - inHeartbeat = true; - continue; - } - if (line === HEARTBEAT_END) { - inHeartbeat = false; - heartbeatRaw = heartbeatBuf.join("\n").trim(); - continue; - } - if (line === LOG_BEGIN) { - inLogs = true; - continue; - } - if (line === LOG_END) { - inLogs = false; - continue; - } - if (inHeartbeat) { - heartbeatBuf.push(line); - continue; - } - if (inLogs) { - const trimmed = line.trim(); - if (trimmed.length > 0) logLines.push(trimmed); - continue; - } - const dirMatch = line.match(/^DIR\s+\S+\s+(MISSING|EMPTY|POPULATED)$/); - if (dirMatch) { - sawAnyDir = true; - if (dirMatch[1] === "POPULATED") stateDirPopulated = true; - continue; - } - if (line.startsWith("PROC ")) { - sawProcMatch = true; - continue; - } - if (line === PROC_DONE) { - sawProcDone = true; - continue; - } - if (line === GW_ALIVE) { - gatewayProviderAlive = true; - continue; - } - if (line.startsWith(`${GW_LAST_INBOUND} `)) { - gatewayLastInboundAt = line.slice(GW_LAST_INBOUND.length + 1).trim() || null; - continue; - } +function mapOpenclawWaState(wa: OpenclawWhatsappState): ProbeResult { + const linked = wa.linked === true; + const running = wa.running === true; + const connected = wa.connected === true; + const healthState = readStringValue(wa.healthState); + const heartbeat: WhatsappHeartbeat | null = running + ? { + connectionState: openclawConnectionState(connected, healthState), + lastInboundAt: epochMsToIso(wa.lastInboundAt), + // The OpenClaw JSON does not expose a cumulative inbound counter — + // the evaluator treats `null` here as "not reported" rather than + // "zero", which is the accurate reading. + messagesHandled: null, + // Never copy the bridge's free-text `lastError` — it can carry phone + // numbers and message bodies. If the evaluator needs error signal it + // reads healthState/connectionState instead. + noteCategory: null, + } + : null; + return { + probeReachable: true, + // linked is the authoritative pairing bit; the credentials-directory + // check that used to sit here mistook half-written state as "populated". + stateDirPopulated: linked, + // running is the authoritative liveness bit; the pgrep check that used + // to sit here could not see the in-process bridge, and the gateway-log + // breadcrumbs are append-only so they survived a stopped bridge. + bridgeProcessAlive: running, + heartbeat, + recentLogSignals: summarizeOpenclawLive(healthState, wa.reconnectAttempts), + }; +} + +function openclawConnectionState(connected: boolean, healthState: string | null): string { + if (connected) return "open"; + return healthState === "starting" || healthState === "stale" ? "connecting" : "close"; +} + +// Never emit raw error text or self.* PII. Only healthState (an enum) and +// reconnectAttempts (a non-negative integer) are surfaced, and only when they +// carry non-healthy signal. +function summarizeOpenclawLive( + healthState: string | null, + reconnectAttemptsRaw: unknown, +): readonly string[] { + const parts: string[] = []; + if (healthState && healthState !== "healthy") { + parts.push(`healthState=${healthState}`); } - // Three states: - // true → pgrep printed at least one matching process - // false → pgrep completed with no matches; either the bridge is dead - // OR it runs inside the parent agent process under a name that - // does not contain `whatsapp`/`baileys`. The evaluator resolves - // that ambiguity using heartbeat freshness. - // null → the probe aborted before reaching pgrep (timeout, exec - // failure); we cannot infer anything about the bridge state. - const bridgeProcessAliveOut = sawProcMatch ? true : sawProcDone ? false : null; + const reconnectAttempts = + typeof reconnectAttemptsRaw === "number" && Number.isFinite(reconnectAttemptsRaw) + ? reconnectAttemptsRaw + : null; + if (reconnectAttempts !== null && reconnectAttempts > 0) { + parts.push(`reconnectAttempts=${reconnectAttempts}`); + } + return parts.length > 0 ? [parts.join("; ")] : []; +} + +/** + * Hermes branch. Hermes' sandbox has no `openclaw` CLI, so the probe checks + * for the actual Baileys session artifact (`creds.json`) under the platform's + * session directory. A missing file is authoritative "not paired"; a present + * file is authoritative "session material exists". Bridge liveness/heartbeat + * are not available from a session file alone, so those stay null and the + * evaluator lands on "idle" for a paired-but-unprobed hermes runtime — that + * is honest for a secondary agent whose runtime we cannot inspect further. + */ +function runHermesSessionProbe( + execute: NonNullable, + sandboxName: string, + timeoutMs: number, +): ProbeResult { + const command = [ + `set +e`, + `if [ -f ${quotePath(HERMES_SESSION_CREDS_FILE)} ]; then`, + ` printf '%s\\n' ${quotePath(HERMES_SESSION_PRESENT)}`, + `else`, + ` printf '%s\\n' ${quotePath(HERMES_SESSION_ABSENT)}`, + `fi`, + ].join("\n"); + const exec = execute(sandboxName, command, timeoutMs); + if (!exec || exec.status !== 0) return PROBE_UNREACHABLE; + const lines = String(exec.stdout ?? "").split(/\r?\n/); + const present = lines.includes(HERMES_SESSION_PRESENT); + const absent = lines.includes(HERMES_SESSION_ABSENT); + if (!present && !absent) return PROBE_UNREACHABLE; return { - reachable: true, - stateDirPopulated: sawAnyDir ? stateDirPopulated : null, - heartbeatRaw, - logLines, - bridgeProcessAlive: bridgeProcessAliveOut, - gatewayProviderAlive, - gatewayLastInboundAt, + probeReachable: true, + stateDirPopulated: present, + bridgeProcessAlive: null, + heartbeat: null, + recentLogSignals: [], }; } +function parseOpenclawJson(stdout: string): Record | null { + const trimmed = stdout.trim(); + if (trimmed.length === 0) return null; + const attempts: string[] = [trimmed]; + // Fall back to substring parsing if the CLI ever emits a leading warning + // line on stdout — stdout should be clean JSON but be defensive. + const braceIdx = trimmed.indexOf("{"); + if (braceIdx > 0) attempts.push(trimmed.slice(braceIdx)); + for (const candidate of attempts) { + try { + const parsed = JSON.parse(candidate); + if (isObjectRecord(parsed)) return parsed; + } catch { + // Try the next candidate. + } + } + return null; +} + +function readObject(value: unknown): Record | null { + return isObjectRecord(value) ? value : null; +} + +function readStringValue(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function epochMsToIso(value: unknown): string | null { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; + const iso = new Date(value).toISOString(); + return iso; +} + function normalizeString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } From 015639345dfb8e35817b073ca245cf9a545e8c05 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Fri, 17 Jul 2026 12:11:57 +0530 Subject: [PATCH 04/17] fix(whatsapp): harden JSON status probe (epoch range, agent, gateway msg) Signed-off-by: Hung Le --- .../whatsapp/hooks/status-health.test.ts | 45 +++++++++++++++++-- .../channels/whatsapp/hooks/status-health.ts | 37 +++++++++++---- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index 8ead67be596..497ac93df23 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -183,20 +183,57 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { expect(serialized).not.toContain("disconnect from"); }); - it("gateway-unreachable JSON yields a non-healthy verdict without crashing", () => { + it("reports whatsapp not configured when the gateway returns an unknown-channel error", () => { const exec = makeExec({ status: 0, stdout: openclawJson(null), stderr: "" }); const report = reportOf( createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()), ); expect(report?.verdict).not.toBe("healthy"); - // stateDirPopulated stays null in this branch, so the evaluator lands on - // "unknown" (a non-fail, non-idle verdict) — an honest report that the - // gateway didn't answer rather than a fabricated healthy. + // stateDirPopulated stays null here, so the evaluator lands on "unknown" — + // an honest "the gateway did not report whatsapp" rather than a fabricated + // healthy. openclawJson(null) carries `error: "unknown channel: …"`. expect(report?.verdict).toBe("unknown"); const logSignal = report?.signals.find((s) => s.label === "Recent log signals"); + expect(logSignal?.detail).toMatch(/not configured on the gateway/); + }); + + it("reports gateway unreachable when no unknown-channel error is present", () => { + const exec = makeExec({ + status: 0, + stdout: JSON.stringify({ gatewayReachable: false }), + stderr: "", + }); + const report = reportOf( + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()), + ); + expect(report?.verdict).not.toBe("healthy"); + const logSignal = report?.signals.find((s) => s.label === "Recent log signals"); expect(logSignal?.detail).toMatch(/gateway not reachable/); }); + it("degrades an out-of-range lastInboundAt to null instead of crashing", () => { + // A finite-but-out-of-Date-range epoch (e.g. 1e300) passes Number.isFinite + // yet makes `new Date(v).toISOString()` throw RangeError. The probe must + // degrade it to null, not crash the whole status command. + const exec = makeExec({ + status: 0, + stdout: openclawJson({ ...HEALTHY_WA, lastInboundAt: 1e300 }), + stderr: "", + }); + const run = () => createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); + expect(run).not.toThrow(); + expect(reportOf(run())?.verdict).toBeDefined(); + }); + + it("no-ops for an agent that is neither openclaw nor hermes", () => { + const exec = makeExec({ status: 0, stdout: openclawJson(HEALTHY_WA), stderr: "" }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( + context({ ...BASE_INPUTS, agent: "gemini" }), + ); + expect(outputsOf(result)).toBeUndefined(); + expect(exec).not.toHaveBeenCalled(); + }); + it.each([ { label: "non-zero exec", diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index 31fe80b38ba..70abc808c48 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -85,6 +85,11 @@ export function createWhatsappStatusHealthHook( if (!execute || !sandboxName) return {}; const agent = normalizeString(context.inputs?.agent) ?? "openclaw"; + // Only openclaw and hermes have a defined WhatsApp bridge shape. The + // manifest already gates this hook to those agents; guard explicitly so a + // future agent added to the manifest without a probe here degrades to the + // basic report instead of silently running the openclaw CLI against it. + if (agent !== "openclaw" && agent !== "hermes") return {}; const stateDirs = resolveWhatsappStateDirs(agent); const timeoutMs = normalizeTimeoutMs(options.timeoutMs); const probe = @@ -201,17 +206,17 @@ function runOpenclawStatusProbe( const channels = readObject(json.channels); const wa = channels ? readObject(channels.whatsapp) : null; if (!wa) { - // No `channels.whatsapp` — the CLI is reporting the gateway is - // unreachable or the channel is not on the gateway yet - // (`{ gatewayReachable: false, error: "unknown channel: ...", ... }`). - // Signal probe-reachable (we did get a clean exit and valid JSON) but - // leave the runtime fields null so the evaluator lands on "unknown". + // No `channels.whatsapp`. Two distinct causes the CLI reports, told apart + // by the `error` string: the gateway is up but whatsapp is not configured + // on it (`error: "unknown channel: …"`), or the gateway is unreachable. + // Either way leave the runtime fields null so the evaluator lands on + // "unknown"; only the diagnostic wording differs. return { probeReachable: true, stateDirPopulated: null, bridgeProcessAlive: null, heartbeat: null, - recentLogSignals: ["gateway not reachable — live WhatsApp health unavailable"], + recentLogSignals: [describeMissingWaChannel(json)], }; } return mapOpenclawWaState(wa); @@ -340,14 +345,30 @@ function readStringValue(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } +// The CLI reports a missing `channels.whatsapp` for two different reasons; +// an `error: "unknown channel: …"` means the gateway is up but whatsapp is +// not configured on it, otherwise treat the gateway as unreachable. Emit a +// fixed diagnostic string — never the raw `error`, which can carry PII. +function describeMissingWaChannel(json: Record): string { + const error = readStringValue(json.error); + return error !== null && /unknown channel/i.test(error) + ? "whatsapp is not configured on the gateway — live health unavailable" + : "gateway not reachable — live WhatsApp health unavailable"; +} + function isObjectRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +// The largest timestamp the ECMAScript Date type can represent; beyond it +// `new Date(v).toISOString()` throws RangeError. A garbage `lastInboundAt` +// from the gateway JSON must degrade to null, not crash the status command. +const MAX_ECMASCRIPT_DATE_MS = 8_640_000_000_000_000; + function epochMsToIso(value: unknown): string | null { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; - const iso = new Date(value).toISOString(); - return iso; + if (value > MAX_ECMASCRIPT_DATE_MS) return null; + return new Date(value).toISOString(); } function normalizeString(value: unknown): string | null { From 0486c588468abfe42b9a189ab8ec449849a2ee01 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Fri, 17 Jul 2026 12:35:43 +0530 Subject: [PATCH 05/17] fix(whatsapp): whitelist healthState before surfacing it Signed-off-by: Hung Le --- .../whatsapp/hooks/status-health.test.ts | 11 +++++++++++ .../channels/whatsapp/hooks/status-health.ts | 16 +++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index 497ac93df23..006d0a414a8 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -183,6 +183,17 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { expect(serialized).not.toContain("disconnect from"); }); + it("maps an unrecognized healthState to a fixed token so free text cannot leak", () => { + // healthState is external JSON text; a compromised gateway could stuff PII + // into it. Only the documented enum may be surfaced verbatim. + const wa: WaFixture = { ...HEALTHY_WA, healthState: `leaked ${REDACTION_PHONE}` }; + const exec = makeExec({ status: 0, stdout: openclawJson(wa), stderr: "" }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); + const serialized = stringifyReport(result); + expect(serialized).not.toContain(REDACTION_PHONE); + expect(serialized).toContain("healthState=unknown"); + }); + it("reports whatsapp not configured when the gateway returns an unknown-channel error", () => { const exec = makeExec({ status: 0, stdout: openclawJson(null), stderr: "" }); const report = reportOf( diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index 70abc808c48..23e2b29c8f4 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -260,7 +260,17 @@ function openclawConnectionState(connected: boolean, healthState: string | null) return healthState === "starting" || healthState === "stale" ? "connecting" : "close"; } -// Never emit raw error text or self.* PII. Only healthState (an enum) and +// The documented healthState enum. `readStringValue` would otherwise pass +// arbitrary external text through, so any non-enum value is mapped to a fixed +// "unknown" token before it can reach diagnostics (redaction contract). +const KNOWN_HEALTH_STATES: ReadonlySet = new Set([ + "starting", + "healthy", + "stale", + "stopped", +]); + +// Never emit raw error text or self.* PII. Only the healthState enum and // reconnectAttempts (a non-negative integer) are surfaced, and only when they // carry non-healthy signal. function summarizeOpenclawLive( @@ -268,8 +278,8 @@ function summarizeOpenclawLive( reconnectAttemptsRaw: unknown, ): readonly string[] { const parts: string[] = []; - if (healthState && healthState !== "healthy") { - parts.push(`healthState=${healthState}`); + if (healthState !== null && healthState !== "healthy") { + parts.push(`healthState=${KNOWN_HEALTH_STATES.has(healthState) ? healthState : "unknown"}`); } const reconnectAttempts = typeof reconnectAttemptsRaw === "number" && Number.isFinite(reconnectAttemptsRaw) From 9aba17d0435fed493c15251c78641412d8dd4d1a Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 17 Jul 2026 15:56:55 -0700 Subject: [PATCH 06/17] fix(messaging): fail closed on unreachable WhatsApp status Treat gateway reachability as authoritative, require strict JSON output, and keep the OpenClaw-only status contract out of unsupported Hermes runtimes. Co-authored-by: Hung Le Signed-off-by: Apurv Kumaria --- .../actions/sandbox/channel-status.test.ts | 74 +++-------- src/lib/messaging/channels/manifests.test.ts | 6 + .../whatsapp/hooks/status-health.test.ts | 99 ++++++--------- .../channels/whatsapp/hooks/status-health.ts | 115 ++++-------------- .../messaging/channels/whatsapp/manifest.ts | 2 +- 5 files changed, 78 insertions(+), 218 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 7463b95e282..d58746a5cd1 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -2,12 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; -import { - type ExecResult, - entry, - makeDeps, - showSandboxChannelStatus, -} from "./channel-status.test-helpers"; +import { entry, makeDeps, showSandboxChannelStatus } from "./channel-status.test-helpers"; // The whatsapp status hook now reads OpenClaw's authoritative live status JSON // (`openclaw channels status --channel whatsapp --json`) instead of scraping @@ -193,61 +188,22 @@ describe("showSandboxChannelStatus (whatsapp)", () => { expect(threw?.message).toBe("process.exit(1)"); }); - it("uses the hermes pairing hint when the agent is hermes and no session file exists", async () => { - // Hermes has no `openclaw` CLI in the sandbox; the probe stats for the - // Baileys `creds.json` session artifact and emits a present/absent marker. - const stdout = "NEMOCLAW_WA_HERMES_SESSION_ABSENT\n"; - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`process.exit(${code})`); - }) as never); - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout, stderr: "" }), + it("falls back to basic status for Hermes without running the OpenClaw probe", async () => { + const exec = vi.fn((_sandbox: string, _command: string, _timeoutMs?: number) => ({ + status: 0, + stdout: "", + stderr: "", + })); + const { deps } = makeDeps({ + exec, agentName: "hermes", + sandbox: entry(["whatsapp"], [], {}, "hermes"), }); - try { - await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); - } catch { - /* expected exit(1) for unpaired */ - } finally { - exitSpy.mockRestore(); - } - const dump = out_lines.join("\n"); - expect(dump).toMatch(/hermes whatsapp/); - expect(dump).toMatch(/Verdict:.*unpaired/); - }); - - it("emits a syntactically valid /bin/sh program for the hermes session probe", async () => { - // Regression guard: the hermes branch is the only remaining probe that - // builds a multi-line shell script. Validate it with `sh -n` so a future - // edit cannot ship a script that fails to parse and reads as exec failure. - let capturedCmd: string | null = null; - const exec = (_sb: string, cmd: string): ExecResult | null => { - capturedCmd = cmd; - return { - status: 0, - stdout: "NEMOCLAW_WA_HERMES_SESSION_ABSENT\n", - stderr: "", - }; - }; - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`process.exit(${code})`); - }) as never); - const { deps } = makeDeps({ exec, agentName: "hermes" }); - try { - await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); - } catch { - /* unpaired path exits 1 */ - } finally { - exitSpy.mockRestore(); - } - expect(capturedCmd).not.toBeNull(); - const { spawnSync } = await import("node:child_process"); - const validation = spawnSync("sh", ["-n", "-c", capturedCmd as unknown as string], { - encoding: "utf-8", - }); - expect(validation.status, validation.stderr || validation.stdout).toBe(0); - // The hermes probe stats the authoritative Baileys credentials artifact. - expect(capturedCmd as unknown as string).toMatch(/creds\.json/); + const result = await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); + const commands = exec.mock.calls.map((call) => String(call[1] ?? "")).join("\n"); + expect(commands).not.toContain("openclaw channels status"); + expect(commands).not.toContain("platforms/whatsapp/session/creds.json"); + expect(result && "verdict" in result && result.verdict).toBe("info"); }); it("skips the deep probe and reports paused state when WhatsApp is in disabledChannels", async () => { diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index a8d9d0eed1e..dbdae30f01f 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -43,6 +43,12 @@ describe("built-in channel manifests", () => { ).toEqual(BUILT_IN_CHANNEL_MANIFESTS.map((manifest) => [manifest.id, true])); }); + it("limits the WhatsApp live-health hook to its OpenClaw status contract", () => { + const whatsapp = BUILT_IN_CHANNEL_MANIFESTS.find((manifest) => manifest.id === "whatsapp"); + const statusHealth = whatsapp?.hooks.find((hook) => hook.id === "whatsapp-status-health"); + expect(statusHealth?.agents).toEqual(["openclaw"]); + }); + it("keeps rendered config parser keys limited to manifest config inputs", () => { const agentIds: readonly MessagingAgentId[] = ["openclaw", "hermes"]; const secretLikePattern = /(?:token|secret|password|client_secret|client-secret)/i; diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index 006d0a414a8..e3a4fcc9dfc 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -86,8 +86,8 @@ type WaFixture = { function openclawJson(wa: WaFixture | null): string { const payload = wa === null - ? { gatewayReachable: false, error: "unknown channel: whatsapp", configOnly: true } - : { channels: { whatsapp: wa } }; + ? { gatewayReachable: true, error: "unknown channel: whatsapp", configOnly: true } + : { gatewayReachable: true, channels: { whatsapp: wa } }; return JSON.stringify(payload); } @@ -208,18 +208,31 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { expect(logSignal?.detail).toMatch(/not configured on the gateway/); }); - it("reports gateway unreachable when no unknown-channel error is present", () => { - const exec = makeExec({ - status: 0, - stdout: JSON.stringify({ gatewayReachable: false }), - stderr: "", - }); + it.each([ + { + label: "stale healthy channel state", + payload: { + gatewayReachable: false, + configOnly: true, + channels: { whatsapp: HEALTHY_WA }, + }, + }, + { + label: "misleading unknown-channel error with populated channel state", + payload: { + gatewayReachable: false, + error: "unknown channel: whatsapp", + configOnly: true, + channels: { whatsapp: UNPAIRED_WA }, + }, + }, + ])("fails closed when the gateway is unreachable despite $label", ({ payload }) => { + const exec = makeExec({ status: 0, stdout: JSON.stringify(payload), stderr: "" }); const report = reportOf( createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()), ); - expect(report?.verdict).not.toBe("healthy"); - const logSignal = report?.signals.find((s) => s.label === "Recent log signals"); - expect(logSignal?.detail).toMatch(/gateway not reachable/); + expect(report?.verdict).toBe("probe_failed"); + expect(report?.signals.some((signal) => signal.label === "Recent log signals")).toBe(false); }); it("degrades an out-of-range lastInboundAt to null instead of crashing", () => { @@ -236,10 +249,10 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { expect(reportOf(run())?.verdict).toBeDefined(); }); - it("no-ops for an agent that is neither openclaw nor hermes", () => { + it("no-ops for Hermes because the live status contract is OpenClaw-only", () => { const exec = makeExec({ status: 0, stdout: openclawJson(HEALTHY_WA), stderr: "" }); const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( - context({ ...BASE_INPUTS, agent: "gemini" }), + context({ ...BASE_INPUTS, agent: "hermes" }), ); expect(outputsOf(result)).toBeUndefined(); expect(exec).not.toHaveBeenCalled(); @@ -253,6 +266,14 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { { label: "null exec (sandbox down)", exec: null as ExecResult }, { label: "empty stdout", exec: { status: 0, stdout: "", stderr: "" } }, { label: "non-JSON stdout", exec: { status: 0, stdout: "not json at all", stderr: "" } }, + { + label: "arbitrary stdout preamble before valid JSON", + exec: { + status: 0, + stdout: `warning: untrusted preamble\n${openclawJson(HEALTHY_WA)}`, + stderr: "", + }, + }, { label: "JSON but not an object", exec: { status: 0, stdout: '"hello"', stderr: "" } }, ] as const)("verdict=probe_failed when $label", ({ exec }) => { const runner = makeExec(exec); @@ -297,58 +318,6 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { }); }); -describe("whatsapp.statusHealth hermes credentials probe", () => { - it.each([ - { - label: "session creds present", - stdout: "NEMOCLAW_WA_HERMES_SESSION_PRESENT\n", - populated: true, - }, - { - label: "session creds absent", - stdout: "NEMOCLAW_WA_HERMES_SESSION_ABSENT\n", - populated: false, - }, - ] as const)("reports stateDirPopulated=$populated for $label", ({ stdout, populated }) => { - const exec = makeExec({ status: 0, stdout, stderr: "" }); - const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( - context({ ...BASE_INPUTS, agent: "hermes" }), - ); - const pairing = reportOf(result)?.signals.find((s) => s.label === "Pairing / session"); - expect(pairing?.severity).toBe(populated ? "ok" : "warn"); - }); - - it("targets the authoritative session credentials file, not a dir listing", () => { - const exec = makeExec({ - status: 0, - stdout: "NEMOCLAW_WA_HERMES_SESSION_PRESENT\n", - stderr: "", - }); - createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( - context({ ...BASE_INPUTS, agent: "hermes" }), - ); - const command = String(exec.mock.calls[0]?.[1] ?? ""); - expect(command).toContain("/sandbox/.hermes/platforms/whatsapp/session/creds.json"); - // A missing creds file must be authoritative — the old dir-listing logic - // that treated any non-empty dir as "populated" is gone. - expect(command).not.toMatch(/ls -A/); - // Hermes does not carry the openclaw CLI. - expect(command).not.toContain("openclaw channels status"); - }); - - it("hermes probe with neither marker present classifies as probe_failed", () => { - // A malformed exec (e.g. `sh` errors before either branch fires) must not - // be read as a fabricated verdict; require one of the two literal markers. - const exec = makeExec({ status: 0, stdout: "", stderr: "" }); - const report = reportOf( - createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( - context({ ...BASE_INPUTS, agent: "hermes" }), - ), - ); - expect(report?.verdict).toBe("probe_failed"); - }); -}); - describe("whatsapp.statusHealth wiring guards", () => { it("no-ops for a non-whatsapp channel or without an exec runner", () => { const exec = makeExec({ status: 0, stdout: openclawJson(HEALTHY_WA), stderr: "" }); diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index 23e2b29c8f4..5a2f5c381e9 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -28,18 +28,12 @@ * could not enumerate it and the probe would report "unpaired" for * a working bridge. * - * For Hermes (secondary), which has no `openclaw` CLI in the sandbox, the - * probe checks for an authoritative session credentials file (Baileys - * `creds.json`) under `/platforms/whatsapp/session` instead of a - * loose directory-listing check. - * * Redaction contract: this probe never reads, stores, logs, or emits the * self.e164 / self.jid / self.lid values or the raw `lastError` string * from the OpenClaw JSON — those can carry phone numbers. Only booleans, * state-string enums, and epoch timestamps make it into the report. */ -import { shellQuote as quotePath } from "../../../../core/shell-quote"; import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; import type { MessagingSerializableValue } from "../../../manifest"; import { @@ -59,15 +53,10 @@ export const WHATSAPP_STATUS_HEALTH_HOOK_HANDLER_ID = "whatsapp.statusHealth"; // the Noise WebSocket is stuck; a fast hard cap keeps channels status from // inheriting that hang. const DEFAULT_TIMEOUT_MS = 8_000; -// The Hermes credentials probe emits one of these two literal markers so the -// host parser can tell "session file present" from "session file missing" or -// "probe failed". Absent stdout is treated as probe failure. -const HERMES_SESSION_PRESENT = "NEMOCLAW_WA_HERMES_SESSION_PRESENT"; -const HERMES_SESSION_ABSENT = "NEMOCLAW_WA_HERMES_SESSION_ABSENT"; -// Baileys writes the paired session under `/creds.json`. Hermes' -// WhatsApp adapter follows that convention, so a present `creds.json` is the -// authoritative pairing signal — a non-empty session dir alone is not. -const HERMES_SESSION_CREDS_FILE = "/sandbox/.hermes/platforms/whatsapp/session/creds.json"; +const OPENCLAW_WHATSAPP_STATE_DIRS = [ + "/sandbox/.openclaw/whatsapp", + "/sandbox/.openclaw/credentials/whatsapp", +] as const; /** WhatsApp uses the generic channel-health hook options unchanged. */ export type WhatsappStatusHealthHookOptions = ChannelStatusHealthHookOptions; @@ -85,21 +74,16 @@ export function createWhatsappStatusHealthHook( if (!execute || !sandboxName) return {}; const agent = normalizeString(context.inputs?.agent) ?? "openclaw"; - // Only openclaw and hermes have a defined WhatsApp bridge shape. The - // manifest already gates this hook to those agents; guard explicitly so a - // future agent added to the manifest without a probe here degrades to the - // basic report instead of silently running the openclaw CLI against it. - if (agent !== "openclaw" && agent !== "hermes") return {}; - const stateDirs = resolveWhatsappStateDirs(agent); + // This hook consumes an OpenClaw-specific status contract. The manifest + // gates it to OpenClaw; keep the handler fail-safe when invoked directly + // so another agent never receives an unsupported health verdict. + if (agent !== "openclaw") return {}; const timeoutMs = normalizeTimeoutMs(options.timeoutMs); - const probe = - agent === "hermes" - ? runHermesSessionProbe(execute, sandboxName, timeoutMs) - : runOpenclawStatusProbe(execute, sandboxName, timeoutMs); + const probe = runOpenclawStatusProbe(execute, sandboxName, timeoutMs); const input: WhatsappProbeInput = { agent, - stateDirs, + stateDirs: OPENCLAW_WHATSAPP_STATE_DIRS, stateDirPopulated: probe.stateDirPopulated, heartbeat: probe.heartbeat, heartbeatParseError: null, @@ -135,23 +119,6 @@ export function createWhatsappStatusHealthHookRegistration( }; } -/** - * The two known WhatsApp bridge state layouts, keyed by agent name. The - * OpenClaw entries are informational — the openclaw probe no longer inspects - * these directories (it reads the CLI's live JSON instead) but the paths are - * still surfaced in the "Pairing / session" signal detail so the operator - * knows where the session material lives if they need to intervene. The - * Hermes entry is the actual credentials-file parent the hermes probe stats. - */ -export function resolveWhatsappStateDirs(agent: string): string[] { - if (agent === "hermes") { - return ["/sandbox/.hermes/platforms/whatsapp/session"]; - } - // OpenClaw 2026.6.10+ writes the paired Baileys session under - // `credentials/whatsapp//creds.json`, not `/whatsapp`. - return ["/sandbox/.openclaw/whatsapp", "/sandbox/.openclaw/credentials/whatsapp"]; -} - type OpenclawWhatsappState = { readonly configured?: unknown; readonly statusState?: unknown; @@ -202,6 +169,10 @@ function runOpenclawStatusProbe( if (!exec || exec.status !== 0) return PROBE_UNREACHABLE; const json = parseOpenclawJson(String(exec.stdout ?? "")); if (!json) return PROBE_UNREACHABLE; + // The CLI can include config-only/stale channel state when the gateway is + // unreachable. That root reachability bit is authoritative and must win + // before any nested WhatsApp fields are interpreted. + if (json.gatewayReachable === false) return PROBE_UNREACHABLE; const channels = readObject(json.channels); const wa = channels ? readObject(channels.whatsapp) : null; @@ -291,60 +262,18 @@ function summarizeOpenclawLive( return parts.length > 0 ? [parts.join("; ")] : []; } -/** - * Hermes branch. Hermes' sandbox has no `openclaw` CLI, so the probe checks - * for the actual Baileys session artifact (`creds.json`) under the platform's - * session directory. A missing file is authoritative "not paired"; a present - * file is authoritative "session material exists". Bridge liveness/heartbeat - * are not available from a session file alone, so those stay null and the - * evaluator lands on "idle" for a paired-but-unprobed hermes runtime — that - * is honest for a secondary agent whose runtime we cannot inspect further. - */ -function runHermesSessionProbe( - execute: NonNullable, - sandboxName: string, - timeoutMs: number, -): ProbeResult { - const command = [ - `set +e`, - `if [ -f ${quotePath(HERMES_SESSION_CREDS_FILE)} ]; then`, - ` printf '%s\\n' ${quotePath(HERMES_SESSION_PRESENT)}`, - `else`, - ` printf '%s\\n' ${quotePath(HERMES_SESSION_ABSENT)}`, - `fi`, - ].join("\n"); - const exec = execute(sandboxName, command, timeoutMs); - if (!exec || exec.status !== 0) return PROBE_UNREACHABLE; - const lines = String(exec.stdout ?? "").split(/\r?\n/); - const present = lines.includes(HERMES_SESSION_PRESENT); - const absent = lines.includes(HERMES_SESSION_ABSENT); - if (!present && !absent) return PROBE_UNREACHABLE; - return { - probeReachable: true, - stateDirPopulated: present, - bridgeProcessAlive: null, - heartbeat: null, - recentLogSignals: [], - }; -} - function parseOpenclawJson(stdout: string): Record | null { const trimmed = stdout.trim(); if (trimmed.length === 0) return null; - const attempts: string[] = [trimmed]; - // Fall back to substring parsing if the CLI ever emits a leading warning - // line on stdout — stdout should be clean JSON but be defensive. - const braceIdx = trimmed.indexOf("{"); - if (braceIdx > 0) attempts.push(trimmed.slice(braceIdx)); - for (const candidate of attempts) { - try { - const parsed = JSON.parse(candidate); - if (isObjectRecord(parsed)) return parsed; - } catch { - // Try the next candidate. - } + try { + const parsed = JSON.parse(trimmed); + return isObjectRecord(parsed) ? parsed : null; + } catch { + // `--json` is a strict machine-readable contract. Do not scan past an + // arbitrary stdout preamble and then trust a later object as gateway + // status; an exact documented prefix can be handled here if one exists. + return null; } - return null; } function readObject(value: unknown): Record | null { diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index 6f6c25492e1..b35eb0e3e18 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -125,7 +125,7 @@ export const whatsappManifest = { id: "whatsapp-status-health", phase: "status", handler: "whatsapp.statusHealth", - agents: ["openclaw", "hermes"], + agents: ["openclaw"], outputs: [ { id: "channelHealth", From bac2877f50f9ba90a273bd741e2390b8f6a65825 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 17 Jul 2026 16:00:46 -0700 Subject: [PATCH 07/17] docs(messaging): clarify OpenClaw-only health probes Document that Hermes WhatsApp status uses the basic registration and policy report because the live runtime probe is an OpenClaw-specific contract. Co-authored-by: Hung Le Signed-off-by: Apurv Kumaria --- docs/reference/commands.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index fe1c20b1e87..d07f3d84dee 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1921,12 +1921,13 @@ $$nemoclaw my-assistant channels start telegram Run messaging channel status checks. Without `--channel`, the command prints a compact summary for every configured channel, including registration, policy coverage, and non-secret rendered config comparisons. -For channels that support a live health probe (WhatsApp, Telegram), the summary adds a `Runtime health: not checked in summary view` pointer instead of running the probe, so it never reads as healthy without an explicit check. +For channel and agent combinations that support a live health probe (WhatsApp or Telegram on OpenClaw), the summary adds a `Runtime health: not checked in summary view` pointer instead of running the probe, so it never reads as healthy without an explicit check. With `--channel`, it prints the detailed status for that channel. -For WhatsApp, `--channel whatsapp` also probes the sandbox to separately report pairing/session state, the Noise WebSocket connection, inbound event delivery, and policy coverage. +For an OpenClaw WhatsApp sandbox, `--channel whatsapp` also probes the sandbox to separately report pairing/session state, the Noise WebSocket connection, inbound event delivery, and policy coverage. A paired channel with no observed inbound delivery exits non-zero with verdict `idle` so an unhealthy bridge cannot pass as healthy. The detailed WhatsApp probe stays focused on QR/session runtime diagnostics and does not include rendered-config comparison lines. +A Hermes WhatsApp sandbox uses the basic registration, policy, and config report because NemoClaw does not treat the Hermes session file as a live-health signal. For Telegram, `--channel telegram` probes the sandbox to report the gateway process, Bot API reachability, and inbound delivery alongside the config comparison, and classifies the current state into a verdict such as `healthy`, `idle`, `unreachable` (network or egress), `token_rejected`, or `not_started`. It reads the gateway's own startup and poll log breadcrumbs rather than issuing its own Bot API request, so the resolved bot token never leaves the gateway. @@ -1947,7 +1948,7 @@ $$nemoclaw my-assistant channels status --channel telegram | Flag | Description | |------|-------------| | `--channel ` | Channel to inspect in detail | -| `--json` | Emit the status report as JSON (for the detailed WhatsApp and Telegram probes, exit non-zero when the verdict is not `healthy` or `unknown`) | +| `--json` | Emit the status report as JSON (for an available detailed WhatsApp or Telegram probe on OpenClaw, exit non-zero when the verdict is not `healthy` or `unknown`) | Each probe is bounded by an in-sandbox `openshell sandbox exec` with a hard timeout and returns only matched bridge/gateway log lines (e.g. `connection.open`, `401 unauthorized`, `qr expired`, or `[telegram]` startup breadcrumbs) to the host, where NemoClaw reduces them to fixed classifications; the raw lines are never rendered, so the diagnostic output carries only those classifications, never message bodies or tokens. From 78303e1a53bffdb24f43d4eceddabcb5549a218c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 17 Jul 2026 16:44:00 -0700 Subject: [PATCH 08/17] fix(messaging): bound unconfigured WhatsApp probe Accept only OpenClaw's exact unconfigured response when root reachability is false. Keep every other unreachable response fail-closed. Co-authored-by: Hung Le Signed-off-by: Apurv Kumaria --- .../whatsapp/hooks/status-health.test.ts | 20 ++++++++++-- .../channels/whatsapp/hooks/status-health.ts | 32 ++++++++++++++----- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index e3a4fcc9dfc..36fd09b4fd6 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -86,7 +86,7 @@ type WaFixture = { function openclawJson(wa: WaFixture | null): string { const payload = wa === null - ? { gatewayReachable: true, error: "unknown channel: whatsapp", configOnly: true } + ? { gatewayReachable: false, error: "unknown channel: whatsapp", configOnly: true } : { gatewayReachable: true, channels: { whatsapp: wa } }; return JSON.stringify(payload); } @@ -194,7 +194,7 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { expect(serialized).toContain("healthState=unknown"); }); - it("reports whatsapp not configured when the gateway returns an unknown-channel error", () => { + it("reports unknown for the bounded unconfigured response with no channel state", () => { const exec = makeExec({ status: 0, stdout: openclawJson(null), stderr: "" }); const report = reportOf( createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()), @@ -226,6 +226,22 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { channels: { whatsapp: UNPAIRED_WA }, }, }, + { + label: "unknown-channel error for a different channel", + payload: { + gatewayReachable: false, + error: "unknown channel: telegram", + configOnly: true, + }, + }, + { + label: "embellished unknown-channel error text", + payload: { + gatewayReachable: false, + error: "prefix unknown channel: whatsapp", + configOnly: true, + }, + }, ])("fails closed when the gateway is unreachable despite $label", ({ payload }) => { const exec = makeExec({ status: 0, stdout: JSON.stringify(payload), stderr: "" }); const report = reportOf( diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index 5a2f5c381e9..bb7d6f59c76 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -169,17 +169,22 @@ function runOpenclawStatusProbe( if (!exec || exec.status !== 0) return PROBE_UNREACHABLE; const json = parseOpenclawJson(String(exec.stdout ?? "")); if (!json) return PROBE_UNREACHABLE; + const channels = readObject(json.channels); + const wa = channels ? readObject(channels.whatsapp) : null; // The CLI can include config-only/stale channel state when the gateway is // unreachable. That root reachability bit is authoritative and must win - // before any nested WhatsApp fields are interpreted. - if (json.gatewayReachable === false) return PROBE_UNREACHABLE; + // before any nested WhatsApp fields are interpreted. The one bounded + // exception is OpenClaw's known unconfigured response: it carries the fixed + // unknown-channel error without nested WhatsApp state even though its root + // reachability bit is false. + if (json.gatewayReachable === false && !isKnownUnconfiguredWhatsappResponse(json, channels)) { + return PROBE_UNREACHABLE; + } - const channels = readObject(json.channels); - const wa = channels ? readObject(channels.whatsapp) : null; if (!wa) { // No `channels.whatsapp`. Two distinct causes the CLI reports, told apart // by the `error` string: the gateway is up but whatsapp is not configured - // on it (`error: "unknown channel: …"`), or the gateway is unreachable. + // on it (`error: "unknown channel: whatsapp"`), or the gateway is unreachable. // Either way leave the runtime fields null so the evaluator lands on // "unknown"; only the diagnostic wording differs. return { @@ -284,13 +289,24 @@ function readStringValue(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } +function isKnownUnconfiguredWhatsappResponse( + json: Record, + channels: Record | null, +): boolean { + if (channels !== null && Object.hasOwn(channels, "whatsapp")) return false; + return hasUnknownChannelError(json); +} + +function hasUnknownChannelError(json: Record): boolean { + return readStringValue(json.error) === "unknown channel: whatsapp"; +} + // The CLI reports a missing `channels.whatsapp` for two different reasons; -// an `error: "unknown channel: …"` means the gateway is up but whatsapp is +// the exact `error: "unknown channel: whatsapp"` means the gateway is up but whatsapp is // not configured on it, otherwise treat the gateway as unreachable. Emit a // fixed diagnostic string — never the raw `error`, which can carry PII. function describeMissingWaChannel(json: Record): string { - const error = readStringValue(json.error); - return error !== null && /unknown channel/i.test(error) + return hasUnknownChannelError(json) ? "whatsapp is not configured on the gateway — live health unavailable" : "gateway not reachable — live WhatsApp health unavailable"; } From d82c9f3d5fffe1fcd829200ca725ac1a87eba98a Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 09:11:32 -0700 Subject: [PATCH 09/17] fix(whatsapp): report runtime pairing evidence Signed-off-by: Carlos Villela --- docs/reference/commands.mdx | 4 ++- .../whatsapp/hooks/status-health-eval.test.ts | 17 +++++----- .../whatsapp/hooks/status-health-eval.ts | 34 ++++++++----------- .../whatsapp/hooks/status-health.test.ts | 2 +- .../channels/whatsapp/hooks/status-health.ts | 18 ++++------ 5 files changed, 34 insertions(+), 41 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index e398f3ff1e3..2529bc2ed7a 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1950,7 +1950,9 @@ $$nemoclaw my-assistant channels status --channel telegram | `--channel ` | Channel to inspect in detail | | `--json` | Emit the status report as JSON (for an available detailed WhatsApp or Telegram probe on OpenClaw, exit non-zero when the verdict is not `healthy` or `unknown`) | -Each probe is bounded by an in-sandbox `openshell sandbox exec` with a hard timeout and returns only matched bridge/gateway log lines (e.g. `connection.open`, `401 unauthorized`, `qr expired`, or `[telegram]` startup breadcrumbs) to the host, where NemoClaw reduces them to fixed classifications; the raw lines are never rendered, so the diagnostic output carries only those classifications, never message bodies or tokens. +Each live probe is bounded by an in-sandbox `openshell sandbox exec` with a hard timeout. +The WhatsApp probe returns strict OpenClaw status JSON to the host, where NemoClaw allowlists pairing, liveness, connection-state, and timestamp fields before rendering the report and discards phone-number and free-text error fields. +The Telegram probe returns only matched gateway log lines to the host, where NemoClaw reduces them to fixed classifications without rendering the raw lines, message bodies, or tokens. diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts index 9276111774d..85e98cf0117 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts @@ -15,8 +15,7 @@ const PROBED_AT = "2026-05-28T04:00:00.000Z"; function baseInput(overrides: Partial = {}): WhatsappProbeInput { return { agent: "openclaw", - stateDirs: ["/sandbox/.openclaw/whatsapp"], - stateDirPopulated: true, + paired: true, heartbeat: null, heartbeatParseError: null, bridgeProcessAlive: true, @@ -35,7 +34,7 @@ describe("evaluateWhatsappDiagnostics", () => { const report = evaluateWhatsappDiagnostics( baseInput({ probeReachable: false, - stateDirPopulated: null, + paired: null, bridgeProcessAlive: null, presetOnGateway: null, }), @@ -62,18 +61,17 @@ describe("evaluateWhatsappDiagnostics", () => { expect(policy?.detail).toMatch(/preset is not applied/); }); - it("returns unpaired when the bridge state directory is empty", () => { - const report = evaluateWhatsappDiagnostics(baseInput({ stateDirPopulated: false })); + it("returns unpaired when the channel runtime reports no link", () => { + const report = evaluateWhatsappDiagnostics(baseInput({ paired: false })); expect(report.verdict).toBe("unpaired"); const pairing = report.signals.find((s) => s.label === "Pairing / session"); expect(pairing?.severity).toBe("warn"); + expect(pairing?.detail).toBe("channel runtime reports WhatsApp is not paired"); expect(pairing?.hint).toMatch(/QR code/); }); it("returns the hermes-flavored pairing hint when the agent is hermes", () => { - const report = evaluateWhatsappDiagnostics( - baseInput({ agent: "hermes", stateDirPopulated: false }), - ); + const report = evaluateWhatsappDiagnostics(baseInput({ agent: "hermes", paired: false })); const pairing = report.signals.find((s) => s.label === "Pairing / session"); expect(pairing?.hint).toMatch(/hermes whatsapp/); expect(report.hints.join(" ")).toMatch(/hermes whatsapp/); @@ -111,6 +109,9 @@ describe("evaluateWhatsappDiagnostics", () => { }), ); expect(report.verdict).toBe("healthy"); + expect(report.signals.find((s) => s.label === "Pairing / session")?.detail).toBe( + "paired (reported by channel runtime)", + ); const inbound = report.signals.find((s) => s.label === "Inbound delivery"); expect(inbound?.severity).toBe("ok"); expect(inbound?.detail).toMatch(/messagesHandled=5/); diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts index b5c47880707..d597affec06 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts @@ -57,13 +57,10 @@ export type WhatsappHeartbeat = { export type WhatsappProbeInput = { // Agent owning the sandbox: "openclaw", "hermes", etc. Used for hint text. agent: string; - // State directories inspected inside the sandbox. Discovered from the - // agent-scoped path convention by the hook that builds this input. - stateDirs: readonly string[]; - // True when the bridge state directory exists inside the sandbox and is - // non-empty. False when the directory is missing or empty. Null when the - // probe could not run (sandbox stopped, exec failed, etc.). - stateDirPopulated: boolean | null; + // Pairing state reported by the channel runtime. True when the runtime + // reports a linked account, false when it reports no link, and null when + // the probe could not determine pairing state. + paired: boolean | null; // Parsed heartbeat — null when no heartbeat file was found or it failed // to parse. parseError records the reason a present file failed. heartbeat: WhatsappHeartbeat | null; @@ -103,10 +100,9 @@ export type WhatsappDiagnosticReport = ChannelHealthReport & { heartbeat: WhatsappHeartbeat | null; }; -// Bridges flush their session blob immediately after a successful QR pair; -// treat "missing or empty state dir" as the strongest no-pair signal. An -// existing dir with no heartbeat is treated as "paired, status unknown" -// rather than "unpaired" because some builds defer the heartbeat file. +// Treat the runtime's explicit unlinked state as the strongest no-pair signal. +// A linked runtime with no heartbeat is treated as "paired, status unknown" +// rather than "unpaired" because some builds defer heartbeat evidence. const NO_INBOUND_WARN_MINUTES = 5; function minutesSince(iso: string | null, probedAt: string): number | null { @@ -135,14 +131,14 @@ function pairingSignal(input: WhatsappProbeInput): DiagnosticSignal { hint: "start the sandbox before re-running channels status", }; } - if (input.stateDirPopulated === null) { + if (input.paired === null) { return { label: "Pairing / session", severity: "info", - detail: "session state directory probe did not complete", + detail: "pairing status probe did not complete", }; } - if (input.stateDirPopulated === false) { + if (input.paired === false) { const loginHint = input.agent === "hermes" ? "run `hermes whatsapp` inside the sandbox to display a QR code" @@ -150,14 +146,14 @@ function pairingSignal(input: WhatsappProbeInput): DiagnosticSignal { return { label: "Pairing / session", severity: "warn", - detail: "no WhatsApp session state in the sandbox — never paired or session cleared", + detail: "channel runtime reports WhatsApp is not paired", hint: loginHint, }; } return { label: "Pairing / session", severity: "ok", - detail: `paired (session state present at ${input.stateDirs.join(", ") || "agent state dir"})`, + detail: "paired (reported by channel runtime)", }; } @@ -213,7 +209,7 @@ function websocketSignal(input: WhatsappProbeInput): DiagnosticSignal { function inboundSignal(input: WhatsappProbeInput): DiagnosticSignal { const hb = input.heartbeat; if (!hb) { - if (input.stateDirPopulated === true && input.bridgeProcessAlive !== false) { + if (input.paired === true && input.bridgeProcessAlive !== false) { return { label: "Inbound delivery", severity: "warn", @@ -366,7 +362,7 @@ function pickVerdict(signals: DiagnosticSignal[], input: WhatsappProbeInput): Wh if (signals.some((s) => s.label === "Policy coverage" && s.severity === "fail")) { return "policy_gap"; } - if (input.stateDirPopulated === false) return "unpaired"; + if (input.paired === false) return "unpaired"; const hb = input.heartbeat; const hasInboundEvidence = !!hb && @@ -374,7 +370,7 @@ function pickVerdict(signals: DiagnosticSignal[], input: WhatsappProbeInput): Wh (hb.messagesHandled !== null && hb.messagesHandled > 0)); if (hb && !hasInboundEvidence) return "idle"; if (!hb) { - return input.stateDirPopulated === true ? "idle" : "unknown"; + return input.paired === true ? "idle" : "unknown"; } if (signals.some((s) => s.label === "Noise WebSocket" && s.severity === "fail")) { return "idle"; diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index 36fd09b4fd6..0959652344f 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -200,7 +200,7 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()), ); expect(report?.verdict).not.toBe("healthy"); - // stateDirPopulated stays null here, so the evaluator lands on "unknown" — + // paired stays null here, so the evaluator lands on "unknown" — // an honest "the gateway did not report whatsapp" rather than a fabricated // healthy. openclawJson(null) carries `error: "unknown channel: …"`. expect(report?.verdict).toBe("unknown"); diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index bb7d6f59c76..05743c0ea03 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -53,11 +53,6 @@ export const WHATSAPP_STATUS_HEALTH_HOOK_HANDLER_ID = "whatsapp.statusHealth"; // the Noise WebSocket is stuck; a fast hard cap keeps channels status from // inheriting that hang. const DEFAULT_TIMEOUT_MS = 8_000; -const OPENCLAW_WHATSAPP_STATE_DIRS = [ - "/sandbox/.openclaw/whatsapp", - "/sandbox/.openclaw/credentials/whatsapp", -] as const; - /** WhatsApp uses the generic channel-health hook options unchanged. */ export type WhatsappStatusHealthHookOptions = ChannelStatusHealthHookOptions; @@ -83,8 +78,7 @@ export function createWhatsappStatusHealthHook( const input: WhatsappProbeInput = { agent, - stateDirs: OPENCLAW_WHATSAPP_STATE_DIRS, - stateDirPopulated: probe.stateDirPopulated, + paired: probe.paired, heartbeat: probe.heartbeat, heartbeatParseError: null, bridgeProcessAlive: probe.bridgeProcessAlive, @@ -134,7 +128,7 @@ type OpenclawWhatsappState = { type ProbeResult = { readonly probeReachable: boolean; - readonly stateDirPopulated: boolean | null; + readonly paired: boolean | null; readonly bridgeProcessAlive: boolean | null; readonly heartbeat: WhatsappHeartbeat | null; readonly recentLogSignals: readonly string[]; @@ -142,7 +136,7 @@ type ProbeResult = { const PROBE_UNREACHABLE: ProbeResult = { probeReachable: false, - stateDirPopulated: null, + paired: null, bridgeProcessAlive: null, heartbeat: null, recentLogSignals: [], @@ -189,7 +183,7 @@ function runOpenclawStatusProbe( // "unknown"; only the diagnostic wording differs. return { probeReachable: true, - stateDirPopulated: null, + paired: null, bridgeProcessAlive: null, heartbeat: null, recentLogSignals: [describeMissingWaChannel(json)], @@ -220,8 +214,8 @@ function mapOpenclawWaState(wa: OpenclawWhatsappState): ProbeResult { return { probeReachable: true, // linked is the authoritative pairing bit; the credentials-directory - // check that used to sit here mistook half-written state as "populated". - stateDirPopulated: linked, + // check that used to sit here mistook half-written state as pairing. + paired: linked, // running is the authoritative liveness bit; the pgrep check that used // to sit here could not see the in-process bridge, and the gateway-log // breadcrumbs are append-only so they survived a stopped bridge. From 769f3143363f6c65602af2e2717c54fcb2b66a20 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 09:29:09 -0700 Subject: [PATCH 10/17] fix(whatsapp): honor unreachable gateway status --- .../whatsapp/hooks/status-health.test.ts | 10 +++++++++- .../channels/whatsapp/hooks/status-health.ts | 17 ++--------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index 0959652344f..9d26e853698 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -86,7 +86,7 @@ type WaFixture = { function openclawJson(wa: WaFixture | null): string { const payload = wa === null - ? { gatewayReachable: false, error: "unknown channel: whatsapp", configOnly: true } + ? { gatewayReachable: true, error: "unknown channel: whatsapp", configOnly: true } : { gatewayReachable: true, channels: { whatsapp: wa } }; return JSON.stringify(payload); } @@ -217,6 +217,14 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { channels: { whatsapp: HEALTHY_WA }, }, }, + { + label: "unknown-channel error without channel state", + payload: { + gatewayReachable: false, + error: "unknown channel: whatsapp", + configOnly: true, + }, + }, { label: "misleading unknown-channel error with populated channel state", payload: { diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index 05743c0ea03..41a385f429e 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -167,13 +167,8 @@ function runOpenclawStatusProbe( const wa = channels ? readObject(channels.whatsapp) : null; // The CLI can include config-only/stale channel state when the gateway is // unreachable. That root reachability bit is authoritative and must win - // before any nested WhatsApp fields are interpreted. The one bounded - // exception is OpenClaw's known unconfigured response: it carries the fixed - // unknown-channel error without nested WhatsApp state even though its root - // reachability bit is false. - if (json.gatewayReachable === false && !isKnownUnconfiguredWhatsappResponse(json, channels)) { - return PROBE_UNREACHABLE; - } + // before any nested WhatsApp fields or free-form error text are interpreted. + if (json.gatewayReachable === false) return PROBE_UNREACHABLE; if (!wa) { // No `channels.whatsapp`. Two distinct causes the CLI reports, told apart @@ -283,14 +278,6 @@ function readStringValue(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } -function isKnownUnconfiguredWhatsappResponse( - json: Record, - channels: Record | null, -): boolean { - if (channels !== null && Object.hasOwn(channels, "whatsapp")) return false; - return hasUnknownChannelError(json); -} - function hasUnknownChannelError(json: Record): boolean { return readStringValue(json.error) === "unknown channel: whatsapp"; } From 1e805528975be6c62293437de64f36fa5a3e231e Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 10:25:06 -0700 Subject: [PATCH 11/17] test(e2e): reject unrelated rate-limit skips --- test/e2e/live/messaging-providers-helpers.ts | 4 +-- ...messaging-providers-runtime-proofs.test.ts | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 2bf2b4691db..fa9e8512d8d 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -143,8 +143,8 @@ export function isUnresolvedPlaceholderRejection(text: string): boolean { export function isNvidiaEndpointRateLimitFailure(text: string): boolean { return ( - /\b429\b|too many requests|rate limit/i.test(text) && - /NVIDIA|endpoint|validation|models|inference/i.test(text) + /NVIDIA Endpoints endpoint validation failed/i.test(text) && + /HTTP 429|too many requests|rate limit/i.test(text) ); } diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 1fc3f876d85..e0fe9ebf81e 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -12,6 +12,7 @@ import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; import { buildSandboxNodeInvocation, buildSandboxShellInvocation, + isNvidiaEndpointRateLimitFailure, OPENSHELL_EXEC_ARGUMENT_LIMIT_BYTES, parseRuntimeProofPort, } from "../live/messaging-providers-helpers.ts"; @@ -142,6 +143,33 @@ describe("messaging provider installed-runtime proofs", () => { expect(() => parseRuntimeProofPort(rawPort)).toThrow(/runtime proof port/u); }); + it("classifies only rate-limited NVIDIA endpoint validation failures", () => { + expect( + isNvidiaEndpointRateLimitFailure( + "NVIDIA Endpoints endpoint validation failed.\nChat Completions API validation returned HTTP 429", + ), + ).toBe(true); + expect( + isNvidiaEndpointRateLimitFailure( + "NVIDIA Endpoints endpoint validation failed: too many requests", + ), + ).toBe(true); + expect( + isNvidiaEndpointRateLimitFailure( + [ + "Using Other OpenAI-compatible endpoint with model: nvidia/nvidia/nemotron-3-ultra", + "No GITHUB_TOKEN (60 req/hr rate limit — set it for better rates)", + "Docker GPU patch failed: spawnSync docker ETIMEDOUT", + ].join("\n"), + ), + ).toBe(false); + expect( + isNvidiaEndpointRateLimitFailure( + "NVIDIA Endpoints endpoint validation failed: invalid credential", + ), + ).toBe(false); + }); + it("keeps the Slack allow, deny, feedback, and send contract on installed exports", () => { expectValidModuleSource(SLACK_INSTALLED_RUNTIME_PROOF_SOURCE); expect(SLACK_INSTALLED_RUNTIME_PROOF_SOURCE).toContain("prepareSlackMessage"); From c4af726cf20e5d979493f0dc34ed282edccc7de5 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 14:20:20 -0700 Subject: [PATCH 12/17] fix(sandbox): retry pinned Hermes preflight reads --- scripts/managed-gateway-control.py | 29 ++++++++++- test/managed-gateway-control.test.ts | 77 +++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/scripts/managed-gateway-control.py b/scripts/managed-gateway-control.py index 65ffcd2e8f0..0272fabc67a 100755 --- a/scripts/managed-gateway-control.py +++ b/scripts/managed-gateway-control.py @@ -795,6 +795,28 @@ def _recapture_exact_identity( return current +def _read_stable_file_with_proof_grace( + reader: ProcReader, + identity: ProcessIdentity, + name: str, + limit: int, +) -> bytes: + """Retry an inconsistent proc read only while the pinned process is exact.""" + + deadline = time.monotonic() + PROCESS_PROOF_GRACE_SECONDS + while True: + try: + return reader.read_stable_file(identity, name, limit) + except ControlError as error: + if error.code != "SUPERVISOR_UNAVAILABLE": + raise + _recapture_exact_identity(reader, identity, deadline=deadline) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise + time.sleep(min(PROCESS_PROOF_RETRY_SECONDS, remaining)) + + def _basename(value: bytes) -> bytes: return value.rsplit(b"/", 1)[-1] @@ -1313,7 +1335,12 @@ def _hermes_preflight(reader: ProcReader, supervisor: ProcessIdentity) -> None: validator, ["env-file", _system_path("/sandbox/.hermes/.env")], ) - raw_environment = reader.read_stable_file(supervisor, "environ", MAX_ENV_BYTES) + raw_environment = _read_stable_file_with_proof_grace( + reader, + supervisor, + "environ", + MAX_ENV_BYTES, + ) _validate_runtime_environment(validator, _parse_environment(raw_environment)) _verify_locked_hermes_hash() diff --git a/test/managed-gateway-control.test.ts b/test/managed-gateway-control.test.ts index 41f4f237b8f..558ebdc422c 100644 --- a/test/managed-gateway-control.test.ts +++ b/test/managed-gateway-control.test.ts @@ -390,6 +390,71 @@ with tempfile.TemporaryDirectory() as root: control._verify_locked_hermes_hash = lambda: preflight_steps.append({"hash": "checked"}) try: control._hermes_preflight(reader, supervisor) + verified_preflight_steps = list(preflight_steps) + + real_read_stable_file = reader.read_stable_file + real_monotonic = control.time.monotonic + real_sleep = control.time.sleep + transient_preflight_reads = [] + fake_clock = [0.0] + def transient_preflight_read(identity, name, limit): + transient_preflight_reads.append(identity.pid) + if len(transient_preflight_reads) <= 2: + raise control.ControlError("SUPERVISOR_UNAVAILABLE") + return real_read_stable_file(identity, name, limit) + reader.read_stable_file = transient_preflight_read + control.time.monotonic = lambda: fake_clock[0] + control.time.sleep = lambda seconds: fake_clock.__setitem__(0, fake_clock[0] + seconds) + try: + control._hermes_preflight(reader, supervisor) + transient_preflight_retry = [ + len(transient_preflight_reads), + round(fake_clock[0], 3), + ] + finally: + reader.read_stable_file = real_read_stable_file + + persistent_preflight_reads = [] + fake_clock[0] = 0.0 + def persistent_preflight_read(identity, _name, _limit): + persistent_preflight_reads.append(identity.pid) + raise control.ControlError("SUPERVISOR_UNAVAILABLE") + reader.read_stable_file = persistent_preflight_read + try: + control._hermes_preflight(reader, supervisor) + persistent_preflight_retry = ["accepted", False, fake_clock[0]] + except control.ControlError as error: + persistent_preflight_retry = [ + error.code, + len(persistent_preflight_reads) > 1, + round(fake_clock[0], 3), + ] + finally: + reader.read_stable_file = real_read_stable_file + + identity_change_reads = [] + fake_clock[0] = 0.0 + real_capture = reader.capture + def identity_change_read(identity, _name, _limit): + identity_change_reads.append(identity.pid) + raise control.ControlError("SUPERVISOR_UNAVAILABLE") + def capture_changed_supervisor(pid): + captured = real_capture(pid) + if pid == supervisor.pid: + return replace(captured, start_time="replaced") + return captured + reader.read_stable_file = identity_change_read + reader.capture = capture_changed_supervisor + try: + control._hermes_preflight(reader, supervisor) + changed_preflight_identity = ["accepted", fake_clock[0]] + except control.ControlError as error: + changed_preflight_identity = [error.code, fake_clock[0]] + finally: + reader.read_stable_file = real_read_stable_file + reader.capture = real_capture + control.time.monotonic = real_monotonic + control.time.sleep = real_sleep finally: control._run_fixed_validator = real_validator control._validate_runtime_environment = real_runtime_validator @@ -900,7 +965,12 @@ with tempfile.TemporaryDirectory() as root: "persistent_supervisor_churn": persistent_supervisor_churn, "transient_gateway_candidates": transient_gateway_candidates, "namespace_denied": namespace_denied, - "preflight": preflight_steps, + "preflight": verified_preflight_steps, + "preflight_proof_retry": [ + transient_preflight_retry, + persistent_preflight_retry, + changed_preflight_identity, + ], "runtime_validation": runtime_validation, "missing_supervisor": missing_supervisor, "appearing_supervisor": appearing_supervisor, @@ -991,6 +1061,11 @@ describe("managed gateway root control", () => { }, { hash: "checked" }, ], + preflight_proof_retry: [ + [3, 0.1], + ["SUPERVISOR_UNAVAILABLE", true, 1], + ["SUPERVISOR_UNAVAILABLE", 0], + ], runtime_validation: "in-process", missing_supervisor: "SUPERVISOR_NOT_RUNNING", appearing_supervisor: "SUPERVISOR_UNAVAILABLE", From b02c964b73a14e091c092096202186a3213bb4f4 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 19 Jul 2026 04:53:34 -0700 Subject: [PATCH 13/17] fix(messaging): validate WhatsApp status contract Co-authored-by: Hung Le Signed-off-by: Apurv Kumaria --- .../actions/sandbox/channel-status.test.ts | 2 +- .../whatsapp/hooks/status-health.test.ts | 60 +++++++++++++++++++ .../channels/whatsapp/hooks/status-health.ts | 25 ++++++-- 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index d58746a5cd1..cd0d933f2b7 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -9,7 +9,7 @@ import { entry, makeDeps, showSandboxChannelStatus } from "./channel-status.test // shell markers, so these integration tests feed that JSON shape through the // mocked sandbox exec. `wa` is the per-channel object under `channels.whatsapp`. function waStatusJson(wa: Record): string { - return JSON.stringify({ channels: { whatsapp: wa } }); + return JSON.stringify({ gatewayReachable: true, channels: { whatsapp: wa } }); } describe("showSandboxChannelStatus (whatsapp)", () => { diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index 9d26e853698..bb5d91750d5 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -259,6 +259,66 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { expect(report?.signals.some((signal) => signal.label === "Recent log signals")).toBe(false); }); + it.each([ + { + label: "gatewayReachable is absent", + payload: { channels: { whatsapp: HEALTHY_WA } }, + }, + { + label: "gatewayReachable is non-boolean", + payload: { gatewayReachable: "true", channels: { whatsapp: HEALTHY_WA } }, + }, + { + label: "linked is absent", + payload: { + gatewayReachable: true, + channels: { whatsapp: { ...HEALTHY_WA, linked: undefined } }, + }, + }, + { + label: "linked is non-boolean", + payload: { + gatewayReachable: true, + channels: { whatsapp: { ...HEALTHY_WA, linked: "true" } }, + }, + }, + { + label: "running is absent", + payload: { + gatewayReachable: true, + channels: { whatsapp: { ...HEALTHY_WA, running: undefined } }, + }, + }, + { + label: "running is non-boolean", + payload: { + gatewayReachable: true, + channels: { whatsapp: { ...HEALTHY_WA, running: 1 } }, + }, + }, + { + label: "connected is absent", + payload: { + gatewayReachable: true, + channels: { whatsapp: { ...HEALTHY_WA, connected: undefined } }, + }, + }, + { + label: "connected is non-boolean", + payload: { + gatewayReachable: true, + channels: { whatsapp: { ...HEALTHY_WA, connected: "yes" } }, + }, + }, + ])("fails closed when the live-status contract is invalid: $label (#7016)", ({ payload }) => { + const exec = makeExec({ status: 0, stdout: JSON.stringify(payload), stderr: "" }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); + const report = reportOf(result); + expect(report?.verdict).toBe("probe_failed"); + expect(stringifyReport(result)).not.toContain("channel runtime reports WhatsApp is not paired"); + expect(stringifyReport(result)).not.toContain("no bridge process"); + }); + it("degrades an out-of-range lastInboundAt to null instead of crashing", () => { // A finite-but-out-of-Date-range epoch (e.g. 1e300) passes Number.isFinite // yet makes `new Date(v).toISOString()` throw RangeError. The probe must diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index 41a385f429e..cf50a25cb97 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -126,6 +126,12 @@ type OpenclawWhatsappState = { readonly reconnectAttempts?: unknown; }; +type ValidatedOpenclawWhatsappState = OpenclawWhatsappState & { + readonly linked: boolean; + readonly running: boolean; + readonly connected: boolean; +}; + type ProbeResult = { readonly probeReachable: boolean; readonly paired: boolean | null; @@ -168,7 +174,7 @@ function runOpenclawStatusProbe( // The CLI can include config-only/stale channel state when the gateway is // unreachable. That root reachability bit is authoritative and must win // before any nested WhatsApp fields or free-form error text are interpreted. - if (json.gatewayReachable === false) return PROBE_UNREACHABLE; + if (json.gatewayReachable !== true) return PROBE_UNREACHABLE; if (!wa) { // No `channels.whatsapp`. Two distinct causes the CLI reports, told apart @@ -184,13 +190,22 @@ function runOpenclawStatusProbe( recentLogSignals: [describeMissingWaChannel(json)], }; } + if (!hasRequiredWhatsappLiveness(wa)) return PROBE_UNREACHABLE; return mapOpenclawWaState(wa); } -function mapOpenclawWaState(wa: OpenclawWhatsappState): ProbeResult { - const linked = wa.linked === true; - const running = wa.running === true; - const connected = wa.connected === true; +function hasRequiredWhatsappLiveness( + wa: OpenclawWhatsappState, +): wa is ValidatedOpenclawWhatsappState { + return ( + typeof wa.linked === "boolean" && + typeof wa.running === "boolean" && + typeof wa.connected === "boolean" + ); +} + +function mapOpenclawWaState(wa: ValidatedOpenclawWhatsappState): ProbeResult { + const { linked, running, connected } = wa; const healthState = readStringValue(wa.healthState); const heartbeat: WhatsappHeartbeat | null = running ? { From 3e324bd3d96390a08df070723810cc3cfe37da5f Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 19 Jul 2026 06:10:43 -0700 Subject: [PATCH 14/17] fix(messaging): accept live WhatsApp status payload Co-authored-by: Hung Le Signed-off-by: Apurv Kumaria --- .../actions/sandbox/channel-status.test.ts | 2 +- .../whatsapp/hooks/status-health.test.ts | 17 +++++++-- .../channels/whatsapp/hooks/status-health.ts | 38 ++++++++++++------- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index cd0d933f2b7..d58746a5cd1 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -9,7 +9,7 @@ import { entry, makeDeps, showSandboxChannelStatus } from "./channel-status.test // shell markers, so these integration tests feed that JSON shape through the // mocked sandbox exec. `wa` is the per-channel object under `channels.whatsapp`. function waStatusJson(wa: Record): string { - return JSON.stringify({ gatewayReachable: true, channels: { whatsapp: wa } }); + return JSON.stringify({ channels: { whatsapp: wa } }); } describe("showSandboxChannelStatus (whatsapp)", () => { diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index bb5d91750d5..f346146b886 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -86,8 +86,8 @@ type WaFixture = { function openclawJson(wa: WaFixture | null): string { const payload = wa === null - ? { gatewayReachable: true, error: "unknown channel: whatsapp", configOnly: true } - : { gatewayReachable: true, channels: { whatsapp: wa } }; + ? { channels: {}, error: "unknown channel: whatsapp" } + : { channels: { whatsapp: wa } }; return JSON.stringify(payload); } @@ -208,6 +208,15 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { expect(logSignal?.detail).toMatch(/not configured on the gateway/); }); + it("accepts the canonical successful payload without the failure-only reachability field", () => { + const payload = { channels: { whatsapp: HEALTHY_WA } }; + const exec = makeExec({ status: 0, stdout: JSON.stringify(payload), stderr: "" }); + const report = reportOf( + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()), + ); + expect(report?.verdict).toBe("healthy"); + }); + it.each([ { label: "stale healthy channel state", @@ -261,8 +270,8 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { it.each([ { - label: "gatewayReachable is absent", - payload: { channels: { whatsapp: HEALTHY_WA } }, + label: "both gatewayReachable and the canonical channels map are absent", + payload: {}, }, { label: "gatewayReachable is non-boolean", diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index cf50a25cb97..0d4cbd9928b 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -170,18 +170,18 @@ function runOpenclawStatusProbe( const json = parseOpenclawJson(String(exec.stdout ?? "")); if (!json) return PROBE_UNREACHABLE; const channels = readObject(json.channels); + // Successful OpenClaw responses expose the live `channels` map and omit + // `gatewayReachable`; only the CLI's config-only failure response sets that + // field to false. Honor an explicit reachability bit when present, while + // accepting the canonical successful shape only when its channel map exists. + if (!isReachableGatewayStatusPayload(json, channels)) return PROBE_UNREACHABLE; const wa = channels ? readObject(channels.whatsapp) : null; - // The CLI can include config-only/stale channel state when the gateway is - // unreachable. That root reachability bit is authoritative and must win - // before any nested WhatsApp fields or free-form error text are interpreted. - if (json.gatewayReachable !== true) return PROBE_UNREACHABLE; if (!wa) { - // No `channels.whatsapp`. Two distinct causes the CLI reports, told apart - // by the `error` string: the gateway is up but whatsapp is not configured - // on it (`error: "unknown channel: whatsapp"`), or the gateway is unreachable. - // Either way leave the runtime fields null so the evaluator lands on - // "unknown"; only the diagnostic wording differs. + // No `channels.whatsapp`. The exact legacy unknown-channel error means + // WhatsApp is not configured; otherwise the reachable gateway simply did + // not include live WhatsApp status. Leave runtime fields null so the + // evaluator lands on an honest "unknown" verdict in either case. return { probeReachable: true, paired: null, @@ -285,6 +285,16 @@ function parseOpenclawJson(stdout: string): Record | null { } } +function isReachableGatewayStatusPayload( + json: Record, + channels: Record | null, +): boolean { + if (Object.prototype.hasOwnProperty.call(json, "gatewayReachable")) { + return json.gatewayReachable === true; + } + return channels !== null; +} + function readObject(value: unknown): Record | null { return isObjectRecord(value) ? value : null; } @@ -297,14 +307,14 @@ function hasUnknownChannelError(json: Record): boolean { return readStringValue(json.error) === "unknown channel: whatsapp"; } -// The CLI reports a missing `channels.whatsapp` for two different reasons; -// the exact `error: "unknown channel: whatsapp"` means the gateway is up but whatsapp is -// not configured on it, otherwise treat the gateway as unreachable. Emit a -// fixed diagnostic string — never the raw `error`, which can carry PII. +// The CLI can report a missing `channels.whatsapp` with the exact +// `error: "unknown channel: whatsapp"` when WhatsApp is not configured. A +// canonical successful payload can also omit that channel without an error. +// Emit fixed diagnostic strings only — never the raw error, which can carry PII. function describeMissingWaChannel(json: Record): string { return hasUnknownChannelError(json) ? "whatsapp is not configured on the gateway — live health unavailable" - : "gateway not reachable — live WhatsApp health unavailable"; + : "gateway returned no live WhatsApp status"; } function isObjectRecord(value: unknown): value is Record { From c7278e54f6a4e1a47a1ce7c29c4dc6555a5d6d07 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 19 Jul 2026 07:47:50 -0700 Subject: [PATCH 15/17] fix(messaging): fail closed on WhatsApp probe errors Signed-off-by: Carlos Villela --- .../actions/sandbox/channel-status.test.ts | 21 +++++++++++++++++++ .../whatsapp/hooks/status-health.test.ts | 10 +++++++++ .../channels/whatsapp/hooks/status-health.ts | 7 ++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index d58746a5cd1..28f66519b7a 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -140,6 +140,27 @@ describe("showSandboxChannelStatus (whatsapp)", () => { expect(threw?.message).toBe("process.exit(1)"); }); + it("returns probe_failed when the openclaw status command throws", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + const { deps, out_lines } = makeDeps({ + exec: () => { + throw new Error("sandbox exec unavailable"); + }, + }); + let threw: Error | null = null; + try { + await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); + } catch (err) { + threw = err as Error; + } finally { + exitSpy.mockRestore(); + } + expect(threw?.message).toBe("process.exit(1)"); + expect(out_lines.join("\n")).toMatch(/Verdict:.*probe_failed/); + }); + it("returns probe_failed when openshell exec returns null (timeout)", async () => { const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`process.exit(${code})`); diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index f346146b886..f1d3be5b36c 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -376,6 +376,16 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { expect(report?.verdict).toBe("probe_failed"); }); + it("reports probe_failed when the sandbox exec runner throws", () => { + const exec = vi.fn(() => { + throw new Error("sandbox exec unavailable"); + }); + const report = reportOf( + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()), + ); + expect(report?.verdict).toBe("probe_failed"); + }); + it("invokes the openclaw CLI with the JSON + timeout flags", () => { const exec = makeExec({ status: 0, stdout: openclawJson(HEALTHY_WA), stderr: "" }); createWhatsappStatusHealthHook({ executeSandboxCommand: exec, timeoutMs: 4500 })(context()); diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index 0d4cbd9928b..f660ea92ad6 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -161,7 +161,12 @@ function runOpenclawStatusProbe( timeoutMs: number, ): ProbeResult { const command = `openclaw channels status --channel whatsapp --json --timeout ${timeoutMs}`; - const exec = execute(sandboxName, command, timeoutMs); + let exec: ReturnType; + try { + exec = execute(sandboxName, command, timeoutMs); + } catch { + return PROBE_UNREACHABLE; + } // A non-zero exec (timeout/kill/unhealthy sandbox) can still carry partial // stdout; require a clean exit before trusting the probe. Otherwise a // stalled openclaw invocation could yield unparseable JSON that reads as a From 046e987703255b60fc2b4b0b2c9063e6268e6659 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 19 Jul 2026 19:16:24 -0700 Subject: [PATCH 16/17] fix(messaging): consume WhatsApp account status Co-authored-by: Hung Le Signed-off-by: Apurv Kumaria --- .../actions/sandbox/channel-status.test.ts | 9 ++- .../whatsapp/hooks/status-health.test.ts | 60 ++++++++++++++-- .../channels/whatsapp/hooks/status-health.ts | 71 ++++++++++++++++--- 3 files changed, 123 insertions(+), 17 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 28f66519b7a..6602a7580ab 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -7,9 +7,14 @@ import { entry, makeDeps, showSandboxChannelStatus } from "./channel-status.test // The whatsapp status hook now reads OpenClaw's authoritative live status JSON // (`openclaw channels status --channel whatsapp --json`) instead of scraping // shell markers, so these integration tests feed that JSON shape through the -// mocked sandbox exec. `wa` is the per-channel object under `channels.whatsapp`. +// mocked sandbox exec. `wa` is the default-account object under +// `channelAccounts.whatsapp` in OpenClaw 2026.6.10. function waStatusJson(wa: Record): string { - return JSON.stringify({ channels: { whatsapp: wa } }); + return JSON.stringify({ + channels: { whatsapp: { configured: true } }, + channelAccounts: { whatsapp: [{ ...wa, accountId: "default" }] }, + channelDefaultAccountId: { whatsapp: "default" }, + }); } describe("showSandboxChannelStatus (whatsapp)", () => { diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index f1d3be5b36c..fe232f755da 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -84,10 +84,16 @@ type WaFixture = { }; function openclawJson(wa: WaFixture | null): string { - const payload = - wa === null - ? { channels: {}, error: "unknown channel: whatsapp" } - : { channels: { whatsapp: wa } }; + const payload = { + channels: { + whatsapp: wa === null ? { configured: false } : { configured: wa.configured ?? false }, + }, + channelAccounts: { + whatsapp: wa === null ? [] : [{ ...wa, accountId: "default" }], + }, + channelDefaultAccountId: { whatsapp: "default" }, + ...(wa === null ? { error: "unknown channel: whatsapp" } : {}), + }; return JSON.stringify(payload); } @@ -209,7 +215,33 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { }); it("accepts the canonical successful payload without the failure-only reachability field", () => { - const payload = { channels: { whatsapp: HEALTHY_WA } }; + const payload = { + channels: { whatsapp: { configured: true } }, + channelAccounts: { + whatsapp: [{ ...HEALTHY_WA, accountId: "default" }], + }, + channelDefaultAccountId: { whatsapp: "default" }, + }; + const exec = makeExec({ status: 0, stdout: JSON.stringify(payload), stderr: "" }); + const report = reportOf( + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()), + ); + expect(report?.verdict).toBe("healthy"); + }); + + it("selects the declared default account instead of trusting account-array order", () => { + const payload = { + // Keep the summary deliberately unpaired so this test also proves the + // probe consumes authoritative per-account state rather than the summary. + channels: { whatsapp: UNPAIRED_WA }, + channelAccounts: { + whatsapp: [ + { ...UNPAIRED_WA, accountId: "secondary" }, + { ...HEALTHY_WA, accountId: "default" }, + ], + }, + channelDefaultAccountId: { whatsapp: "default" }, + }; const exec = makeExec({ status: 0, stdout: JSON.stringify(payload), stderr: "" }); const report = reportOf( createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()), @@ -319,6 +351,24 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { channels: { whatsapp: { ...HEALTHY_WA, connected: "yes" } }, }, }, + { + label: "default account id does not identify exactly one account", + payload: { + channels: { whatsapp: HEALTHY_WA }, + channelAccounts: { + whatsapp: [{ ...HEALTHY_WA, accountId: "secondary" }], + }, + channelDefaultAccountId: { whatsapp: "default" }, + }, + }, + { + label: "per-account state is not an array", + payload: { + channels: { whatsapp: HEALTHY_WA }, + channelAccounts: { whatsapp: HEALTHY_WA }, + channelDefaultAccountId: { whatsapp: "default" }, + }, + }, ])("fails closed when the live-status contract is invalid: $label (#7016)", ({ payload }) => { const exec = makeExec({ status: 0, stdout: JSON.stringify(payload), stderr: "" }); const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()); diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index f660ea92ad6..a1a75a40630 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -175,18 +175,23 @@ function runOpenclawStatusProbe( const json = parseOpenclawJson(String(exec.stdout ?? "")); if (!json) return PROBE_UNREACHABLE; const channels = readObject(json.channels); - // Successful OpenClaw responses expose the live `channels` map and omit + const channelAccounts = readObject(json.channelAccounts); + // Successful OpenClaw responses expose live channel/account maps and omit // `gatewayReachable`; only the CLI's config-only failure response sets that // field to false. Honor an explicit reachability bit when present, while - // accepting the canonical successful shape only when its channel map exists. - if (!isReachableGatewayStatusPayload(json, channels)) return PROBE_UNREACHABLE; - const wa = channels ? readObject(channels.whatsapp) : null; + // accepting the canonical successful shape only when a live map exists. + if (!isReachableGatewayStatusPayload(json, channels, channelAccounts)) { + return PROBE_UNREACHABLE; + } + const waLookup = readWhatsappState(json, channels, channelAccounts); + if (waLookup.kind === "invalid") return PROBE_UNREACHABLE; + const wa = waLookup.kind === "found" ? waLookup.state : null; if (!wa) { - // No `channels.whatsapp`. The exact legacy unknown-channel error means - // WhatsApp is not configured; otherwise the reachable gateway simply did - // not include live WhatsApp status. Leave runtime fields null so the - // evaluator lands on an honest "unknown" verdict in either case. + // No authoritative WhatsApp account. The exact legacy unknown-channel + // error means WhatsApp is not configured; otherwise the reachable gateway + // simply did not include live WhatsApp status. Leave runtime fields null so + // the evaluator lands on an honest "unknown" verdict in either case. return { probeReachable: true, paired: null, @@ -293,11 +298,57 @@ function parseOpenclawJson(stdout: string): Record | null { function isReachableGatewayStatusPayload( json: Record, channels: Record | null, + channelAccounts: Record | null, ): boolean { if (Object.prototype.hasOwnProperty.call(json, "gatewayReachable")) { return json.gatewayReachable === true; } - return channels !== null; + return channels !== null || channelAccounts !== null; +} + +type WhatsappStateLookup = + | { readonly kind: "found"; readonly state: OpenclawWhatsappState } + | { readonly kind: "missing" } + | { readonly kind: "invalid" }; + +/** + * OpenClaw 2026.6.10 exposes live per-account state under + * `channelAccounts.whatsapp` and names the authoritative account through + * `channelDefaultAccountId.whatsapp`. Select that exact account rather than + * trusting array order or the channel-level summary. The summary-only branch + * remains as a bounded compatibility path for older reviewed payloads. + */ +function readWhatsappState( + json: Record, + channels: Record | null, + channelAccounts: Record | null, +): WhatsappStateLookup { + if (!Object.prototype.hasOwnProperty.call(json, "channelAccounts")) { + const legacyState = channels ? readObject(channels.whatsapp) : null; + return legacyState ? { kind: "found", state: legacyState } : { kind: "missing" }; + } + if (!channelAccounts) return { kind: "invalid" }; + if (!Object.prototype.hasOwnProperty.call(channelAccounts, "whatsapp")) { + return { kind: "missing" }; + } + + const rawAccounts = channelAccounts.whatsapp; + if (!Array.isArray(rawAccounts)) return { kind: "invalid" }; + const accounts: Record[] = []; + for (const rawAccount of rawAccounts) { + const account = readObject(rawAccount); + if (!account) return { kind: "invalid" }; + accounts.push(account); + } + if (accounts.length === 0) return { kind: "missing" }; + + const defaultAccountIds = readObject(json.channelDefaultAccountId); + const defaultAccountId = defaultAccountIds ? readStringValue(defaultAccountIds.whatsapp) : null; + if (!defaultAccountId) return { kind: "invalid" }; + const matches = accounts.filter( + (account) => readStringValue(account.accountId) === defaultAccountId, + ); + return matches.length === 1 ? { kind: "found", state: matches[0] } : { kind: "invalid" }; } function readObject(value: unknown): Record | null { @@ -312,7 +363,7 @@ function hasUnknownChannelError(json: Record): boolean { return readStringValue(json.error) === "unknown channel: whatsapp"; } -// The CLI can report a missing `channels.whatsapp` with the exact +// The CLI can report missing WhatsApp status with the exact // `error: "unknown channel: whatsapp"` when WhatsApp is not configured. A // canonical successful payload can also omit that channel without an error. // Emit fixed diagnostic strings only — never the raw error, which can carry PII. From d68b3958edc372310a1b54313b7bca248c392c07 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 19 Jul 2026 19:28:56 -0700 Subject: [PATCH 17/17] fix(messaging): fail closed on summary-only status Co-authored-by: Hung Le Signed-off-by: Apurv Kumaria --- .../whatsapp/hooks/status-health.test.ts | 45 +++++++------------ .../channels/whatsapp/hooks/status-health.ts | 19 +++----- 2 files changed, 24 insertions(+), 40 deletions(-) diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index fe232f755da..2f99d79e244 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -83,8 +83,8 @@ type WaFixture = { readonly lastError?: string | null; }; -function openclawJson(wa: WaFixture | null): string { - const payload = { +function openclawPayload(wa: Record | null): Record { + return { channels: { whatsapp: wa === null ? { configured: false } : { configured: wa.configured ?? false }, }, @@ -94,7 +94,10 @@ function openclawJson(wa: WaFixture | null): string { channelDefaultAccountId: { whatsapp: "default" }, ...(wa === null ? { error: "unknown channel: whatsapp" } : {}), }; - return JSON.stringify(payload); +} + +function openclawJson(wa: Record | null): string { + return JSON.stringify(openclawPayload(wa)); } const HEALTHY_WA: WaFixture = { @@ -307,49 +310,35 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { }, { label: "gatewayReachable is non-boolean", - payload: { gatewayReachable: "true", channels: { whatsapp: HEALTHY_WA } }, + payload: { ...openclawPayload(HEALTHY_WA), gatewayReachable: "true" }, + }, + { + label: "summary-only payload has no authoritative account state", + payload: { channels: { whatsapp: HEALTHY_WA } }, }, { label: "linked is absent", - payload: { - gatewayReachable: true, - channels: { whatsapp: { ...HEALTHY_WA, linked: undefined } }, - }, + payload: openclawPayload({ ...HEALTHY_WA, linked: undefined }), }, { label: "linked is non-boolean", - payload: { - gatewayReachable: true, - channels: { whatsapp: { ...HEALTHY_WA, linked: "true" } }, - }, + payload: openclawPayload({ ...HEALTHY_WA, linked: "true" }), }, { label: "running is absent", - payload: { - gatewayReachable: true, - channels: { whatsapp: { ...HEALTHY_WA, running: undefined } }, - }, + payload: openclawPayload({ ...HEALTHY_WA, running: undefined }), }, { label: "running is non-boolean", - payload: { - gatewayReachable: true, - channels: { whatsapp: { ...HEALTHY_WA, running: 1 } }, - }, + payload: openclawPayload({ ...HEALTHY_WA, running: 1 }), }, { label: "connected is absent", - payload: { - gatewayReachable: true, - channels: { whatsapp: { ...HEALTHY_WA, connected: undefined } }, - }, + payload: openclawPayload({ ...HEALTHY_WA, connected: undefined }), }, { label: "connected is non-boolean", - payload: { - gatewayReachable: true, - channels: { whatsapp: { ...HEALTHY_WA, connected: "yes" } }, - }, + payload: openclawPayload({ ...HEALTHY_WA, connected: "yes" }), }, { label: "default account id does not identify exactly one account", diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index a1a75a40630..9bc215c7285 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -174,16 +174,15 @@ function runOpenclawStatusProbe( if (!exec || exec.status !== 0) return PROBE_UNREACHABLE; const json = parseOpenclawJson(String(exec.stdout ?? "")); if (!json) return PROBE_UNREACHABLE; - const channels = readObject(json.channels); const channelAccounts = readObject(json.channelAccounts); // Successful OpenClaw responses expose live channel/account maps and omit // `gatewayReachable`; only the CLI's config-only failure response sets that // field to false. Honor an explicit reachability bit when present, while // accepting the canonical successful shape only when a live map exists. - if (!isReachableGatewayStatusPayload(json, channels, channelAccounts)) { + if (!isReachableGatewayStatusPayload(json, channelAccounts)) { return PROBE_UNREACHABLE; } - const waLookup = readWhatsappState(json, channels, channelAccounts); + const waLookup = readWhatsappState(json, channelAccounts); if (waLookup.kind === "invalid") return PROBE_UNREACHABLE; const wa = waLookup.kind === "found" ? waLookup.state : null; @@ -297,13 +296,12 @@ function parseOpenclawJson(stdout: string): Record | null { function isReachableGatewayStatusPayload( json: Record, - channels: Record | null, channelAccounts: Record | null, ): boolean { if (Object.prototype.hasOwnProperty.call(json, "gatewayReachable")) { return json.gatewayReachable === true; } - return channels !== null || channelAccounts !== null; + return channelAccounts !== null; } type WhatsappStateLookup = @@ -315,18 +313,15 @@ type WhatsappStateLookup = * OpenClaw 2026.6.10 exposes live per-account state under * `channelAccounts.whatsapp` and names the authoritative account through * `channelDefaultAccountId.whatsapp`. Select that exact account rather than - * trusting array order or the channel-level summary. The summary-only branch - * remains as a bounded compatibility path for older reviewed payloads. + * trusting array order or the channel-level summary. Every supported OpenClaw + * producer, down to the blueprint compatibility floor, provides this account + * map, so a summary-only response is an unknown contract and fails closed. */ function readWhatsappState( json: Record, - channels: Record | null, channelAccounts: Record | null, ): WhatsappStateLookup { - if (!Object.prototype.hasOwnProperty.call(json, "channelAccounts")) { - const legacyState = channels ? readObject(channels.whatsapp) : null; - return legacyState ? { kind: "found", state: legacyState } : { kind: "missing" }; - } + if (!Object.prototype.hasOwnProperty.call(json, "channelAccounts")) return { kind: "invalid" }; if (!channelAccounts) return { kind: "invalid" }; if (!Object.prototype.hasOwnProperty.call(channelAccounts, "whatsapp")) { return { kind: "missing" };