From 122c51a50de90559d4364016acfcad2367862199 Mon Sep 17 00:00:00 2001 From: danielpolimac Date: Wed, 5 Aug 2026 07:33:07 +0200 Subject: [PATCH 1/9] fix(messaging): diagnose Hermes WhatsApp session split Signed-off-by: danielpolimac --- docs/manage-sandboxes/set-up-whatsapp.mdx | 20 +++++ docs/reference/commands.mdx | 14 ++-- .../actions/sandbox/channel-status.test.ts | 59 +++++++++++-- src/lib/actions/sandbox/channel-status.ts | 2 +- .../policy-channel-remove-flow.test.ts | 76 +++++++++++++++++ src/lib/actions/sandbox/policy-channel.ts | 12 ++- src/lib/messaging/channels/manifests.test.ts | 4 +- .../whatsapp/hooks/status-health-eval.test.ts | 40 +++++++++ .../whatsapp/hooks/status-health-eval.ts | 79 +++++++++++++++++ .../whatsapp/hooks/status-health.test.ts | 83 +++++++++++++++++- .../channels/whatsapp/hooks/status-health.ts | 84 +++++++++++++++++-- .../messaging/channels/whatsapp/manifest.ts | 4 +- 12 files changed, 449 insertions(+), 28 deletions(-) diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index 59dbf03daa1..c9641c4daaa 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -32,6 +32,24 @@ 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 dashboard-home 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 config set --key platforms.whatsapp.extra.session_path --value /sandbox/.hermes/dashboard-home/platforms/whatsapp/session --restart --config-accept-new-path +``` + +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. +This is a bounded compatibility repair for Hermes dashboard pairing that writes session credentials under dashboard-home while the gateway still reads the default session path. +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 @@ -50,6 +68,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 2c527bca5d6..3c7b8f75e81 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2165,10 +2165,11 @@ When the bridge provider is attached to a live sandbox, NemoClaw detaches it bef If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, or `whatsapp`, 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 `/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. @@ -2220,13 +2221,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 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`. @@ -2249,10 +2252,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..22b296c9bc6 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/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 c6931fb0f05..0c399b24e00 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,19 @@ import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; +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,6 +27,12 @@ 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(() => { @@ -42,6 +56,68 @@ describe("policy channel remove/enable flows", () => { expect(exitSpy).not.toHaveBeenCalled(); }); + it("clears Hermes WhatsApp default and dashboard-home sessions before removal", async () => { + 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", "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]); + vi.spyOn(policies, "removePreset").mockReturnValue(true); + vi.spyOn(policyChannelDependencies, "rebuildSandbox").mockResolvedValue(undefined); + + const previousNonInteractive = process.env.NEMOCLAW_NON_INTERACTIVE; + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + try { + await expect(removeSandboxChannel("alpha", { channel: "whatsapp" })).resolves.toBeUndefined(); + } finally { + Reflect.deleteProperty(process.env, "NEMOCLAW_NON_INTERACTIVE"); + Object.assign( + process.env, + previousNonInteractive === undefined + ? {} + : { NEMOCLAW_NON_INTERACTIVE: previousNonInteractive }, + ); + } + + 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/dashboard-home/platforms/whatsapp/session"); + expect(updateSandbox).toHaveBeenCalled(); + expect( + vi.mocked(processRecovery.executeSandboxExecCommand).mock.invocationCallOrder[0], + ).toBeLessThan(updateSandbox.mock.invocationCallOrder[0]); + }); + it("supports stop dry runs for configured channels", async () => { vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha" }); vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index ebad45040b4..07ed5fa3ff2 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -1463,13 +1463,17 @@ export function applyChannelPresetIfAvailable( function getSandboxChannelStatePaths(agent: AgentDefinition, channelName: string): string[] { const configDir = agent.configPaths.dir; const stateDirs = new Set(agent.stateDirs); + const paths: string[] = []; if (stateDirs.has("platforms")) { - return [`${configDir}/platforms/${channelName}`]; + paths.push(`${configDir}/platforms/${channelName}`); } - if (stateDirs.has(channelName)) { - return [`${configDir}/${channelName}`]; + if (channelName === "whatsapp" && stateDirs.has("dashboard-home")) { + paths.push(`${configDir}/dashboard-home/platforms/whatsapp/session`); } - return []; + 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..94afe58fc68 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,46 @@ 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/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("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..ec523914ce6 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/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/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..99aed48481e 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/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..2a3a250f896 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -8,6 +8,19 @@ * 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/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 dashboard-home while the gateway reads the default + * session path. Remove the dashboard-home 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 +57,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 +67,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 +84,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 +105,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 +156,7 @@ type ProbeResult = { readonly bridgeProcessAlive: boolean | null; readonly heartbeat: WhatsappHeartbeat | null; readonly recentLogSignals: readonly string[]; + readonly sessionLocations?: WhatsappSessionLocations; }; const PROBE_UNREACHABLE: ProbeResult = { @@ -203,6 +222,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/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", From 2d55cc0618214e49c41ed3c8e806af85e27416e1 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 5 Aug 2026 08:05:55 -0700 Subject: [PATCH 2/9] fix(messaging): use Hermes dashboard profile path Signed-off-by: Apurv Kumaria --- docs/manage-sandboxes/set-up-whatsapp.mdx | 6 +++--- docs/reference/commands.mdx | 4 ++-- src/lib/actions/sandbox/channel-status.test.ts | 2 +- .../sandbox/policy-channel-remove-flow.test.ts | 7 +++++-- src/lib/actions/sandbox/policy-channel.ts | 6 ++++++ .../whatsapp/hooks/status-health-eval.test.ts | 2 +- .../channels/whatsapp/hooks/status-health-eval.ts | 4 ++-- .../channels/whatsapp/hooks/status-health.test.ts | 2 +- .../channels/whatsapp/hooks/status-health.ts | 11 ++++++----- 9 files changed, 27 insertions(+), 17 deletions(-) diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index c9641c4daaa..365d472369c 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -33,16 +33,16 @@ NemoClaw validates the gateway URL before pairing and renders the WhatsApp QR co 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 dashboard-home while the gateway reads the default `platforms/whatsapp/session` path. +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 config set --key platforms.whatsapp.extra.session_path --value /sandbox/.hermes/dashboard-home/platforms/whatsapp/session --restart --config-accept-new-path +$$nemoclaw config set --key platforms.whatsapp.extra.session_path --value /sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session --restart --config-accept-new-path ``` 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. -This is a bounded compatibility repair for Hermes dashboard pairing that writes session credentials under dashboard-home while the gateway still reads the default session path. +This is a bounded compatibility repair for Hermes dashboard pairing that writes session credentials under `profiles/dashboard-home` while the gateway still reads the default session path. 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. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 76f4c842a65..4224c90ae79 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2254,7 +2254,7 @@ For QR-paired channels (today: WhatsApp), NemoClaw destructively clears the in-s - OpenClaw: `/sandbox/.openclaw//` (for example `/sandbox/.openclaw/whatsapp/`). - Hermes: `/sandbox/.hermes/platforms//` (for example `/sandbox/.hermes/platforms/whatsapp/`). - For WhatsApp, NemoClaw also clears `/sandbox/.hermes/dashboard-home/platforms/whatsapp/session/`. + 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. @@ -2316,7 +2316,7 @@ With `--channel`, it prints the detailed status for that channel. 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. -For a Hermes WhatsApp sandbox, `--channel whatsapp` probes only whether the default gateway session path and dashboard-home session path contain `creds.json`. +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. diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 22b296c9bc6..c65e2b840f9 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -255,7 +255,7 @@ describe("showSandboxChannelStatus (whatsapp)", () => { expect(commands).not.toContain("openclaw channels status"); expect(commands).toContain("/sandbox/.hermes/platforms/whatsapp/session/creds.json"); expect(commands).toContain( - "/sandbox/.hermes/dashboard-home/platforms/whatsapp/session/creds.json", + "/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session/creds.json", ); }); 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 97643c82675..1af92b49945 100644 --- a/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts +++ b/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts @@ -56,7 +56,7 @@ describe("policy channel remove/enable flows", () => { expect(exitSpy).not.toHaveBeenCalled(); }); - it("clears Hermes WhatsApp default and dashboard-home sessions before removal", async () => { + it("clears Hermes WhatsApp default, profile, and legacy sessions before removal", async () => { const plan = await new MessagingWorkflowPlanner( createBuiltInChannelManifestRegistry(), undefined, @@ -81,7 +81,7 @@ describe("policy channel remove/enable flows", () => { name: "hermes", displayName: "Hermes", configPaths: { dir: "/sandbox/.hermes" }, - stateDirs: ["platforms", "dashboard-home"], + stateDirs: ["platforms", "profiles", "dashboard-home"], } as unknown as defs.AgentDefinition); vi.spyOn(registry, "getSandbox").mockReturnValue(current); vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["whatsapp"]); @@ -111,6 +111,9 @@ describe("policy channel remove/enable flows", () => { ); 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( diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index b3bf6f2b9e9..1e06fbc37e3 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -1467,6 +1467,12 @@ function getSandboxChannelStatePaths(agent: AgentDefinition, channelName: string if (stateDirs.has("platforms")) { paths.push(`${configDir}/platforms/${channelName}`); } + if (channelName === "whatsapp" && stateDirs.has("profiles")) { + paths.push(`${configDir}/profiles/dashboard-home/platforms/whatsapp/session`); + } + // 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 (channelName === "whatsapp" && stateDirs.has("dashboard-home")) { paths.push(`${configDir}/dashboard-home/platforms/whatsapp/session`); } 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 94afe58fc68..68ca4cf50f7 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 @@ -95,7 +95,7 @@ describe("evaluateWhatsappDiagnostics", () => { expect(session?.hint).toContain("platforms.whatsapp.extra.session_path"); expect(session?.hint).toContain("--config-accept-new-path"); expect(report.hints.join(" ")).toContain( - "/sandbox/.hermes/dashboard-home/platforms/whatsapp/session", + "/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session", ); expect(report.hints.join(" ")).toContain("--config-accept-new-path"); }); 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 ec523914ce6..d0a92183e68 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts @@ -195,7 +195,7 @@ function sessionLocationSignal(input: WhatsappProbeInput): DiagnosticSignal | nu 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/dashboard-home/platforms/whatsapp/session --restart --config-accept-new-path`", + 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) { @@ -473,7 +473,7 @@ function buildHints(verdict: WhatsappVerdict, input: WhatsappProbeInput): string 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/dashboard-home/platforms/whatsapp/session --restart --config-accept-new-path` to point the gateway at that session.", + "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; } 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 99aed48481e..ab27d6bad51 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -415,7 +415,7 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { ); expect(command).toContain("/sandbox/.hermes/platforms/whatsapp/session/creds.json"); expect(command).toContain( - "/sandbox/.hermes/dashboard-home/platforms/whatsapp/session/creds.json", + "/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session/creds.json", ); expect(command).not.toMatch(/(^|[;&|]\s*)(cat|grep|find|ls)\b/); }); diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index 2a3a250f896..e6ba55de5fc 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -12,14 +12,15 @@ * WhatsApp session paths: * * /sandbox/.hermes/platforms/whatsapp/session/creds.json - * /sandbox/.hermes/dashboard-home/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 dashboard-home while the gateway reads the default - * session path. Remove the dashboard-home branch after Hermes uses one shared - * WhatsApp session path for both dashboard pairing and gateway startup. + * 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: * @@ -251,7 +252,7 @@ function runHermesSessionProbe( function hermesSessionProbeCommand(): string { return [ 'gateway="/sandbox/.hermes/platforms/whatsapp/session/creds.json"', - 'dashboard="/sandbox/.hermes/dashboard-home/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', From b07bbc6a2b713104200e4d11197254a3d9aecd74 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 5 Aug 2026 08:43:53 -0700 Subject: [PATCH 3/9] docs(messaging): clarify Hermes WhatsApp state Signed-off-by: Apurv Kumaria --- docs/manage-sandboxes/set-up-whatsapp.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index 365d472369c..7b080439c89 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -57,7 +57,7 @@ Supervisor quarantine happens after repeated relaunch failures or other integrit 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. From d812ca99fcc6a8afb2dd151abc48d871840dc9f9 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 5 Aug 2026 12:20:53 -0700 Subject: [PATCH 4/9] test(messaging): cover duplicate Hermes sessions (#8184) Signed-off-by: Charan Jagwani --- .../whatsapp/hooks/status-health-eval.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 68ca4cf50f7..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 @@ -117,6 +117,24 @@ describe("evaluateWhatsappDiagnostics", () => { 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. From 1de30e61ac7128ef58a4cdf86feee477d82d5ef1 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 5 Aug 2026 12:57:59 -0700 Subject: [PATCH 5/9] test(messaging): cover Hermes cleanup fallback Signed-off-by: Apurv Kumaria --- .../policy-channel-remove-flow.test.ts | 121 +++++++++++++++--- 1 file changed, 100 insertions(+), 21 deletions(-) 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 1af92b49945..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,6 +3,7 @@ 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, @@ -39,24 +40,7 @@ describe("policy channel remove/enable flows", () => { vi.restoreAllMocks(); }); - 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)"); - - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - it("supports a remove dry run without gateway, registry, or rebuild side effects", async () => { - await expect( - removeSandboxChannel("alpha", { channel: "telegram", dryRun: true }), - ).resolves.toBeUndefined(); - - expect(logSpy.mock.calls.flat().join("\n")).toContain( - "--dry-run: would remove channel 'telegram' for 'alpha'.", - ); - expect(exitSpy).not.toHaveBeenCalled(); - }); - - it("clears Hermes WhatsApp default, profile, and legacy sessions before removal", async () => { + async function arrangeHermesWhatsappRemoval() { const plan = await new MessagingWorkflowPlanner( createBuiltInChannelManifestRegistry(), undefined, @@ -89,13 +73,18 @@ describe("policy channel remove/enable flows", () => { const updateSandbox = vi.spyOn(registry, "updateSandbox").mockReturnValue(true); vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["whatsapp"]); vi.spyOn(policies, "listPresets").mockReturnValue([{ name: "whatsapp" } as never]); - vi.spyOn(policies, "removePreset").mockReturnValue(true); - vi.spyOn(policyChannelDependencies, "rebuildSandbox").mockResolvedValue(undefined); + 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 expect(removeSandboxChannel("alpha", { channel: "whatsapp" })).resolves.toBeUndefined(); + await removeSandboxChannel("alpha", { channel: "whatsapp" }); } finally { Reflect.deleteProperty(process.env, "NEMOCLAW_NON_INTERACTIVE"); Object.assign( @@ -105,6 +94,29 @@ describe("policy channel remove/enable flows", () => { : { 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)"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("supports a remove dry run without gateway, registry, or rebuild side effects", async () => { + await expect( + removeSandboxChannel("alpha", { channel: "telegram", dryRun: true }), + ).resolves.toBeUndefined(); + + expect(logSpy.mock.calls.flat().join("\n")).toContain( + "--dry-run: would remove channel 'telegram' for 'alpha'.", + ); + 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] ?? "", @@ -121,6 +133,73 @@ describe("policy channel remove/enable flows", () => { ).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"]); From 987d04d477f110e168f2deb12555c85e9e9bd84c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 5 Aug 2026 13:13:30 -0700 Subject: [PATCH 6/9] docs(messaging): clarify Hermes session redirect Signed-off-by: Apurv Kumaria --- docs/manage-sandboxes/set-up-whatsapp.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index 7b080439c89..fcfbd203fcb 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -42,7 +42,7 @@ $$nemoclaw config set --key platforms.whatsapp.extra.session_path --va ``` 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. -This is a bounded compatibility repair for Hermes dashboard pairing that writes session credentials under `profiles/dashboard-home` while the gateway still reads the default session path. +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. From 4ea2dd05917e78040491c5645f6f27c76207726c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 5 Aug 2026 20:00:05 -0700 Subject: [PATCH 7/9] fix(messaging): scope WhatsApp profile cleanup to Hermes Signed-off-by: Apurv Kumaria --- src/lib/actions/sandbox/policy-channel.ts | 5 +++-- test/channels-remove-full-teardown.test.ts | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 1e06fbc37e3..a9b47c924e2 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -1464,16 +1464,17 @@ function getSandboxChannelStatePaths(agent: AgentDefinition, channelName: 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")) { paths.push(`${configDir}/platforms/${channelName}`); } - if (channelName === "whatsapp" && stateDirs.has("profiles")) { + if (isHermesWhatsapp && stateDirs.has("profiles")) { paths.push(`${configDir}/profiles/dashboard-home/platforms/whatsapp/session`); } // 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 (channelName === "whatsapp" && stateDirs.has("dashboard-home")) { + if (isHermesWhatsapp && stateDirs.has("dashboard-home")) { paths.push(`${configDir}/dashboard-home/platforms/whatsapp/session`); } if (paths.length === 0 && stateDirs.has(channelName)) { diff --git a/test/channels-remove-full-teardown.test.ts b/test/channels-remove-full-teardown.test.ts index 06b5be1b265..dd039b6c1aa 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}`, ); + if (sandboxAgent === "openclaw") { + assert.ok( + !cleanupCalls[0].command.includes("/sandbox/.openclaw/profiles/dashboard-home"), + `OpenClaw cleanup must not receive Hermes Dashboard paths; got ${cleanupCalls[0].command}`, + ); + } const rebuildIdx = payload.callOrder.indexOf("promptAndRebuild"); const clearIdx = payload.callOrder.indexOf("clearedSandboxState"); From 17ca0620c87e96458c5923808712fae543802b88 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 5 Aug 2026 20:05:00 -0700 Subject: [PATCH 8/9] test(messaging): keep cleanup path assertions linear Signed-off-by: Apurv Kumaria --- test/channels-remove-full-teardown.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/channels-remove-full-teardown.test.ts b/test/channels-remove-full-teardown.test.ts index dd039b6c1aa..4c276de741e 100644 --- a/test/channels-remove-full-teardown.test.ts +++ b/test/channels-remove-full-teardown.test.ts @@ -282,12 +282,12 @@ const ctx = module.exports; cleanupCalls[0].command.includes(expectedPath), `expected cleanup to target '${expectedPath}'; got ${cleanupCalls[0].command}`, ); - if (sandboxAgent === "openclaw") { - assert.ok( - !cleanupCalls[0].command.includes("/sandbox/.openclaw/profiles/dashboard-home"), - `OpenClaw cleanup must not receive Hermes Dashboard paths; 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"); From c296f46e19d7f82738836d285b92f9b53019653d Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 5 Aug 2026 20:27:18 -0700 Subject: [PATCH 9/9] docs(messaging): document Shields-safe WhatsApp repair Signed-off-by: Apurv Kumaria --- docs/manage-sandboxes/set-up-whatsapp.mdx | 3 +++ test/agent-variant-docs.test.ts | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index fcfbd203fcb..8d9954da76e 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -38,9 +38,12 @@ After scanning a dashboard QR code, run `$$nemoclaw channels status -- 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. 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),