diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 85451dca94b..29404545ff7 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -759,6 +759,21 @@ $ nemoclaw my-assistant channels start telegram |------|-------------| | `--dry-run` | Report the channel that would be re-enabled without updating the registry or rebuilding | +### `nemoclaw channels status` + +Run channel-specific runtime diagnostics. For WhatsApp the command probes the sandbox to separately report pairing/session state, the Noise WebSocket connection, inbound event delivery, and policy/config coverage; a paired channel with no observed inbound delivery exits non-zero with verdict `idle` so an unhealthy bridge cannot pass as healthy. + +```bash +nemoclaw my-assistant channels status --channel whatsapp +``` + +| Flag | Description | +|------|-------------| +| `--channel ` | Channel to inspect; defaults to `whatsapp` when registered | +| `--json` | Emit the diagnostic report as JSON (exit non-zero when the verdict is not `healthy` or `unknown`) | + +The probe is bounded by an in-sandbox `openshell sandbox exec` with a hard timeout, captures only short matched bridge log signals (e.g. `connection.open`, `401 unauthorized`, `qr expired`), and never forwards message bodies to the host diagnostic output. + ### `nemoclaw skill install ` Deploy a skill directory to a running sandbox. diff --git a/src/commands/sandbox/channels/status.ts b/src/commands/sandbox/channels/status.ts new file mode 100644 index 00000000000..731120dbb26 --- /dev/null +++ b/src/commands/sandbox/channels/status.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flags } from "@oclif/core"; + +import { showSandboxChannelStatus } from "../../../lib/actions/sandbox/channel-status"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +export default class SandboxChannelsStatusCommand extends NemoClawCommand { + static id = "sandbox:channels:status"; + static strict = true; + static enableJsonFlag = true; + static summary = "Inspect a messaging channel's runtime diagnostics"; + static description = + "Report channel-specific runtime diagnostics — for WhatsApp, separately reports QR/session state, Noise WebSocket state, inbound event delivery, and policy coverage so a paired-but-idle channel does not appear healthy."; + static usage = [" [--channel ] [--json]"]; + static examples = [ + "<%= config.bin %> sandbox channels status alpha --channel whatsapp", + "<%= config.bin %> sandbox channels status alpha --json", + ]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + channel: Flags.string({ + description: "Messaging channel to inspect (defaults to whatsapp when registered)", + required: false, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxChannelsStatusCommand); + const report = await showSandboxChannelStatus(args.sandboxName, { + channel: flags.channel, + asJson: this.jsonEnabled(), + quietJson: this.jsonEnabled(), + }); + if (this.jsonEnabled()) { + if (report && "report" in report) { + const verdict = report.report.verdict; + if (verdict !== "healthy" && verdict !== "unknown") { + process.exitCode = 1; + } + } + return report; + } + } +} diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts new file mode 100644 index 00000000000..439f5fbd746 --- /dev/null +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -0,0 +1,480 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +// The orchestrator transitively pulls in policy/index.ts and agent/defs.ts, +// both of which require runner.ts via CJS; runner.ts uses `require()` calls +// vitest cannot resolve from a TS source file. Stub the heavy modules so the +// test stays focused on the orchestrator's diagnostic glue. See +// src/lib/shields/index.test.ts for the same workaround pattern. +vi.mock("../../policy", () => ({ + getAppliedPresets: vi.fn(() => []), + getGatewayPresets: vi.fn(() => null), +})); + +vi.mock("../../state/registry", () => ({ + getSandbox: vi.fn(), +})); + +vi.mock("../../agent/defs", () => ({ + loadAgent: vi.fn(), +})); + +vi.mock("./process-recovery", () => ({ + executeSandboxExecCommand: vi.fn(), +})); + +import type { AgentDefinition } from "../../agent/defs"; +import type { SandboxEntry } from "../../state/registry"; +import { showSandboxChannelStatus } from "./channel-status"; + +type ExecResult = { status: number; stdout: string; stderr: string }; + +const PROBED_AT = new Date("2026-05-28T04:00:00.000Z"); + +function fakeAgent(name: "openclaw" | "hermes" = "openclaw"): AgentDefinition { + const configDir = name === "openclaw" ? "/sandbox/.openclaw" : "/sandbox/.hermes"; + const stateDirs = name === "openclaw" ? ["whatsapp"] : ["platforms"]; + const messagingPlatforms = ["telegram", "discord", "slack", "wechat", "whatsapp"]; + return { + name, + agentDir: `/fake/${name}`, + manifestPath: `/fake/${name}/manifest.yaml`, + get displayName() { + return name; + }, + get healthProbe() { + return { url: "http://localhost:0/", port: 0, timeout_seconds: 5 }; + }, + get forwardPort() { + return 0; + }, + get dashboard() { + return { kind: "ui" as const, label: "UI", path: "/" }; + }, + get configPaths() { + return { dir: configDir, configFile: "config.json", envFile: null, format: "json" }; + }, + get inferenceProviderOptions() { + return []; + }, + get stateDirs() { + return stateDirs; + }, + get stateFiles() { + return []; + }, + get versionCommand() { + return `${name} --version`; + }, + get expectedVersion() { + return null; + }, + get hasDevicePairing() { + return false; + }, + get phoneHomeHosts() { + return []; + }, + get messagingPlatforms() { + return messagingPlatforms; + }, + get dockerfileBasePath() { + return null; + }, + get dockerfilePath() { + return null; + }, + get startScriptPath() { + return null; + }, + get policyAdditionsPath() { + return null; + }, + get policyPermissivePath() { + return null; + }, + get pluginDir() { + return null; + }, + get legacyPaths() { + return null; + }, + } as unknown as AgentDefinition; +} + +function entry( + messagingChannels: string[] = ["whatsapp"], + disabledChannels: string[] = [], +): SandboxEntry { + return { + name: "alpha", + agent: "openclaw", + messagingChannels, + disabledChannels, + } as SandboxEntry; +} + +function makeDeps(opts: { + exec: (sandboxName: string, command: string, timeoutMs?: number) => ExecResult | null; + appliedPresets?: string[]; + gatewayPresets?: string[] | null; + agentName?: "openclaw" | "hermes"; + sandbox?: SandboxEntry | undefined; + out?: (line: string) => void; +}) { + const calls: string[] = []; + const out = opts.out ?? ((line: string) => calls.push(line)); + return { + out, + deps: { + loadAgent: () => fakeAgent(opts.agentName), + getSandbox: () => opts.sandbox ?? entry(), + getAppliedPresets: () => opts.appliedPresets ?? ["whatsapp"], + getGatewayPresets: () => + opts.gatewayPresets === undefined ? ["whatsapp"] : opts.gatewayPresets, + execSandbox: vi.fn(opts.exec), + now: () => PROBED_AT, + out, + }, + out_lines: calls, + }; +} + +describe("showSandboxChannelStatus (whatsapp)", () => { + it("returns idle verdict and exit code 1 when paired but no inbound observed", async () => { + const heartbeat = JSON.stringify({ + 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); + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout, stderr: "" }), + }); + try { + await showSandboxChannelStatus("alpha", { deps, quietJson: true, asJson: true }); + } finally { + exitSpy.mockRestore(); + } + const dump = out_lines.join("\n"); + // The text report is suppressed when asJson && quietJson; the action returns + // the report. Use the JSON-less path next to inspect rendering. + expect(dump).toBe(""); + }); + + it("renders an idle verdict in the text report and exits non-zero", async () => { + const heartbeat = JSON.stringify({ + 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); + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout, stderr: "" }), + }); + let threw: Error | null = null; + try { + await showSandboxChannelStatus("alpha", { deps }); + } catch (err) { + threw = err as Error; + } finally { + exitSpy.mockRestore(); + } + expect(threw?.message).toBe("process.exit(1)"); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/Verdict:.*idle/); + expect(dump).toMatch(/Inbound delivery: paired but no inbound message observed/); + expect(dump).toMatch(/Bridge process: bridge process running/); + }); + + 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 = [ + "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: "" }), + }); + const result = await showSandboxChannelStatus("alpha", { deps }); + expect(result && "report" in result && result.report.verdict).toBe("healthy"); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/Verdict:.*healthy/); + }); + + it("returns probe_failed when openshell exec produces no marker", async () => { + 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" }), + }); + let threw: Error | null = null; + try { + await showSandboxChannelStatus("alpha", { deps }); + } catch (err) { + threw = err as Error; + } finally { + exitSpy.mockRestore(); + } + expect(threw?.message).toBe("process.exit(1)"); + }); + + 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 } = makeDeps({ + exec: () => null, + }); + let threw: Error | null = null; + try { + await showSandboxChannelStatus("alpha", { deps, asJson: true }); + } 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(); + }); + + 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 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 { + await showSandboxChannelStatus("alpha", { deps }); + } catch (err) { + threw = err as Error; + } finally { + exitSpy.mockRestore(); + } + 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"); + 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", + }); + try { + await showSandboxChannelStatus("alpha", { deps }); + } 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("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 }); + } 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 }); + 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. + 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 }); + } 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 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/); + }); + + it("skips the deep probe and reports paused state when WhatsApp is in disabledChannels", async () => { + // Regression guard: `channels stop whatsapp` deliberately drops the + // bridge and preset until the operator runs `channels start`. The + // status command should reflect that rather than probing a torn-down + // bridge and reporting failures. + const execSpy = vi.fn(() => ({ status: 0, stdout: "", stderr: "" })); + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["whatsapp"], ["whatsapp"]), + }); + deps.execSandbox = execSpy as unknown as typeof deps.execSandbox; + const result = await showSandboxChannelStatus("alpha", { deps }); + expect(execSpy).not.toHaveBeenCalled(); + expect(result && "verdict" in result && result.verdict).toBe("info"); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/registered but currently paused/); + }); + + it("emits a basic per-channel report for non-whatsapp channels", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["telegram"]), + appliedPresets: ["telegram"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "telegram", + }); + expect(result && "verdict" in result && result.verdict).toBe("info"); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/telegram registered/); + expect(dump).toMatch(/preset applied/); + }); +}); diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts new file mode 100644 index 00000000000..c9f410c9552 --- /dev/null +++ b/src/lib/actions/sandbox/channel-status.ts @@ -0,0 +1,583 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * `nemoclaw channels status [--channel ] [--json]` — + * surface bounded, channel-specific diagnostics so the operator can tell + * apart QR/session state, WebSocket state, inbound event delivery, and + * policy/config coverage. Issue #4386: a paired WhatsApp channel with a + * live Noise WebSocket and zero inbound events used to render as + * "healthy" because the existing `doctor` check only inspected the + * registry list. The diagnostic below has to fail loud for paired-but-idle. + */ + +import { loadAgent, type AgentDefinition } from "../../agent/defs"; +import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; +import { B, D, G, R, RD, YW } from "../../cli/terminal-style"; +import * as policies from "../../policy"; +import { + KNOWN_CHANNELS, + knownChannelNames, +} from "../../sandbox/channels"; +import { + evaluateWhatsappDiagnostics, + parseWhatsappHeartbeat, + summarizeWhatsappLogLines, + type DiagnosticSeverity, + type DiagnosticSignal, + type WhatsappDiagnosticReport, + type WhatsappHeartbeat, + type WhatsappProbeInput, +} from "../../sandbox/whatsapp-diagnostics"; +import * as registry from "../../state/registry"; + +// runner.ts (which process-recovery transitively depends on) uses a few CJS +// `require()` calls that vitest's CLI-test project cannot resolve at import +// time. The default in-sandbox exec implementation lives in this lazy loader +// so unit tests can inject an `execSandbox` mock without pulling the runner. +function loadProcessRecovery(): typeof import("./process-recovery") { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require("./process-recovery") as typeof import("./process-recovery"); +} + +// Inline single-quote shell quoting — the probe script only ever quotes +// trusted path strings derived from the agent manifest (`configDir/...`), +// so we don't need the full quoting matrix from `runner.shellQuote`. Keep +// the implementation tiny and avoid the runner import so the orchestrator +// stays loadable from unit tests. +function quotePath(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +type ExecRunner = (sandboxName: string, command: string, timeoutMs?: number) => { + status: number; + stdout: string; + stderr: string; +} | null; + +type StatusDeps = { + loadAgent?: (name: string) => AgentDefinition; + getSandbox?: typeof registry.getSandbox; + getAppliedPresets?: (sandboxName: string) => string[]; + getGatewayPresets?: (sandboxName: string) => string[] | null; + execSandbox?: ExecRunner; + now?: () => Date; + out?: (line: string) => void; +}; + +export type ChannelStatusOptions = { + channel?: string; + asJson?: boolean; + // When true the action returns the report instead of printing JSON to + // stdout. The oclif wrapper sets this so the framework's --json handler + // owns serialization; without it we would print JSON twice. + quietJson?: boolean; + deps?: StatusDeps; +}; + +export type ChannelStatusReport = + | { schemaVersion: 1; sandbox: string; channel: "whatsapp"; report: WhatsappDiagnosticReport } + | { + schemaVersion: 1; + sandbox: string; + channel: string; + verdict: "info"; + signals: DiagnosticSignal[]; + }; + +// 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 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": + return `${G}[ok]${R}`; + case "warn": + return `${YW}[warn]${R}`; + case "fail": + return `${RD}[fail]${R}`; + case "info": + default: + return `${D}[info]${R}`; + } +} + +function defaultExec( + sandboxName: string, + command: string, + timeoutMs?: number, +): { status: number; stdout: string; stderr: string } | null { + return loadProcessRecovery().executeSandboxExecCommand(sandboxName, command, timeoutMs); +} + +function defaultDeps(deps: StatusDeps | undefined): Required { + return { + loadAgent: deps?.loadAgent ?? loadAgent, + getSandbox: deps?.getSandbox ?? registry.getSandbox, + getAppliedPresets: deps?.getAppliedPresets ?? policies.getAppliedPresets, + getGatewayPresets: deps?.getGatewayPresets ?? policies.getGatewayPresets, + execSandbox: deps?.execSandbox ?? defaultExec, + now: deps?.now ?? (() => new Date()), + out: deps?.out ?? ((line: string) => console.log(line)), + }; +} + +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 = (entry?.messagingChannels ?? []).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, deps: Required): void { + if (asJson) { + deps.out(JSON.stringify(report, null, 2)); + return; + } + deps.out(""); + deps.out(` ${B}${CLI_DISPLAY_NAME} channels status:${R} ${report.sandbox} / ${report.channel}`); + if ("report" in report) { + deps.out(` Probed at ${report.report.probedAt} (agent: ${report.report.agent})`); + deps.out(""); + for (const signal of report.report.signals) { + deps.out(` ${severityLabel(signal.severity)} ${signal.label}: ${signal.detail}`); + if (signal.hint) deps.out(` ${D}hint: ${signal.hint}${R}`); + } + deps.out(""); + const verdictColor = + report.report.verdict === "healthy" + ? G + : report.report.verdict === "idle" || report.report.verdict === "unpaired" + ? YW + : RD; + deps.out(` Verdict: ${verdictColor}${report.report.verdict}${R}`); + for (const hint of report.report.hints) { + deps.out(` ${D}- ${hint}${R}`); + } + deps.out(""); + return; + } + for (const signal of report.signals) { + deps.out(` ${severityLabel(signal.severity)} ${signal.label}: ${signal.detail}`); + if (signal.hint) deps.out(` ${D}hint: ${signal.hint}${R}`); + } + deps.out(""); +} + +function exitCodeFor(report: ChannelStatusReport): number { + if ("report" in report) { + switch (report.report.verdict) { + case "healthy": + case "unknown": + return 0; + default: + return 1; + } + } + return 0; +} + +function buildBasicChannelReport( + sandboxName: string, + channelName: string, + agent: AgentDefinition, + deps: Required, +): ChannelStatusReport { + const entry = deps.getSandbox(sandboxName); + const enabled = (entry?.messagingChannels ?? []).includes(channelName); + const disabled = (entry?.disabledChannels ?? []).includes(channelName); + const appliedPresets = deps.getAppliedPresets(sandboxName); + const presetInRegistry = appliedPresets.includes(channelName); + const signals: DiagnosticSignal[] = []; + signals.push({ + label: "Channel registration", + severity: enabled ? (disabled ? "warn" : "ok") : "info", + detail: enabled + ? disabled + ? `${channelName} registered but currently paused` + : `${channelName} registered` + : `${channelName} not registered`, + hint: enabled + ? undefined + : `run \`${CLI_NAME} ${sandboxName} channels add ${channelName}\` to enable it`, + }); + signals.push({ + label: "Policy coverage", + severity: presetInRegistry ? "ok" : enabled ? "warn" : "info", + detail: presetInRegistry + ? `${channelName} preset applied` + : `${channelName} preset not applied`, + hint: presetInRegistry + ? undefined + : `run \`${CLI_NAME} ${sandboxName} policy-add ${channelName}\``, + }); + signals.push({ + label: "Deep diagnostics", + severity: "info", + detail: `not implemented for ${channelName}; see \`${CLI_NAME} ${sandboxName} doctor\` and \`${CLI_NAME} ${sandboxName} logs --follow\``, + }); + // Reference the agent in a hint so the deep-diagnostic section is + // discoverable per agent without needing extra plumbing. + if (!agent.messagingPlatforms.includes(channelName)) { + signals.unshift({ + label: "Agent support", + severity: "warn", + detail: `agent '${agent.name}' does not declare support for ${channelName}`, + }); + } + return { + schemaVersion: 1, + sandbox: sandboxName, + channel: channelName, + verdict: "info", + signals, + }; +} + +/** + * Run the WhatsApp diagnostic or a thin per-channel summary for the named + * sandbox. The function never throws: any unexpected condition is rendered + * as a `probe_failed` verdict so a paired-but-idle channel does not get + * silently marked healthy because a probe step blew up. + */ +export async function showSandboxChannelStatus( + sandboxName: string, + options: ChannelStatusOptions = {}, +): Promise { + const deps = defaultDeps(options.deps); + const channelArg = options.channel?.trim().toLowerCase(); + const asJson = Boolean(options.asJson); + const quietJson = Boolean(options.quietJson); + + const entry = deps.getSandbox(sandboxName); + if (!entry) { + if (asJson) { + deps.out( + JSON.stringify( + { schemaVersion: 1, sandbox: sandboxName, error: "sandbox not registered" }, + null, + 2, + ), + ); + } else { + deps.out(` Sandbox '${sandboxName}' is not registered.`); + } + process.exit(1); + } + + let channelName = channelArg; + if (!channelName) { + const enabled = (entry.messagingChannels ?? []).filter( + (name: string) => name === "whatsapp", + ); + if (enabled.length > 0) { + channelName = "whatsapp"; + } else if ((entry.messagingChannels ?? []).length > 0) { + channelName = entry.messagingChannels?.[0]; + } else { + channelName = "whatsapp"; + } + } + + if (!channelName || !knownChannelNames().includes(channelName)) { + const known = knownChannelNames().join(", "); + if (asJson) { + deps.out( + JSON.stringify( + { schemaVersion: 1, sandbox: sandboxName, error: `unknown channel '${channelName}'` }, + null, + 2, + ), + ); + } else { + deps.out(` Unknown channel '${channelName}'. Valid channels: ${known}.`); + } + process.exit(1); + } + + const agent = deps.loadAgent(entry.agent || "openclaw"); + + const disabledChannels = new Set(entry.disabledChannels ?? []); + const channelIsPaused = disabledChannels.has(channelName); + + let report: ChannelStatusReport; + if (channelName === "whatsapp" && channelIsPaused) { + // The operator stopped this channel with `channels stop whatsapp`; the + // bridge and policy are intentionally absent after the rebuild. Skip + // the deep probe so the diagnostic does not flag the deliberate gap as + // an unhealthy bridge. The non-WhatsApp path already covers paused + // channels via buildBasicChannelReport, so route through it. + report = buildBasicChannelReport(sandboxName, channelName, agent, deps); + } else if (channelName === "whatsapp") { + const input = buildWhatsappProbeInput(sandboxName, agent, deps); + const whatsappReport = evaluateWhatsappDiagnostics(input); + report = { + schemaVersion: 1, + sandbox: sandboxName, + channel: "whatsapp", + report: whatsappReport, + }; + } else { + if (!KNOWN_CHANNELS[channelName]) { + // Defensive — already validated above, but keeps type narrowing happy. + report = buildBasicChannelReport(sandboxName, channelName, agent, deps); + } else { + report = buildBasicChannelReport(sandboxName, channelName, agent, deps); + } + } + + if (!(asJson && quietJson)) { + renderReport(report, asJson, deps); + } + + const code = exitCodeFor(report); + if (asJson) return report; + if (code !== 0) process.exit(code); + return report; +} diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index 03c70d34efb..48d4167974a 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -378,6 +378,20 @@ function messagingDoctorCheck(sandboxName: string, sb: SandboxEntry): DoctorChec const pausedSuffix = pausedChannels.length > 0 ? `; paused channels skipped: ${pausedChannels.join(", ")}` : ""; if (degraded.length === 0) { + // WhatsApp's inbound delivery cannot be inferred from the conflict-signature + // heuristic — issue #4386 showed a paired channel with a live Noise + // WebSocket that never delivered inbound events, while this check rendered + // "ok". Downgrade to "info" with a pointer to `channels status` so doctor + // never claims WhatsApp is healthy without running the deep probe. + if (channels.includes("whatsapp")) { + return { + group: "Messaging", + label: "Channels", + status: "info", + detail: `${channels.join(", ")} enabled; whatsapp inbound delivery is not inferred from conflict signatures${pausedSuffix}`, + hint: `run \`${CLI_NAME} ${sandboxName} channels status --channel whatsapp\` to probe inbound delivery`, + }; + } return { group: "Messaging", label: "Channels", diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 982e42bbae1..d88c9d38b49 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -801,6 +801,12 @@ export async function addSandboxChannel( console.log( ` ${G}✓${R} Enabled ${canonical} channel. Complete QR pairing from inside the sandbox after rebuild.`, ); + // Show post-pair guidance (e.g. the channels status hint for WhatsApp) + // here because the in-sandbox QR branch returns before the shared note + // loop the non-QR branches use. + for (const line of channel.setupNotes ?? []) { + console.log(` ${line}`); + } const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`); if (rebuilt) verifyChannelBridgeAfterRebuild(sandboxName, canonical); return; diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index 275523be1a2..4c6b5484460 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -17,10 +17,10 @@ import { getRegisteredOclifCommandsMetadata } from "./oclif-metadata"; describe("command-registry", () => { describe("COMMANDS array", () => { - it("should contain exactly 62 commands", () => { + it("should contain exactly 63 commands", () => { // 28 global (22 visible + 6 hidden help/version aliases) - // 34 sandbox (28 visible + 6 hidden shields/config) - expect(COMMANDS).toHaveLength(62); + // 35 sandbox (29 visible + 6 hidden shields/config) + expect(COMMANDS).toHaveLength(63); }); it("should have no duplicate usage strings", () => { @@ -52,9 +52,9 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 34 entries", () => { - // 28 visible + 6 hidden (shields×3 + config get/set/rotate-token) - expect(sandboxCommands()).toHaveLength(34); + it("should return exactly 35 entries", () => { + // 29 visible + 6 hidden (shields×3 + config get/set/rotate-token) + expect(sandboxCommands()).toHaveLength(35); }); it("every entry has scope sandbox", () => { @@ -65,10 +65,10 @@ describe("command-registry", () => { }); describe("visibleCommands()", () => { - it("should exclude 12 hidden commands (50 visible)", () => { + it("should exclude 12 hidden commands (51 visible)", () => { // 6 hidden global (help, --help, -h, version, --version, -v) + // 6 hidden sandbox (shields×3, config get/set/rotate-token) - expect(visibleCommands()).toHaveLength(50); + expect(visibleCommands()).toHaveLength(51); }); it("no visible command has hidden=true", () => { diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index f1b77b50d2a..c9ff085db07 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -177,6 +177,15 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { "flags": "[--dry-run]" } ], + "sandbox:channels:status": [ + { + "group": "Messaging Channels", + "order": 25, + "usage": "nemoclaw channels status", + "description": "Channel-specific runtime diagnostics", + "flags": "[--channel ] [--json]" + } + ], "sandbox:config:get": [ { "group": "Sandbox Management", diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index f846cdf0630..19744858876 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -73,6 +73,12 @@ export async function setupSelectedMessagingChannels( console.log( ` ✓ ${ch.name} enabled — complete QR pairing from inside the sandbox after rebuild.`, ); + // Surface the post-pair diagnostic hint here too — in-sandbox-qr + // channels skipped the shared setupNotes block below by `continue`, + // so users would never see the `channels status` guidance otherwise. + for (const line of ch.setupNotes ?? []) { + console.log(` ${line}`); + } continue; } else { if (!channelHasStaticToken(ch)) continue; diff --git a/src/lib/sandbox/channels.ts b/src/lib/sandbox/channels.ts index 56350c876c2..b41a40c5956 100644 --- a/src/lib/sandbox/channels.ts +++ b/src/lib/sandbox/channels.ts @@ -131,6 +131,9 @@ export const KNOWN_CHANNELS: Record = { help: "WhatsApp Web pairs via QR code scanned with your phone — no host-side token. After the sandbox is running, run `openshell term` and then use `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes to display the QR.", label: "WhatsApp", loginMethod: "in-sandbox-qr", + setupNotes: [ + "After pairing, run `nemoclaw channels status --channel whatsapp` to confirm the bridge is delivering inbound messages — pairing alone does not guarantee inbound delivery (issue #4386).", + ], }, }; diff --git a/src/lib/sandbox/whatsapp-diagnostics.test.ts b/src/lib/sandbox/whatsapp-diagnostics.test.ts new file mode 100644 index 00000000000..6958819aef8 --- /dev/null +++ b/src/lib/sandbox/whatsapp-diagnostics.test.ts @@ -0,0 +1,396 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + evaluateWhatsappDiagnostics, + parseWhatsappHeartbeat, + summarizeWhatsappLogLines, + type WhatsappProbeInput, +} from "./whatsapp-diagnostics"; + +const PROBED_AT = "2026-05-28T04:00:00.000Z"; + +function baseInput(overrides: Partial = {}): WhatsappProbeInput { + return { + agent: "openclaw", + stateDirs: ["/sandbox/.openclaw/whatsapp"], + stateDirPopulated: true, + heartbeat: null, + heartbeatParseError: null, + bridgeProcessAlive: true, + recentLogSignals: [], + probeReachable: true, + probedAt: PROBED_AT, + presetInRegistry: true, + presetOnGateway: true, + channelEnabledInRegistry: true, + ...overrides, + }; +} + +describe("evaluateWhatsappDiagnostics", () => { + it("returns probe_failed when the sandbox cannot be reached", () => { + const report = evaluateWhatsappDiagnostics( + baseInput({ + probeReachable: false, + stateDirPopulated: null, + bridgeProcessAlive: null, + presetOnGateway: null, + }), + ); + expect(report.verdict).toBe("probe_failed"); + expect(report.signals.find((s) => s.label === "Pairing / session")?.severity).toBe("info"); + expect(report.hints[0]).toMatch(/Start the sandbox/); + }); + + it("returns config_gap when the channel is not registered for the sandbox", () => { + const report = evaluateWhatsappDiagnostics(baseInput({ channelEnabledInRegistry: false })); + expect(report.verdict).toBe("config_gap"); + expect( + report.signals.find((s) => s.label === "Channel registration")?.severity, + ).toBe("fail"); + expect(report.hints[0]).toMatch(/channels add whatsapp/); + }); + + it("returns policy_gap when the whatsapp preset is missing", () => { + const report = evaluateWhatsappDiagnostics( + baseInput({ presetInRegistry: false, presetOnGateway: false }), + ); + expect(report.verdict).toBe("policy_gap"); + const policy = report.signals.find((s) => s.label === "Policy coverage"); + expect(policy?.severity).toBe("fail"); + expect(policy?.detail).toMatch(/preset is not applied/); + }); + + it("returns unpaired when the bridge state directory is empty", () => { + const report = evaluateWhatsappDiagnostics(baseInput({ stateDirPopulated: false })); + expect(report.verdict).toBe("unpaired"); + const pairing = report.signals.find((s) => s.label === "Pairing / session"); + expect(pairing?.severity).toBe("warn"); + 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 pairing = report.signals.find((s) => s.label === "Pairing / session"); + expect(pairing?.hint).toMatch(/hermes whatsapp/); + expect(report.hints.join(" ")).toMatch(/hermes whatsapp/); + }); + + it("returns idle when paired with a live WebSocket but no inbound event observed", () => { + // This is the exact #4386 shape: pairing is fine, WebSocket is up, but + // lastInboundAt is still null. We MUST NOT report this as healthy. + const report = evaluateWhatsappDiagnostics( + baseInput({ + heartbeat: { + lastInboundAt: null, + messagesHandled: 0, + connectionState: "open", + noteCategory: null, + }, + }), + ); + expect(report.verdict).toBe("idle"); + const inbound = report.signals.find((s) => s.label === "Inbound delivery"); + expect(inbound?.severity).toBe("warn"); + expect(inbound?.detail).toMatch(/no inbound message observed/); + expect(inbound?.detail).toMatch(/messagesHandled=0/); + }); + + it("returns healthy when paired and a recent inbound event is present", () => { + const report = evaluateWhatsappDiagnostics( + baseInput({ + heartbeat: { + lastInboundAt: "2026-05-28T03:59:30.000Z", + messagesHandled: 5, + connectionState: "open", + noteCategory: null, + }, + }), + ); + expect(report.verdict).toBe("healthy"); + const inbound = report.signals.find((s) => s.label === "Inbound delivery"); + expect(inbound?.severity).toBe("ok"); + expect(inbound?.detail).toMatch(/messagesHandled=5/); + }); + + it("downgrades a stale-but-present inbound timestamp to info", () => { + const report = evaluateWhatsappDiagnostics( + baseInput({ + heartbeat: { + lastInboundAt: "2026-05-28T03:00:00.000Z", + messagesHandled: 12, + connectionState: "open", + noteCategory: null, + }, + }), + ); + const inbound = report.signals.find((s) => s.label === "Inbound delivery"); + expect(inbound?.severity).toBe("info"); + expect(inbound?.detail).toMatch(/60m ago/); + }); + + it("treats heartbeat parse errors as a warn signal without claiming healthy", () => { + const report = evaluateWhatsappDiagnostics( + baseInput({ + heartbeat: null, + heartbeatParseError: "Unexpected token", + }), + ); + expect(report.verdict).not.toBe("healthy"); + const ws = report.signals.find((s) => s.label === "Noise WebSocket"); + expect(ws?.severity).toBe("warn"); + expect(ws?.detail).toMatch(/unparseable/); + }); + + it("reports fail when no bridge process is running and no heartbeat is present", () => { + const report = evaluateWhatsappDiagnostics( + baseInput({ heartbeat: null, bridgeProcessAlive: false }), + ); + const ws = report.signals.find((s) => s.label === "Noise WebSocket"); + expect(ws?.severity).toBe("fail"); + const proc = report.signals.find((s) => s.label === "Bridge process"); + expect(proc?.severity).toBe("fail"); + expect(report.verdict).toBe("idle"); + }); + + it("does not return healthy when the heartbeat is recent but the bridge process is missing", () => { + // The #4386 shape: a paired sandbox can leave a stale heartbeat on disk + // claiming connection.open + recent inbound while the actual bridge + // process has died. Never let that combination render as healthy. + const report = evaluateWhatsappDiagnostics( + baseInput({ + heartbeat: { + lastInboundAt: "2026-05-28T03:59:30.000Z", + messagesHandled: 4, + connectionState: "open", + noteCategory: null, + }, + bridgeProcessAlive: false, + }), + ); + const proc = report.signals.find((s) => s.label === "Bridge process"); + expect(proc?.severity).toBe("fail"); + expect(report.verdict).not.toBe("healthy"); + expect(report.verdict).toBe("idle"); + }); + + it("warns when the preset is recorded locally but missing from the gateway", () => { + const report = evaluateWhatsappDiagnostics( + baseInput({ presetInRegistry: true, presetOnGateway: false }), + ); + const policy = report.signals.find((s) => s.label === "Policy coverage"); + expect(policy?.severity).toBe("fail"); + expect(policy?.detail).toMatch(/missing from the gateway/); + }); + + it("does not downgrade a healthy heartbeat when pgrep could not enumerate the bridge", () => { + // Regression guard: when the WhatsApp adapter runs inside the parent + // gateway process (no `whatsapp`/`baileys` substring in argv), the + // probe cannot enumerate it and reports `bridgeProcessAlive: null`. + // The diagnostic must keep the verdict healthy as long as the + // heartbeat shows recent inbound + an open WebSocket. + const report = evaluateWhatsappDiagnostics( + baseInput({ + bridgeProcessAlive: null, + heartbeat: { + lastInboundAt: "2026-05-28T03:59:30.000Z", + messagesHandled: 6, + connectionState: "open", + noteCategory: null, + }, + }), + ); + expect(report.verdict).toBe("healthy"); + const proc = report.signals.find((s) => s.label === "Bridge process"); + expect(proc?.severity).toBe("info"); + }); + + it("treats a missing local preset as fail even when the gateway is unreachable", () => { + const report = evaluateWhatsappDiagnostics( + baseInput({ presetInRegistry: false, presetOnGateway: null }), + ); + const policy = report.signals.find((s) => s.label === "Policy coverage"); + expect(policy?.severity).toBe("fail"); + expect(report.verdict).toBe("policy_gap"); + }); + + it("does not return healthy when lastInboundAt is unparseable text and counters are absent", () => { + // Regression guard: an earlier draft accepted any non-null string as + // delivery evidence, so a heartbeat that wrote a free-form + // `lastInboundAt` would render as healthy and leak the raw string in + // the rendered detail. The diagnostic must instead fall back to idle. + const report = evaluateWhatsappDiagnostics( + baseInput({ + heartbeat: { + lastInboundAt: "never", + messagesHandled: null, + connectionState: "open", + noteCategory: null, + }, + }), + ); + const inbound = report.signals.find((s) => s.label === "Inbound delivery"); + expect(inbound?.severity).toBe("warn"); + expect(inbound?.detail).not.toMatch(/never/); + expect(report.verdict).toBe("idle"); + }); + + it("does not mark inbound as 'no message observed' when messagesHandled>0 without lastInboundAt", () => { + // Some bridge builds publish the counter without a timestamp. That + // already proves inbound delivery, so the verdict should not fall back + // to idle just because the timestamp field is missing. + const report = evaluateWhatsappDiagnostics( + baseInput({ + heartbeat: { + lastInboundAt: null, + messagesHandled: 3, + connectionState: "open", + noteCategory: null, + }, + }), + ); + const inbound = report.signals.find((s) => s.label === "Inbound delivery"); + expect(inbound?.severity).toBe("info"); + expect(inbound?.detail).toMatch(/messagesHandled=3/); + expect(report.verdict).toBe("healthy"); + }); + + it("includes a recent-log-signals row when any are observed", () => { + const report = evaluateWhatsappDiagnostics( + baseInput({ + recentLogSignals: ["connection.open", "401 unauthorized"], + }), + ); + const logs = report.signals.find((s) => s.label === "Recent log signals"); + expect(logs?.detail).toContain("connection.open"); + expect(logs?.detail).toContain("401 unauthorized"); + }); +}); + +describe("parseWhatsappHeartbeat", () => { + it("extracts canonical field names", () => { + const result = parseWhatsappHeartbeat( + JSON.stringify({ + lastInboundAt: "2026-05-28T03:59:00.000Z", + messagesHandled: 7, + connectionState: "open", + }), + ); + expect(result).toEqual({ + heartbeat: { + lastInboundAt: "2026-05-28T03:59:00.000Z", + messagesHandled: 7, + connectionState: "open", + noteCategory: null, + }, + }); + }); + + it("redacts free-text error fields to a category and accepts snake_case keys", () => { + const result = parseWhatsappHeartbeat( + JSON.stringify({ + last_inbound_at: "2026-05-28T03:50:00.000Z", + inbound_count: "3", + wsState: "connecting", + lastError: "401 unauthorized for +14155551212", + }), + ); + expect(result).toEqual({ + heartbeat: { + lastInboundAt: "2026-05-28T03:50:00.000Z", + messagesHandled: 3, + connectionState: "connecting", + noteCategory: "unauthorized", + }, + }); + }); + + it("collapses unknown connection-state strings to 'other' so bridge text never leaks", () => { + const result = parseWhatsappHeartbeat( + JSON.stringify({ + connectionState: "hi +14155551212 message body 123", + lastError: "rate limit exceeded", + }), + ); + expect( + "heartbeat" in result && + result.heartbeat.connectionState === "other" && + result.heartbeat.noteCategory === "rate-limited", + ).toBe(true); + }); + + it("returns a fixed parseError when the input is not valid JSON", () => { + // The parser must never echo Node's `JSON.parse` error message because + // it can include a snippet of the offending input (which may carry + // phone numbers or message bodies for a corrupt heartbeat). + const result = parseWhatsappHeartbeat("{not json with +14155551212 inside"); + expect(result).toEqual({ parseError: "heartbeat is not valid JSON" }); + }); + + it("drops a non-timestamp lastInboundAt so it never reaches the JSON report", () => { + const result = parseWhatsappHeartbeat( + JSON.stringify({ lastInboundAt: "hi +14155551212 message body", messagesHandled: 0 }), + ); + expect( + "heartbeat" in result && + result.heartbeat.lastInboundAt === null && + result.heartbeat.messagesHandled === 0, + ).toBe(true); + }); + + it("rejects loose Date.parse-compatible timestamps that are not strict ISO 8601", () => { + // Regression guard: `Date.parse` accepts values like a bare integer or + // `Date.toString()` output with parenthesized text. Treating those as + // valid timestamps would (a) leak the raw string into the JSON report + // and (b) mark malformed heartbeat text as healthy inbound evidence. + for (const bad of [ + "42", + "Wed May 28 2026 04:00:00 GMT+0000 (Coordinated Universal Time)", + "2026/05/28 04:00:00", + "not a date", + ]) { + const result = parseWhatsappHeartbeat(JSON.stringify({ lastInboundAt: bad })); + expect( + "heartbeat" in result && result.heartbeat.lastInboundAt, + `expected ${JSON.stringify(bad)} to be rejected`, + ).toBeNull(); + } + }); + + it("normalizes accepted timestamps to canonical ISO form", () => { + const result = parseWhatsappHeartbeat( + JSON.stringify({ lastInboundAt: "2026-05-28T03:59:30+00:00" }), + ); + expect( + "heartbeat" in result && result.heartbeat.lastInboundAt, + ).toBe("2026-05-28T03:59:30.000Z"); + }); + + it("returns parseError when the heartbeat is not an object", () => { + const result = parseWhatsappHeartbeat("[]"); + expect(result).toEqual({ parseError: "heartbeat JSON must be an object" }); + }); +}); + +describe("summarizeWhatsappLogLines", () => { + it("returns deduped summary phrases, dropping unrelated lines", () => { + const summaries = summarizeWhatsappLogLines([ + "2026-05-28 ws open", + "2026-05-28 routine event", + "2026-05-28 connection.open ack", + "2026-05-28 unauthorized: 401", + "2026-05-28 qr expired (retry)", + ]); + expect(summaries).toEqual(["connection.open", "401 unauthorized", "qr expired"]); + }); + + it("returns an empty list when nothing matches", () => { + const summaries = summarizeWhatsappLogLines(["2026-05-28 normal traffic"]); + expect(summaries).toEqual([]); + }); +}); diff --git a/src/lib/sandbox/whatsapp-diagnostics.ts b/src/lib/sandbox/whatsapp-diagnostics.ts new file mode 100644 index 00000000000..9db60fc66ef --- /dev/null +++ b/src/lib/sandbox/whatsapp-diagnostics.ts @@ -0,0 +1,614 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * 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. + */ + +export type DiagnosticSeverity = "ok" | "warn" | "fail" | "info"; + +export type DiagnosticSignal = { + label: string; + severity: DiagnosticSeverity; + detail: string; + hint?: string; +}; + +export type WhatsappVerdict = + | "healthy" + | "idle" + | "unpaired" + | "policy_gap" + | "config_gap" + | "unknown" + | "probe_failed"; + +export type WhatsappHeartbeat = { + // ISO 8601 timestamp of the most recent inbound event observed by the + // bridge, or null when the bridge has never reported one. We do not parse + // message content — only timestamps and counters — to avoid pulling + // personal data into the host diagnostic output. + lastInboundAt: string | null; + // Cumulative count of inbound messages handled. Optional because not every + // bridge build emits it; absence is rendered as "not reported". + messagesHandled: number | null; + // Optional connection state string (e.g. "open", "connecting", "close"). + // The parser whitelists short, known token shapes only; arbitrary text + // from bridge `state` fields is dropped to avoid leaking message bodies + // or phone numbers into the host diagnostic output. + connectionState: string | null; + // Sanitized one-word category derived from any bridge `lastError`/`note` + // field. We never copy the raw error string — bridges have been observed + // to embed phone numbers, message snippets, and even tokens in these + // fields. Possible values: "unauthorized", "connection-closed", + // "rate-limited", "logged-out", "other", or null. + noteCategory: string | null; +}; + +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; + // 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; + heartbeatParseError: string | null; + // True when at least one bridge process (Baileys, openclaw-whatsapp, + // hermes whatsapp adapter) was observed running. Null on probe failure. + bridgeProcessAlive: boolean | null; + // Snippets of recent bridge log output that mention well-known signals + // (connection.open, 401 unauthorized, qr expired). The diagnostic never + // surfaces raw message bodies — only short matched lines. + recentLogSignals: readonly string[]; + // Whether the orchestrator could run `openshell sandbox exec` at all. + probeReachable: boolean; + // ISO timestamp captured by the orchestrator when the probe ran. The + // diagnostic uses it to compute "minutes since last inbound" without + // depending on the system clock. + probedAt: string; + // Whether the whatsapp preset is recorded in the sandbox registry. + presetInRegistry: boolean; + // Whether the whatsapp preset's network policy is loaded on the gateway, + // or null when the gateway could not be reached. + presetOnGateway: boolean | null; + // Whether the whatsapp channel is recorded in the registry's + // messagingChannels list. + channelEnabledInRegistry: boolean; +}; + +export type WhatsappDiagnosticReport = { + schemaVersion: 1; + 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. +const NO_INBOUND_WARN_MINUTES = 5; + +function minutesSince(iso: string | null, probedAt: string): number | null { + if (!iso) return null; + const last = Date.parse(iso); + const now = Date.parse(probedAt); + if (!Number.isFinite(last) || !Number.isFinite(now)) return null; + if (now < last) return 0; + return Math.floor((now - last) / 60_000); +} + +// Bridges have shipped builds that wrote free-form strings into +// `lastInboundAt`. Treat any non-date value as "no inbound observed" so +// the diagnostic neither prints the raw string nor declares healthy on +// the strength of an unparseable timestamp. +function isParseableTimestamp(value: string | null): value is string { + return value !== null && Number.isFinite(Date.parse(value)); +} + +function pairingSignal(input: WhatsappProbeInput): DiagnosticSignal { + if (!input.probeReachable) { + return { + label: "Pairing / session", + severity: "info", + detail: "could not reach sandbox to inspect WhatsApp session state", + hint: "start the sandbox before re-running channels status", + }; + } + if (input.stateDirPopulated === null) { + return { + label: "Pairing / session", + severity: "info", + detail: "session state directory probe did not complete", + }; + } + if (input.stateDirPopulated === false) { + const loginHint = + input.agent === "hermes" + ? "run `hermes whatsapp` inside the sandbox to display a QR code" + : "run `openclaw channels login --channel whatsapp` inside the sandbox to display a QR code"; + return { + label: "Pairing / session", + severity: "warn", + detail: "no WhatsApp session state in the sandbox — never paired or session cleared", + hint: loginHint, + }; + } + return { + label: "Pairing / session", + severity: "ok", + detail: `paired (session state present at ${input.stateDirs.join(", ") || "agent state dir"})`, + }; +} + +function websocketSignal(input: WhatsappProbeInput): DiagnosticSignal { + if (input.heartbeatParseError) { + return { + label: "Noise WebSocket", + severity: "warn", + detail: `heartbeat file present but unparseable: ${input.heartbeatParseError}`, + hint: "rebuild the sandbox if the heartbeat format is stale", + }; + } + const state = input.heartbeat?.connectionState ?? null; + if (!state) { + if (input.bridgeProcessAlive === false) { + return { + label: "Noise WebSocket", + severity: "fail", + detail: "no bridge process and no heartbeat — WhatsApp Web is not connected", + hint: "check `nemoclaw logs --follow` for bridge startup errors", + }; + } + return { + label: "Noise WebSocket", + severity: "info", + detail: "connection state not reported by the bridge", + }; + } + const normalized = state.toLowerCase(); + if (normalized === "open" || normalized === "connected") { + return { + label: "Noise WebSocket", + severity: "ok", + detail: `connection state: ${state}`, + }; + } + if (normalized === "connecting" || normalized === "reconnecting") { + return { + label: "Noise WebSocket", + severity: "warn", + detail: `connection state: ${state}`, + hint: "re-run channels status in a minute; if it stays connecting, restart the bridge", + }; + } + return { + label: "Noise WebSocket", + severity: "fail", + detail: `connection state: ${state}`, + hint: "check `nemoclaw logs --follow` and re-pair if WhatsApp Web kicked the session", + }; +} + +function inboundSignal(input: WhatsappProbeInput): DiagnosticSignal { + const hb = input.heartbeat; + if (!hb) { + if (input.stateDirPopulated === true && input.bridgeProcessAlive !== false) { + return { + label: "Inbound delivery", + severity: "warn", + detail: "bridge has not published a heartbeat — inbound delivery is not observable", + hint: + "send a test message to the bot from a paired phone; if `lastInboundAt` stays null, the bridge is not subscribed to inbound events", + }; + } + return { + label: "Inbound delivery", + severity: "info", + detail: "no heartbeat available", + }; + } + const lastInbound = isParseableTimestamp(hb.lastInboundAt) ? hb.lastInboundAt : null; + if (lastInbound === null) { + // A bridge that publishes the counter without a timestamp still proves + // some inbound traffic has reached the handler — surface that as + // information rather than as a "no inbound" warning that would gate the + // overall verdict on a non-existent timestamp. + if (hb.messagesHandled !== null && hb.messagesHandled > 0) { + return { + label: "Inbound delivery", + severity: "info", + detail: `lastInboundAt not reported by the bridge (messagesHandled=${hb.messagesHandled})`, + }; + } + return { + label: "Inbound delivery", + severity: "warn", + detail: + hb.messagesHandled !== null + ? `paired but no inbound message observed (messagesHandled=${hb.messagesHandled})` + : "paired but no inbound message observed (lastInboundAt is null)", + hint: + "send a test message to the bot from a paired phone, then re-run; if it stays null, restart the bridge and re-check logs", + }; + } + const stale = minutesSince(lastInbound, input.probedAt); + const note = hb.messagesHandled !== null ? ` (messagesHandled=${hb.messagesHandled})` : ""; + if (stale !== null && stale > NO_INBOUND_WARN_MINUTES) { + return { + label: "Inbound delivery", + severity: "info", + detail: `last inbound ${stale}m ago at ${lastInbound}${note}`, + }; + } + return { + label: "Inbound delivery", + severity: "ok", + detail: `last inbound at ${lastInbound}${note}`, + }; +} + +function policyCoverageSignal(input: WhatsappProbeInput): DiagnosticSignal { + if (input.presetOnGateway === false && input.presetInRegistry) { + return { + label: "Policy coverage", + severity: "fail", + detail: "whatsapp preset recorded locally but missing from the gateway policy", + hint: "rebuild the sandbox so the preset is reapplied to the OpenShell gateway", + }; + } + if (!input.presetInRegistry) { + // A missing local preset is a deterministic gap regardless of gateway + // reachability — the next rebuild will not reapply WhatsApp egress and + // the channel will eventually fail closed. Treat it as a fail so the + // verdict short-circuits into "policy_gap" even when a stale heartbeat + // would otherwise suggest healthy inbound delivery. + return { + label: "Policy coverage", + severity: "fail", + detail: "whatsapp preset is not applied to the sandbox", + hint: "run `nemoclaw policy-add whatsapp` and rebuild the sandbox", + }; + } + if (input.presetOnGateway === null) { + return { + label: "Policy coverage", + severity: "info", + detail: "whatsapp preset recorded locally; gateway is unreachable for cross-check", + }; + } + return { + label: "Policy coverage", + severity: "ok", + detail: "whatsapp preset applied and loaded on the gateway", + }; +} + +function configCoverageSignal(input: WhatsappProbeInput): DiagnosticSignal { + if (!input.channelEnabledInRegistry) { + return { + label: "Channel registration", + severity: "fail", + detail: "whatsapp is not in the sandbox messagingChannels list", + hint: "run `nemoclaw channels add whatsapp` before pairing", + }; + } + return { + label: "Channel registration", + severity: "ok", + detail: "whatsapp channel registered for the sandbox", + }; +} + +function logSignals(input: WhatsappProbeInput): DiagnosticSignal | null { + if (!input.recentLogSignals || input.recentLogSignals.length === 0) return null; + return { + label: "Recent log signals", + severity: "info", + detail: input.recentLogSignals.slice(0, 4).join("; "), + }; +} + +function bridgeProcessSignal(input: WhatsappProbeInput): DiagnosticSignal { + if (input.bridgeProcessAlive === null) { + // pgrep never ran or its output never reached the parser — almost + // always a probe timeout. Treat as info so a non-bridge probe failure + // does not gate the verdict on this signal. + return { + label: "Bridge process", + severity: "info", + detail: "could not enumerate sandbox processes", + }; + } + if (input.bridgeProcessAlive === false) { + // pgrep completed and matched neither `whatsapp`, `baileys`, nor any + // WhatsApp state-dir path. Either the bridge crashed after leaving a + // heartbeat behind, or it runs under a process name the pattern + // cannot catch. Fail loud either way — a recent heartbeat on disk is + // not, on its own, proof the bridge is still running. + return { + label: "Bridge process", + severity: "fail", + detail: "no WhatsApp bridge process observed", + hint: "check `nemoclaw logs --follow` for startup errors", + }; + } + return { + label: "Bridge process", + severity: "ok", + detail: "bridge process running", + }; +} + +function pickVerdict(signals: DiagnosticSignal[], input: WhatsappProbeInput): WhatsappVerdict { + if (!input.probeReachable) return "probe_failed"; + if (signals.some((s) => s.label === "Channel registration" && s.severity === "fail")) { + return "config_gap"; + } + if (signals.some((s) => s.label === "Policy coverage" && s.severity === "fail")) { + return "policy_gap"; + } + if (input.stateDirPopulated === false) return "unpaired"; + const hb = input.heartbeat; + const hasInboundEvidence = + !!hb && + (isParseableTimestamp(hb.lastInboundAt) || + (hb.messagesHandled !== null && hb.messagesHandled > 0)); + if (hb && !hasInboundEvidence) return "idle"; + if (!hb) { + return input.stateDirPopulated === true ? "idle" : "unknown"; + } + if (signals.some((s) => s.label === "Noise WebSocket" && s.severity === "fail")) { + return "idle"; + } + // A heartbeat that claims a recent inbound is not enough to declare + // healthy when any signal (bridge process, log, policy cross-check) is + // still failing — that combination is exactly the #4386 shape where the + // recorded heartbeat is stale relative to the live bridge state. Fall + // back to "idle" so the exit code stays non-zero and the operator sees + // the failing signal. + if (signals.some((s) => s.severity === "fail")) return "idle"; + return "healthy"; +} + +function buildHints(verdict: WhatsappVerdict, input: WhatsappProbeInput): string[] { + const hints: string[] = []; + switch (verdict) { + case "healthy": + hints.push( + "If the agent stops responding, re-run channels status — `lastInboundAt` going stale is the first NemoClaw-visible symptom.", + ); + break; + case "idle": + hints.push( + "Paired channel but no inbound event was observed. Send a test message from a paired phone and re-run.", + "If inbound stays unreported, restart the bridge and re-check `nemoclaw logs --follow` for `401`, `getMessage`, or `connection.update` warnings.", + ); + break; + case "unpaired": + hints.push( + input.agent === "hermes" + ? "Run `hermes whatsapp` inside the sandbox and scan the QR with your phone." + : "Run `openclaw channels login --channel whatsapp` inside the sandbox and scan the QR with your phone.", + ); + break; + case "policy_gap": + hints.push( + "WhatsApp Web requires a raw L4 CONNECT tunnel for `web.whatsapp.com` — install the preset, then rebuild.", + ); + break; + case "config_gap": + hints.push("Run `nemoclaw channels add whatsapp` to enable the channel."); + break; + case "probe_failed": + hints.push( + "Start the sandbox and verify the OpenShell gateway is healthy, then re-run channels status.", + ); + break; + case "unknown": + hints.push( + "Diagnostic evidence was insufficient. Try rebuilding the sandbox, then re-run channels status after pairing.", + ); + break; + } + return hints; +} + +export function evaluateWhatsappDiagnostics(input: WhatsappProbeInput): WhatsappDiagnosticReport { + const signals: DiagnosticSignal[] = [ + configCoverageSignal(input), + pairingSignal(input), + bridgeProcessSignal(input), + websocketSignal(input), + inboundSignal(input), + policyCoverageSignal(input), + ]; + const logs = logSignals(input); + if (logs) signals.push(logs); + + const verdict = pickVerdict(signals, input); + return { + schemaVersion: 1, + channel: "whatsapp", + agent: input.agent, + verdict, + probedAt: input.probedAt, + signals, + heartbeat: input.heartbeat, + hints: buildHints(verdict, input), + }; +} + +// Heartbeat shape varies across bridges; accept any of the documented field +// names without re-keying so additional bridges only need to teach this +// function their alias. +type RawHeartbeat = Record; + +function readString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function readNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +// Connection-state values we are willing to surface to the host +// diagnostic. Anything else (including suspiciously long strings) is +// reduced to "other" so bridges that pack arbitrary text into these +// fields cannot leak it. +const SAFE_CONNECTION_STATES = new Set([ + "open", + "connected", + "connecting", + "reconnecting", + "close", + "closed", + "closing", + "logging_out", + "logged_out", + "starting", + "stopped", + "unknown", +]); + +function sanitizeConnectionState(value: string | null): string | null { + if (!value) return null; + const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_"); + if (normalized.length > 32) return "other"; + return SAFE_CONNECTION_STATES.has(normalized) ? normalized : "other"; +} + +// Categorize the bridge's free-text error/note field without copying its +// contents. The goal is to surface enough signal for an operator to +// recognize the failure mode while never forwarding strings that could +// contain phone numbers, message bodies, or tokens. +function categorizeNote(value: string | null): string | null { + if (!value) return null; + const normalized = value.toLowerCase(); + if (/(401|unauthor)/.test(normalized)) return "unauthorized"; + if (/(logged ?out|logout|loggedout)/.test(normalized)) return "logged-out"; + if (/(rate ?limit|429|too many)/.test(normalized)) return "rate-limited"; + if (/(connection.*close|disconnect|stream.*close)/.test(normalized)) { + return "connection-closed"; + } + if (/(qr.*expired|qr.*timeout)/.test(normalized)) return "qr-expired"; + return "other"; +} + +// Heartbeat files are written by code outside NemoClaw's control. Drop any +// `lastInboundAt` value that is not strict ISO 8601 — `Date.parse` accepts +// loose values such as a bare integer or `Date.toString()` output with +// parenthesized text, which the diagnostic would later echo through +// `channels status --json` despite the redaction guarantee. Re-emit the +// canonical `toISOString()` form so the rendered output is deterministic +// regardless of the bridge's exact serialization. +const STRICT_ISO_8601 = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/; + +function sanitizeTimestamp(value: string | null): string | null { + if (value === null) return null; + if (!STRICT_ISO_8601.test(value)) return null; + const ms = Date.parse(value); + if (!Number.isFinite(ms)) return null; + return new Date(ms).toISOString(); +} + +export function parseWhatsappHeartbeat( + raw: string, +): { heartbeat: WhatsappHeartbeat } | { parseError: string } { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // The original `err.message` from `JSON.parse` can include a snippet of + // the offending input, which may contain message bodies or phone + // numbers when the bridge wrote a corrupt heartbeat. Surface a fixed + // string instead so the rendered diagnostic never echoes arbitrary + // sandbox-owned file contents back to the host. + return { parseError: "heartbeat is not valid JSON" }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { parseError: "heartbeat JSON must be an object" }; + } + const record = parsed as RawHeartbeat; + const lastInboundAt = sanitizeTimestamp( + readString(record.lastInboundAt) ?? + readString(record.last_inbound_at) ?? + readString(record.lastInboundMessageAt) ?? + readString(record.lastMessageAt), + ); + const messagesHandled = + readNumber(record.messagesHandled) ?? + readNumber(record.messages_handled) ?? + readNumber(record.inboundCount) ?? + readNumber(record.inbound_count); + const rawConnectionState = + readString(record.connectionState) ?? + readString(record.connection_state) ?? + readString(record.wsState) ?? + readString(record.state); + const rawNote = + readString(record.note) ?? readString(record.lastError) ?? readString(record.error); + return { + heartbeat: { + lastInboundAt, + messagesHandled, + connectionState: sanitizeConnectionState(rawConnectionState), + noteCategory: categorizeNote(rawNote), + }, + }; +} + +// Map well-known bridge log keywords to short summary phrases. The probe +// caller never forwards full log lines, so this list is intentionally narrow +// and avoids anything that could carry message bodies or phone numbers. +const LOG_PATTERNS: Array<{ pattern: RegExp; summary: string }> = [ + { pattern: /connection\.open|connection opened|ws open/i, summary: "connection.open" }, + { pattern: /connection\.close|connection closed|ws close/i, summary: "connection.close" }, + { pattern: /401\b|unauthorized/i, summary: "401 unauthorized" }, + { pattern: /qr\b.*(expired|timeout)/i, summary: "qr expired" }, + { pattern: /restartRequired|loggedOut|logged out/i, summary: "session logged out" }, + { pattern: /getMessage.*missing|message-not-found/i, summary: "getMessage miss (out-of-order delivery)" }, +]; + +export function summarizeWhatsappLogLines(lines: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const line of lines) { + if (typeof line !== "string") continue; + for (const { pattern, summary } of LOG_PATTERNS) { + if (pattern.test(line) && !seen.has(summary)) { + seen.add(summary); + out.push(summary); + } + } + } + return out; +}