diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 05307e1bd37..76026691ce7 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1938,12 +1938,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. @@ -1964,9 +1965,11 @@ $$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. +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/scripts/managed-gateway-control.py b/scripts/managed-gateway-control.py index 19932a1969b..0d30eeb02b9 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/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.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 063595a7510..6602a7580ab 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -2,33 +2,30 @@ // 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 +// shell markers, so these integration tests feed that JSON shape through the +// 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: { configured: true } }, + channelAccounts: { whatsapp: [{ ...wa, accountId: "default" }] }, + channelDefaultAccountId: { whatsapp: "default" }, + }); +} 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,12 +94,24 @@ 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 } = makeDeps({ - exec: () => ({ status: 1, stdout: "", stderr: "Error: not running" }), + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout, stderr: "" }), }); let threw: Error | null = null; try { @@ -131,43 +122,37 @@ describe("showSandboxChannelStatus (whatsapp)", () => { 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 openshell exec returns null (timeout)", async () => { + 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); const { deps } = makeDeps({ - exec: () => null, + exec: () => ({ status: 1, stdout: "", stderr: "Error: not running" }), }); let threw: Error | null = null; try { - await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp", asJson: true }); + await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); } catch (err) { threw = err as Error; } finally { exitSpy.mockRestore(); } - // asJson w/o quietJson still prints the JSON, then returns; the exit code - // is set via `if (asJson) return report;` so no process.exit is called. - expect(threw).toBeNull(); + expect(threw?.message).toBe("process.exit(1)"); }); - 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"); + 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 } = makeDeps({ - exec: () => ({ status: 0, stdout, stderr: "" }), - sandbox: entry([]), - appliedPresets: [], - gatewayPresets: [], + const { deps, out_lines } = makeDeps({ + exec: () => { + throw new Error("sandbox exec unavailable"); + }, }); let threw: Error | null = null; try { @@ -178,132 +163,73 @@ describe("showSandboxChannelStatus (whatsapp)", () => { exitSpy.mockRestore(); } expect(threw?.message).toBe("process.exit(1)"); + expect(out_lines.join("\n")).toMatch(/Verdict:.*probe_failed/); }); - 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("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})`); }) as never); - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout, stderr: "" }), - agentName: "hermes", + const { deps } = makeDeps({ + exec: () => null, }); + let threw: Error | null = null; try { - await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); - } catch { - /* expected exit(1) for unpaired */ + await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp", asJson: true }); + } catch (err) { + threw = err as Error; } finally { exitSpy.mockRestore(); } - const dump = out_lines.join("\n"); - expect(dump).toMatch(/hermes whatsapp/); - expect(dump).toMatch(/Verdict:.*unpaired/); + // asJson w/o quietJson still prints the JSON, then returns; the exit code + // is set via `if (asJson) return report;` so no process.exit is called. + expect(threw).toBeNull(); }); - 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"); + it("returns config_gap when the sandbox has whatsapp neither registered nor enabled", async () => { + 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); + const { deps } = makeDeps({ + exec: () => ({ status: 0, stdout, stderr: "" }), + sandbox: entry([]), + appliedPresets: [], + gatewayPresets: [], + }); + let threw: Error | null = null; 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/); + await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); + } catch (err) { + threw = err as Error; } finally { exitSpy.mockRestore(); } + expect(threw?.message).toBe("process.exit(1)"); }); - 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. - 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", - stderr: "", - }; - }; - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`process.exit(${code})`); - }) as never); - const { deps } = makeDeps({ exec }); - 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", + 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"), }); - 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/); + 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/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index 8e063a0dae9..0edfb8e69d6 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,6 +21,8 @@ import { import { type ChannelHealthReport, channelHealthProbeInputs, + type DiagnosticSeverity, + type DiagnosticSignal, } from "../../messaging/channels/channel-health"; import { collectBuiltInMessagingChannelDiagnostics, @@ -33,16 +34,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"; @@ -90,7 +81,7 @@ type ChannelStatusSingleReport = schemaVersion: 1; sandbox: string; channel: string; - report: WhatsappDiagnosticReport | ChannelHealthReport; + report: ChannelHealthReport; } | { schemaVersion: 1; @@ -108,21 +99,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 +146,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 +336,29 @@ 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 +// 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 -// (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 +478,20 @@ 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. + // 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" && !channelIsPaused + channelHasChannelHealthStatusHook(channelName) && !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..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; @@ -86,6 +92,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 96% rename from src/lib/sandbox/whatsapp-diagnostics.test.ts rename to src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts index 34b58a51262..85e98cf0117 100644 --- a/src/lib/sandbox/whatsapp-diagnostics.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts @@ -8,15 +8,14 @@ import { parseWhatsappHeartbeat, summarizeWhatsappLogLines, type WhatsappProbeInput, -} from "./whatsapp-diagnostics"; +} from "./status-health-eval"; 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/sandbox/whatsapp-diagnostics.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts similarity index 90% rename from src/lib/sandbox/whatsapp-diagnostics.ts rename to src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts index a509cec3c4b..d597affec06 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,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 - // manifest in the orchestrator. - 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; @@ -91,21 +87,22 @@ 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; -// 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 { @@ -134,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" @@ -149,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)", }; } @@ -212,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", @@ -365,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 && @@ -373,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 new file mode 100644 index 00000000000..2f99d79e244 --- /dev/null +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -0,0 +1,485 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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"; + +const BASE_INPUTS = { + currentSandbox: "alpha", + agent: "openclaw", + probedAt: "2026-07-14T00:00:00.000Z", + channelEnabledInRegistry: true, + presetInRegistry: true, + 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", +): 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); +} + +// 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, +): ChannelHealthReport | undefined { + const value = (result as MessagingHookResult).outputs?.channelHealth?.value as unknown as + | { report?: ChannelHealthReport } + | undefined; + return value?.report; +} + +function outputsOf(result: MessagingHookResult | Promise) { + return (result as MessagingHookResult).outputs; +} + +function stringifyReport(result: MessagingHookResult | Promise): string { + return JSON.stringify(reportOf(result)); +} + +// 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; +}; + +function openclawPayload(wa: Record | null): Record { + return { + 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" } : {}), + }; +} + +function openclawJson(wa: Record | null): string { + return JSON.stringify(openclawPayload(wa)); +} + +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()); + 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("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.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("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 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()), + ); + expect(report?.verdict).not.toBe("healthy"); + // 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"); + const logSignal = report?.signals.find((s) => s.label === "Recent log signals"); + 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: { 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()), + ); + expect(report?.verdict).toBe("healthy"); + }); + + it.each([ + { + label: "stale healthy channel state", + payload: { + gatewayReachable: false, + configOnly: true, + 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: { + gatewayReachable: false, + error: "unknown channel: whatsapp", + configOnly: true, + 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( + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(context()), + ); + expect(report?.verdict).toBe("probe_failed"); + expect(report?.signals.some((signal) => signal.label === "Recent log signals")).toBe(false); + }); + + it.each([ + { + label: "both gatewayReachable and the canonical channels map are absent", + payload: {}, + }, + { + label: "gatewayReachable is non-boolean", + 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: openclawPayload({ ...HEALTHY_WA, linked: undefined }), + }, + { + label: "linked is non-boolean", + payload: openclawPayload({ ...HEALTHY_WA, linked: "true" }), + }, + { + label: "running is absent", + payload: openclawPayload({ ...HEALTHY_WA, running: undefined }), + }, + { + label: "running is non-boolean", + payload: openclawPayload({ ...HEALTHY_WA, running: 1 }), + }, + { + label: "connected is absent", + payload: openclawPayload({ ...HEALTHY_WA, connected: undefined }), + }, + { + label: "connected is non-boolean", + payload: openclawPayload({ ...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()); + 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 + // 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 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: "hermes" }), + ); + expect(outputsOf(result)).toBeUndefined(); + expect(exec).not.toHaveBeenCalled(); + }); + + 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: "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); + const report = reportOf( + createWhatsappStatusHealthHook({ executeSandboxCommand: runner })(context()), + ); + 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()); + 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("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/); + }); +}); + +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: "" }); + expect( + outputsOf( + createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( + context(BASE_INPUTS, "slack"), + ), + ), + ).toBeUndefined(); + 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 new file mode 100644 index 00000000000..9bc215c7285 --- /dev/null +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -0,0 +1,400 @@ +// 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. + * + * The probe reads OpenClaw's authoritative live status JSON: + * + * openclaw channels status --channel whatsapp --json --timeout + * + * 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: + * + * - 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. + * + * 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 type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; +import type { MessagingSerializableValue } from "../../../manifest"; +import { + type ChannelStatusHealthHookOptions, + MESSAGING_CHANNEL_HEALTH_OUTPUT_TYPE, +} from "../../channel-health"; +import { + evaluateWhatsappDiagnostics, + 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 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; +/** 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"; + // 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 = runOpenclawStatusProbe(execute, sandboxName, timeoutMs); + + const input: WhatsappProbeInput = { + agent, + paired: probe.paired, + 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), + 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), + }; +} + +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 ValidatedOpenclawWhatsappState = OpenclawWhatsappState & { + readonly linked: boolean; + readonly running: boolean; + readonly connected: boolean; +}; + +type ProbeResult = { + readonly probeReachable: boolean; + readonly paired: boolean | null; + readonly bridgeProcessAlive: boolean | null; + readonly heartbeat: WhatsappHeartbeat | null; + readonly recentLogSignals: readonly string[]; +}; + +const PROBE_UNREACHABLE: ProbeResult = { + probeReachable: false, + paired: 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}`; + 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 + // 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 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, channelAccounts)) { + return PROBE_UNREACHABLE; + } + const waLookup = readWhatsappState(json, channelAccounts); + if (waLookup.kind === "invalid") return PROBE_UNREACHABLE; + const wa = waLookup.kind === "found" ? waLookup.state : null; + + if (!wa) { + // 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, + bridgeProcessAlive: null, + heartbeat: null, + recentLogSignals: [describeMissingWaChannel(json)], + }; + } + if (!hasRequiredWhatsappLiveness(wa)) return PROBE_UNREACHABLE; + return mapOpenclawWaState(wa); +} + +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 + ? { + 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 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. + 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"; +} + +// 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( + healthState: string | null, + reconnectAttemptsRaw: unknown, +): readonly string[] { + const parts: string[] = []; + if (healthState !== null && healthState !== "healthy") { + parts.push(`healthState=${KNOWN_HEALTH_STATES.has(healthState) ? healthState : "unknown"}`); + } + 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("; ")] : []; +} + +function parseOpenclawJson(stdout: string): Record | null { + const trimmed = stdout.trim(); + if (trimmed.length === 0) return null; + 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; + } +} + +function isReachableGatewayStatusPayload( + json: Record, + channelAccounts: Record | null, +): boolean { + if (Object.prototype.hasOwnProperty.call(json, "gatewayReachable")) { + return json.gatewayReachable === true; + } + return 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. 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, + channelAccounts: Record | null, +): WhatsappStateLookup { + 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" }; + } + + 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 { + return isObjectRecord(value) ? value : null; +} + +function readStringValue(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function hasUnknownChannelError(json: Record): boolean { + return readStringValue(json.error) === "unknown channel: whatsapp"; +} + +// 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. +function describeMissingWaChannel(json: Record): string { + return hasUnknownChannelError(json) + ? "whatsapp is not configured on the gateway — live health unavailable" + : "gateway returned no live WhatsApp status"; +} + +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; + if (value > MAX_ECMASCRIPT_DATE_MS) return null; + return new Date(value).toISOString(); +} + +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..b35eb0e3e18 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"], + 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", ]); }); 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"); diff --git a/test/managed-gateway-control.test.ts b/test/managed-gateway-control.test.ts index 5b7ca9f0156..0cc201b1811 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 @@ -898,7 +963,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, @@ -990,6 +1060,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",