diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index 59dbf03daa1..8d9954da76e 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -32,6 +32,27 @@ hermes whatsapp NemoClaw validates the gateway URL before pairing and renders the WhatsApp QR code in a compact terminal form so it fits in smaller terminal windows. If pairing exits with a gateway close such as `1008`, rerun the login command once, then check `$$nemoclaw channels status --channel whatsapp` to diagnose the gateway and session path separately from QR rendering. + +Hermes can pair from the Dashboard profile while the gateway reads the default `platforms/whatsapp/session` path. +After scanning a dashboard QR code, run `$$nemoclaw channels status --channel whatsapp`. +If the report says `dashboard-home has WhatsApp credentials, but the Hermes gateway session path is empty`, point the gateway at the dashboard session with the supported config command: + +```bash +$$nemoclaw shields down --reason "repair Hermes WhatsApp session path" +$$nemoclaw config set --key platforms.whatsapp.extra.session_path --value /sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session --restart --config-accept-new-path +$$nemoclaw shields up +``` + +Keep Shields down until `config set` finishes, then restore the restrictive posture with `shields up`. +The `--config-accept-new-path` flag lets the command write the `platforms.whatsapp.extra.session_path` configuration key when the generated config does not already contain it. +Dashboard pairing writes session credentials under `profiles/dashboard-home`; this bounded compatibility repair redirects the gateway to that session path while it still reads the default path otherwise. +The repair stays documented until Hermes dashboard pairing state and gateway startup converge on one shared session path; NemoClaw can redirect the configured path but does not rewrite Hermes pairing state. +Before removing the override, run `$$nemoclaw channels status --channel whatsapp` after dashboard pairing and gateway startup and confirm the report no longer shows a dashboard-home session with an empty gateway session path. +Remove the override only after Hermes uses one shared WhatsApp session path for dashboard pairing and gateway startup. +Do not edit `/sandbox/.hermes/.env` or `/sandbox/.hermes/config.yaml` by hand. +Manual edits can cause the Hermes config-integrity guard to reject restart with `hash-mismatch`. +Supervisor quarantine happens after repeated relaunch failures or other integrity failures. + ## Protect Paired Session State @@ -39,7 +60,7 @@ If pairing exits with a gateway close such as `1008`, rerun the login command on The sandbox stores mutable session credentials in durable `whatsapp` state. -The sandbox stores mutable session credentials in durable `platforms/whatsapp` state. +The sandbox stores mutable session credentials in durable `platforms/whatsapp` state for the gateway and `profiles/dashboard-home/platforms/whatsapp` state for Dashboard pairing. The credentials survive rebuilds without re-pairing. This is the runtime tradeoff of enabling WhatsApp without a host bridge: a paired sandbox can use that WhatsApp account until you unpair it or clear the durable state. @@ -50,6 +71,8 @@ Pair only one sandbox per WhatsApp account at a time. For non-interactive onboarding, set `WHATSAPP_ALLOWED_IDS` to a nonempty comma-separated sender list to select WhatsApp for either agent. Hermes also uses these values as its WhatsApp sender allowlist. +Use digits only, such as `15551234567`. +Do not include a leading `+`. Continue with [Enable Channels During Onboarding](enable-channels-during-onboarding) or [Add Channels After Onboarding](add-channels-after-onboarding). diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index bf789ea109c..660b6258f84 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2285,10 +2285,11 @@ When the bridge provider is attached to a live sandbox, NemoClaw detaches it bef If the matching built-in policy preset is applied, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone. NemoClaw also strips the channel from `session.policyPresets` so a subsequent `onboard --resume` does not re-apply the preset on the next rebuild. -For QR-paired channels (today: WhatsApp), NemoClaw destructively clears the in-sandbox session directory before the rebuild so the `state_dirs` backup does not restore the auth blob and let the channel reconnect: +For QR-paired channels (today: WhatsApp), NemoClaw destructively clears the in-sandbox session directories before the rebuild so the `state_dirs` backup does not restore the auth blob and let the channel reconnect: - OpenClaw: `/sandbox/.openclaw//` (for example `/sandbox/.openclaw/whatsapp/`). - Hermes: `/sandbox/.hermes/platforms//` (for example `/sandbox/.hermes/platforms/whatsapp/`). + For WhatsApp, NemoClaw also clears the current Dashboard profile at `/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session/` and the legacy migration source at `/sandbox/.hermes/dashboard-home/platforms/whatsapp/session/`. The cleanup tries `openshell sandbox exec` first and falls back to SSH if the exec wrapper does not return the success sentinel. If both transports fail (the sandbox is stopped, the gateway is down, or SSH cannot reach it) the command refuses to proceed to the rebuild and asks you to start the sandbox and re-run, so a half-removed state cannot leave stale Baileys auth files behind for the next rebuild to restore. @@ -2344,13 +2345,15 @@ $$nemoclaw my-assistant channels start telegram Run messaging channel status checks. Without `--channel`, the command prints a compact summary for every configured channel, including registration, policy coverage, and non-secret rendered config comparisons. -For channel and agent combinations that support a live health probe (WhatsApp or Telegram on OpenClaw), the summary adds a `Runtime health: not checked in summary view` pointer instead of running the probe, so it never reads as healthy without an explicit check. +For channel and agent combinations that support a detailed probe, the summary adds a `Runtime health: not checked in summary view` pointer instead of running the probe, so it never reads as healthy without an explicit check. With `--channel`, it prints the detailed status for that channel. -For an OpenClaw WhatsApp sandbox, `--channel whatsapp` also probes the sandbox to separately report pairing/session state, the Noise WebSocket connection, inbound event delivery, and policy coverage. +For an OpenClaw WhatsApp sandbox, `--channel whatsapp` probes the sandbox to separately report pairing/session state, the Noise WebSocket connection, inbound event delivery, and policy coverage. A paired channel with no observed inbound delivery exits non-zero with verdict `idle` so an unhealthy bridge cannot pass as healthy. The detailed WhatsApp probe stays focused on QR/session runtime diagnostics and does not include rendered-config comparison lines. -A Hermes WhatsApp sandbox uses the basic registration, policy, and config report because NemoClaw does not treat the Hermes session file as a live-health signal. +For a Hermes WhatsApp sandbox, `--channel whatsapp` probes only whether the default gateway session path and the `profiles/dashboard-home` session path contain `creds.json`. +If the dashboard path has credentials and the gateway path is empty, the report prints the supported `config set` command for the `platforms.whatsapp.extra.session_path` configuration key, including the new-configuration-key opt-in when generated configs do not already contain that key. +NemoClaw does not treat a Hermes session file as live inbound-health evidence. For Telegram, `--channel telegram` probes the sandbox to report the gateway process, Bot API reachability, and inbound delivery alongside the config comparison. It classifies the state as `healthy`, `idle`, `token_rejected`, `unreachable`, `not_started`, `policy_gap`, `config_gap`, `unknown`, or `probe_failed`. @@ -2373,10 +2376,11 @@ $$nemoclaw my-assistant channels status --channel telegram | Flag | Description | |------|-------------| | `--channel ` | Channel to inspect in detail | -| `--json` | Emit the status report as JSON (for an available detailed WhatsApp or Telegram probe on OpenClaw, exit non-zero when the verdict is not `healthy` or `unknown`) | +| `--json` | Emit the status report as JSON (for an available detailed WhatsApp or Telegram probe, exit non-zero when the verdict is not `healthy` or `unknown`) | Each live probe is bounded by an in-sandbox `openshell sandbox exec` with a hard timeout. The WhatsApp probe returns strict OpenClaw status JSON to the host, where NemoClaw allowlists pairing, liveness, connection-state, and timestamp fields before rendering the report and discards phone-number and free-text error fields. +The Hermes WhatsApp probe returns only fixed session-path booleans to the host. The Telegram probe returns only matched gateway log lines to the host, where NemoClaw reduces them to fixed classifications without rendering the raw lines, message bodies, or tokens. diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 6602a7580ab..c65e2b840f9 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -17,6 +17,17 @@ function waStatusJson(wa: Record): string { }); } +function hermesSessionProbeOutput(options: { + gatewaySessionCreds: boolean; + dashboardSessionCreds: boolean; +}): string { + return [ + "NEMOCLAW_HERMES_WHATSAPP_SESSION_V1", + `GATEWAY_SESSION=${options.gatewaySessionCreds ? "present" : "missing"}`, + `DASHBOARD_SESSION=${options.dashboardSessionCreds ? "present" : "missing"}`, + ].join("\n"); +} + describe("showSandboxChannelStatus (whatsapp)", () => { it("returns idle verdict and exit code 1 when paired but no inbound observed", async () => { const stdout = waStatusJson({ @@ -214,22 +225,60 @@ describe("showSandboxChannelStatus (whatsapp)", () => { expect(threw?.message).toBe("process.exit(1)"); }); - it("falls back to basic status for Hermes without running the OpenClaw probe", async () => { + it("reports a Hermes dashboard-home session that the gateway path cannot read", async () => { const exec = vi.fn((_sandbox: string, _command: string, _timeoutMs?: number) => ({ status: 0, - stdout: "", + stdout: hermesSessionProbeOutput({ + gatewaySessionCreds: false, + dashboardSessionCreds: true, + }), stderr: "", })); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); const { deps } = makeDeps({ exec, agentName: "hermes", sandbox: entry(["whatsapp"], [], {}, "hermes"), }); - const result = await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); + let threw: Error | null = null; + try { + await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); + } catch (err) { + threw = err as Error; + } finally { + exitSpy.mockRestore(); + } const commands = exec.mock.calls.map((call) => String(call[1] ?? "")).join("\n"); + expect(threw?.message).toBe("process.exit(1)"); expect(commands).not.toContain("openclaw channels status"); - expect(commands).not.toContain("platforms/whatsapp/session/creds.json"); - expect(result && "verdict" in result && result.verdict).toBe("info"); + expect(commands).toContain("/sandbox/.hermes/platforms/whatsapp/session/creds.json"); + expect(commands).toContain( + "/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session/creds.json", + ); + }); + + it("keeps Hermes gateway session presence as an unknown live-health verdict", async () => { + const { deps } = makeDeps({ + exec: () => ({ + status: 0, + stdout: hermesSessionProbeOutput({ + gatewaySessionCreds: true, + dashboardSessionCreds: false, + }), + stderr: "", + }), + agentName: "hermes", + sandbox: entry(["whatsapp"], [], {}, "hermes"), + }); + const result = await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); + expect(result && "report" in result && result.report.verdict).toBe("unknown"); + const session = + result && "report" in result + ? result.report.signals.find((signal) => signal.label === "Session location") + : undefined; + expect(session?.severity).toBe("ok"); }); it("skips the deep probe and reports paused state when WhatsApp is in disabledChannels", async () => { diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index e7e91e81803..009ce243698 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -284,7 +284,7 @@ function buildBasicChannelReport( : "not checked in summary view", hint: options.channelPaused ? undefined - : `run \`${CLI_NAME} ${sandboxName} channels status --channel ${channelName}\` to probe live health`, + : `run \`${CLI_NAME} ${sandboxName} channels status --channel ${channelName}\` for detailed status`, }); } else if (options.includeDeepDiagnostics ?? true) { signals.push({ diff --git a/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts b/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts index ed84d8bd5c7..c1ab86423a5 100644 --- a/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts +++ b/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts @@ -3,11 +3,20 @@ import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; +import * as openshellRuntime from "../../adapters/openshell/runtime"; +import * as defs from "../../agent/defs"; +import { + createBuiltInChannelManifestRegistry, + createBuiltInRenderTemplateResolver, + MessagingWorkflowPlanner, +} from "../../messaging"; import * as policies from "../../policy"; import * as runner from "../../runner"; +import type { SandboxEntry } from "../../state/registry/types"; import * as registry from "../../state/registry"; import { removeSandboxChannel, startSandboxChannel, stopSandboxChannel } from "./policy-channel"; import { policyChannelDependencies } from "./policy-channel-dependencies"; +import * as processRecovery from "./process-recovery"; describe("policy channel remove/enable flows", () => { let exitSpy: MockInstance; @@ -19,12 +28,74 @@ describe("policy channel remove/enable flows", () => { }) as never); logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(processRecovery, "executeSandboxExecCommand").mockReturnValue({ + status: 0, + stdout: "NEMOCLAW_CHANNEL_CLEAR_OK\n", + stderr: "", + }); + vi.spyOn(processRecovery, "executeSandboxCommand").mockReturnValue(null); }); afterEach(() => { vi.restoreAllMocks(); }); + async function arrangeHermesWhatsappRemoval() { + const plan = await new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + undefined, + createBuiltInRenderTemplateResolver(), + ).buildPlan({ + sandboxName: "alpha", + agent: "hermes", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["whatsapp"], + }); + const current = { + name: "alpha", + agent: "hermes", + policies: ["whatsapp"], + messaging: { + schemaVersion: 1, + plan, + }, + } as SandboxEntry; + vi.spyOn(defs, "loadAgent").mockReturnValue({ + name: "hermes", + displayName: "Hermes", + configPaths: { dir: "/sandbox/.hermes" }, + stateDirs: ["platforms", "profiles", "dashboard-home"], + } as unknown as defs.AgentDefinition); + vi.spyOn(registry, "getSandbox").mockReturnValue(current); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["whatsapp"]); + vi.spyOn(registry, "getDisabledChannels").mockReturnValue([]); + const updateSandbox = vi.spyOn(registry, "updateSandbox").mockReturnValue(true); + vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["whatsapp"]); + vi.spyOn(policies, "listPresets").mockReturnValue([{ name: "whatsapp" } as never]); + const removePreset = vi.spyOn(policies, "removePreset").mockReturnValue(true); + const rebuildSandbox = vi + .spyOn(policyChannelDependencies, "rebuildSandbox") + .mockResolvedValue(undefined); + return { rebuildSandbox, removePreset, updateSandbox }; + } + + async function removeWhatsappNonInteractive() { + const previousNonInteractive = process.env.NEMOCLAW_NON_INTERACTIVE; + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + try { + await removeSandboxChannel("alpha", { channel: "whatsapp" }); + } finally { + Reflect.deleteProperty(process.env, "NEMOCLAW_NON_INTERACTIVE"); + Object.assign( + process.env, + previousNonInteractive === undefined + ? {} + : { NEMOCLAW_NON_INTERACTIVE: previousNonInteractive }, + ); + } + } + it("reports remove usage and exits before touching channel state when no channel is supplied", async () => { await expect(removeSandboxChannel("alpha", {})).rejects.toThrow("process.exit(1)"); @@ -42,6 +113,93 @@ describe("policy channel remove/enable flows", () => { expect(exitSpy).not.toHaveBeenCalled(); }); + it("clears Hermes WhatsApp default, profile, and legacy sessions before removal", async () => { + const { updateSandbox } = await arrangeHermesWhatsappRemoval(); + + await expect(removeWhatsappNonInteractive()).resolves.toBeUndefined(); + + const clearCommand = String( + vi.mocked(processRecovery.executeSandboxExecCommand).mock.calls[0]?.[1] ?? "", + ); + expect(clearCommand).toContain("rm -rf --"); + expect(clearCommand).toContain("/sandbox/.hermes/platforms/whatsapp"); + expect(clearCommand).toContain( + "/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session", + ); + expect(clearCommand).toContain("/sandbox/.hermes/dashboard-home/platforms/whatsapp/session"); + expect(updateSandbox).toHaveBeenCalled(); + expect( + vi.mocked(processRecovery.executeSandboxExecCommand).mock.invocationCallOrder[0], + ).toBeLessThan(updateSandbox.mock.invocationCallOrder[0]); + }); + + it("clears every Hermes WhatsApp session path through the SSH fallback", async () => { + const { updateSandbox } = await arrangeHermesWhatsappRemoval(); + vi.mocked(processRecovery.executeSandboxExecCommand).mockReturnValue({ + status: 1, + stdout: "", + stderr: "exec unavailable", + }); + vi.mocked(processRecovery.executeSandboxCommand).mockReturnValue({ + status: 0, + stdout: "NEMOCLAW_CHANNEL_CLEAR_OK\n", + stderr: "", + }); + + await expect(removeWhatsappNonInteractive()).resolves.toBeUndefined(); + + for (const command of [ + vi.mocked(processRecovery.executeSandboxExecCommand).mock.calls[0]?.[1], + vi.mocked(processRecovery.executeSandboxCommand).mock.calls[0]?.[1], + ]) { + expect(String(command)).toContain("/sandbox/.hermes/platforms/whatsapp"); + expect(String(command)).toContain( + "/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session", + ); + expect(String(command)).toContain( + "/sandbox/.hermes/dashboard-home/platforms/whatsapp/session", + ); + } + expect(updateSandbox).toHaveBeenCalled(); + expect( + vi.mocked(processRecovery.executeSandboxCommand).mock.invocationCallOrder[0], + ).toBeLessThan(updateSandbox.mock.invocationCallOrder[0]); + }); + + it("keeps channel state unchanged when both Hermes cleanup transports fail", async () => { + const { rebuildSandbox, removePreset, updateSandbox } = await arrangeHermesWhatsappRemoval(); + const runOpenshell = vi.spyOn(openshellRuntime, "runOpenshell"); + vi.mocked(processRecovery.executeSandboxExecCommand).mockReturnValue({ + status: 1, + stdout: "", + stderr: "exec unavailable", + }); + vi.mocked(processRecovery.executeSandboxCommand).mockReturnValue({ + status: 1, + stdout: "", + stderr: "ssh unavailable", + }); + + await expect(removeWhatsappNonInteractive()).rejects.toThrow("process.exit(1)"); + + for (const command of [ + vi.mocked(processRecovery.executeSandboxExecCommand).mock.calls[0]?.[1], + vi.mocked(processRecovery.executeSandboxCommand).mock.calls[0]?.[1], + ]) { + expect(String(command)).toContain("/sandbox/.hermes/platforms/whatsapp"); + expect(String(command)).toContain( + "/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session", + ); + expect(String(command)).toContain( + "/sandbox/.hermes/dashboard-home/platforms/whatsapp/session", + ); + } + expect(runOpenshell).not.toHaveBeenCalled(); + expect(updateSandbox).not.toHaveBeenCalled(); + expect(removePreset).not.toHaveBeenCalled(); + expect(rebuildSandbox).not.toHaveBeenCalled(); + }); + it("supports stop dry runs for configured Hermes channels", async () => { vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", agent: "hermes" }); vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["teams"]); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 1edb4737f6d..a9b47c924e2 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -1463,13 +1463,24 @@ export function applyChannelPresetIfAvailable( function getSandboxChannelStatePaths(agent: AgentDefinition, channelName: string): string[] { const configDir = agent.configPaths.dir; const stateDirs = new Set(agent.stateDirs); + const paths: string[] = []; + const isHermesWhatsapp = agent.name === "hermes" && channelName === "whatsapp"; if (stateDirs.has("platforms")) { - return [`${configDir}/platforms/${channelName}`]; + paths.push(`${configDir}/platforms/${channelName}`); } - if (stateDirs.has(channelName)) { - return [`${configDir}/${channelName}`]; + if (isHermesWhatsapp && stateDirs.has("profiles")) { + paths.push(`${configDir}/profiles/dashboard-home/platforms/whatsapp/session`); } - return []; + // Retain cleanup for the pre-profile Dashboard home while Hermes startup + // still treats it as migration input. This prevents legacy credentials from + // being migrated back into the canonical profile during a later rebuild. + if (isHermesWhatsapp && stateDirs.has("dashboard-home")) { + paths.push(`${configDir}/dashboard-home/platforms/whatsapp/session`); + } + if (paths.length === 0 && stateDirs.has(channelName)) { + paths.push(`${configDir}/${channelName}`); + } + return paths; } function isSafeChannelStatePath(p: string): boolean { diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index c07c54690a4..966c61de0ec 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -44,10 +44,10 @@ describe("built-in channel manifests", () => { ).toEqual(BUILT_IN_CHANNEL_MANIFESTS.map((manifest) => [manifest.id, true])); }); - it("limits the WhatsApp live-health hook to its OpenClaw status contract", () => { + it("limits the WhatsApp status hook to OpenClaw live health and Hermes session-location checks", () => { const whatsapp = BUILT_IN_CHANNEL_MANIFESTS.find((manifest) => manifest.id === "whatsapp"); const statusHealth = whatsapp?.hooks.find((hook) => hook.id === "whatsapp-status-health"); - expect(statusHealth?.agents).toEqual(["openclaw"]); + expect(statusHealth?.agents).toEqual(["openclaw", "hermes"]); }); it("keeps rendered config parser keys limited to manifest config inputs", () => { diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts index 85e98cf0117..09da557c522 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts @@ -77,6 +77,64 @@ describe("evaluateWhatsappDiagnostics", () => { expect(report.hints.join(" ")).toMatch(/hermes whatsapp/); }); + it("reports the Hermes dashboard-only session path split with the supported repair", () => { + const report = evaluateWhatsappDiagnostics( + baseInput({ + agent: "hermes", + paired: false, + sessionLocations: { + gatewaySessionCreds: false, + dashboardSessionCreds: true, + }, + }), + ); + const session = report.signals.find((s) => s.label === "Session location"); + expect(report.verdict).toBe("unpaired"); + expect(session?.severity).toBe("warn"); + expect(session?.detail).toMatch(/dashboard-home has WhatsApp credentials/); + expect(session?.hint).toContain("platforms.whatsapp.extra.session_path"); + expect(session?.hint).toContain("--config-accept-new-path"); + expect(report.hints.join(" ")).toContain( + "/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session", + ); + expect(report.hints.join(" ")).toContain("--config-accept-new-path"); + }); + + it("keeps Hermes gateway session file evidence out of the live-health verdict", () => { + const report = evaluateWhatsappDiagnostics( + baseInput({ + agent: "hermes", + paired: null, + sessionLocations: { + gatewaySessionCreds: true, + dashboardSessionCreds: false, + }, + }), + ); + const session = report.signals.find((s) => s.label === "Session location"); + expect(report.verdict).toBe("unknown"); + expect(session?.severity).toBe("ok"); + expect(session?.detail).toMatch(/gateway session path contains WhatsApp credentials/); + }); + + it("reports duplicate Hermes session paths without claiming live health (#8184)", () => { + const report = evaluateWhatsappDiagnostics( + baseInput({ + agent: "hermes", + paired: null, + sessionLocations: { + gatewaySessionCreds: true, + dashboardSessionCreds: true, + }, + }), + ); + const session = report.signals.find((s) => s.label === "Session location"); + expect(report.verdict).toBe("unknown"); + expect(session?.severity).toBe("info"); + expect(session?.detail).toBe("both Hermes session paths contain WhatsApp credentials"); + expect(session?.hint).toBe("use one active WhatsApp bridge for the paired account"); + }); + 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. diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts index e7ccfea856e..d0a92183e68 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts @@ -85,6 +85,14 @@ export type WhatsappProbeInput = { presetOnGateway: boolean | null; // Whether the whatsapp channel is recorded in the registry messaging plan. channelEnabledInRegistry: boolean; + // Hermes runs the dashboard under a separate home. This probe records only + // whether the two expected credential files exist, never their contents. + sessionLocations?: WhatsappSessionLocations; +}; + +export type WhatsappSessionLocations = { + gatewaySessionCreds: boolean | null; + dashboardSessionCreds: boolean | null; }; /** @@ -139,6 +147,17 @@ function pairingSignal(input: WhatsappProbeInput): DiagnosticSignal { }; } if (input.paired === false) { + const dashboardOnly = hermesDashboardOnlySession(input); + if (input.agent === "hermes") { + return { + label: "Pairing / session", + severity: "warn", + detail: "Hermes gateway session path has no WhatsApp credentials", + hint: dashboardOnly + ? "point the Hermes WhatsApp session_path at the dashboard-home session path, then restart the gateway" + : "run `hermes whatsapp` inside the sandbox to display a QR code", + }; + } const loginHint = input.agent === "hermes" ? "run `hermes whatsapp` inside the sandbox to display a QR code" @@ -157,6 +176,57 @@ function pairingSignal(input: WhatsappProbeInput): DiagnosticSignal { }; } +function hermesDashboardOnlySession(input: WhatsappProbeInput): boolean { + return ( + input.agent === "hermes" && + input.sessionLocations?.gatewaySessionCreds === false && + input.sessionLocations.dashboardSessionCreds === true + ); +} + +function sessionLocationSignal(input: WhatsappProbeInput): DiagnosticSignal | null { + const locations = input.sessionLocations; + if (!locations) return null; + const gateway = locations.gatewaySessionCreds; + const dashboard = locations.dashboardSessionCreds; + if (gateway === false && dashboard === true) { + return { + label: "Session location", + severity: "warn", + detail: + "dashboard-home has WhatsApp credentials, but the Hermes gateway session path is empty", + hint: "run `nemoclaw config set --key platforms.whatsapp.extra.session_path --value /sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session --restart --config-accept-new-path`", + }; + } + if (gateway === true && dashboard === false) { + return { + label: "Session location", + severity: "ok", + detail: "Hermes gateway session path contains WhatsApp credentials", + }; + } + if (gateway === false && dashboard === false) { + return { + label: "Session location", + severity: "info", + detail: "no WhatsApp credentials found in the Hermes gateway or dashboard session paths", + }; + } + if (gateway === true && dashboard === true) { + return { + label: "Session location", + severity: "info", + detail: "both Hermes session paths contain WhatsApp credentials", + hint: "use one active WhatsApp bridge for the paired account", + }; + } + return { + label: "Session location", + severity: "info", + detail: "Hermes session paths could not be inspected", + }; +} + function websocketSignal(input: WhatsappProbeInput): DiagnosticSignal { if (input.heartbeatParseError) { return { @@ -400,6 +470,13 @@ function buildHints(verdict: WhatsappVerdict, input: WhatsappProbeInput): string ); break; case "unpaired": + if (hermesDashboardOnlySession(input)) { + hints.push( + "Hermes dashboard pairing wrote credentials under dashboard-home, but the gateway reads the default platforms path.", + "Use `nemoclaw config set --key platforms.whatsapp.extra.session_path --value /sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session --restart --config-accept-new-path` to point the gateway at that session.", + ); + break; + } hints.push( input.agent === "hermes" ? "Run `hermes whatsapp` inside the sandbox and scan the QR with your phone." @@ -429,9 +506,11 @@ function buildHints(verdict: WhatsappVerdict, input: WhatsappProbeInput): string } export function evaluateWhatsappDiagnostics(input: WhatsappProbeInput): WhatsappDiagnosticReport { + const sessionSignal = sessionLocationSignal(input); const signals: DiagnosticSignal[] = [ configCoverageSignal(input), pairingSignal(input), + ...(sessionSignal ? [sessionSignal] : []), bridgeProcessSignal(input), websocketSignal(input), inboundSignal(input), diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index 2f99d79e244..ab27d6bad51 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -100,6 +100,17 @@ function openclawJson(wa: Record | null): string { return JSON.stringify(openclawPayload(wa)); } +function hermesSessionProbeOutput(options: { + gatewaySessionCreds: boolean; + dashboardSessionCreds: boolean; +}): string { + return [ + "NEMOCLAW_HERMES_WHATSAPP_SESSION_V1", + `GATEWAY_SESSION=${options.gatewaySessionCreds ? "present" : "missing"}`, + `DASHBOARD_SESSION=${options.dashboardSessionCreds ? "present" : "missing"}`, + ].join("\n"); +} + const HEALTHY_WA: WaFixture = { configured: true, statusState: "linked", @@ -381,13 +392,77 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { expect(reportOf(run())?.verdict).toBeDefined(); }); - it("no-ops for Hermes because the live status contract is OpenClaw-only", () => { - const exec = makeExec({ status: 0, stdout: openclawJson(HEALTHY_WA), stderr: "" }); + it("reports the Hermes dashboard-only session path split from fixed session roots", () => { + const exec = makeExec({ + status: 0, + stdout: hermesSessionProbeOutput({ + gatewaySessionCreds: false, + dashboardSessionCreds: true, + }), + stderr: "", + }); const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( context({ ...BASE_INPUTS, agent: "hermes" }), ); - expect(outputsOf(result)).toBeUndefined(); - expect(exec).not.toHaveBeenCalled(); + const report = reportOf(result); + const command = String(exec.mock.calls[0]?.[1] ?? ""); + expect(report?.verdict).toBe("unpaired"); + expect(report?.signals.find((s) => s.label === "Session location")?.hint).toContain( + "platforms.whatsapp.extra.session_path", + ); + expect(report?.signals.find((s) => s.label === "Session location")?.hint).toContain( + "--config-accept-new-path", + ); + expect(command).toContain("/sandbox/.hermes/platforms/whatsapp/session/creds.json"); + expect(command).toContain( + "/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session/creds.json", + ); + expect(command).not.toMatch(/(^|[;&|]\s*)(cat|grep|find|ls)\b/); + }); + + it("does not treat a Hermes gateway session file as live inbound health", () => { + const exec = makeExec({ + status: 0, + stdout: hermesSessionProbeOutput({ + gatewaySessionCreds: true, + dashboardSessionCreds: false, + }), + stderr: "", + }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( + context({ ...BASE_INPUTS, agent: "hermes" }), + ); + const report = reportOf(result); + expect(report?.verdict).toBe("unknown"); + expect(report?.signals.find((s) => s.label === "Session location")?.severity).toBe("ok"); + }); + + it.each([ + { + label: "missing sentinel", + stdout: ["GATEWAY_SESSION=missing", "DASHBOARD_SESSION=present"].join("\n"), + }, + { + label: "missing gateway session line", + stdout: ["NEMOCLAW_HERMES_WHATSAPP_SESSION_V1", "DASHBOARD_SESSION=present"].join("\n"), + }, + { + label: "invalid dashboard session value", + stdout: [ + "NEMOCLAW_HERMES_WHATSAPP_SESSION_V1", + "GATEWAY_SESSION=missing", + "DASHBOARD_SESSION=yes", + ].join("\n"), + }, + ])("reports probe_failed for malformed Hermes probe output: $label", ({ stdout }) => { + const exec = makeExec({ status: 0, stdout, stderr: "" }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( + context({ ...BASE_INPUTS, agent: "hermes" }), + ); + const report = reportOf(result); + expect(report?.verdict).toBe("probe_failed"); + expect(report?.signals.find((s) => s.label === "Session location")).toBeUndefined(); + expect(stringifyReport(result)).not.toContain("platforms.whatsapp.extra.session_path"); }); it.each([ diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index c169fd080a5..e6ba55de5fc 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -8,6 +8,20 @@ * command via the status-hook runner, so no whatsapp-specific code lives in * the generic status orchestrator. * + * For Hermes, the probe reads only fixed boolean evidence about the two known + * WhatsApp session paths: + * + * /sandbox/.hermes/platforms/whatsapp/session/creds.json + * /sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session/creds.json + * + * It never reads credential file contents or lists session directories. + * + * This is a bounded compatibility probe for Hermes dashboard pairing that can + * write credentials under profiles/dashboard-home while the gateway reads the + * default session path. Remove the dashboard profile branch after Hermes uses + * one shared WhatsApp session path for both dashboard pairing and gateway + * startup. + * * The probe reads OpenClaw's authoritative live status JSON: * * openclaw channels status --channel whatsapp --json --timeout @@ -44,6 +58,7 @@ import { evaluateWhatsappDiagnostics, type WhatsappHeartbeat, type WhatsappProbeInput, + type WhatsappSessionLocations, } from "./status-health-eval"; export const WHATSAPP_STATUS_HEALTH_HOOK_HANDLER_ID = "whatsapp.statusHealth"; @@ -53,6 +68,7 @@ export const WHATSAPP_STATUS_HEALTH_HOOK_HANDLER_ID = "whatsapp.statusHealth"; // the Noise WebSocket is stuck; a fast hard cap keeps channels status from // inheriting that hang. const DEFAULT_TIMEOUT_MS = 8_000; +const HERMES_SESSION_PROBE_SENTINEL = "NEMOCLAW_HERMES_WHATSAPP_SESSION_V1"; /** WhatsApp uses the generic channel-health hook options unchanged. */ export type WhatsappStatusHealthHookOptions = ChannelStatusHealthHookOptions; @@ -69,12 +85,14 @@ export function createWhatsappStatusHealthHook( if (!execute || !sandboxName) return {}; const agent = normalizeString(context.inputs?.agent) ?? "openclaw"; - // This hook consumes an OpenClaw-specific status contract. The manifest - // gates it to OpenClaw; keep the handler fail-safe when invoked directly - // so another agent never receives an unsupported health verdict. - if (agent !== "openclaw") return {}; const timeoutMs = normalizeTimeoutMs(options.timeoutMs); - const probe = runOpenclawStatusProbe(execute, sandboxName, timeoutMs); + const probe = + agent === "openclaw" + ? runOpenclawStatusProbe(execute, sandboxName, timeoutMs) + : agent === "hermes" + ? runHermesSessionProbe(execute, sandboxName, timeoutMs) + : null; + if (!probe) return {}; const input: WhatsappProbeInput = { agent, @@ -88,6 +106,7 @@ export function createWhatsappStatusHealthHook( presetInRegistry: Boolean(context.inputs?.presetInRegistry), presetOnGateway: normalizeTristate(context.inputs?.presetOnGateway), channelEnabledInRegistry: Boolean(context.inputs?.channelEnabledInRegistry), + ...(probe.sessionLocations ? { sessionLocations: probe.sessionLocations } : {}), }; const report = evaluateWhatsappDiagnostics(input); return { @@ -138,6 +157,7 @@ type ProbeResult = { readonly bridgeProcessAlive: boolean | null; readonly heartbeat: WhatsappHeartbeat | null; readonly recentLogSignals: readonly string[]; + readonly sessionLocations?: WhatsappSessionLocations; }; const PROBE_UNREACHABLE: ProbeResult = { @@ -203,6 +223,61 @@ function runOpenclawStatusProbe( return mapOpenclawWaState(wa); } +function runHermesSessionProbe( + execute: NonNullable, + sandboxName: string, + timeoutMs: number, +): ProbeResult { + const command = hermesSessionProbeCommand(); + let exec: ReturnType; + try { + exec = execute(sandboxName, command, timeoutMs); + } catch { + return PROBE_UNREACHABLE; + } + if (!exec || exec.status !== 0) return PROBE_UNREACHABLE; + const locations = parseHermesSessionProbe(String(exec.stdout ?? "")); + if (!locations) return PROBE_UNREACHABLE; + const gatewaySession = locations.gatewaySessionCreds === true; + return { + probeReachable: true, + paired: gatewaySession ? null : false, + bridgeProcessAlive: null, + heartbeat: null, + recentLogSignals: [], + sessionLocations: locations, + }; +} + +function hermesSessionProbeCommand(): string { + return [ + 'gateway="/sandbox/.hermes/platforms/whatsapp/session/creds.json"', + 'dashboard="/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session/creds.json"', + `printf '%s\\n' '${HERMES_SESSION_PROBE_SENTINEL}'`, + 'if [ -f "$gateway" ]; then printf "%s\\n" "GATEWAY_SESSION=present"; else printf "%s\\n" "GATEWAY_SESSION=missing"; fi', + 'if [ -f "$dashboard" ]; then printf "%s\\n" "DASHBOARD_SESSION=present"; else printf "%s\\n" "DASHBOARD_SESSION=missing"; fi', + ].join("; "); +} + +function parseHermesSessionProbe(stdout: string): WhatsappSessionLocations | null { + const lines = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (!lines.includes(HERMES_SESSION_PROBE_SENTINEL)) return null; + const gateway = readProbeBoolean(lines, "GATEWAY_SESSION"); + const dashboard = readProbeBoolean(lines, "DASHBOARD_SESSION"); + if (gateway === null || dashboard === null) return null; + return { gatewaySessionCreds: gateway, dashboardSessionCreds: dashboard }; +} + +function readProbeBoolean(lines: readonly string[], key: string): boolean | null { + const match = lines.find((line) => line === `${key}=present` || line === `${key}=missing`); + if (match === `${key}=present`) return true; + if (match === `${key}=missing`) return false; + return null; +} + function hasRequiredWhatsappLiveness( wa: OpenclawWhatsappState, ): wa is ValidatedOpenclawWhatsappState { diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index b6fa79d3fff..f2ee7462638 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -11,7 +11,7 @@ export const whatsappManifest = { enrollmentHelp: "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.", enrollmentNotes: [ - "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).", + "After pairing, run `nemoclaw channels status --channel whatsapp`. OpenClaw reports inbound delivery evidence; Hermes reports gateway and dashboard session-path diagnostics.", ], supportedAgents: ["openclaw", "hermes"], auth: { @@ -125,7 +125,7 @@ export const whatsappManifest = { id: "whatsapp-status-health", phase: "status", handler: "whatsapp.statusHealth", - agents: ["openclaw"], + agents: ["openclaw", "hermes"], outputs: [ { id: "channelHealth", diff --git a/test/agent-variant-docs.test.ts b/test/agent-variant-docs.test.ts index f04886c7d63..5c40fd7eac1 100644 --- a/test/agent-variant-docs.test.ts +++ b/test/agent-variant-docs.test.ts @@ -330,6 +330,27 @@ import { AgentOnly } from "../_components/AgentGuide"; expect(hermes.split(baselineExplanation)).toHaveLength(2); }); + it("keeps the Hermes WhatsApp repair inside a shields maintenance window (#8184)", () => { + const whatsapp = readFileSync( + new URL("../docs/manage-sandboxes/set-up-whatsapp.mdx", import.meta.url), + "utf8", + ); + const hermes = renderAgentVariantPage(whatsapp, "hermes", { + sourcePath: "/repo/docs/manage-sandboxes/set-up-whatsapp.mdx", + }); + const shieldsDown = hermes.indexOf( + 'nemohermes shields down --reason "repair Hermes WhatsApp session path"', + ); + const configSet = hermes.indexOf( + "nemohermes config set --key platforms.whatsapp.extra.session_path", + ); + const shieldsUp = hermes.indexOf("nemohermes shields up", configSet); + + expect(shieldsDown).toBeGreaterThanOrEqual(0); + expect(configSet).toBeGreaterThan(shieldsDown); + expect(shieldsUp).toBeGreaterThan(configSet); + }); + it("keeps the troubleshooting security review link within each agent guide (#6558)", () => { const troubleshooting = readFileSync( new URL("../docs/reference/troubleshooting.mdx", import.meta.url), diff --git a/test/channels-remove-full-teardown.test.ts b/test/channels-remove-full-teardown.test.ts index 06b5be1b265..4c276de741e 100644 --- a/test/channels-remove-full-teardown.test.ts +++ b/test/channels-remove-full-teardown.test.ts @@ -282,6 +282,12 @@ const ctx = module.exports; cleanupCalls[0].command.includes(expectedPath), `expected cleanup to target '${expectedPath}'; got ${cleanupCalls[0].command}`, ); + const dashboardPath = `/sandbox/.${sandboxAgent}/profiles/dashboard-home`; + assert.equal( + cleanupCalls[0].command.includes(dashboardPath), + sandboxAgent === "hermes", + `${sandboxAgent} Dashboard cleanup selection was incorrect; got ${cleanupCalls[0].command}`, + ); const rebuildIdx = payload.callOrder.indexOf("promptAndRebuild"); const clearIdx = payload.callOrder.indexOf("clearedSandboxState");