Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7aa7d11
refactor(messaging): retire WhatsApp session path compatibility
HOYALIM Aug 16, 2026
c856e15
merge: synchronize current main into WhatsApp cleanup
cv Aug 22, 2026
f2d8be9
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 22, 2026
bc6bd3f
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 22, 2026
e20d3a2
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 22, 2026
129eb8f
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 22, 2026
9dbead7
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 23, 2026
2ee067b
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
apurvvkumaria Aug 24, 2026
7656bed
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 24, 2026
1834089
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 24, 2026
f66d1fd
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 24, 2026
fb79723
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 24, 2026
a076200
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 24, 2026
712504f
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 24, 2026
66db0ec
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 24, 2026
353426a
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 24, 2026
0618fb2
Merge branch 'main' into codex/8947-remove-whatsapp-session-compat
cv Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"src/lib/cli/terminal-style.ts": 43,
"src/lib/core/json-types.ts": 37,
"src/lib/core/ports.ts": 89,
"src/lib/core/shell-quote.ts": 28,
"src/lib/core/shell-quote.ts": 27,
"src/lib/core/url-utils.ts": 30,
"src/lib/core/wait.ts": 36,
"src/lib/credentials/store.ts": 46,
Expand Down
14 changes: 0 additions & 14 deletions docs/manage-sandboxes/set-up-whatsapp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,6 @@ If pairing exits with a gateway close such as `1008`, rerun the login command on
<AgentOnly variant="hermes">
Hermes dashboard pairing and the gateway share `/sandbox/.hermes/platforms/whatsapp/session`.
After scanning a dashboard QR code, run `$$nemoclaw <sandbox> channels status --channel whatsapp` to confirm that the shared session is available to the gateway.

If `channels status` reports credentials only under the legacy `profiles/dashboard-home` path, the sandbox has credentials from an image that wrote dashboard sessions to that path.
Rebuilding and pairing again writes new credentials to the shared path, but it leaves the legacy credentials in durable state, and sandbox startup treats that copy as migration input.
Clear every WhatsApp session path first with the supported removal command, then re-add the channel:

```bash
$$nemoclaw <sandbox> channels remove whatsapp
$$nemoclaw <sandbox> channels add whatsapp
```

`channels remove` clears `/sandbox/.hermes/platforms/whatsapp`, the dashboard profile session at `/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session`, and the legacy migration source at `/sandbox/.hermes/dashboard-home/platforms/whatsapp/session`.
The sandbox must be running for that cleanup to succeed; `channels remove` refuses to proceed when it cannot reach the sandbox.
Pair again from the dashboard so the credentials are written to `/sandbox/.hermes/platforms/whatsapp/session`.
Rerun `$$nemoclaw <sandbox> channels status --channel whatsapp` and confirm that the gateway session path holds the credentials and that the report no longer lists a `profiles/dashboard-home` session.
</AgentOnly>

## Protect Paired Session State
Expand Down
20 changes: 2 additions & 18 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2979,7 +2979,6 @@ For QR-paired channels (today: WhatsApp), NemoClaw destructively clears the in-s

- OpenClaw: `/sandbox/.openclaw/<channel>/` (for example `/sandbox/.openclaw/whatsapp/`).
- Hermes: `/sandbox/.hermes/platforms/<channel>/` (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.

Expand Down Expand Up @@ -3050,23 +3049,8 @@ 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` first checks the shared gateway path and the `profiles/dashboard-home` path for `creds.json`.
The status compatibility probe has this contract:

| Property | Contract |
|------|-------------|
| Owner | The NemoClaw WhatsApp status hook owns the probe. |
| Scope | The hook reads only `platforms.whatsapp.extra.session_path` from `/sandbox/.hermes/config.yaml` when the durable session path has no `creds.json`. The hook does not write the field or configure Hermes. |
| Validation | The value must be an absolute path under `/sandbox/.hermes` with no `.` or `..` segment. |
| Supported state | The durable Hermes WhatsApp session remains `/sandbox/.hermes/platforms/whatsapp/session`. The compatibility field is not a supported NemoClaw setup or session-storage control. |
| Support period | Keep the probe only while a supported Hermes image can place dashboard-paired credentials outside the durable path. |
| Retirement | [Issue #8947](https://github.com/NVIDIA/NemoClaw/issues/8947) tracks removal after every supported Hermes image uses the durable path for dashboard pairing and gateway startup. |

After validation, the command checks the configured path and reports a `Session path override` signal.
An unsupported path produces a warning, and the report uses the default gateway path.
If the dashboard path has credentials and the gateway path is empty, the report tells you to run `$$nemoclaw <sandbox> channels remove whatsapp` and then `$$nemoclaw <sandbox> channels add whatsapp`, because a rebuild restores the legacy session instead of dropping it.
Pair again from the dashboard so credentials use `/sandbox/.hermes/platforms/whatsapp/session`.
Rerun `$$nemoclaw <sandbox> channels status --channel whatsapp`.
For a Hermes WhatsApp sandbox, `--channel whatsapp` checks only the shared gateway session path at `/sandbox/.hermes/platforms/whatsapp/session/creds.json`.
If the file is missing, pair with `hermes whatsapp` inside the sandbox and rerun `$$nemoclaw <sandbox> channels status --channel whatsapp`.
NemoClaw does not treat a Hermes session file as live inbound-health evidence.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

For Telegram, `--channel telegram` probes the sandbox to report the gateway process, Bot API reachability, and inbound delivery alongside the config comparison.
Expand Down
110 changes: 0 additions & 110 deletions src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,40 +77,13 @@ describe("evaluateWhatsappDiagnostics", () => {
expect(report.hints.join(" ")).toMatch(/hermes whatsapp/);
});

it("guides legacy Hermes dashboard sessions through channel removal and re-pairing (#8184)", () => {
const report = evaluateWhatsappDiagnostics(
baseInput({
agent: "hermes",
paired: false,
sessionLocations: {
gatewaySessionCreds: false,
dashboardSessionCreds: true,
},
}),
);
const session = report.signals.find((s) => s.label === "Session location");
const pairing = report.signals.find((s) => s.label === "Pairing / session");
const hints = report.hints.join(" ");
expect(report.verdict).toBe("unpaired");
expect(session?.severity).toBe("warn");
expect(session?.detail).toMatch(/dashboard-home has WhatsApp credentials/);
expect(pairing?.hint).toContain("`nemoclaw <sandbox> channels remove whatsapp`");
expect(session?.hint).toContain("`nemoclaw <sandbox> channels add whatsapp`");
expect(session?.hint).toMatch(/Pair again from the dashboard/);
expect(hints).toContain("/sandbox/.hermes/platforms/whatsapp/session");
expect(hints).toContain("`nemoclaw <sandbox> channels status --channel whatsapp`");
expect(hints).not.toContain("platforms.whatsapp.extra.session_path");
expect(hints).not.toContain("profiles/dashboard-home/platforms/whatsapp/session");
});

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,
},
}),
);
Expand All @@ -120,89 +93,6 @@ 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("accepts credentials found through the configured session path (#8718)", () => {
const report = evaluateWhatsappDiagnostics(
baseInput({
agent: "hermes",
paired: null,
sessionLocations: {
gatewaySessionCreds: true,
dashboardSessionCreds: true,
gatewaySessionPathSource: "config",
},
}),
);
const session = report.signals.find((s) => s.label === "Session location");
const override = report.signals.find((s) => s.label === "Session path override");
expect(report.verdict).toBe("unknown");
expect(session?.severity).toBe("ok");
expect(session?.detail).toBe(
"the configured Hermes WhatsApp session path contains credentials",
);
expect(override?.severity).toBe("info");
expect(override?.detail).toContain("platforms.whatsapp.extra.session_path");
});

it("stops repeating the repair command when the configured session path is empty (#8718)", () => {
const report = evaluateWhatsappDiagnostics(
baseInput({
agent: "hermes",
paired: false,
sessionLocations: {
gatewaySessionCreds: false,
dashboardSessionCreds: true,
gatewaySessionPathSource: "config",
},
}),
);
const session = report.signals.find((s) => s.label === "Session location");
expect(report.verdict).toBe("unpaired");
expect(session?.severity).toBe("warn");
expect(session?.detail).toBe(
"the configured Hermes WhatsApp session path has no WhatsApp credentials",
);
expect(report.hints.join(" ")).not.toContain("--config-accept-new-path");
expect(report.hints.join(" ")).toContain("hermes whatsapp");
});

it("warns when the configured session path is not a supported sandbox path (#8718)", () => {
const report = evaluateWhatsappDiagnostics(
baseInput({
agent: "hermes",
paired: false,
sessionLocations: {
gatewaySessionCreds: false,
dashboardSessionCreds: true,
gatewaySessionPathSource: "unsupported",
},
}),
);
const session = report.signals.find((s) => s.label === "Session location");
const override = report.signals.find((s) => s.label === "Session path override");
expect(session?.severity).toBe("warn");
expect(session?.detail).toMatch(/dashboard-home has WhatsApp credentials/);
expect(override?.severity).toBe("warn");
expect(override?.detail).toContain("not a supported session path");
});

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.
Expand Down
102 changes: 7 additions & 95 deletions src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,13 @@ 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.
// Hermes dashboard pairing and gateway startup share one canonical session
// path. This probe records only whether its credential file exists.
sessionLocations?: WhatsappSessionLocations;
};

export type WhatsappSessionLocations = {
gatewaySessionCreds: boolean | null;
dashboardSessionCreds: boolean | null;
gatewaySessionPathSource?: "default" | "config" | "unsupported";
gatewaySessionDir?: string;
};

/**
Expand Down Expand Up @@ -132,12 +129,6 @@ function isParseableTimestamp(value: string | null): value is string {
return value !== null && Number.isFinite(Date.parse(value));
}

// A rebuild restores the legacy dashboard session instead of dropping it, so
// re-pairing alone leaves the sandbox holding two credential sets. Send the
// operator through `channels remove`, which clears every WhatsApp session path.
const HERMES_LEGACY_DASHBOARD_SESSION_HINT =
"Run `nemoclaw <sandbox> channels remove whatsapp` to clear every WhatsApp session path, then `nemoclaw <sandbox> channels add whatsapp`. Pair again from the dashboard so credentials are stored in `/sandbox/.hermes/platforms/whatsapp/session`. Rerun `nemoclaw <sandbox> channels status --channel whatsapp`.";

function pairingSignal(input: WhatsappProbeInput): DiagnosticSignal {
if (!input.probeReachable) {
return {
Expand All @@ -155,15 +146,12 @@ 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
? HERMES_LEGACY_DASHBOARD_SESSION_HINT
: "run `hermes whatsapp` inside the sandbox to display a QR code",
hint: "run `hermes whatsapp` inside the sandbox to display a QR code",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This command does not write the canonical durable path required by the probe above. In the pinned Hermes image, #8229 patches dashboard pairing only and deliberately leaves the CLI path unchanged, so this hint can leave status unpaired and the session non-durable after rebuild. Point to dashboard pairing, or add tested canonical CLI support across restart, status, and rebuild.

};
}
const loginHint =
Expand All @@ -184,98 +172,31 @@ function pairingSignal(input: WhatsappProbeInput): DiagnosticSignal {
};
}

function hermesDashboardOnlySession(input: WhatsappProbeInput): boolean {
return (
input.agent === "hermes" &&
input.sessionLocations?.gatewaySessionCreds === false &&
input.sessionLocations.dashboardSessionCreds === true &&
input.sessionLocations.gatewaySessionPathSource !== "config"
);
}

function sessionLocationSignal(input: WhatsappProbeInput): DiagnosticSignal | null {
const locations = input.sessionLocations;
if (!locations) return null;
const gateway = locations.gatewaySessionCreds;
const dashboard = locations.dashboardSessionCreds;
if (locations.gatewaySessionPathSource === "config" && gateway !== null) {
const configuredPath = locations.gatewaySessionDir
? ` at \`${locations.gatewaySessionDir}\``
: "";
return gateway
? {
label: "Session location",
severity: "ok",
detail: `the configured Hermes WhatsApp session path${configuredPath} contains credentials`,
}
: {
label: "Session location",
severity: "warn",
detail: `the configured Hermes WhatsApp session path${configuredPath} has no WhatsApp credentials`,
hint: "pair WhatsApp again, or set `platforms.whatsapp.extra.session_path` to the session path that holds the credentials",
};
}
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: HERMES_LEGACY_DASHBOARD_SESSION_HINT,
};
}
if (gateway === true && dashboard === false) {
if (gateway === true) {
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) {
if (gateway === false) {
return {
label: "Session location",
severity: "info",
detail: "both Hermes session paths contain WhatsApp credentials",
hint: "use one active WhatsApp bridge for the paired account",
detail: "the Hermes gateway session path has no WhatsApp credentials",
};
}
return {
label: "Session location",
severity: "info",
detail: "Hermes session paths could not be inspected",
detail: "the Hermes gateway session path could not be inspected",
};
}

function sessionPathOverrideSignal(input: WhatsappProbeInput): DiagnosticSignal | null {
const source = input.sessionLocations?.gatewaySessionPathSource;
if (source === "config") {
return {
label: "Session path override",
severity: "info",
detail:
"the gateway session check used the path set in `platforms.whatsapp.extra.session_path`",
hint: "remove the override after Hermes uses one WhatsApp session path for dashboard pairing and gateway startup",
};
}
if (source === "unsupported") {
return {
label: "Session path override",
severity: "warn",
detail:
"`platforms.whatsapp.extra.session_path` is not a supported session path, so the gateway session check used the default path",
hint: "set `platforms.whatsapp.extra.session_path` to an absolute path under `/sandbox/.hermes` that has no `.` or `..` segment",
};
}
return null;
}

function websocketSignal(input: WhatsappProbeInput): DiagnosticSignal {
if (input.heartbeatParseError) {
return {
Expand Down Expand Up @@ -519,13 +440,6 @@ 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.",
HERMES_LEGACY_DASHBOARD_SESSION_HINT,
);
break;
}
hints.push(
input.agent === "hermes"
? "Run `hermes whatsapp` inside the sandbox and scan the QR with your phone."
Expand Down Expand Up @@ -556,12 +470,10 @@ function buildHints(verdict: WhatsappVerdict, input: WhatsappProbeInput): string

export function evaluateWhatsappDiagnostics(input: WhatsappProbeInput): WhatsappDiagnosticReport {
const sessionSignal = sessionLocationSignal(input);
const overrideSignal = sessionPathOverrideSignal(input);
const signals: DiagnosticSignal[] = [
configCoverageSignal(input),
pairingSignal(input),
...(sessionSignal ? [sessionSignal] : []),
...(overrideSignal ? [overrideSignal] : []),
bridgeProcessSignal(input),
websocketSignal(input),
inboundSignal(input),
Expand Down
Loading
Loading