Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
48fc16c
fix(whatsapp): report the paired in-process bridge in channels status…
hunglp6d Jul 16, 2026
07300cc
Merge branch 'main' of github.com:NVIDIA/NemoClaw into fix/whatsapp-s…
hunglp6d Jul 16, 2026
ede4ffe
fix(whatsapp): read the canonical /tmp/gateway.log for in-process bri…
hunglp6d Jul 16, 2026
bb55301
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
hunglp6d Jul 16, 2026
dc3b06d
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
hunglp6d Jul 16, 2026
7ee22fa
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
hunglp6d Jul 16, 2026
dff0ad1
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
hunglp6d Jul 16, 2026
fab911c
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
hunglp6d Jul 16, 2026
8001168
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
hunglp6d Jul 17, 2026
1acc455
fix(whatsapp): probe live gateway status JSON, not log scraping
hunglp6d Jul 17, 2026
39ba950
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
hunglp6d Jul 17, 2026
0156393
fix(whatsapp): harden JSON status probe (epoch range, agent, gateway …
hunglp6d Jul 17, 2026
58d0f12
Merge branch 'fix/whatsapp-status-probe-unpaired' of github.com:NVIDI…
hunglp6d Jul 17, 2026
0486c58
fix(whatsapp): whitelist healthState before surfacing it
hunglp6d Jul 17, 2026
6c0dbe8
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
hunglp6d Jul 17, 2026
a4d7b16
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
hunglp6d Jul 17, 2026
9aba17d
fix(messaging): fail closed on unreachable WhatsApp status
apurvvkumaria Jul 17, 2026
bac2877
docs(messaging): clarify OpenClaw-only health probes
apurvvkumaria Jul 17, 2026
78303e1
fix(messaging): bound unconfigured WhatsApp probe
apurvvkumaria Jul 17, 2026
c57636f
merge(ci): refresh protected E2E interface
apurvvkumaria Jul 18, 2026
ec87c87
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
cv Jul 18, 2026
d82c9f3
fix(whatsapp): report runtime pairing evidence
cv Jul 18, 2026
7a9a67a
merge(main): refresh PR 7015
cv Jul 18, 2026
769f314
fix(whatsapp): honor unreachable gateway status
cv Jul 18, 2026
5f375d7
Merge remote-tracking branch 'origin/main' into codex/salvage-7015-pa…
cv Jul 18, 2026
1e80552
test(e2e): reject unrelated rate-limit skips
cv Jul 18, 2026
0dd0e6f
merge: refresh PR #7015 from main
cv Jul 18, 2026
c4af726
fix(sandbox): retry pinned Hermes preflight reads
cv Jul 18, 2026
e8263bf
Merge remote-tracking branch 'origin/main' into codex/salvage-7015-pa…
cv Jul 19, 2026
638cd28
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
cv Jul 19, 2026
b02c964
fix(messaging): validate WhatsApp status contract
apurvvkumaria Jul 19, 2026
3e324bd
fix(messaging): accept live WhatsApp status payload
apurvvkumaria Jul 19, 2026
dfcf933
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
cv Jul 19, 2026
33c6f2f
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
cv Jul 19, 2026
c7278e5
fix(messaging): fail closed on WhatsApp probe errors
cv Jul 19, 2026
6c04f8f
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
cv Jul 19, 2026
ebfb80b
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
cv Jul 19, 2026
e0100d4
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
cv Jul 19, 2026
046e987
fix(messaging): consume WhatsApp account status
apurvvkumaria Jul 20, 2026
d68b395
fix(messaging): fail closed on summary-only status
apurvvkumaria Jul 20, 2026
62c5f84
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
cjagwani Jul 20, 2026
2560dd6
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
cjagwani Jul 20, 2026
8b4e51f
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
hunglp6d Jul 20, 2026
b6d0ae1
Merge branch 'main' into fix/whatsapp-status-probe-unpaired
hunglp6d Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/lib/actions/sandbox/channel-status-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
312 changes: 18 additions & 294 deletions src/lib/actions/sandbox/channel-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@
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,
} from "../../messaging";
import {
type ChannelHealthReport,
channelHealthProbeInputs,
type DiagnosticSeverity,
type DiagnosticSignal,
} from "../../messaging/channels/channel-health";
import type { WhatsappDiagnosticReport } from "../../messaging/channels/whatsapp/hooks/status-health-eval";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
import {
collectBuiltInMessagingChannelDiagnostics,
type MessagingChannelDiagnosticSpec,
Expand All @@ -33,16 +35,6 @@ import {
runMessagingStatusHooks,
} from "../../messaging/hooks/status-runner";
import * as policies from "../../policy";
import {
type DiagnosticSeverity,
type DiagnosticSignal,
evaluateWhatsappDiagnostics,
parseWhatsappHeartbeat,
summarizeWhatsappLogLines,
type WhatsappDiagnosticReport,
type WhatsappHeartbeat,
type WhatsappProbeInput,
} from "../../sandbox/whatsapp-diagnostics";
import * as registry from "../../state/registry";
import { buildConfigStatusSignals } from "./channel-status-config";

Expand Down Expand Up @@ -108,21 +100,9 @@ export type ChannelStatusReport =
channels: ChannelStatusSingleReport[];
};

// Bound how long we are willing to block inside an `openshell sandbox exec`
// for the inline diagnostic snippet. WhatsApp's bridge sometimes goes
// unresponsive when the Noise WebSocket is stuck; a fast hard cap keeps
// channels status from inheriting that hang.
const WHATSAPP_PROBE_TIMEOUT_MS = 8_000;
const CHANNEL_STATUS_DIAGNOSTICS = collectBuiltInMessagingChannelDiagnostics();
const channelManifestRegistry = createBuiltInChannelManifestRegistry();

const SHELL_OK = "NEMOCLAW_WA_DIAG_OK";
const HEARTBEAT_BEGIN = "NEMOCLAW_WA_HEARTBEAT_BEGIN";
const HEARTBEAT_END = "NEMOCLAW_WA_HEARTBEAT_END";
const LOG_BEGIN = "NEMOCLAW_WA_LOG_BEGIN";
const LOG_END = "NEMOCLAW_WA_LOG_END";
const PROC_DONE = "NEMOCLAW_WA_PROC_DONE";

function severityLabel(severity: DiagnosticSeverity): string {
switch (severity) {
case "ok":
Expand Down Expand Up @@ -167,256 +147,6 @@ function diagnosticChannelNames(): string[] {
return CHANNEL_STATUS_DIAGNOSTICS.map((diagnostic) => diagnostic.channelId);
}

function resolveStateDirs(agent: AgentDefinition): string[] {
const configDir = agent.configPaths?.dir;
if (!configDir) return [];
const stateDirs = new Set(agent.stateDirs ?? []);
// The two known WhatsApp bridge layouts:
// OpenClaw: <configDir>/whatsapp
// Hermes: <configDir>/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<StatusDeps>,
): 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,
Expand Down Expand Up @@ -607,13 +337,14 @@ function channelSupportedByAgent(channelName: string, agent: AgentDefinition): b
.some((manifest) => manifest.id === channelName);
}

// Runs a `log-tail` deep-probe channel's `phase:"status"` health hook through
// the generic status-hook runner and returns its channel-health report. All
// Runs a deep-probe channel's `phase:"status"` health hook through the
// generic status-hook runner and returns its channel-health report. All
// channel-specific probing + classification lives in the channel's own hook
// (e.g. channels/telegram/hooks/status-health.ts); this stays channel-agnostic.
// The hook's own `agents` gate skips channels with no breadcrumb producer for
// the requested agent (e.g. Hermes), so the caller falls back to the basic
// report when no health output is returned.
// (e.g. channels/telegram/hooks/status-health.ts, channels/whatsapp/hooks);
// this stays channel-agnostic. The hook's own `agents` gate skips channels
// with no breadcrumb producer for the requested agent (e.g. Hermes
// telegram), so the caller falls back to the basic report when no health
// output is returned.
function runChannelHealthHook(
sandboxName: string,
channelName: string,
Expand Down Expand Up @@ -733,25 +464,18 @@ export async function showSandboxChannelStatus(
const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(entry));
const channelIsPaused = disabledChannels.has(channelName);

// A `log-tail` deep-probe channel runs its `phase:"status"` health hook via
// the generic status-hook runner (the hook lives in the channel folder). The
// hook's `agents` gate skips channels with no breadcrumb producer for this
// agent (e.g. Hermes telegram), so those fall back to the basic config report.
// A deep-probe channel (log-tail or in-sandbox-qr) runs its `phase:"status"`
// health hook via the generic status-hook runner (the hook lives in the
// channel folder). The hook's `agents` gate skips channels with no
// breadcrumb producer for this agent (e.g. Hermes telegram), so those fall
// back to the basic config report.
const healthReport =
diagnostic.deepProbe === "log-tail" && !channelIsPaused
(diagnostic.deepProbe === "log-tail" || diagnostic.deepProbe === "in-sandbox-qr") &&
!channelIsPaused
? runChannelHealthHook(sandboxName, channelName, agent, deps, diagnostic)
: undefined;
let report: ChannelStatusReport;
if (diagnostic.deepProbe === "in-sandbox-qr" && !channelIsPaused) {
const input = buildWhatsappProbeInput(sandboxName, agent, deps);
const whatsappReport = evaluateWhatsappDiagnostics(input);
report = {
schemaVersion: 1,
sandbox: sandboxName,
channel: channelName,
report: whatsappReport,
};
} else if (healthReport) {
if (healthReport) {
// Append the config-value signals (#5691/#5695: group policy, mention mode,
// allowed IDs) the basic report shows, so `--channel <ch>` reports both the
// channel config and live runtime health.
Expand Down
3 changes: 3 additions & 0 deletions src/lib/messaging/channels/manifests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ describe("built-in channel manifests", () => {
"src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.ts",
"src/lib/messaging/channels/slack/hooks/validate-credentials.ts",
"src/lib/messaging/channels/whatsapp/manifest.ts",
"src/lib/messaging/channels/whatsapp/hooks/index.ts",
"src/lib/messaging/channels/whatsapp/hooks/status-health.ts",
"src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts",
"src/lib/messaging/channels/teams/manifest.ts",
"src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts",
"src/lib/messaging/hooks/common/config-prompt.ts",
Expand Down
Loading
Loading