From fd5a8a2a4c39a424d07272e13be10aedabe32562 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 4 Aug 2026 22:02:46 +0700 Subject: [PATCH 01/31] fix(hermes): unify WhatsApp session state Signed-off-by: San Dang --- agents/hermes/Dockerfile | 34 ++++ agents/hermes/patch-whatsapp-session-path.mts | 171 ++++++++++++++++++ test/hermes-final-image-layout.test.ts | 6 + ...hermes-whatsapp-session-path-patch.test.ts | 85 +++++++++ 4 files changed, 296 insertions(+) create mode 100644 agents/hermes/patch-whatsapp-session-path.mts create mode 100644 test/hermes-whatsapp-session-path-patch.test.ts diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 616f4e5ad9f..c6cd9e63c21 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -103,6 +103,7 @@ COPY agents/hermes/patch-gateway-runtime-metadata.py /opt/nemoclaw-hermes-config COPY agents/hermes/patch-gateway-process-identity.py /opt/nemoclaw-hermes-config/patch-gateway-process-identity.py COPY agents/hermes/patch-cron-execution-runtime.py /opt/nemoclaw-hermes-config/patch-cron-execution-runtime.py COPY agents/hermes/patch-neutral-platform-env-activation.py /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py +COPY agents/hermes/patch-whatsapp-session-path.mts /opt/nemoclaw-hermes-config/patch-whatsapp-session-path.mts COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY src/lib/messaging/ /src/lib/messaging/ @@ -381,6 +382,39 @@ RUN /usr/bin/python3 -I \ "$NEMOCLAW_HERMES_NEUTRAL_PLATFORM_OUTPUT_SHA256" /opt/hermes/gateway/config.py \ | sha256sum -c - +# Hermes v0.19.0 resolves WhatsApp state relative to each process's +# HERMES_HOME. NemoClaw isolates the dashboard home, and the CLI still uses a +# legacy path, so successful pairing can write credentials outside the +# manifest-owned durable directory. Bind the three consumers to their reviewed +# sources before making the existing /sandbox/.hermes/platforms/whatsapp/session +# directory authoritative for pairing and gateway startup. Dashboard and CLI +# pairing child processes use umask 0007 so their state remains accessible to +# the separate Hermes gateway user through the shared sandbox group. Other +# processes retain their inherited umask. +ARG NEMOCLAW_HERMES_WHATSAPP_SESSION_PATCHER_SHA256=20e768d4617108180246c531475e21f00738272fcc0a8448d598534aa1eb16cb +ARG NEMOCLAW_HERMES_WHATSAPP_ADAPTER_SOURCE_SHA256=96730b2261eed2eb34affd1cec980a039c8cb8a7cc864b798b6e1b7f17a354a9 +ARG NEMOCLAW_HERMES_WHATSAPP_WEB_SERVER_SOURCE_SHA256=0bf9d4dd17a1b7c3d96c94dacea9884426e7bbb5c8818685b43ef68f2465b3f2 +ARG NEMOCLAW_HERMES_WHATSAPP_MAIN_SOURCE_SHA256=18bab193cf86e1198bcea7ca09f62b88f1d30615aeed367b88388de21c3723c0 +ARG NEMOCLAW_HERMES_WHATSAPP_ADAPTER_OUTPUT_SHA256=4e480d766267478a3dac7a0cdb2ab3c2264b33bec29860dc21e93f1422e742fd +ARG NEMOCLAW_HERMES_WHATSAPP_WEB_SERVER_OUTPUT_SHA256=4a773388ab1cd38ace0a8c7e21388aaa6c4a775ddbf6d7f1623cca461a09f3b3 +ARG NEMOCLAW_HERMES_WHATSAPP_MAIN_OUTPUT_SHA256=5c175a2dbeb23ccad66a3ce1cdbaa12f424cb925abc4fcf5e4d0064240d4f400 +# hadolint ignore=DL4006 +RUN printf '%s %s\n' \ + "$NEMOCLAW_HERMES_WHATSAPP_SESSION_PATCHER_SHA256" /opt/nemoclaw-hermes-config/patch-whatsapp-session-path.mts \ + "$NEMOCLAW_HERMES_WHATSAPP_ADAPTER_SOURCE_SHA256" /opt/hermes/plugins/platforms/whatsapp/adapter.py \ + "$NEMOCLAW_HERMES_WHATSAPP_WEB_SERVER_SOURCE_SHA256" /opt/hermes/hermes_cli/web_server.py \ + "$NEMOCLAW_HERMES_WHATSAPP_MAIN_SOURCE_SHA256" /opt/hermes/hermes_cli/main.py \ + | sha256sum -c - \ + || { echo "ERROR: Hermes WhatsApp session source identity mismatch" >&2; exit 1; } +# hadolint ignore=DL4006 +RUN node --experimental-strip-types \ + /opt/nemoclaw-hermes-config/patch-whatsapp-session-path.mts \ + && printf '%s %s\n' \ + "$NEMOCLAW_HERMES_WHATSAPP_ADAPTER_OUTPUT_SHA256" /opt/hermes/plugins/platforms/whatsapp/adapter.py \ + "$NEMOCLAW_HERMES_WHATSAPP_WEB_SERVER_OUTPUT_SHA256" /opt/hermes/hermes_cli/web_server.py \ + "$NEMOCLAW_HERMES_WHATSAPP_MAIN_OUTPUT_SHA256" /opt/hermes/hermes_cli/main.py \ + | sha256sum -c - + # Prove the source-level fallback on the real installed runtime through the # user-facing profile creation path. The new home must remain config-less while # all security, privacy, update, browser, and session defaults fail safe. diff --git a/agents/hermes/patch-whatsapp-session-path.mts b/agents/hermes/patch-whatsapp-session-path.mts new file mode 100644 index 00000000000..fc41d4784f0 --- /dev/null +++ b/agents/hermes/patch-whatsapp-session-path.mts @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Keep pinned Hermes WhatsApp pairing state in one durable directory. + * + * Hermes v0.19.0 resolves WhatsApp session state relative to each process's + * `HERMES_HOME`. NemoClaw gives the dashboard an isolated home, so dashboard QR + * pairing writes credentials that the gateway cannot read. The interactive + * `hermes whatsapp` command also uses the legacy `whatsapp/session` path. + * + * Patch the dashboard onboarding handler, gateway adapter, and CLI command to use + * the manifest-owned `/sandbox/.hermes/platforms/whatsapp/session` directory. + * Dashboard and CLI pairing child processes use umask 0007 so the Hermes gateway + * user can read pairing state through the shared sandbox group. Other processes + * keep their inherited umask. + * The Dockerfile binds each input to its reviewed source hash. Remove this patch + * when the minimum supported Hermes release provides one authoritative WhatsApp + * session path for these three consumers. + */ + +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { parseArgs } from "node:util"; +import { fileURLToPath } from "node:url"; + +export const CANONICAL_SESSION_PATH = "/sandbox/.hermes/platforms/whatsapp/session"; + +const EXPECTED_SOURCE_SHA256 = { + adapter: "96730b2261eed2eb34affd1cec980a039c8cb8a7cc864b798b6e1b7f17a354a9", + webServer: "0bf9d4dd17a1b7c3d96c94dacea9884426e7bbb5c8818685b43ef68f2465b3f2", + // patch-profile-policy-defaults.py changes independent update-policy code first. + main: "18bab193cf86e1198bcea7ca09f62b88f1d30615aeed367b88388de21c3723c0", +} as const; + +const ADAPTER_OLD = ` self._session_path: Path = Path(config.extra.get( + "session_path", + get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") + )) +`; +const ADAPTER_NEW = ` self._session_path: Path = Path(config.extra.get( + "session_path", + # NemoClaw keeps WhatsApp pairing credentials in manifest-owned durable state. + "${CANONICAL_SESSION_PATH}" + )) +`; + +const WEB_SERVER_OLD = `def _whatsapp_session_path() -> Path: + from hermes_constants import get_hermes_dir + + return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") +`; +const WEB_SERVER_NEW = `def _whatsapp_session_path() -> Path: + # NemoClaw isolates the dashboard home from gateway configuration and state. + return Path("${CANONICAL_SESSION_PATH}") +`; +const WEB_SERVER_POPEN_OLD = ` start_new_session=True, + env=env, + creationflags=windows_hide_flags(), +`; +const WEB_SERVER_POPEN_NEW = ` start_new_session=True, + env=env, + # Preserve pairing-state group access for the Hermes gateway user in the shared sandbox group. + umask=0o007, + creationflags=windows_hide_flags(), +`; + +const MAIN_OLD = ` session_dir = get_hermes_home() / "whatsapp" / "session" +`; +const MAIN_NEW = ` # NemoClaw keeps CLI and dashboard pairing in the gateway's durable state. + session_dir = Path("${CANONICAL_SESSION_PATH}") +`; +const MAIN_PAIRING_PROCESS_OLD = ` str(session_dir), + ], + cwd=str(bridge_dir), + env=with_hermes_node_path(), + ) +`; +const MAIN_PAIRING_PROCESS_NEW = ` str(session_dir), + ], + cwd=str(bridge_dir), + env=with_hermes_node_path(), + # Preserve pairing-state group access for the Hermes gateway user in the shared sandbox group. + umask=0o007, + ) +`; + +function sha256(source: string): string { + return createHash("sha256").update(source).digest("hex"); +} + +function replaceExact(source: string, oldValue: string, newValue: string, label: string): string { + const oldCount = source.split(oldValue).length - 1; + const newCount = source.split(newValue).length - 1; + if (oldCount !== 1 || newCount !== 0) { + throw new Error( + `Hermes ${label} source shape changed: expected one unpatched occurrence, found ${oldCount}; prepatched occurrences: ${newCount}`, + ); + } + return source.replace(oldValue, newValue); +} + +export function patchAdapterSource(source: string): string { + return replaceExact(source, ADAPTER_OLD, ADAPTER_NEW, "WhatsApp adapter"); +} + +export function patchWebServerSource(source: string): string { + const patchedPath = replaceExact(source, WEB_SERVER_OLD, WEB_SERVER_NEW, "WhatsApp dashboard"); + return replaceExact( + patchedPath, + WEB_SERVER_POPEN_OLD, + WEB_SERVER_POPEN_NEW, + "WhatsApp dashboard pairing process", + ); +} + +export function patchMainSource(source: string): string { + const patchedPath = replaceExact(source, MAIN_OLD, MAIN_NEW, "WhatsApp CLI"); + return replaceExact( + patchedPath, + MAIN_PAIRING_PROCESS_OLD, + MAIN_PAIRING_PROCESS_NEW, + "WhatsApp CLI pairing process", + ); +} + +function patchFile( + sourcePath: string, + kind: keyof typeof EXPECTED_SOURCE_SHA256, + patcher: (source: string) => string, +): void { + const source = fs.readFileSync(sourcePath, "utf8"); + const actualSha256 = sha256(source); + const expectedSha256 = EXPECTED_SOURCE_SHA256[kind]; + if (actualSha256 !== expectedSha256) { + throw new Error( + `${sourcePath} is not the reviewed Hermes v2026.7.20 ${kind} source; expected sha256 ${expectedSha256}, got ${actualSha256}`, + ); + } + fs.writeFileSync(sourcePath, patcher(source), "utf8"); +} + +function main(): void { + const { values } = parseArgs({ + options: { + adapter: { + type: "string", + default: "/opt/hermes/plugins/platforms/whatsapp/adapter.py", + }, + "web-server": { + type: "string", + default: "/opt/hermes/hermes_cli/web_server.py", + }, + main: { type: "string", default: "/opt/hermes/hermes_cli/main.py" }, + }, + strict: true, + }); + + patchFile(values.adapter, "adapter", patchAdapterSource); + patchFile(values["web-server"], "webServer", patchWebServerSource); + patchFile(values.main, "main", patchMainSource); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 93867396d2b..c81f16dc40c 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -77,6 +77,11 @@ const HERMES_INTEGRITY_FILES = [ source: "agents/hermes/patch-neutral-platform-env-activation.py", target: "/opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py", }, + { + arg: "NEMOCLAW_HERMES_WHATSAPP_SESSION_PATCHER_SHA256", + source: "agents/hermes/patch-whatsapp-session-path.mts", + target: "/opt/nemoclaw-hermes-config/patch-whatsapp-session-path.mts", + }, ] as const; type LegacyDataFixture = @@ -239,6 +244,7 @@ describe("Hermes final image layout", () => { "COPY agents/hermes/patch-gateway-process-identity.py /opt/nemoclaw-hermes-config/patch-gateway-process-identity.py", "COPY agents/hermes/patch-cron-execution-runtime.py /opt/nemoclaw-hermes-config/patch-cron-execution-runtime.py", "COPY agents/hermes/patch-neutral-platform-env-activation.py /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py", + "COPY agents/hermes/patch-whatsapp-session-path.mts /opt/nemoclaw-hermes-config/patch-whatsapp-session-path.mts", "COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json", "COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts", "COPY src/lib/messaging/ /src/lib/messaging/", diff --git a/test/hermes-whatsapp-session-path-patch.test.ts b/test/hermes-whatsapp-session-path-patch.test.ts new file mode 100644 index 00000000000..365a60ce3ea --- /dev/null +++ b/test/hermes-whatsapp-session-path-patch.test.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + CANONICAL_SESSION_PATH, + patchAdapterSource, + patchMainSource, + patchWebServerSource, +} from "../agents/hermes/patch-whatsapp-session-path.mts"; + +const ADAPTER_FIXTURE = ` self._session_path: Path = Path(config.extra.get( + "session_path", + get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") + )) +`; + +const WEB_SERVER_FIXTURE = `def _whatsapp_session_path() -> Path: + from hermes_constants import get_hermes_dir + + return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") + +def _spawn(): + return subprocess.Popen( + ["node", "bridge.js"], + start_new_session=True, + env=env, + creationflags=windows_hide_flags(), + ) +`; + +const MAIN_FIXTURE = `def cmd_whatsapp(): + session_dir = get_hermes_home() / "whatsapp" / "session" + try: + subprocess.run( + [ + "node", + "bridge.js", + "--session", + str(session_dir), + ], + cwd=str(bridge_dir), + env=with_hermes_node_path(), + ) +`; + +const patchers = { + adapter: patchAdapterSource, + webServer: patchWebServerSource, + main: patchMainSource, +}; + +describe("Hermes WhatsApp session location", () => { + it.each([ + ["gateway adapter", "adapter", ADAPTER_FIXTURE], + ["dashboard QR pairing", "webServer", WEB_SERVER_FIXTURE], + ["CLI QR pairing", "main", MAIN_FIXTURE], + ] as const)("uses the manifest-owned durable directory for %s (#8184)", (_label, kind, fixture) => { + const result = patchers[kind](fixture); + + expect(result).toContain(CANONICAL_SESSION_PATH); + expect(result).not.toContain('get_hermes_dir("platforms/whatsapp/session"'); + expect(result).not.toContain('get_hermes_home() / "whatsapp" / "session"'); + if (kind === "adapter") { + expect(result).toContain('config.extra.get(\n "session_path"'); + } + }); + + it.each([ + ["dashboard", patchWebServerSource, WEB_SERVER_FIXTURE], + ["CLI", patchMainSource, MAIN_FIXTURE], + ] as const)("preserves shared-group access when %s pairing creates session state (#8184)", (_label, patcher, fixture) => { + expect(patcher(fixture)).toContain("umask=0o007"); + }); + + it.each([ + ["adapter", ADAPTER_FIXTURE.replace('"whatsapp/session"', '"changed/session"')], + ["webServer", WEB_SERVER_FIXTURE.replace('"whatsapp/session"', '"changed/session"')], + ["webServer", WEB_SERVER_FIXTURE.replace("creationflags=", "changed_creationflags=")], + ["main", MAIN_FIXTURE.replace('"whatsapp"', '"changed"')], + ["main", MAIN_FIXTURE.replace("env=with_hermes_node_path()", "changed_env=True")], + ] as const)("fails closed when the pinned %s pairing source changes", (kind, fixture) => { + expect(() => patchers[kind](fixture)).toThrow("source shape changed"); + }); +}); From d7d2c17e4f9318deb33d388a1a9deb263f6dd8d7 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 4 Aug 2026 22:10:27 +0700 Subject: [PATCH 02/31] test(hermes): keep session patch cases linear Signed-off-by: San Dang --- test/hermes-whatsapp-session-path-patch.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/hermes-whatsapp-session-path-patch.test.ts b/test/hermes-whatsapp-session-path-patch.test.ts index 365a60ce3ea..ec5d1b5b3fc 100644 --- a/test/hermes-whatsapp-session-path-patch.test.ts +++ b/test/hermes-whatsapp-session-path-patch.test.ts @@ -61,9 +61,12 @@ describe("Hermes WhatsApp session location", () => { expect(result).toContain(CANONICAL_SESSION_PATH); expect(result).not.toContain('get_hermes_dir("platforms/whatsapp/session"'); expect(result).not.toContain('get_hermes_home() / "whatsapp" / "session"'); - if (kind === "adapter") { - expect(result).toContain('config.extra.get(\n "session_path"'); - } + }); + + it("preserves the explicit adapter session path override (#8184)", () => { + expect(patchAdapterSource(ADAPTER_FIXTURE)).toContain( + 'config.extra.get(\n "session_path"', + ); }); it.each([ From c3e80d3c3142a7ef2ec43d949650fbdffb684f6c Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 4 Aug 2026 23:21:39 +0700 Subject: [PATCH 03/31] refactor(messaging): use runtime manifest for Hermes WhatsApp Signed-off-by: San Dang --- agents/hermes/Dockerfile | 46 +-- agents/hermes/patch-whatsapp-session-path.mts | 171 -------- agents/hermes/start.sh | 18 +- scripts/lib/sandbox-init.sh | 387 ++++++++++++++++++ scripts/nemoclaw-start.sh | 383 +---------------- .../messaging/channels/whatsapp/manifest.ts | 16 + .../runtime/whatsapp-hermes-session.test.ts | 88 ++++ .../runtime/whatsapp-hermes-session.ts | 44 ++ src/lib/messaging/manifest/types.ts | 2 +- test/generate-hermes-config.test.ts | 7 +- test/hermes-final-image-layout.test.ts | 7 +- test/hermes-messaging-runtime-setup.test.ts | 142 +++++++ test/hermes-start.test.ts | 6 +- ...hermes-whatsapp-session-path-patch.test.ts | 88 ---- test/http-proxy-fix-sync.test.ts | 3 +- ...essaging-runtime-preload-packaging.test.ts | 21 + test/nemoclaw-start-guard-recovery.test.ts | 3 +- test/nemoclaw-start-runtime-env-alias.test.ts | 5 +- test/nemoclaw-start-slack-runtime.test.ts | 5 +- test/nemoclaw-start-telegram-runtime.test.ts | 5 +- test/nemoclaw-start.test.ts | 12 +- test/nemotron-inference-fix.test.ts | 3 +- test/support/entrypoint-script-fixture.ts | 12 + 23 files changed, 771 insertions(+), 703 deletions(-) delete mode 100644 agents/hermes/patch-whatsapp-session-path.mts create mode 100644 src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts create mode 100644 src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts create mode 100644 test/hermes-messaging-runtime-setup.test.ts delete mode 100644 test/hermes-whatsapp-session-path-patch.test.ts diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index c6cd9e63c21..18fd9911979 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -21,6 +21,12 @@ RUN ./install-reviewed-runtime.sh \ RUN chown -R root:root /opt/mcp-tool-discovery-runtime \ && chmod -R a=rX /opt/mcp-tool-discovery-runtime +FROM mcp-tool-discovery-runtime AS runtime-preload-builder +WORKDIR /opt/nemoclaw-root +COPY tsconfig.runtime-preloads.json /opt/nemoclaw-root/ +COPY src/lib/messaging/channels/ /opt/nemoclaw-root/src/lib/messaging/channels/ +RUN /opt/mcp-tool-discovery-runtime/node_modules/.bin/tsc -p tsconfig.runtime-preloads.json + FROM mcp-tool-discovery-runtime AS managed-startup-runtime-builder WORKDIR /opt/nemoclaw-managed-startup-build COPY src/lib/core/json-types.ts src/lib/core/ports.ts ./src/lib/core/ @@ -103,7 +109,6 @@ COPY agents/hermes/patch-gateway-runtime-metadata.py /opt/nemoclaw-hermes-config COPY agents/hermes/patch-gateway-process-identity.py /opt/nemoclaw-hermes-config/patch-gateway-process-identity.py COPY agents/hermes/patch-cron-execution-runtime.py /opt/nemoclaw-hermes-config/patch-cron-execution-runtime.py COPY agents/hermes/patch-neutral-platform-env-activation.py /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py -COPY agents/hermes/patch-whatsapp-session-path.mts /opt/nemoclaw-hermes-config/patch-whatsapp-session-path.mts COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY src/lib/messaging/ /src/lib/messaging/ @@ -137,6 +142,7 @@ COPY agents/hermes/mcp-config-transaction.py /usr/local/lib/nemoclaw/hermes-mcp- COPY src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.85.json /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json COPY scripts/state-dir-guard.py /usr/local/lib/nemoclaw/state-dir-guard.py COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ +COPY --from=runtime-preload-builder /opt/nemoclaw-root/dist/lib/messaging/channels/ /usr/local/lib/nemoclaw/preloads-compiled-channels/ FROM scratch AS hermes-wrapper-payload @@ -318,6 +324,11 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-managed-star && chmod 444 /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py \ && chmod 444 /usr/local/lib/nemoclaw/patch-hermes-langfuse-credentials.mts \ && chmod 444 /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ + && if [ -d /usr/local/lib/nemoclaw/preloads-compiled-channels ]; then \ + find /usr/local/lib/nemoclaw/preloads-compiled-channels -path '*/runtime/*.js' -type f \ + -exec sh -c 'for file do cp "$file" "/usr/local/lib/nemoclaw/preloads/$(basename "$file")"; done' sh {} +; \ + fi \ + && rm -rf /usr/local/lib/nemoclaw/preloads-compiled-channels \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then \ chown -R 0:0 /usr/local/lib/nemoclaw/preloads \ && find /usr/local/lib/nemoclaw/preloads -type f -exec chmod 444 {} + \ @@ -382,39 +393,6 @@ RUN /usr/bin/python3 -I \ "$NEMOCLAW_HERMES_NEUTRAL_PLATFORM_OUTPUT_SHA256" /opt/hermes/gateway/config.py \ | sha256sum -c - -# Hermes v0.19.0 resolves WhatsApp state relative to each process's -# HERMES_HOME. NemoClaw isolates the dashboard home, and the CLI still uses a -# legacy path, so successful pairing can write credentials outside the -# manifest-owned durable directory. Bind the three consumers to their reviewed -# sources before making the existing /sandbox/.hermes/platforms/whatsapp/session -# directory authoritative for pairing and gateway startup. Dashboard and CLI -# pairing child processes use umask 0007 so their state remains accessible to -# the separate Hermes gateway user through the shared sandbox group. Other -# processes retain their inherited umask. -ARG NEMOCLAW_HERMES_WHATSAPP_SESSION_PATCHER_SHA256=20e768d4617108180246c531475e21f00738272fcc0a8448d598534aa1eb16cb -ARG NEMOCLAW_HERMES_WHATSAPP_ADAPTER_SOURCE_SHA256=96730b2261eed2eb34affd1cec980a039c8cb8a7cc864b798b6e1b7f17a354a9 -ARG NEMOCLAW_HERMES_WHATSAPP_WEB_SERVER_SOURCE_SHA256=0bf9d4dd17a1b7c3d96c94dacea9884426e7bbb5c8818685b43ef68f2465b3f2 -ARG NEMOCLAW_HERMES_WHATSAPP_MAIN_SOURCE_SHA256=18bab193cf86e1198bcea7ca09f62b88f1d30615aeed367b88388de21c3723c0 -ARG NEMOCLAW_HERMES_WHATSAPP_ADAPTER_OUTPUT_SHA256=4e480d766267478a3dac7a0cdb2ab3c2264b33bec29860dc21e93f1422e742fd -ARG NEMOCLAW_HERMES_WHATSAPP_WEB_SERVER_OUTPUT_SHA256=4a773388ab1cd38ace0a8c7e21388aaa6c4a775ddbf6d7f1623cca461a09f3b3 -ARG NEMOCLAW_HERMES_WHATSAPP_MAIN_OUTPUT_SHA256=5c175a2dbeb23ccad66a3ce1cdbaa12f424cb925abc4fcf5e4d0064240d4f400 -# hadolint ignore=DL4006 -RUN printf '%s %s\n' \ - "$NEMOCLAW_HERMES_WHATSAPP_SESSION_PATCHER_SHA256" /opt/nemoclaw-hermes-config/patch-whatsapp-session-path.mts \ - "$NEMOCLAW_HERMES_WHATSAPP_ADAPTER_SOURCE_SHA256" /opt/hermes/plugins/platforms/whatsapp/adapter.py \ - "$NEMOCLAW_HERMES_WHATSAPP_WEB_SERVER_SOURCE_SHA256" /opt/hermes/hermes_cli/web_server.py \ - "$NEMOCLAW_HERMES_WHATSAPP_MAIN_SOURCE_SHA256" /opt/hermes/hermes_cli/main.py \ - | sha256sum -c - \ - || { echo "ERROR: Hermes WhatsApp session source identity mismatch" >&2; exit 1; } -# hadolint ignore=DL4006 -RUN node --experimental-strip-types \ - /opt/nemoclaw-hermes-config/patch-whatsapp-session-path.mts \ - && printf '%s %s\n' \ - "$NEMOCLAW_HERMES_WHATSAPP_ADAPTER_OUTPUT_SHA256" /opt/hermes/plugins/platforms/whatsapp/adapter.py \ - "$NEMOCLAW_HERMES_WHATSAPP_WEB_SERVER_OUTPUT_SHA256" /opt/hermes/hermes_cli/web_server.py \ - "$NEMOCLAW_HERMES_WHATSAPP_MAIN_OUTPUT_SHA256" /opt/hermes/hermes_cli/main.py \ - | sha256sum -c - - # Prove the source-level fallback on the real installed runtime through the # user-facing profile creation path. The new home must remain config-less while # all security, privacy, update, browser, and session defaults fail safe. diff --git a/agents/hermes/patch-whatsapp-session-path.mts b/agents/hermes/patch-whatsapp-session-path.mts deleted file mode 100644 index fc41d4784f0..00000000000 --- a/agents/hermes/patch-whatsapp-session-path.mts +++ /dev/null @@ -1,171 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** Keep pinned Hermes WhatsApp pairing state in one durable directory. - * - * Hermes v0.19.0 resolves WhatsApp session state relative to each process's - * `HERMES_HOME`. NemoClaw gives the dashboard an isolated home, so dashboard QR - * pairing writes credentials that the gateway cannot read. The interactive - * `hermes whatsapp` command also uses the legacy `whatsapp/session` path. - * - * Patch the dashboard onboarding handler, gateway adapter, and CLI command to use - * the manifest-owned `/sandbox/.hermes/platforms/whatsapp/session` directory. - * Dashboard and CLI pairing child processes use umask 0007 so the Hermes gateway - * user can read pairing state through the shared sandbox group. Other processes - * keep their inherited umask. - * The Dockerfile binds each input to its reviewed source hash. Remove this patch - * when the minimum supported Hermes release provides one authoritative WhatsApp - * session path for these three consumers. - */ - -import { createHash } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { parseArgs } from "node:util"; -import { fileURLToPath } from "node:url"; - -export const CANONICAL_SESSION_PATH = "/sandbox/.hermes/platforms/whatsapp/session"; - -const EXPECTED_SOURCE_SHA256 = { - adapter: "96730b2261eed2eb34affd1cec980a039c8cb8a7cc864b798b6e1b7f17a354a9", - webServer: "0bf9d4dd17a1b7c3d96c94dacea9884426e7bbb5c8818685b43ef68f2465b3f2", - // patch-profile-policy-defaults.py changes independent update-policy code first. - main: "18bab193cf86e1198bcea7ca09f62b88f1d30615aeed367b88388de21c3723c0", -} as const; - -const ADAPTER_OLD = ` self._session_path: Path = Path(config.extra.get( - "session_path", - get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") - )) -`; -const ADAPTER_NEW = ` self._session_path: Path = Path(config.extra.get( - "session_path", - # NemoClaw keeps WhatsApp pairing credentials in manifest-owned durable state. - "${CANONICAL_SESSION_PATH}" - )) -`; - -const WEB_SERVER_OLD = `def _whatsapp_session_path() -> Path: - from hermes_constants import get_hermes_dir - - return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") -`; -const WEB_SERVER_NEW = `def _whatsapp_session_path() -> Path: - # NemoClaw isolates the dashboard home from gateway configuration and state. - return Path("${CANONICAL_SESSION_PATH}") -`; -const WEB_SERVER_POPEN_OLD = ` start_new_session=True, - env=env, - creationflags=windows_hide_flags(), -`; -const WEB_SERVER_POPEN_NEW = ` start_new_session=True, - env=env, - # Preserve pairing-state group access for the Hermes gateway user in the shared sandbox group. - umask=0o007, - creationflags=windows_hide_flags(), -`; - -const MAIN_OLD = ` session_dir = get_hermes_home() / "whatsapp" / "session" -`; -const MAIN_NEW = ` # NemoClaw keeps CLI and dashboard pairing in the gateway's durable state. - session_dir = Path("${CANONICAL_SESSION_PATH}") -`; -const MAIN_PAIRING_PROCESS_OLD = ` str(session_dir), - ], - cwd=str(bridge_dir), - env=with_hermes_node_path(), - ) -`; -const MAIN_PAIRING_PROCESS_NEW = ` str(session_dir), - ], - cwd=str(bridge_dir), - env=with_hermes_node_path(), - # Preserve pairing-state group access for the Hermes gateway user in the shared sandbox group. - umask=0o007, - ) -`; - -function sha256(source: string): string { - return createHash("sha256").update(source).digest("hex"); -} - -function replaceExact(source: string, oldValue: string, newValue: string, label: string): string { - const oldCount = source.split(oldValue).length - 1; - const newCount = source.split(newValue).length - 1; - if (oldCount !== 1 || newCount !== 0) { - throw new Error( - `Hermes ${label} source shape changed: expected one unpatched occurrence, found ${oldCount}; prepatched occurrences: ${newCount}`, - ); - } - return source.replace(oldValue, newValue); -} - -export function patchAdapterSource(source: string): string { - return replaceExact(source, ADAPTER_OLD, ADAPTER_NEW, "WhatsApp adapter"); -} - -export function patchWebServerSource(source: string): string { - const patchedPath = replaceExact(source, WEB_SERVER_OLD, WEB_SERVER_NEW, "WhatsApp dashboard"); - return replaceExact( - patchedPath, - WEB_SERVER_POPEN_OLD, - WEB_SERVER_POPEN_NEW, - "WhatsApp dashboard pairing process", - ); -} - -export function patchMainSource(source: string): string { - const patchedPath = replaceExact(source, MAIN_OLD, MAIN_NEW, "WhatsApp CLI"); - return replaceExact( - patchedPath, - MAIN_PAIRING_PROCESS_OLD, - MAIN_PAIRING_PROCESS_NEW, - "WhatsApp CLI pairing process", - ); -} - -function patchFile( - sourcePath: string, - kind: keyof typeof EXPECTED_SOURCE_SHA256, - patcher: (source: string) => string, -): void { - const source = fs.readFileSync(sourcePath, "utf8"); - const actualSha256 = sha256(source); - const expectedSha256 = EXPECTED_SOURCE_SHA256[kind]; - if (actualSha256 !== expectedSha256) { - throw new Error( - `${sourcePath} is not the reviewed Hermes v2026.7.20 ${kind} source; expected sha256 ${expectedSha256}, got ${actualSha256}`, - ); - } - fs.writeFileSync(sourcePath, patcher(source), "utf8"); -} - -function main(): void { - const { values } = parseArgs({ - options: { - adapter: { - type: "string", - default: "/opt/hermes/plugins/platforms/whatsapp/adapter.py", - }, - "web-server": { - type: "string", - default: "/opt/hermes/hermes_cli/web_server.py", - }, - main: { type: "string", default: "/opt/hermes/hermes_cli/main.py" }, - }, - strict: true, - }); - - patchFile(values.adapter, "adapter", patchAdapterSource); - patchFile(values["web-server"], "webServer", patchWebServerSource); - patchFile(values.main, "main", patchMainSource); -} - -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - try { - main(); - } catch (error) { - console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - } -} diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index a251b4d155b..428e406dec8 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -359,6 +359,13 @@ verify_hermes_config_integrity() { # configure_messaging_channels is provided by sandbox-init.sh (shared). +prepare_hermes_messaging_runtime() { + write_messaging_runtime_setup_plan || return 1 + install_messaging_runtime_preloads || return 1 + verify_messaging_runtime_secret_scans || return 1 + write_runtime_shell_env +} + print_dashboard_urls() { local api_url dashboard_url api_url="http://127.0.0.1:${PUBLIC_PORT}/v1" @@ -1770,6 +1777,9 @@ if [ -f /opt/hermes/ui-tui/dist/entry.js ]; then export HERMES_TUI_DIR="/opt/hermes/ui-tui" fi TUIENVEOF + if type emit_messaging_connect_runtime_preload_exports >/dev/null 2>&1; then + emit_messaging_connect_runtime_preload_exports + fi for _ca_env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO NODE_EXTRA_CA_CERTS; do _ca_env_value="${!_ca_env_name:-}" if [ -n "$_ca_env_value" ]; then @@ -2161,7 +2171,7 @@ validate_running_hermes_boundary() { validate_hermes_runtime_env_secret_boundary || return 1 HERMES_RESTART_FAILURE_CODE=preload-missing # shellcheck disable=SC2119 - validate_tmp_permissions || return 1 + validate_messaging_runtime_tmp_permissions || return 1 } prepare_hermes_gateway_restart() { @@ -2875,6 +2885,7 @@ prepare_hermes_nonroot_runtime() { refresh_hermes_runtime_config_hashes compat || return 1 inspect_hermes_mcp_integrity "${HERMES_DIR}/.config-hash" || return 1 configure_messaging_channels || return 1 + prepare_hermes_messaging_runtime || return 1 prepare_tirith_marker_retry || return 1 } @@ -2887,6 +2898,7 @@ prepare_hermes_root_runtime() { validate_hermes_runtime_env_secret_boundary || return 1 refresh_hermes_provider_placeholders both || return 1 configure_messaging_channels || return 1 + prepare_hermes_messaging_runtime || return 1 prepare_tirith_marker_retry || return 1 } @@ -3241,7 +3253,7 @@ if [ "$(id -u)" -ne 0 ]; then # Defence-in-depth: verify /tmp file permissions before launching services. # shellcheck disable=SC2119 - validate_tmp_permissions + validate_messaging_runtime_tmp_permissions # Start Hermes gateway. Messaging egress goes directly through OpenShell. umask 0007 @@ -3284,7 +3296,7 @@ prepare_restricted_log /tmp/gateway.log gateway:gateway 600 # Defence-in-depth: verify /tmp file permissions before launching services. # shellcheck disable=SC2119 -validate_tmp_permissions +validate_messaging_runtime_tmp_permissions # Start Hermes gateway. Messaging egress goes directly through OpenShell. launch_hermes_gateway diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index ffa7ff3fdef..66ac4eb7bc2 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -819,3 +819,390 @@ for item in plan.get("channels", []): print(channel) PY } + +# ── Messaging runtime setup from manifest metadata ─────────────── +# Channel-owned runtime setup is compiled from manifests at image build time. +# Both agent entrypoints consume the same generic declarations: envAliases, +# nodePreloads, and secretScans. Prefer a forwarded env plan when present; +# otherwise load the reduced image artifact written by the messaging build +# applier. +_MESSAGING_RUNTIME_PLAN_ARTIFACT="${NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH:-/usr/local/share/nemoclaw/messaging-runtime-plan.json}" +_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json" +_MESSAGING_CONNECT_PRELOADS_FILE="/tmp/nemoclaw-messaging-connect-preloads.list" + +write_messaging_runtime_setup_plan() { + python3 - "$_MESSAGING_RUNTIME_PLAN_ARTIFACT" <<'PYMESSAGINGRUNTIME' | emit_sandbox_sourced_file "$_MESSAGING_RUNTIME_SETUP_PLAN" +import base64 +import json +import os +import re +import sys + +EMPTY = {"nodePreloads": [], "envAliases": [], "secretScans": []} +PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/" +PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-" +ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$") + + +def fail(message): + print(f"[channels] Invalid messaging runtime setup plan: {message}", file=sys.stderr) + raise SystemExit(1) + + +def clean_string(value, field, *, allow_empty=False): + if not isinstance(value, str): + fail(f"{field} must be a string") + if not allow_empty and not value: + fail(f"{field} must not be empty") + if any(ch in value for ch in "\x00\r\n\t"): + fail(f"{field} contains a control character") + return value + + +def clean_message(value, field): + if value is None: + return "" + if not isinstance(value, str): + fail(f"{field} must be a string") + if any(ch in value for ch in "\x00\r\n\t"): + fail(f"{field} contains a control character") + return value + + +def clean_node_preload(entry, index): + if not isinstance(entry, dict): + fail(f"nodePreloads[{index}] must be an object") + source = clean_string(entry.get("source"), f"nodePreloads[{index}].source") + target = clean_string(entry.get("target"), f"nodePreloads[{index}].target") + if not source.startswith(PRELOAD_SOURCE_PREFIX) or not source.endswith(".js"): + fail(f"nodePreloads[{index}].source must be a preload JavaScript file under {PRELOAD_SOURCE_PREFIX}") + if not target.startswith(PRELOAD_TARGET_PREFIX) or not target.endswith(".js"): + fail(f"nodePreloads[{index}].target must be a JavaScript file under {PRELOAD_TARGET_PREFIX}*") + inject_into = entry.get("injectInto", []) + if not isinstance(inject_into, list): + fail(f"nodePreloads[{index}].injectInto must be a list") + normalized_scopes = [] + for scope in inject_into: + if scope not in ("boot", "connect"): + fail(f"nodePreloads[{index}].injectInto contains unsupported value {scope!r}") + if scope not in normalized_scopes: + normalized_scopes.append(scope) + optional = entry.get("optional", False) + if not isinstance(optional, bool): + fail(f"nodePreloads[{index}].optional must be a boolean") + return { + "source": source, + "target": target, + "injectInto": normalized_scopes, + "optional": optional, + "installMessage": clean_message(entry.get("installMessage"), f"nodePreloads[{index}].installMessage"), + "installedMessage": clean_message(entry.get("installedMessage"), f"nodePreloads[{index}].installedMessage"), + } + + +def clean_env_alias(entry, index): + if not isinstance(entry, dict): + fail(f"envAliases[{index}] must be an object") + env_key = clean_string(entry.get("envKey"), f"envAliases[{index}].envKey") + if not ENV_KEY_RE.match(env_key): + fail(f"envAliases[{index}].envKey is not a safe environment key") + pattern = clean_string(entry.get("match"), f"envAliases[{index}].match") + try: + re.compile(pattern) + except re.error as exc: + fail(f"envAliases[{index}].match is not a valid regex: {exc}") + return { + "envKey": env_key, + "match": pattern, + "value": clean_string(entry.get("value"), f"envAliases[{index}].value", allow_empty=True), + "message": clean_message(entry.get("message"), f"envAliases[{index}].message"), + } + + +def clean_secret_scan(entry, index): + if not isinstance(entry, dict): + fail(f"secretScans[{index}] must be an object") + path = clean_string(entry.get("path"), f"secretScans[{index}].path") + if not path.startswith("/sandbox/"): + fail(f"secretScans[{index}].path must be under /sandbox") + pattern = clean_string(entry.get("pattern"), f"secretScans[{index}].pattern") + try: + re.compile(pattern) + except re.error as exc: + fail(f"secretScans[{index}].pattern is not a valid regex: {exc}") + exit_code = entry.get("exitCode", 78) + if not isinstance(exit_code, int) or exit_code < 1 or exit_code > 255: + fail(f"secretScans[{index}].exitCode must be an integer from 1 to 255") + return { + "path": path, + "pattern": pattern, + "message": clean_message(entry.get("message"), f"secretScans[{index}].message") or "[SECURITY] Runtime secret scan failed for {path}", + "exitCode": exit_code, + } + + +def load_messaging_plan(): + raw_plan = os.environ.get("NEMOCLAW_MESSAGING_PLAN_B64", "").strip() + if raw_plan: + try: + return json.loads(base64.b64decode(raw_plan, validate=True).decode("utf-8")) + except Exception as exc: + fail(f"NEMOCLAW_MESSAGING_PLAN_B64 is not valid base64 JSON: {exc}") + artifact_path = sys.argv[1] if len(sys.argv) > 1 else "" + if not artifact_path or not os.path.isfile(artifact_path): + return None + try: + with open(artifact_path, encoding="utf-8") as handle: + return json.load(handle) + except Exception as exc: + fail(f"messaging runtime plan artifact {artifact_path} is not valid JSON: {exc}") + + +plan = load_messaging_plan() +if plan is None: + print(json.dumps(EMPTY, sort_keys=True)) + raise SystemExit(0) +if not isinstance(plan, dict): + fail("decoded plan must be an object") + +disabled_channels = { + channel_id + for channel_id in plan.get("disabledChannels", []) + if isinstance(channel_id, str) +} +active_channel_ids = set() +for channel in plan.get("channels", []): + if not isinstance(channel, dict): + continue + channel_id = channel.get("channelId") + if not isinstance(channel_id, str): + continue + if channel.get("active") is True and channel.get("disabled") is not True and channel_id not in disabled_channels: + active_channel_ids.add(channel_id) + +runtime_setup = plan.get("runtimeSetup", EMPTY) +if runtime_setup is None: + runtime_setup = EMPTY +if not isinstance(runtime_setup, dict): + fail("runtimeSetup must be an object") + + +def runtime_setup_entries(key): + entries = runtime_setup.get(key, []) + if not isinstance(entries, list): + fail(f"runtimeSetup.{key} must be a list") + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + fail(f"runtimeSetup.{key}[{index}] must be an object") + channel_id = entry.get("channelId") + if not isinstance(channel_id, str) or not channel_id: + fail(f"runtimeSetup.{key}[{index}].channelId must be a string") + if channel_id not in active_channel_ids: + continue + yield entry + + +node_preloads = [] +env_aliases = [] +secret_scans = [] +seen_node_preloads = set() +seen_aliases = set() +seen_scans = set() + +for entry in runtime_setup_entries("nodePreloads"): + preload = clean_node_preload(entry, len(node_preloads)) + preload_key = (preload["source"], preload["target"]) + if preload_key not in seen_node_preloads: + seen_node_preloads.add(preload_key) + node_preloads.append(preload) +for entry in runtime_setup_entries("envAliases"): + alias = clean_env_alias(entry, len(env_aliases)) + alias_key = (alias["envKey"], alias["match"], alias["value"]) + if alias_key not in seen_aliases: + seen_aliases.add(alias_key) + env_aliases.append(alias) +for entry in runtime_setup_entries("secretScans"): + scan = clean_secret_scan(entry, len(secret_scans)) + scan_key = (scan["path"], scan["pattern"]) + if scan_key not in seen_scans: + seen_scans.add(scan_key) + secret_scans.append(scan) + +print(json.dumps({"nodePreloads": node_preloads, "envAliases": env_aliases, "secretScans": secret_scans}, sort_keys=True)) +PYMESSAGINGRUNTIME +} + +apply_messaging_runtime_env_aliases() { + [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 + local _rows + _rows="$( + python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGALIASES' +import json +import os +import re +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + plan = json.load(handle) +for alias in plan.get("envAliases", []): + if not re.search(alias["match"], os.environ.get(alias["envKey"], "")): + continue + print("\t".join([ + alias["envKey"], + alias["value"], + alias.get("message", ""), + ])) +PYMESSAGINGALIASES + )" || return $? + [ -n "$_rows" ] || return 0 + + local _env_key _value _message + while IFS=$'\t' read -r _env_key _value _message; do + export "$_env_key=$_value" + [ -n "$_message" ] && printf '%s\n' "$_message" >&2 + done <<<"$_rows" +} + +node_options_has_require() { + local wanted="$1" + local previous="" + local token + local tokens=() + IFS=$' \t\n' read -r -a tokens <<<"${NODE_OPTIONS:-}" + # Iterating "${tokens[@]}" on an empty array trips `set -u` on bash 3.2 + # (macOS default); guard so the local unit harnesses run there too. + [ "${#tokens[@]}" -gt 0 ] || return 1 + for token in "${tokens[@]}"; do + if [ "$previous" = "--require" ] && [ "$token" = "$wanted" ]; then + return 0 + fi + [ "$token" = "--require=$wanted" ] && return 0 + previous="$token" + done + return 1 +} + +append_node_require_once() { + local wanted="$1" + if ! node_options_has_require "$wanted"; then + export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $wanted" + fi +} + +install_messaging_runtime_preloads() { + [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 + local _rows + _rows="$( + python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGPRELOADS' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + plan = json.load(handle) +for preload in plan.get("nodePreloads", []): + print("\t".join([ + preload["source"], + preload["target"], + ",".join(preload.get("injectInto", [])), + "1" if preload.get("optional") else "0", + preload.get("installMessage", ""), + preload.get("installedMessage", ""), + ])) +PYMESSAGINGPRELOADS + )" || return $? + + local _connect_preloads=() + if [ -n "$_rows" ]; then + local _source _target _inject_into _optional _install_message _installed_message + while IFS=$'\t' read -r _source _target _inject_into _optional _install_message _installed_message; do + if [ ! -f "$_source" ]; then + [ "$_optional" = "1" ] && continue + printf '[channels] Missing runtime preload source: %s\n' "$_source" >&2 + return 1 + fi + [ -n "$_install_message" ] && printf '%s\n' "$_install_message" >&2 + emit_sandbox_sourced_file "$_target" <"$_source" || return 1 + case ",$_inject_into," in + *,boot,*) + append_node_require_once "$_target" + ;; + esac + case ",$_inject_into," in + *,connect,*) + _connect_preloads+=("$_target") + ;; + esac + [ -n "$_installed_message" ] && printf '%s\n' "$_installed_message" >&2 + done <<<"$_rows" + fi + + if [ "${#_connect_preloads[@]}" -gt 0 ]; then + printf '%s\n' "${_connect_preloads[@]}" \ + | emit_sandbox_sourced_file "$_MESSAGING_CONNECT_PRELOADS_FILE" || return 1 + else + : | emit_sandbox_sourced_file "$_MESSAGING_CONNECT_PRELOADS_FILE" || return 1 + fi +} + +emit_messaging_connect_runtime_preload_exports() { + cat < 255: - fail(f"secretScans[{index}].exitCode must be an integer from 1 to 255") - return { - "path": path, - "pattern": pattern, - "message": clean_message(entry.get("message"), f"secretScans[{index}].message") or "[SECURITY] Runtime secret scan failed for {path}", - "exitCode": exit_code, - } - - -def load_messaging_plan(): - raw_plan = os.environ.get("NEMOCLAW_MESSAGING_PLAN_B64", "").strip() - if raw_plan: - try: - return json.loads(base64.b64decode(raw_plan, validate=True).decode("utf-8")) - except Exception as exc: - fail(f"NEMOCLAW_MESSAGING_PLAN_B64 is not valid base64 JSON: {exc}") - artifact_path = sys.argv[1] if len(sys.argv) > 1 else "" - if not artifact_path or not os.path.isfile(artifact_path): - return None - try: - with open(artifact_path, encoding="utf-8") as handle: - return json.load(handle) - except Exception as exc: - fail(f"messaging runtime plan artifact {artifact_path} is not valid JSON: {exc}") - - -plan = load_messaging_plan() -if plan is None: - print(json.dumps(EMPTY, sort_keys=True)) - raise SystemExit(0) -if not isinstance(plan, dict): - fail("decoded plan must be an object") - -disabled_channels = { - channel_id - for channel_id in plan.get("disabledChannels", []) - if isinstance(channel_id, str) -} -active_channel_ids = set() -for channel in plan.get("channels", []): - if not isinstance(channel, dict): - continue - channel_id = channel.get("channelId") - if not isinstance(channel_id, str): - continue - if channel.get("active") is True and channel.get("disabled") is not True and channel_id not in disabled_channels: - active_channel_ids.add(channel_id) - -runtime_setup = plan.get("runtimeSetup", EMPTY) -if runtime_setup is None: - runtime_setup = EMPTY -if not isinstance(runtime_setup, dict): - fail("runtimeSetup must be an object") - - -def runtime_setup_entries(key): - entries = runtime_setup.get(key, []) - if not isinstance(entries, list): - fail(f"runtimeSetup.{key} must be a list") - for index, entry in enumerate(entries): - if not isinstance(entry, dict): - fail(f"runtimeSetup.{key}[{index}] must be an object") - channel_id = entry.get("channelId") - if not isinstance(channel_id, str) or not channel_id: - fail(f"runtimeSetup.{key}[{index}].channelId must be a string") - if channel_id not in active_channel_ids: - continue - yield entry - - -node_preloads = [] -env_aliases = [] -secret_scans = [] -seen_node_preloads = set() -seen_aliases = set() -seen_scans = set() - -for entry in runtime_setup_entries("nodePreloads"): - preload = clean_node_preload(entry, len(node_preloads)) - preload_key = (preload["source"], preload["target"]) - if preload_key not in seen_node_preloads: - seen_node_preloads.add(preload_key) - node_preloads.append(preload) -for entry in runtime_setup_entries("envAliases"): - alias = clean_env_alias(entry, len(env_aliases)) - alias_key = (alias["envKey"], alias["match"], alias["value"]) - if alias_key not in seen_aliases: - seen_aliases.add(alias_key) - env_aliases.append(alias) -for entry in runtime_setup_entries("secretScans"): - scan = clean_secret_scan(entry, len(secret_scans)) - scan_key = (scan["path"], scan["pattern"]) - if scan_key not in seen_scans: - seen_scans.add(scan_key) - secret_scans.append(scan) - -print(json.dumps({"nodePreloads": node_preloads, "envAliases": env_aliases, "secretScans": secret_scans}, sort_keys=True)) -PYMESSAGINGRUNTIME -} - -apply_messaging_runtime_env_aliases() { - [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 - local _rows - _rows="$( - python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGALIASES' -import json -import os -import re -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - plan = json.load(handle) -for alias in plan.get("envAliases", []): - if not re.search(alias["match"], os.environ.get(alias["envKey"], "")): - continue - print("\t".join([ - alias["envKey"], - alias["value"], - alias.get("message", ""), - ])) -PYMESSAGINGALIASES - )" || return $? - [ -n "$_rows" ] || return 0 - - local _env_key _value _message - while IFS=$'\t' read -r _env_key _value _message; do - export "$_env_key=$_value" - [ -n "$_message" ] && printf '%s\n' "$_message" >&2 - done <<<"$_rows" -} - -node_options_has_require() { - local wanted="$1" - local previous="" - local token - local tokens=() - IFS=$' \t\n' read -r -a tokens <<<"${NODE_OPTIONS:-}" - # Iterating "${tokens[@]}" on an empty array trips `set -u` on bash 3.2 - # (macOS default); guard so the local unit harnesses run there too. - [ "${#tokens[@]}" -gt 0 ] || return 1 - for token in "${tokens[@]}"; do - if [ "$previous" = "--require" ] && [ "$token" = "$wanted" ]; then - return 0 - fi - [ "$token" = "--require=$wanted" ] && return 0 - previous="$token" - done - return 1 -} - -append_node_require_once() { - local wanted="$1" - if ! node_options_has_require "$wanted"; then - export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $wanted" - fi -} - -install_messaging_runtime_preloads() { - [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 - local _rows - _rows="$( - python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGPRELOADS' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - plan = json.load(handle) -for preload in plan.get("nodePreloads", []): - print("\t".join([ - preload["source"], - preload["target"], - ",".join(preload.get("injectInto", [])), - "1" if preload.get("optional") else "0", - preload.get("installMessage", ""), - preload.get("installedMessage", ""), - ])) -PYMESSAGINGPRELOADS - )" || return $? - - local _connect_preloads=() - if [ -n "$_rows" ]; then - local _source _target _inject_into _optional _install_message _installed_message - while IFS=$'\t' read -r _source _target _inject_into _optional _install_message _installed_message; do - if [ ! -f "$_source" ]; then - [ "$_optional" = "1" ] && continue - printf '[channels] Missing runtime preload source: %s\n' "$_source" >&2 - return 1 - fi - [ -n "$_install_message" ] && printf '%s\n' "$_install_message" >&2 - emit_sandbox_sourced_file "$_target" <"$_source" || return 1 - case ",$_inject_into," in - *,boot,*) - append_node_require_once "$_target" - ;; - esac - case ",$_inject_into," in - *,connect,*) - _connect_preloads+=("$_target") - ;; - esac - [ -n "$_installed_message" ] && printf '%s\n' "$_installed_message" >&2 - done <<<"$_rows" - fi - - if [ "${#_connect_preloads[@]}" -gt 0 ]; then - printf '%s\n' "${_connect_preloads[@]}" \ - | emit_sandbox_sourced_file "$_MESSAGING_CONNECT_PRELOADS_FILE" || return 1 - else - : | emit_sandbox_sourced_file "$_MESSAGING_CONNECT_PRELOADS_FILE" || return 1 - fi -} - -emit_messaging_connect_runtime_preload_exports() { - cat < { + it.each([ + "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", + "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", + ])("normalizes %s to the durable session path (#8184)", (bridgePath) => { + const argv = ["/usr/local/bin/node", bridgePath, "--session", "/split/session"]; + + expect(normalizeHermesWhatsappSessionArgv(argv)).toBe(true); + expect(argv[3]).toBe(HERMES_WHATSAPP_SESSION_PATH); + }); + + it("leaves unrelated Node processes unchanged (#8184)", () => { + const argv = ["/usr/local/bin/node", "/sandbox/tool.js", "--session", "/keep"]; + + expect(normalizeHermesWhatsappSessionArgv(argv)).toBe(false); + expect(argv[3]).toBe("/keep"); + }); + + it("keeps paired credentials shared with the Hermes gateway group (#8184)", () => { + const modes: number[] = []; + const argv = [ + "/usr/local/bin/node", + "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", + "--session", + "/split/session", + ]; + + expect(applyHermesWhatsappSessionPatch(argv, (mode) => modes.push(mode))).toBe(true); + expect(modes).toEqual([0o007]); + }); + + it.each([ + ["missing", ["/usr/local/bin/node", "/sandbox/whatsapp-bridge/bridge.js"]], + [ + "duplicate", + [ + "/usr/local/bin/node", + "/sandbox/whatsapp-bridge/bridge.js", + "--session", + "/one", + "--session", + "/two", + ], + ], + ])("refuses a %s Hermes bridge session argument (#8184)", (_case, argv) => { + expect(() => normalizeHermesWhatsappSessionArgv(argv)).toThrow( + "did not provide exactly one session path", + ); + }); + + it("declares the mandatory preload for Hermes boot and connect (#8184)", () => { + const runtime = planRuntimeSetup([whatsappManifest], "hermes", [ + { + channelId: "whatsapp", + displayName: "WhatsApp", + authMode: "in-sandbox-qr", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ]); + + expect(runtime.nodePreloads).toEqual([ + expect.objectContaining({ + channelId: "whatsapp", + source: "/usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js", + target: "/tmp/nemoclaw-whatsapp-hermes-session.js", + injectInto: ["boot", "connect"], + optional: false, + }), + ]); + }); +}); diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts new file mode 100644 index 00000000000..078cdb2aedb --- /dev/null +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; + +export const HERMES_WHATSAPP_SESSION_PATH = "/sandbox/.hermes/platforms/whatsapp/session"; + +function isHermesWhatsappBridge(scriptPath: string | undefined): boolean { + if (!scriptPath) return false; + return ( + path.basename(scriptPath) === "bridge.js" && + path.basename(path.dirname(scriptPath)) === "whatsapp-bridge" + ); +} + +export function normalizeHermesWhatsappSessionArgv(argv: string[]): boolean { + if (!isHermesWhatsappBridge(argv[1])) return false; + + const sessionIndexes = argv.reduce((indexes, value, index) => { + if (value === "--session") indexes.push(index); + return indexes; + }, []); + if (sessionIndexes.length !== 1 || !argv[sessionIndexes[0] + 1]) { + throw new Error( + "Hermes WhatsApp bridge did not provide exactly one session path; refusing split session state", + ); + } + + argv[sessionIndexes[0] + 1] = HERMES_WHATSAPP_SESSION_PATH; + return true; +} + +export function applyHermesWhatsappSessionPatch( + argv: string[], + setUmask: (mode: number) => unknown = (mode) => process.umask(mode), +): boolean { + if (!normalizeHermesWhatsappSessionArgv(argv)) return false; + // The dashboard and gateway run as separate users in the shared sandbox + // group. Keep pairing state read-write for that group without granting world access. + setUmask(0o007); + return true; +} + +applyHermesWhatsappSessionPatch(process.argv); diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 848ca4a61bd..0c0fa24a5a7 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -189,7 +189,7 @@ export interface ChannelRuntimeVisibilitySpec { export type ChannelRuntimeNodePreloadScope = "boot" | "connect"; -/** Node preload module to inject inside the OpenClaw runtime process. */ +/** Node preload module to inject inside the selected agent runtime process. */ export interface ChannelRuntimeNodePreloadSpec { readonly module: string; readonly injectInto?: readonly ChannelRuntimeNodePreloadScope[]; diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index 5a913d7dd4a..49cad5ad74a 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -1082,7 +1082,12 @@ describe("agents/hermes/generate-config.ts", () => { }); expect(config.whatsapp).toBeUndefined(); - expect(config.platforms.whatsapp).toEqual({ enabled: true }); + expect(config.platforms.whatsapp).toEqual({ + enabled: true, + extra: { + session_path: "/sandbox/.hermes/platforms/whatsapp/session", + }, + }); expectRemotePlatformToolsets(config.platform_toolsets.whatsapp); expect(envFile).toContain("WHATSAPP_ENABLED=true\n"); expect(envFile).toContain("WHATSAPP_MODE=bot\n"); diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index c81f16dc40c..0652702910b 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -77,11 +77,6 @@ const HERMES_INTEGRITY_FILES = [ source: "agents/hermes/patch-neutral-platform-env-activation.py", target: "/opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py", }, - { - arg: "NEMOCLAW_HERMES_WHATSAPP_SESSION_PATCHER_SHA256", - source: "agents/hermes/patch-whatsapp-session-path.mts", - target: "/opt/nemoclaw-hermes-config/patch-whatsapp-session-path.mts", - }, ] as const; type LegacyDataFixture = @@ -244,7 +239,6 @@ describe("Hermes final image layout", () => { "COPY agents/hermes/patch-gateway-process-identity.py /opt/nemoclaw-hermes-config/patch-gateway-process-identity.py", "COPY agents/hermes/patch-cron-execution-runtime.py /opt/nemoclaw-hermes-config/patch-cron-execution-runtime.py", "COPY agents/hermes/patch-neutral-platform-env-activation.py /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py", - "COPY agents/hermes/patch-whatsapp-session-path.mts /opt/nemoclaw-hermes-config/patch-whatsapp-session-path.mts", "COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json", "COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts", "COPY src/lib/messaging/ /src/lib/messaging/", @@ -280,6 +274,7 @@ describe("Hermes final image layout", () => { "COPY src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.85.json /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json", "COPY scripts/state-dir-guard.py /usr/local/lib/nemoclaw/state-dir-guard.py", "COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/", + "COPY --from=runtime-preload-builder /opt/nemoclaw-root/dist/lib/messaging/channels/ /usr/local/lib/nemoclaw/preloads-compiled-channels/", ], }, { diff --git a/test/hermes-messaging-runtime-setup.test.ts b/test/hermes-messaging-runtime-setup.test.ts new file mode 100644 index 00000000000..0325dfadb6a --- /dev/null +++ b/test/hermes-messaging-runtime-setup.test.ts @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const ROOT = path.join(import.meta.dirname, ".."); +const HERMES_START = fs.readFileSync(path.join(ROOT, "agents", "hermes", "start.sh"), "utf-8"); + +function shellFunction(source: string, name: string): string { + const start = source.indexOf(`${name}() {`); + expect(start, `expected ${name}`).toBeGreaterThanOrEqual(0); + const remainder = source.slice(start); + const end = remainder.indexOf("\n}"); + expect(end, `expected ${name} closing brace`).toBeGreaterThan(0); + return remainder.slice(0, end + 2); +} + +function runtimeShellEnvFunction(source: string): string { + const start = source.indexOf("write_runtime_shell_env() {"); + const end = source.indexOf("\nwrite_runtime_shell_env\n", start); + expect(start, "expected write_runtime_shell_env").toBeGreaterThanOrEqual(0); + expect(end, "expected write_runtime_shell_env invocation").toBeGreaterThan(start); + return source.slice(start, end); +} + +describe("Hermes messaging runtime setup", () => { + it("runs the manifest runtime setup in order (#8184)", () => { + const result = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + "set -euo pipefail", + 'write_messaging_runtime_setup_plan() { printf "plan\\n"; }', + 'install_messaging_runtime_preloads() { printf "install\\n"; }', + 'verify_messaging_runtime_secret_scans() { printf "scan\\n"; }', + 'write_runtime_shell_env() { printf "env\\n"; }', + shellFunction(HERMES_START, "prepare_hermes_messaging_runtime"), + "prepare_hermes_messaging_runtime", + ].join("\n"), + ], + { encoding: "utf-8", timeout: 5000 }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe("plan\ninstall\nscan\nenv\n"); + }); + + it("publishes manifest connect preloads through the trusted runtime environment (#8184)", () => { + const result = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + "set -euo pipefail", + "emit_sandbox_sourced_file() { cat; }", + 'emit_messaging_connect_runtime_preload_exports() { printf "export NODE_OPTIONS=manifest-connect\\n"; }', + '_PROXY_ENV_FILE="/tmp/test-runtime-env"', + '_PROXY_URL="http://10.200.0.1:3128"', + '_NO_PROXY_VAL="localhost,127.0.0.1"', + 'HERMES_DIR="/sandbox/.hermes"', + runtimeShellEnvFunction(HERMES_START), + "write_runtime_shell_env", + ].join("\n"), + ], + { encoding: "utf-8", timeout: 5000 }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("export NODE_OPTIONS=manifest-connect"); + }); + + it.each([ + { + name: "prepare_hermes_nonroot_runtime", + stubs: [ + "verify_config_integrity_if_locked", + "validate_hermes_env_secret_boundary", + "inspect_hermes_mcp_integrity", + "ensure_hermes_runtime_api_server_key", + "apply_shields_up_runtime_env", + "validate_hermes_runtime_env_secret_boundary", + "refresh_hermes_provider_placeholders", + "refresh_hermes_runtime_config_hashes", + "configure_messaging_channels", + "write_runtime_shell_env", + "prepare_tirith_marker_retry", + ], + expectedTail: + "configure_messaging_channels\nplan\ninstall\nscan\nwrite_runtime_shell_env\nprepare_tirith_marker_retry\n", + }, + { + name: "prepare_hermes_root_runtime", + stubs: [ + "verify_hermes_config_integrity", + "ensure_hermes_config_root_mode", + "ensure_hermes_runtime_api_server_key", + "apply_shields_up_runtime_env", + "validate_hermes_env_secret_boundary", + "validate_hermes_runtime_env_secret_boundary", + "refresh_hermes_provider_placeholders", + "configure_messaging_channels", + "write_runtime_shell_env", + "prepare_tirith_marker_retry", + ], + expectedTail: + "configure_messaging_channels\nplan\ninstall\nscan\nwrite_runtime_shell_env\nprepare_tirith_marker_retry\n", + }, + ])("installs manifest runtime setup during $name (#8184)", ({ name, stubs, expectedTail }) => { + const stubFunctions = stubs.map((stub) => `${stub}() { printf '${stub}\\n'; }`).join("\n"); + const result = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + "set -euo pipefail", + 'HERMES_DIR="/sandbox/.hermes"', + stubFunctions, + 'write_messaging_runtime_setup_plan() { printf "plan\\n"; }', + 'install_messaging_runtime_preloads() { printf "install\\n"; }', + 'verify_messaging_runtime_secret_scans() { printf "scan\\n"; }', + shellFunction(HERMES_START, "prepare_hermes_messaging_runtime"), + shellFunction(HERMES_START, name), + name, + ].join("\n"), + ], + { encoding: "utf-8", timeout: 5000 }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.endsWith(expectedTail)).toBe(true); + }); +}); diff --git a/test/hermes-start.test.ts b/test/hermes-start.test.ts index 02b157063ac..3144d30c9ad 100644 --- a/test/hermes-start.test.ts +++ b/test/hermes-start.test.ts @@ -341,7 +341,7 @@ function runHermesRootStartupMutableRootPreflight() { "validate_hermes_runtime_env_secret_boundary() { :; }", "refresh_hermes_provider_placeholders() { :; }", "refresh_hermes_runtime_config_hashes() { :; }", - "configure_messaging_channels() { :; }", + "configure_messaging_channels() { :; }; prepare_hermes_messaging_runtime() { :; }", 'retry_tirith_marker_if_needed() { printf "tirith-state=%s\\n" "$TIRITH_RETRY_MARKER_CLEARED"; }', "prepare_tirith_marker_retry() { TIRITH_RETRY_MARKER_CLEARED=0; retry_tirith_marker_if_needed; }", extractShellFunctionFromSource(src, "prepare_hermes_root_runtime"), @@ -1047,7 +1047,7 @@ describe("agents/hermes/start.sh env secret boundary", () => { "validate_hermes_runtime_env_secret_boundary() { trace runtime-boundary; }", "refresh_hermes_provider_placeholders() { trace placeholders; }", "refresh_hermes_runtime_config_hashes() { trace hashes; }", - "configure_messaging_channels() { trace channels; }", + "configure_messaging_channels() { trace channels; }; prepare_hermes_messaging_runtime() { trace messaging-runtime; }", "retry_tirith_marker_if_needed() { trace tirith; }", extractShellFunctionFromSource(source, "prepare_tirith_marker_retry"), extractShellFunctionFromSource(source, "prepare_hermes_nonroot_runtime"), @@ -1069,7 +1069,7 @@ describe("agents/hermes/start.sh env secret boundary", () => { "placeholders", "hashes", "mcp-integrity", - "channels", + ...["channels", "messaging-runtime"], "tirith", ]); }); diff --git a/test/hermes-whatsapp-session-path-patch.test.ts b/test/hermes-whatsapp-session-path-patch.test.ts deleted file mode 100644 index ec5d1b5b3fc..00000000000 --- a/test/hermes-whatsapp-session-path-patch.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; -import { - CANONICAL_SESSION_PATH, - patchAdapterSource, - patchMainSource, - patchWebServerSource, -} from "../agents/hermes/patch-whatsapp-session-path.mts"; - -const ADAPTER_FIXTURE = ` self._session_path: Path = Path(config.extra.get( - "session_path", - get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") - )) -`; - -const WEB_SERVER_FIXTURE = `def _whatsapp_session_path() -> Path: - from hermes_constants import get_hermes_dir - - return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") - -def _spawn(): - return subprocess.Popen( - ["node", "bridge.js"], - start_new_session=True, - env=env, - creationflags=windows_hide_flags(), - ) -`; - -const MAIN_FIXTURE = `def cmd_whatsapp(): - session_dir = get_hermes_home() / "whatsapp" / "session" - try: - subprocess.run( - [ - "node", - "bridge.js", - "--session", - str(session_dir), - ], - cwd=str(bridge_dir), - env=with_hermes_node_path(), - ) -`; - -const patchers = { - adapter: patchAdapterSource, - webServer: patchWebServerSource, - main: patchMainSource, -}; - -describe("Hermes WhatsApp session location", () => { - it.each([ - ["gateway adapter", "adapter", ADAPTER_FIXTURE], - ["dashboard QR pairing", "webServer", WEB_SERVER_FIXTURE], - ["CLI QR pairing", "main", MAIN_FIXTURE], - ] as const)("uses the manifest-owned durable directory for %s (#8184)", (_label, kind, fixture) => { - const result = patchers[kind](fixture); - - expect(result).toContain(CANONICAL_SESSION_PATH); - expect(result).not.toContain('get_hermes_dir("platforms/whatsapp/session"'); - expect(result).not.toContain('get_hermes_home() / "whatsapp" / "session"'); - }); - - it("preserves the explicit adapter session path override (#8184)", () => { - expect(patchAdapterSource(ADAPTER_FIXTURE)).toContain( - 'config.extra.get(\n "session_path"', - ); - }); - - it.each([ - ["dashboard", patchWebServerSource, WEB_SERVER_FIXTURE], - ["CLI", patchMainSource, MAIN_FIXTURE], - ] as const)("preserves shared-group access when %s pairing creates session state (#8184)", (_label, patcher, fixture) => { - expect(patcher(fixture)).toContain("umask=0o007"); - }); - - it.each([ - ["adapter", ADAPTER_FIXTURE.replace('"whatsapp/session"', '"changed/session"')], - ["webServer", WEB_SERVER_FIXTURE.replace('"whatsapp/session"', '"changed/session"')], - ["webServer", WEB_SERVER_FIXTURE.replace("creationflags=", "changed_creationflags=")], - ["main", MAIN_FIXTURE.replace('"whatsapp"', '"changed"')], - ["main", MAIN_FIXTURE.replace("env=with_hermes_node_path()", "changed_env=True")], - ] as const)("fails closed when the pinned %s pairing source changes", (kind, fixture) => { - expect(() => patchers[kind](fixture)).toThrow("source shape changed"); - }); -}); diff --git a/test/http-proxy-fix-sync.test.ts b/test/http-proxy-fix-sync.test.ts index ab0a1fda4b6..9c868331ace 100644 --- a/test/http-proxy-fix-sync.test.ts +++ b/test/http-proxy-fix-sync.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest"; const ROOT = path.join(import.meta.dirname, ".."); const CANONICAL_FIX = path.join(ROOT, "nemoclaw-blueprint", "scripts", "http-proxy-fix.js"); const START_SCRIPT = path.join(ROOT, "scripts", "nemoclaw-start.sh"); +const SANDBOX_INIT = path.join(ROOT, "scripts", "lib", "sandbox-init.sh"); function extractShellFunction(source: string, name: string): string { const header = `${name}() {`; @@ -23,7 +24,7 @@ function extractShellFunction(source: string, name: string): string { describe("http-proxy-fix preload sync (#2109)", () => { it("entrypoint emits the proxy fix preload and registers it in NODE_OPTIONS", () => { - const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); + const startScript = `${fs.readFileSync(SANDBOX_INIT, "utf-8")}\n${fs.readFileSync(START_SCRIPT, "utf-8")}`; const start = startScript.indexOf('_PROXY_FIX_SCRIPT="/tmp/nemoclaw-http-proxy-fix.js"'); const end = startScript.indexOf( "# NVIDIA endpoint model-specific inference parameter injection", diff --git a/test/messaging-runtime-preload-packaging.test.ts b/test/messaging-runtime-preload-packaging.test.ts index 9374c00007d..e987946e029 100644 --- a/test/messaging-runtime-preload-packaging.test.ts +++ b/test/messaging-runtime-preload-packaging.test.ts @@ -7,6 +7,10 @@ import { describe, expect, it } from "vitest"; const repoRoot = path.join(import.meta.dirname, ".."); const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); +const hermesDockerfile = fs.readFileSync( + path.join(repoRoot, "agents", "hermes", "Dockerfile"), + "utf8", +); describe("messaging runtime preload packaging", () => { it("packages preload JavaScript compiled from TypeScript without requiring root npm metadata", () => { @@ -30,4 +34,21 @@ describe("messaging runtime preload packaging", () => { ); expect(dockerfile).not.toContain('basename "$file" .ts'); }); + + it("packages the same manifest-owned runtime preloads for Hermes", () => { + expect(hermesDockerfile).toContain( + "FROM mcp-tool-discovery-runtime AS runtime-preload-builder", + ); + expect(hermesDockerfile).toContain("COPY tsconfig.runtime-preloads.json /opt/nemoclaw-root/"); + expect(hermesDockerfile).toContain( + "COPY src/lib/messaging/channels/ /opt/nemoclaw-root/src/lib/messaging/channels/", + ); + expect(hermesDockerfile).toContain( + "/opt/mcp-tool-discovery-runtime/node_modules/.bin/tsc -p tsconfig.runtime-preloads.json", + ); + expect(hermesDockerfile).toContain( + "COPY --from=runtime-preload-builder /opt/nemoclaw-root/dist/lib/messaging/channels/", + ); + expect(hermesDockerfile).toContain("-path '*/runtime/*.js'"); + }); }); diff --git a/test/nemoclaw-start-guard-recovery.test.ts b/test/nemoclaw-start-guard-recovery.test.ts index 62f0365c69c..60bf2f56855 100644 --- a/test/nemoclaw-start-guard-recovery.test.ts +++ b/test/nemoclaw-start-guard-recovery.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); +const SANDBOX_INIT = path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"); function extractShellFunction(source: string, name: string): string { const header = `${name}() {`; @@ -69,7 +70,7 @@ function runRecoveryHarness({ gatewayLogKind = "regular", missingCiaoSource = false, }: RecoveryHarnessOptions = {}): Harness { - const source = fs.readFileSync(START_SCRIPT, "utf8"); + const source = `${fs.readFileSync(SANDBOX_INIT, "utf8")}\n${fs.readFileSync(START_SCRIPT, "utf8")}`; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-guard-recovery-")); const eventLog = path.join(tmpDir, "events.log"); const gatewayLog = path.join(tmpDir, "gateway.log"); diff --git a/test/nemoclaw-start-runtime-env-alias.test.ts b/test/nemoclaw-start-runtime-env-alias.test.ts index eb960438f85..675ff4ae3e4 100644 --- a/test/nemoclaw-start-runtime-env-alias.test.ts +++ b/test/nemoclaw-start-runtime-env-alias.test.ts @@ -8,10 +8,11 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); +const SANDBOX_INIT = path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"); function messagingRuntimeSetupSection(src: string, planPath: string): string { const start = src.indexOf("# ── Messaging runtime setup from manifest metadata"); - const end = src.indexOf("_read_gateway_token()", start); + const end = src.indexOf("# ── End messaging runtime setup", start); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); return src @@ -64,7 +65,7 @@ function encodeRuntimeSetupPlan(channelId: string, value: Record { it("uses Python regex semantics consistently when applying aliases", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const src = `${fs.readFileSync(SANDBOX_INIT, "utf-8")}\n${fs.readFileSync(START_SCRIPT, "utf-8")}`; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-alias-")); const planPath = path.join(tmpDir, "runtime-plan.json"); const scriptPath = path.join(tmpDir, "run.sh"); diff --git a/test/nemoclaw-start-slack-runtime.test.ts b/test/nemoclaw-start-slack-runtime.test.ts index 702f587014a..8cfaddc3e37 100644 --- a/test/nemoclaw-start-slack-runtime.test.ts +++ b/test/nemoclaw-start-slack-runtime.test.ts @@ -9,10 +9,11 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); +const SANDBOX_INIT = path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"); function messagingRuntimeSetupSection(src: string, planPath: string): string { const start = src.indexOf("# ── Messaging runtime setup from manifest metadata"); - const end = src.indexOf("_read_gateway_token()", start); + const end = src.indexOf("# ── End messaging runtime setup", start); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); return src @@ -64,7 +65,7 @@ function encodeRuntimeSetupPlan(channelId: string, value: Record { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const src = `${fs.readFileSync(SANDBOX_INIT, "utf-8")}\n${fs.readFileSync(START_SCRIPT, "utf-8")}`; function runNormalize(env: Record = {}): { bot: string; diff --git a/test/nemoclaw-start-telegram-runtime.test.ts b/test/nemoclaw-start-telegram-runtime.test.ts index 6d7862c71ad..b1213224886 100644 --- a/test/nemoclaw-start-telegram-runtime.test.ts +++ b/test/nemoclaw-start-telegram-runtime.test.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); +const SANDBOX_INIT = path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"); const TELEGRAM_RUNTIME_PRELOAD = path.join( import.meta.dirname, "..", @@ -25,7 +26,7 @@ function messagingRuntimeSetupSection( }, ): string { const start = src.indexOf("# ── Messaging runtime setup from manifest metadata"); - const end = src.indexOf("_read_gateway_token()", start); + const end = src.indexOf("# ── End messaging runtime setup", start); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); return src @@ -95,7 +96,7 @@ function encodeRuntimeSetupPlan( } describe("Telegram runtime preload installation", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const src = `${fs.readFileSync(SANDBOX_INIT, "utf-8")}\n${fs.readFileSync(START_SCRIPT, "utf-8")}`; it("installs Telegram diagnostics only when Telegram is configured", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-telegram-install-")); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 6a1c41407a0..0dbcc91ecaf 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -8,7 +8,7 @@ import os from "node:os"; import path from "node:path"; import * as ts from "typescript"; import { describe, expect, it } from "vitest"; -import { openClawBootstrapSnippet } from "./support/entrypoint-script-fixture"; +import * as entrypointFixture from "./support/entrypoint-script-fixture"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); const APPROVAL_POLICY_DIR = path.join(import.meta.dirname, "..", "scripts", "lib"); @@ -36,7 +36,7 @@ function messagingRuntimeSetupSection( } = {}, ): string { const start = src.indexOf("# ── Messaging runtime setup from manifest metadata"); - const end = src.indexOf("_read_gateway_token()", start); + const end = src.indexOf("# ── End messaging runtime setup", start); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); let section = src.slice(start, end); @@ -353,7 +353,7 @@ describe("nemoclaw-start non-root fallback", () => { }); it("unwraps the sandbox-create env self-wrapper and applies dashboard port defaults", () => { - const snippet = openClawBootstrapSnippet( + const snippet = entrypointFixture.openClawBootstrapSnippet( START_SCRIPT, path.join(import.meta.dirname, "..", "scripts", "lib", "entrypoint-env-wrapper.sh"), ); @@ -1337,7 +1337,7 @@ describe("runtime CORS origin override (#719)", () => { }); describe("Slack channel guard — unhandled-rejection safety net (#2340)", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const src = entrypointFixture.readOpenClawSources(START_SCRIPT); const extractGuardScript = () => startScriptHeredoc(src, "SLACK_GUARD_EOF"); function runSlackGuardHarness(body: string): ReturnType { @@ -2793,7 +2793,7 @@ describe("seed_default_workspace_templates (#3240)", () => { }); describe("Slack secrets-on-disk tripwire (#2085)", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const src = entrypointFixture.readOpenClawSources(START_SCRIPT); it("refuses to serve when real Slack tokens leak to disk", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-secret-")); @@ -3457,7 +3457,7 @@ describe("provider placeholder refresh (#4251)", () => { }); describe("Telegram diagnostics (#2766)", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const src = entrypointFixture.readOpenClawSources(START_SCRIPT); const telegramDiagnosticsScript = startScriptHeredoc(src, "TELEGRAM_DIAGNOSTICS_EOF"); function preGatewaySetupBlock( diff --git a/test/nemotron-inference-fix.test.ts b/test/nemotron-inference-fix.test.ts index fc202b01528..0b37622f48a 100644 --- a/test/nemotron-inference-fix.test.ts +++ b/test/nemotron-inference-fix.test.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); +const SANDBOX_INIT = path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"); const NEMOTRON_FIX_SOURCE = path.join( import.meta.dirname, "..", @@ -35,7 +36,7 @@ function extractShellFunction(source, name) { } describe("NVIDIA endpoint inference fix preload (#1193, #2051, #4063)", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const src = `${fs.readFileSync(SANDBOX_INIT, "utf-8")}\n${fs.readFileSync(START_SCRIPT, "utf-8")}`; it("entrypoint writes the preload and registers it in NODE_OPTIONS", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-nemotron-entrypoint-")); diff --git a/test/support/entrypoint-script-fixture.ts b/test/support/entrypoint-script-fixture.ts index f6ad838a7d0..2ee6605b207 100644 --- a/test/support/entrypoint-script-fixture.ts +++ b/test/support/entrypoint-script-fixture.ts @@ -2,6 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import path from "node:path"; + +export function readEntrypointSources(...sourcePaths: string[]): string { + return sourcePaths.map((sourcePath) => fs.readFileSync(sourcePath, "utf8")).join("\n"); +} + +export function readOpenClawSources(startScriptPath: string): string { + return readEntrypointSources( + path.join(path.dirname(startScriptPath), "lib", "sandbox-init.sh"), + startScriptPath, + ); +} export function openClawBootstrapSnippet( startScriptPath: string, From 2cc4096fcf26787beaa44d0426e79edc688f6dcd Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 4 Aug 2026 23:43:54 +0700 Subject: [PATCH 04/31] fix(build): resolve Hermes runtime preload types Signed-off-by: San Dang --- agents/hermes/Dockerfile | 3 ++- test/messaging-runtime-preload-packaging.test.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 18fd9911979..9f492d82c1d 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -25,7 +25,8 @@ FROM mcp-tool-discovery-runtime AS runtime-preload-builder WORKDIR /opt/nemoclaw-root COPY tsconfig.runtime-preloads.json /opt/nemoclaw-root/ COPY src/lib/messaging/channels/ /opt/nemoclaw-root/src/lib/messaging/channels/ -RUN /opt/mcp-tool-discovery-runtime/node_modules/.bin/tsc -p tsconfig.runtime-preloads.json +RUN ln -s /opt/mcp-tool-discovery-runtime/node_modules /opt/nemoclaw-root/node_modules \ + && /opt/mcp-tool-discovery-runtime/node_modules/.bin/tsc -p tsconfig.runtime-preloads.json FROM mcp-tool-discovery-runtime AS managed-startup-runtime-builder WORKDIR /opt/nemoclaw-managed-startup-build diff --git a/test/messaging-runtime-preload-packaging.test.ts b/test/messaging-runtime-preload-packaging.test.ts index e987946e029..82a5ee80488 100644 --- a/test/messaging-runtime-preload-packaging.test.ts +++ b/test/messaging-runtime-preload-packaging.test.ts @@ -43,6 +43,9 @@ describe("messaging runtime preload packaging", () => { expect(hermesDockerfile).toContain( "COPY src/lib/messaging/channels/ /opt/nemoclaw-root/src/lib/messaging/channels/", ); + expect(hermesDockerfile).toContain( + "ln -s /opt/mcp-tool-discovery-runtime/node_modules /opt/nemoclaw-root/node_modules", + ); expect(hermesDockerfile).toContain( "/opt/mcp-tool-discovery-runtime/node_modules/.bin/tsc -p tsconfig.runtime-preloads.json", ); From 57f4e94b17676f447fb280f40cacbfea09e7f9b8 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 5 Aug 2026 00:09:48 +0700 Subject: [PATCH 05/31] fix(messaging): address runtime preload review Signed-off-by: San Dang --- agents/hermes/Dockerfile | 10 ++- ci/test-file-size-budget.json | 2 +- scripts/lib/sandbox-init.sh | 5 +- .../runtime/whatsapp-hermes-session.test.ts | 2 +- .../runtime/whatsapp-hermes-session.ts | 2 + test/hermes-final-image-layout.test.ts | 1 + test/hermes-messaging-runtime-setup.test.ts | 74 ++++++++++--------- ...essaging-runtime-preload-packaging.test.ts | 5 ++ test/messaging-runtime-secret-scan.test.ts | 70 ++++++++++++++++++ test/nemoclaw-start-runtime-env-alias.test.ts | 44 +++++++++++ test/nemoclaw-start.test.ts | 52 ------------- 11 files changed, 175 insertions(+), 92 deletions(-) create mode 100644 test/messaging-runtime-secret-scan.test.ts diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 9f492d82c1d..d3d689927ba 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -325,10 +325,11 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-managed-star && chmod 444 /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py \ && chmod 444 /usr/local/lib/nemoclaw/patch-hermes-langfuse-credentials.mts \ && chmod 444 /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ - && if [ -d /usr/local/lib/nemoclaw/preloads-compiled-channels ]; then \ - find /usr/local/lib/nemoclaw/preloads-compiled-channels -path '*/runtime/*.js' -type f \ - -exec sh -c 'for file do cp "$file" "/usr/local/lib/nemoclaw/preloads/$(basename "$file")"; done' sh {} +; \ - fi \ + && test -d /usr/local/lib/nemoclaw/preloads-compiled-channels \ + && runtime_preload_found="$(find /usr/local/lib/nemoclaw/preloads-compiled-channels -path '*/runtime/*.js' -type f -print -quit)" \ + && test -n "$runtime_preload_found" \ + && find /usr/local/lib/nemoclaw/preloads-compiled-channels -path '*/runtime/*.js' -type f \ + -exec sh -c 'for file do target="/usr/local/lib/nemoclaw/preloads/$(basename "$file")"; [ ! -e "$target" ] || { echo "ERROR: duplicate messaging runtime preload basename: $target" >&2; exit 1; }; cp "$file" "$target"; done' sh {} + \ && rm -rf /usr/local/lib/nemoclaw/preloads-compiled-channels \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then \ chown -R 0:0 /usr/local/lib/nemoclaw/preloads \ @@ -1095,6 +1096,7 @@ RUN check_metadata() { \ && check_metadata /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh 'root:root 444' \ && check_metadata /usr/local/bin/nemoclaw-gateway-control 'root:root 700' \ && check_metadata /usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root 444' \ + && check_metadata /usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js 'root:root 444' \ && check_metadata /usr/local/lib/nemoclaw/hermes-wrapper.py 'root:root 755' \ && check_metadata /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py 'root:root 755' \ && check_metadata /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json 'root:root 444' \ diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 596906e3c02..ed975db4e2b 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -7,7 +7,7 @@ "src/lib/onboard/preflight.test.ts": 1904, "test/generate-openclaw-config.test.ts": 1941, "test/install-preflight.test.ts": 3921, - "test/nemoclaw-start.test.ts": 4817, + "test/nemoclaw-start.test.ts": 4765, "test/onboard-messaging.test.ts": 2043, "test/onboard-selection.test.ts": 4769 } diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 66ac4eb7bc2..8870306f4d2 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -1059,8 +1059,11 @@ PYMESSAGINGALIASES local _env_key _value _message while IFS=$'\t' read -r _env_key _value _message; do export "$_env_key=$_value" - [ -n "$_message" ] && printf '%s\n' "$_message" >&2 + if [ -n "$_message" ]; then + printf '%s\n' "$_message" >&2 + fi done <<<"$_rows" + return 0 } node_options_has_require() { diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts index 71200298f4f..fc5b4c63132 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts @@ -14,7 +14,7 @@ describe("Hermes WhatsApp session runtime preload", () => { it.each([ "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", - ])("normalizes %s to the durable session path (#8184)", (bridgePath) => { + ])("forces %s to the manifest-owned durable session path (#8184)", (bridgePath) => { const argv = ["/usr/local/bin/node", bridgePath, "--session", "/split/session"]; expect(normalizeHermesWhatsappSessionArgv(argv)).toBe(true); diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts index 078cdb2aedb..a218685e252 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts @@ -26,6 +26,8 @@ export function normalizeHermesWhatsappSessionArgv(argv: string[]): boolean { ); } + // Both Hermes homes always supply --session. Force the manifest-owned durable + // location so dashboard pairing and gateway delivery cannot split state. argv[sessionIndexes[0] + 1] = HERMES_WHATSAPP_SESSION_PATH; return true; } diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 0652702910b..6a112a79367 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -403,6 +403,7 @@ describe("Hermes final image layout", () => { "/usr/local/bin/nemoclaw-gateway-control 'root:root 700'", "/sandbox/.nemoclaw 'root:root 1755'", "/usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root 444'", + "/usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js 'root:root 444'", "/usr/local/lib/nemoclaw/hermes-wrapper.py 'root:root 755'", "/usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py 'root:root 755'", "/usr/local/share/nemoclaw/hermes-cli-adapter-v1.json 'root:root 444'", diff --git a/test/hermes-messaging-runtime-setup.test.ts b/test/hermes-messaging-runtime-setup.test.ts index 0325dfadb6a..e49abb3ffc7 100644 --- a/test/hermes-messaging-runtime-setup.test.ts +++ b/test/hermes-messaging-runtime-setup.test.ts @@ -3,20 +3,14 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { extractShellFunction } from "./support/hermes-shell-harness"; const ROOT = path.join(import.meta.dirname, ".."); const HERMES_START = fs.readFileSync(path.join(ROOT, "agents", "hermes", "start.sh"), "utf-8"); - -function shellFunction(source: string, name: string): string { - const start = source.indexOf(`${name}() {`); - expect(start, `expected ${name}`).toBeGreaterThanOrEqual(0); - const remainder = source.slice(start); - const end = remainder.indexOf("\n}"); - expect(end, `expected ${name} closing brace`).toBeGreaterThan(0); - return remainder.slice(0, end + 2); -} +const SANDBOX_INIT = fs.readFileSync(path.join(ROOT, "scripts", "lib", "sandbox-init.sh"), "utf-8"); function runtimeShellEnvFunction(source: string): string { const start = source.indexOf("write_runtime_shell_env() {"); @@ -40,7 +34,7 @@ describe("Hermes messaging runtime setup", () => { 'install_messaging_runtime_preloads() { printf "install\\n"; }', 'verify_messaging_runtime_secret_scans() { printf "scan\\n"; }', 'write_runtime_shell_env() { printf "env\\n"; }', - shellFunction(HERMES_START, "prepare_hermes_messaging_runtime"), + extractShellFunction(HERMES_START, "prepare_hermes_messaging_runtime"), "prepare_hermes_messaging_runtime", ].join("\n"), ], @@ -52,29 +46,43 @@ describe("Hermes messaging runtime setup", () => { }); it("publishes manifest connect preloads through the trusted runtime environment (#8184)", () => { - const result = spawnSync( - "bash", - [ - "--noprofile", - "--norc", - "-c", + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-connect-preload-")); + const preloadPath = path.join(tmpDir, "manifest-connect.js"); + const preloadListPath = path.join(tmpDir, "connect-preloads"); + const runtimeEnvPath = path.join(tmpDir, "runtime-env.sh"); + fs.writeFileSync(preloadPath, "module.exports = {};\n"); + fs.writeFileSync(preloadListPath, `${preloadPath}\n`); + + try { + const result = spawnSync( + "bash", [ - "set -euo pipefail", - "emit_sandbox_sourced_file() { cat; }", - 'emit_messaging_connect_runtime_preload_exports() { printf "export NODE_OPTIONS=manifest-connect\\n"; }', - '_PROXY_ENV_FILE="/tmp/test-runtime-env"', - '_PROXY_URL="http://10.200.0.1:3128"', - '_NO_PROXY_VAL="localhost,127.0.0.1"', - 'HERMES_DIR="/sandbox/.hermes"', - runtimeShellEnvFunction(HERMES_START), - "write_runtime_shell_env", - ].join("\n"), - ], - { encoding: "utf-8", timeout: 5000 }, - ); + "--noprofile", + "--norc", + "-c", + [ + "set -euo pipefail", + 'emit_sandbox_sourced_file() { local target="$1"; cat >"$target"; chmod 444 "$target"; }', + extractShellFunction(SANDBOX_INIT, "emit_messaging_connect_runtime_preload_exports"), + `_MESSAGING_CONNECT_PRELOADS_FILE=${JSON.stringify(preloadListPath)}`, + `_PROXY_ENV_FILE=${JSON.stringify(runtimeEnvPath)}`, + '_PROXY_URL="http://10.200.0.1:3128"', + '_NO_PROXY_VAL="localhost,127.0.0.1"', + 'HERMES_DIR="/sandbox/.hermes"', + runtimeShellEnvFunction(HERMES_START), + "write_runtime_shell_env", + `source ${JSON.stringify(runtimeEnvPath)}`, + 'printf "NODE_OPTIONS=%s\\n" "$NODE_OPTIONS"', + ].join("\n"), + ], + { encoding: "utf-8", timeout: 5000, env: { ...process.env, NODE_OPTIONS: "" } }, + ); - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain("export NODE_OPTIONS=manifest-connect"); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain(`NODE_OPTIONS=--require ${preloadPath}`); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); it.each([ @@ -128,8 +136,8 @@ describe("Hermes messaging runtime setup", () => { 'write_messaging_runtime_setup_plan() { printf "plan\\n"; }', 'install_messaging_runtime_preloads() { printf "install\\n"; }', 'verify_messaging_runtime_secret_scans() { printf "scan\\n"; }', - shellFunction(HERMES_START, "prepare_hermes_messaging_runtime"), - shellFunction(HERMES_START, name), + extractShellFunction(HERMES_START, "prepare_hermes_messaging_runtime"), + extractShellFunction(HERMES_START, name), name, ].join("\n"), ], diff --git a/test/messaging-runtime-preload-packaging.test.ts b/test/messaging-runtime-preload-packaging.test.ts index 82a5ee80488..197548019c8 100644 --- a/test/messaging-runtime-preload-packaging.test.ts +++ b/test/messaging-runtime-preload-packaging.test.ts @@ -52,6 +52,11 @@ describe("messaging runtime preload packaging", () => { expect(hermesDockerfile).toContain( "COPY --from=runtime-preload-builder /opt/nemoclaw-root/dist/lib/messaging/channels/", ); + expect(hermesDockerfile).toContain('runtime_preload_found="$(find'); + expect(hermesDockerfile).toContain("duplicate messaging runtime preload basename"); expect(hermesDockerfile).toContain("-path '*/runtime/*.js'"); + expect(hermesDockerfile).toContain( + "check_metadata /usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js 'root:root 444'", + ); }); }); diff --git a/test/messaging-runtime-secret-scan.test.ts b/test/messaging-runtime-secret-scan.test.ts new file mode 100644 index 00000000000..d97989822fb --- /dev/null +++ b/test/messaging-runtime-secret-scan.test.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { extractShellFunction } from "./support/hermes-shell-harness"; + +const SANDBOX_INIT = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"), + "utf-8", +); + +function runSecretScan(config?: string) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-secret-scan-")); + const configPath = path.join(tmpDir, "openclaw.json"); + const planPath = path.join(tmpDir, "runtime-plan.json"); + const scriptPath = path.join(tmpDir, "run.sh"); + if (config !== undefined) fs.writeFileSync(configPath, config); + fs.writeFileSync( + planPath, + JSON.stringify({ + secretScans: [ + { + path: configPath, + pattern: "(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)", + message: "[SECURITY] Slack token leaked into {path} - refusing to serve", + exitCode: 78, + }, + ], + }), + ); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(planPath)}`, + extractShellFunction(SANDBOX_INIT, "verify_messaging_runtime_secret_scans"), + "verify_messaging_runtime_secret_scans", + ].join("\n"), + { mode: 0o700 }, + ); + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + return { configPath, result }; +} + +describe("messaging runtime secret scans", () => { + it.each([ + "xoxb-real-token", + "xapp-real-token", + ])("rejects %s without exposing the secret (#2085)", (secret) => { + const { configPath, result } = runSecretScan(JSON.stringify({ token: secret })); + + expect(result.status).toBe(78); + expect(result.stderr).toContain(configPath); + expect(result.stderr).not.toContain(secret); + }); + + it.each([ + ["approved placeholder", '{"botToken":"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"}\n'], + ["OpenShell reference", '{"token":"openshell:resolve:env:SLACK_BOT_TOKEN"}\n'], + ["missing file", undefined], + ])("skips the %s without failure (#2085)", (_case, config) => { + expect(runSecretScan(config).result.status).toBe(0); + }); +}); diff --git a/test/nemoclaw-start-runtime-env-alias.test.ts b/test/nemoclaw-start-runtime-env-alias.test.ts index 675ff4ae3e4..8126c55d5ae 100644 --- a/test/nemoclaw-start-runtime-env-alias.test.ts +++ b/test/nemoclaw-start-runtime-env-alias.test.ts @@ -111,4 +111,48 @@ describe("messaging runtime env aliases", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("returns success when a matching alias has no message", () => { + const src = `${fs.readFileSync(SANDBOX_INIT, "utf-8")}\n${fs.readFileSync(START_SCRIPT, "utf-8")}`; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-alias-empty-message-")); + const planPath = path.join(tmpDir, "runtime-plan.json"); + const scriptPath = path.join(tmpDir, "run.sh"); + const runtimeValue = { + envAliases: [ + { + envKey: "TEST_ALIAS", + match: "^original$", + value: "normalized", + }, + ], + }; + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimeSetupPlan("test", runtimeValue))}`, + messagingRuntimeSetupSection(src, planPath), + "write_messaging_runtime_setup_plan", + "apply_messaging_runtime_env_aliases", + 'printf "TEST_ALIAS=%s\\n" "$TEST_ALIAS"', + ].join("\n"), + { mode: 0o700 }, + ); + + try { + const result = spawnSync("bash", [scriptPath], { + encoding: "utf-8", + env: { ...process.env, TEST_ALIAS: "original" }, + timeout: 5000, + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("TEST_ALIAS=normalized"); + expect(result.stderr).toBe(""); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 0dbcc91ecaf..ff2c56ac444 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -2792,58 +2792,6 @@ describe("seed_default_workspace_templates (#3240)", () => { }); }); -describe("Slack secrets-on-disk tripwire (#2085)", () => { - const src = entrypointFixture.readOpenClawSources(START_SCRIPT); - - it("refuses to serve when real Slack tokens leak to disk", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-secret-")); - const configPath = path.join(tmpDir, "openclaw.json"); - const planPath = path.join(tmpDir, "runtime-plan.json"); - const runtimeValue = { - secretScans: [ - { - path: configPath, - pattern: "(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)", - message: "[SECURITY] Slack token leaked into {path} - refusing to serve", - exitCode: 78, - }, - ], - }; - const scriptPath = path.join(tmpDir, "run.sh"); - const run = (config: string) => { - fs.writeFileSync(configPath, config); - fs.rmSync(planPath, { force: true }); - fs.writeFileSync( - scriptPath, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', - 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', - `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimeSetupPlan("slack", runtimeValue))}`, - messagingRuntimeSetupSection(src, { - planPath, - secretScanPrefix: tmpDir + path.sep, - }), - "write_messaging_runtime_setup_plan", - "verify_messaging_runtime_secret_scans", - ].join("\n"), - { mode: 0o700 }, - ); - return spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); - }; - - try { - expect(run('{"botToken":"xoxb-real-token"}\n').status).toBe(78); - expect(run('{"appToken":"xapp-real-token"}\n').status).toBe(78); - expect(run('{"botToken":"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"}\n').status).toBe(0); - expect(run('{"token":"openshell:resolve:env:SLACK_BOT_TOKEN"}\n').status).toBe(0); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); -}); - describe("provider placeholder refresh (#4251)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); From 8b2aa86060165ce52fcd2fb3b92e1262649fffc9 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 5 Aug 2026 00:20:21 +0700 Subject: [PATCH 06/31] test(messaging): keep secret scan setup linear Signed-off-by: San Dang --- test/messaging-runtime-secret-scan.test.ts | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/test/messaging-runtime-secret-scan.test.ts b/test/messaging-runtime-secret-scan.test.ts index d97989822fb..43734cc0c66 100644 --- a/test/messaging-runtime-secret-scan.test.ts +++ b/test/messaging-runtime-secret-scan.test.ts @@ -13,12 +13,16 @@ const SANDBOX_INIT = fs.readFileSync( "utf-8", ); -function runSecretScan(config?: string) { +function writeConfig(content: string): (configPath: string) => void { + return (configPath) => fs.writeFileSync(configPath, content); +} + +function runSecretScan(prepareConfig: (configPath: string) => void) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-secret-scan-")); const configPath = path.join(tmpDir, "openclaw.json"); const planPath = path.join(tmpDir, "runtime-plan.json"); const scriptPath = path.join(tmpDir, "run.sh"); - if (config !== undefined) fs.writeFileSync(configPath, config); + prepareConfig(configPath); fs.writeFileSync( planPath, JSON.stringify({ @@ -53,7 +57,7 @@ describe("messaging runtime secret scans", () => { "xoxb-real-token", "xapp-real-token", ])("rejects %s without exposing the secret (#2085)", (secret) => { - const { configPath, result } = runSecretScan(JSON.stringify({ token: secret })); + const { configPath, result } = runSecretScan(writeConfig(JSON.stringify({ token: secret }))); expect(result.status).toBe(78); expect(result.stderr).toContain(configPath); @@ -61,10 +65,13 @@ describe("messaging runtime secret scans", () => { }); it.each([ - ["approved placeholder", '{"botToken":"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"}\n'], - ["OpenShell reference", '{"token":"openshell:resolve:env:SLACK_BOT_TOKEN"}\n'], - ["missing file", undefined], - ])("skips the %s without failure (#2085)", (_case, config) => { - expect(runSecretScan(config).result.status).toBe(0); + [ + "approved placeholder", + writeConfig('{"botToken":"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"}\n'), + ], + ["OpenShell reference", writeConfig('{"token":"openshell:resolve:env:SLACK_BOT_TOKEN"}\n')], + ["missing file", () => undefined], + ])("skips the %s without failure (#2085)", (_case, prepareConfig) => { + expect(runSecretScan(prepareConfig).result.status).toBe(0); }); }); From af461ffa819c79ef7c72432804fb9dbcd92219ff Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 5 Aug 2026 00:34:01 +0700 Subject: [PATCH 07/31] test(messaging): always clean secret scan fixtures Signed-off-by: San Dang --- test/messaging-runtime-secret-scan.test.ts | 59 ++++++++++++---------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/test/messaging-runtime-secret-scan.test.ts b/test/messaging-runtime-secret-scan.test.ts index 43734cc0c66..2f968bc1389 100644 --- a/test/messaging-runtime-secret-scan.test.ts +++ b/test/messaging-runtime-secret-scan.test.ts @@ -22,34 +22,37 @@ function runSecretScan(prepareConfig: (configPath: string) => void) { const configPath = path.join(tmpDir, "openclaw.json"); const planPath = path.join(tmpDir, "runtime-plan.json"); const scriptPath = path.join(tmpDir, "run.sh"); - prepareConfig(configPath); - fs.writeFileSync( - planPath, - JSON.stringify({ - secretScans: [ - { - path: configPath, - pattern: "(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)", - message: "[SECURITY] Slack token leaked into {path} - refusing to serve", - exitCode: 78, - }, - ], - }), - ); - fs.writeFileSync( - scriptPath, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(planPath)}`, - extractShellFunction(SANDBOX_INIT, "verify_messaging_runtime_secret_scans"), - "verify_messaging_runtime_secret_scans", - ].join("\n"), - { mode: 0o700 }, - ); - const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); - fs.rmSync(tmpDir, { recursive: true, force: true }); - return { configPath, result }; + try { + prepareConfig(configPath); + fs.writeFileSync( + planPath, + JSON.stringify({ + secretScans: [ + { + path: configPath, + pattern: "(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)", + message: "[SECURITY] Slack token leaked into {path} - refusing to serve", + exitCode: 78, + }, + ], + }), + ); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(planPath)}`, + extractShellFunction(SANDBOX_INIT, "verify_messaging_runtime_secret_scans"), + "verify_messaging_runtime_secret_scans", + ].join("\n"), + { mode: 0o700 }, + ); + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + return { configPath, result }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } } describe("messaging runtime secret scans", () => { From 02e10e092f3206a8f3158678336ef14fb535287b Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 5 Aug 2026 01:06:43 +0700 Subject: [PATCH 08/31] test(hermes): repair runtime preload fixtures Signed-off-by: San Dang --- test/hermes-doctor-config-hash.test.ts | 9 +++++++++ test/hermes-tirith-retry-finalization.test.ts | 1 + test/sandbox-provisioning.test.ts | 20 ++++++++++++++++++- test/sandbox-rlimit-hooks.test.ts | 17 ++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/test/hermes-doctor-config-hash.test.ts b/test/hermes-doctor-config-hash.test.ts index 7be8f2f420e..ad454df4e45 100644 --- a/test/hermes-doctor-config-hash.test.ts +++ b/test/hermes-doctor-config-hash.test.ts @@ -65,6 +65,13 @@ describe("Hermes doctor and config hash boundary", () => { const binDir = path.join(tmp, "usr-local-bin"); const libDir = path.join(tmp, "usr-local-lib-nemoclaw"); const preloadsDir = path.join(libDir, "preloads"); + const compiledRuntimePreload = path.join( + libDir, + "preloads-compiled-channels", + "whatsapp", + "runtime", + "whatsapp-hermes-session.js", + ); const buildMcpDigestPath = path.join(libDir, "build-hermes-mcp-digest.py"); const mcpConfigTransactionPath = path.join(libDir, "hermes-mcp-config-transaction.py"); const langfuseCredentialPatcherPath = path.join( @@ -112,6 +119,7 @@ describe("Hermes doctor and config hash boundary", () => { path.join(libDir, "state-dir-guard.py"), path.join(libDir, "managed-gateway-control.py"), path.join(libDir, "sandbox-rlimits.sh"), + compiledRuntimePreload, path.join(preloadsDir, "gateway-safety-net.js"), path.join(nestedDir, "ciao-preload.js"), bashrcPath, @@ -164,6 +172,7 @@ describe("Hermes doctor and config hash boundary", () => { expect(mode(preloadsDir)).toBe("755"); expect(mode(nestedDir)).toBe("755"); expect(mode(path.join(preloadsDir, "gateway-safety-net.js"))).toBe("444"); + expect(mode(path.join(preloadsDir, "whatsapp-hermes-session.js"))).toBe("444"); expect(mode(path.join(nestedDir, "ciao-preload.js"))).toBe("444"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); diff --git a/test/hermes-tirith-retry-finalization.test.ts b/test/hermes-tirith-retry-finalization.test.ts index 0eeb2d4d3bf..7e3dcc0b258 100644 --- a/test/hermes-tirith-retry-finalization.test.ts +++ b/test/hermes-tirith-retry-finalization.test.ts @@ -177,6 +177,7 @@ describe("agents/hermes/start.sh Tirith retry finalization", () => { "validate_hermes_runtime_env_secret_boundary() { :; }", "refresh_hermes_provider_placeholders() { :; }", "configure_messaging_channels() { :; }", + "prepare_hermes_messaging_runtime() { :; }", "TIRITH_RETRY_MARKER_CLEARED=1", 'rm -f "$MARKER"', "prepare_hermes_root_runtime", diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 2b79002f099..1b0d84c87cd 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1101,6 +1101,14 @@ describe("Hermes sandbox provisioning", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-helper-modes-")); const localBin = path.join(tmp, "usr", "local", "bin"); const localLib = path.join(tmp, "usr", "local", "lib", "nemoclaw"); + const preloadsDir = path.join(localLib, "preloads"); + const compiledRuntimePreload = path.join( + localLib, + "preloads-compiled-channels", + "whatsapp", + "runtime", + "whatsapp-hermes-session.js", + ); const etcDir = path.join(tmp, "etc"); const profileDir = path.join(etcDir, "profile.d"); const bashrcPath = path.join(etcDir, "bash.bashrc"); @@ -1137,6 +1145,7 @@ describe("Hermes sandbox provisioning", () => { stateDirGuardPath, managedGatewayControlPath, path.join(localLib, "sandbox-rlimits.sh"), + compiledRuntimePreload, ]; const command = dockerRunCommandBetween( dockerfile, @@ -1150,9 +1159,13 @@ describe("Hermes sandbox provisioning", () => { try { fs.mkdirSync(localBin, { recursive: true }); fs.mkdirSync(localLib, { recursive: true }); + fs.mkdirSync(preloadsDir, { recursive: true }); fs.mkdirSync(etcDir, { recursive: true }); fs.writeFileSync(bashrcPath, "# fixture\n", { mode: 0o600 }); - for (const file of files) fs.writeFileSync(file, "# fixture\n", { mode: 0o600 }); + for (const file of files) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, "# fixture\n", { mode: 0o600 }); + } const { result, calls } = runLoggedDockerShell(command, tmp, [ 'chown() { printf "chown %s\\n" "$*" >> "$call_log"; }', ]); @@ -1169,6 +1182,11 @@ describe("Hermes sandbox provisioning", () => { expect((fs.statSync(gatewaySupervisorPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(stateDirGuardPath).mode & 0o777).toString(8)).toBe("500"); expect((fs.statSync(managedGatewayControlPath).mode & 0o777).toString(8)).toBe("500"); + expect( + (fs.statSync(path.join(preloadsDir, "whatsapp-hermes-session.js")).mode & 0o777).toString( + 8, + ), + ).toBe("444"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/test/sandbox-rlimit-hooks.test.ts b/test/sandbox-rlimit-hooks.test.ts index c4c5ae690ad..d1c3027b22b 100644 --- a/test/sandbox-rlimit-hooks.test.ts +++ b/test/sandbox-rlimit-hooks.test.ts @@ -570,6 +570,13 @@ describe("sandbox rlimit system hooks (#2173)", () => { "openshell-child-visible-credentials.v0.0.85.json", ); const preloadDir = path.join(localLib, "preloads"); + const compiledRuntimePreload = path.join( + localLib, + "preloads-compiled-channels", + "whatsapp", + "runtime", + "whatsapp-hermes-session.js", + ); const safetyNet = path.join(preloadDir, "sandbox-safety-net.js"); const ciaoGuard = path.join(preloadDir, "ciao-network-guard.js"); const gatewaySupervisor = path.join(localLib, "gateway-supervisor.sh"); @@ -599,6 +606,10 @@ describe("sandbox rlimit system hooks (#2173)", () => { fs.writeFileSync(buildMcpDigest, "# build MCP digest fixture\n"); fs.writeFileSync(mcpTransaction, "# MCP transaction fixture\n"); fs.writeFileSync(mcpCredentialBoundary, "{}\n"); + fs.mkdirSync(path.dirname(compiledRuntimePreload), { recursive: true }); + fs.writeFileSync(compiledRuntimePreload, "module.exports = 'runtime preload fixture';\n", { + mode: 0o666, + }); fs.mkdirSync(preloadDir, { mode: 0o777 }); fs.writeFileSync(safetyNet, "module.exports = 'safety net fixture';\n", { mode: 0o666 }); fs.writeFileSync(ciaoGuard, "module.exports = 'ciao guard fixture';\n", { mode: 0o666 }); @@ -676,9 +687,13 @@ describe("sandbox rlimit system hooks (#2173)", () => { const hardenedDir = fs.statSync(preloadDir); const hardenedSafetyNet = fs.statSync(safetyNet); const hardenedCiaoGuard = fs.statSync(ciaoGuard); + const hardenedRuntimePreload = fs.statSync( + path.join(preloadDir, "whatsapp-hermes-session.js"), + ); expect(hardenedDir.mode & 0o777).toBe(0o755); expect(hardenedSafetyNet.mode & 0o777).toBe(0o444); expect(hardenedCiaoGuard.mode & 0o777).toBe(0o444); + expect(hardenedRuntimePreload.mode & 0o777).toBe(0o444); expect(fs.statSync(discordRecoveryPatcher).mode & 0o777).toBe(0o755); expect(fs.statSync(profilePolicyPatcher).mode & 0o777).toBe(0o755); expect(fs.statSync(langfuseCredentialPatcher).mode & 0o777).toBe(0o444); @@ -690,6 +705,8 @@ describe("sandbox rlimit system hooks (#2173)", () => { expect(hardenedSafetyNet.gid).toBe(fixtureOwner.gid); expect(hardenedCiaoGuard.uid).toBe(fixtureOwner.uid); expect(hardenedCiaoGuard.gid).toBe(fixtureOwner.gid); + expect(hardenedRuntimePreload.uid).toBe(fixtureOwner.uid); + expect(hardenedRuntimePreload.gid).toBe(fixtureOwner.gid); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } From 2216e9c7e1da03a608436df08454932ddfbf8add Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 4 Aug 2026 18:10:25 -0700 Subject: [PATCH 09/31] fix(messaging): scope Hermes session preload Signed-off-by: Apurv Kumaria --- .../runtime/whatsapp-hermes-session.test.ts | 18 ++++++++++++++++-- .../runtime/whatsapp-hermes-session.ts | 19 ++++++++++--------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts index fc5b4c63132..d83cc55e4f5 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts @@ -28,6 +28,20 @@ describe("Hermes WhatsApp session runtime preload", () => { expect(argv[3]).toBe("/keep"); }); + it("leaves a path-shaped unrelated bridge and its umask unchanged (#8184)", () => { + const modes: number[] = []; + const argv = [ + "/usr/local/bin/node", + "/sandbox/unrelated/whatsapp-bridge/bridge.js", + "--session", + "/keep", + ]; + + expect(applyHermesWhatsappSessionPatch(argv, (mode) => modes.push(mode))).toBe(false); + expect(argv[3]).toBe("/keep"); + expect(modes).toEqual([]); + }); + it("keeps paired credentials shared with the Hermes gateway group (#8184)", () => { const modes: number[] = []; const argv = [ @@ -42,12 +56,12 @@ describe("Hermes WhatsApp session runtime preload", () => { }); it.each([ - ["missing", ["/usr/local/bin/node", "/sandbox/whatsapp-bridge/bridge.js"]], + ["missing", ["/usr/local/bin/node", "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js"]], [ "duplicate", [ "/usr/local/bin/node", - "/sandbox/whatsapp-bridge/bridge.js", + "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", "--session", "/one", "--session", diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts index a218685e252..0253999d04d 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts @@ -1,16 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import path from "node:path"; - export const HERMES_WHATSAPP_SESSION_PATH = "/sandbox/.hermes/platforms/whatsapp/session"; +const HERMES_WHATSAPP_BRIDGE_PATHS = new Set([ + "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", + "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", +]); function isHermesWhatsappBridge(scriptPath: string | undefined): boolean { - if (!scriptPath) return false; - return ( - path.basename(scriptPath) === "bridge.js" && - path.basename(path.dirname(scriptPath)) === "whatsapp-bridge" - ); + return scriptPath !== undefined && HERMES_WHATSAPP_BRIDGE_PATHS.has(scriptPath); } export function normalizeHermesWhatsappSessionArgv(argv: string[]): boolean { @@ -26,8 +24,11 @@ export function normalizeHermesWhatsappSessionArgv(argv: string[]): boolean { ); } - // Both Hermes homes always supply --session. Force the manifest-owned durable - // location so dashboard pairing and gateway delivery cannot split state. + // Hermes currently launches its dashboard and gateway bridge from different homes with + // independent --session values. The channel runtime preload is the shared boundary where + // both launchers can be reconciled; the rendered manifest session_path does not control + // those launcher arguments. Keep the two owned paths and this regression together, and + // remove the preload once both upstream launchers honor one manifest-owned session path. argv[sessionIndexes[0] + 1] = HERMES_WHATSAPP_SESSION_PATH; return true; } From b9f1345cfe93437594739185f39c80ed29d21d5d Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 4 Aug 2026 19:38:22 -0700 Subject: [PATCH 10/31] fix(messaging): apply Hermes runtime aliases Signed-off-by: Apurv Kumaria --- agents/hermes/start.sh | 1 + test/hermes-messaging-runtime-setup.test.ts | 76 ++++++++++++++++++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 428e406dec8..777938187f1 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -361,6 +361,7 @@ verify_hermes_config_integrity() { prepare_hermes_messaging_runtime() { write_messaging_runtime_setup_plan || return 1 + apply_messaging_runtime_env_aliases || return 1 install_messaging_runtime_preloads || return 1 verify_messaging_runtime_secret_scans || return 1 write_runtime_shell_env diff --git a/test/hermes-messaging-runtime-setup.test.ts b/test/hermes-messaging-runtime-setup.test.ts index e49abb3ffc7..7190731776f 100644 --- a/test/hermes-messaging-runtime-setup.test.ts +++ b/test/hermes-messaging-runtime-setup.test.ts @@ -31,6 +31,7 @@ describe("Hermes messaging runtime setup", () => { [ "set -euo pipefail", 'write_messaging_runtime_setup_plan() { printf "plan\\n"; }', + 'apply_messaging_runtime_env_aliases() { printf "alias\\n"; }', 'install_messaging_runtime_preloads() { printf "install\\n"; }', 'verify_messaging_runtime_secret_scans() { printf "scan\\n"; }', 'write_runtime_shell_env() { printf "env\\n"; }', @@ -42,7 +43,7 @@ describe("Hermes messaging runtime setup", () => { ); expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toBe("plan\ninstall\nscan\nenv\n"); + expect(result.stdout).toBe("plan\nalias\ninstall\nscan\nenv\n"); }); it("publishes manifest connect preloads through the trusted runtime environment (#8184)", () => { @@ -98,11 +99,12 @@ describe("Hermes messaging runtime setup", () => { "refresh_hermes_provider_placeholders", "refresh_hermes_runtime_config_hashes", "configure_messaging_channels", + "apply_messaging_runtime_env_aliases", "write_runtime_shell_env", "prepare_tirith_marker_retry", ], expectedTail: - "configure_messaging_channels\nplan\ninstall\nscan\nwrite_runtime_shell_env\nprepare_tirith_marker_retry\n", + "configure_messaging_channels\nplan\napply_messaging_runtime_env_aliases\ninstall\nscan\nwrite_runtime_shell_env\nprepare_tirith_marker_retry\n", }, { name: "prepare_hermes_root_runtime", @@ -115,11 +117,12 @@ describe("Hermes messaging runtime setup", () => { "validate_hermes_runtime_env_secret_boundary", "refresh_hermes_provider_placeholders", "configure_messaging_channels", + "apply_messaging_runtime_env_aliases", "write_runtime_shell_env", "prepare_tirith_marker_retry", ], expectedTail: - "configure_messaging_channels\nplan\ninstall\nscan\nwrite_runtime_shell_env\nprepare_tirith_marker_retry\n", + "configure_messaging_channels\nplan\napply_messaging_runtime_env_aliases\ninstall\nscan\nwrite_runtime_shell_env\nprepare_tirith_marker_retry\n", }, ])("installs manifest runtime setup during $name (#8184)", ({ name, stubs, expectedTail }) => { const stubFunctions = stubs.map((stub) => `${stub}() { printf '${stub}\\n'; }`).join("\n"); @@ -147,4 +150,71 @@ describe("Hermes messaging runtime setup", () => { expect(result.status, result.stderr).toBe(0); expect(result.stdout.endsWith(expectedTail)).toBe(true); }); + + it.each([ + "prepare_hermes_nonroot_runtime", + "prepare_hermes_root_runtime", + ])("applies active Slack runtime aliases during %s (#8184)", (name) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-env-alias-")); + const planPath = path.join(tmpDir, "runtime-plan.json"); + const runtimePlan = JSON.stringify({ + envAliases: [ + { + envKey: "SLACK_BOT_TOKEN", + match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$", + value: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + }, + ], + }); + const prerequisites = [ + "verify_config_integrity_if_locked", + "validate_hermes_env_secret_boundary", + "inspect_hermes_mcp_integrity", + "ensure_hermes_runtime_api_server_key", + "apply_shields_up_runtime_env", + "validate_hermes_runtime_env_secret_boundary", + "refresh_hermes_provider_placeholders", + "refresh_hermes_runtime_config_hashes", + "verify_hermes_config_integrity", + "ensure_hermes_config_root_mode", + "configure_messaging_channels", + "install_messaging_runtime_preloads", + "verify_messaging_runtime_secret_scans", + "write_runtime_shell_env", + "prepare_tirith_marker_retry", + ]; + + try { + const result = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + "set -euo pipefail", + `HERMES_DIR=${JSON.stringify(tmpDir)}`, + `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(planPath)}`, + ...prerequisites.map((stub) => `${stub}() { return 0; }`), + `write_messaging_runtime_setup_plan() { printf '%s' ${JSON.stringify(runtimePlan)} >"$_MESSAGING_RUNTIME_SETUP_PLAN"; }`, + extractShellFunction(SANDBOX_INIT, "apply_messaging_runtime_env_aliases"), + extractShellFunction(HERMES_START, "prepare_hermes_messaging_runtime"), + extractShellFunction(HERMES_START, name), + name, + 'printf "%s\\n" "$SLACK_BOT_TOKEN"', + ].join("\n"), + ], + { + encoding: "utf-8", + timeout: 5000, + env: { ...process.env, SLACK_BOT_TOKEN: "openshell:resolve:env:v42_SLACK_BOT_TOKEN" }, + }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN\n"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); From 8269976ecd234a6c47c8a3196df43e0452a6d45c Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Tue, 4 Aug 2026 21:12:56 -0700 Subject: [PATCH 11/31] fix(messaging): reject missing Hermes session values Signed-off-by: Senthil Ravichandran --- .../whatsapp/runtime/whatsapp-hermes-session.test.ts | 10 ++++++++++ .../whatsapp/runtime/whatsapp-hermes-session.ts | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts index d83cc55e4f5..439ca4ae5b9 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts @@ -57,6 +57,16 @@ describe("Hermes WhatsApp session runtime preload", () => { it.each([ ["missing", ["/usr/local/bin/node", "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js"]], + [ + "missing before another option", + [ + "/usr/local/bin/node", + "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", + "--session", + "--mode", + "bot", + ], + ], [ "duplicate", [ diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts index 0253999d04d..2e2088a0364 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts @@ -18,7 +18,9 @@ export function normalizeHermesWhatsappSessionArgv(argv: string[]): boolean { if (value === "--session") indexes.push(index); return indexes; }, []); - if (sessionIndexes.length !== 1 || !argv[sessionIndexes[0] + 1]) { + const sessionIndex = sessionIndexes.length === 1 ? sessionIndexes[0] : undefined; + const sessionValue = sessionIndex === undefined ? undefined : argv[sessionIndex + 1]; + if (sessionIndex === undefined || !sessionValue || sessionValue.startsWith("-")) { throw new Error( "Hermes WhatsApp bridge did not provide exactly one session path; refusing split session state", ); @@ -29,7 +31,7 @@ export function normalizeHermesWhatsappSessionArgv(argv: string[]): boolean { // both launchers can be reconciled; the rendered manifest session_path does not control // those launcher arguments. Keep the two owned paths and this regression together, and // remove the preload once both upstream launchers honor one manifest-owned session path. - argv[sessionIndexes[0] + 1] = HERMES_WHATSAPP_SESSION_PATH; + argv[sessionIndex + 1] = HERMES_WHATSAPP_SESSION_PATH; return true; } From e994ddc1bfc07915a3f237cd98a0d675c2288144 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 4 Aug 2026 23:06:13 -0700 Subject: [PATCH 12/31] fix(messaging): reject unsafe runtime preload paths Signed-off-by: Apurv Kumaria --- scripts/lib/sandbox-init.sh | 50 +++++++++++-- test/hermes-messaging-runtime-setup.test.ts | 81 +++++++++++++++++++++ 2 files changed, 125 insertions(+), 6 deletions(-) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 8870306f4d2..ae6222c4389 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -869,15 +869,53 @@ def clean_message(value, field): return value +def clean_preload_path(value, field, prefix, description): + path = clean_string(value, field) + if not path.startswith(prefix) or not path.endswith(".js"): + fail(f"{field} must be a {description} under {prefix}*") + + prefix_without_slash = prefix.rstrip("/") + if prefix.endswith("/"): + allowed_directory = prefix_without_slash + basename_prefix = "" + else: + allowed_directory = os.path.dirname(prefix_without_slash) + basename_prefix = os.path.basename(prefix_without_slash) + + normalized = os.path.normpath(path) + basename = os.path.basename(normalized) + if ( + normalized != path + or os.path.dirname(normalized) != allowed_directory + or not basename.startswith(basename_prefix) + ): + fail(f"{field} must be a direct {description} under {prefix}*") + + # Reject an existing symlink that escapes the approved directory. The + # atomic writer replaces an in-directory final symlink instead of following + # it, while this check prevents either source or target from resolving into + # another filesystem location. + resolved_directory = os.path.realpath(allowed_directory) + if os.path.dirname(os.path.realpath(path)) != resolved_directory: + fail(f"{field} must not resolve outside {prefix}*") + return path + + def clean_node_preload(entry, index): if not isinstance(entry, dict): fail(f"nodePreloads[{index}] must be an object") - source = clean_string(entry.get("source"), f"nodePreloads[{index}].source") - target = clean_string(entry.get("target"), f"nodePreloads[{index}].target") - if not source.startswith(PRELOAD_SOURCE_PREFIX) or not source.endswith(".js"): - fail(f"nodePreloads[{index}].source must be a preload JavaScript file under {PRELOAD_SOURCE_PREFIX}") - if not target.startswith(PRELOAD_TARGET_PREFIX) or not target.endswith(".js"): - fail(f"nodePreloads[{index}].target must be a JavaScript file under {PRELOAD_TARGET_PREFIX}*") + source = clean_preload_path( + entry.get("source"), + f"nodePreloads[{index}].source", + PRELOAD_SOURCE_PREFIX, + "preload JavaScript file", + ) + target = clean_preload_path( + entry.get("target"), + f"nodePreloads[{index}].target", + PRELOAD_TARGET_PREFIX, + "JavaScript file", + ) inject_into = entry.get("injectInto", []) if not isinstance(inject_into, list): fail(f"nodePreloads[{index}].injectInto must be a list") diff --git a/test/hermes-messaging-runtime-setup.test.ts b/test/hermes-messaging-runtime-setup.test.ts index 7190731776f..96a8c9f27ac 100644 --- a/test/hermes-messaging-runtime-setup.test.ts +++ b/test/hermes-messaging-runtime-setup.test.ts @@ -20,7 +20,88 @@ function runtimeShellEnvFunction(source: string): string { return source.slice(start, end); } +function messagingRuntimeSetupSection(source: string, planPath: string): string { + const start = source.indexOf("# ── Messaging runtime setup from manifest metadata"); + const end = source.indexOf("# ── End messaging runtime setup", start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return source + .slice(start, end) + .replace( + '_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json"', + `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(planPath)}`, + ); +} + +function encodeRuntimePlan(nodePreloads: Array>): string { + return Buffer.from( + JSON.stringify({ + channels: [ + { + channelId: "whatsapp", + active: true, + disabled: false, + }, + ], + disabledChannels: [], + runtimeSetup: { + nodePreloads: nodePreloads.map((entry) => ({ channelId: "whatsapp", ...entry })), + envAliases: [], + secretScans: [], + }, + }), + ).toString("base64"); +} + describe("Hermes messaging runtime setup", () => { + it("rejects traversal-shaped preload targets before any destination write (#8229)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preload-traversal-")); + const victimPath = path.join(tmpDir, "victim.js"); + const planPath = path.join(tmpDir, "runtime-plan.json"); + const traversalTarget = `/tmp/nemoclaw-/../..${victimPath}`; + fs.writeFileSync(victimPath, "preserve me\n"); + expect(path.resolve(traversalTarget)).toBe(path.resolve(victimPath)); + + try { + const result = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + "set -euo pipefail", + 'emit_sandbox_sourced_file() { local target="$1"; cat >"$target"; }', + messagingRuntimeSetupSection(SANDBOX_INIT, planPath), + "write_messaging_runtime_setup_plan", + ].join("\n"), + ], + { + encoding: "utf-8", + timeout: 5000, + env: { + ...process.env, + NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH: path.join(tmpDir, "missing.json"), + NEMOCLAW_MESSAGING_PLAN_B64: encodeRuntimePlan([ + { + source: "/usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js", + target: traversalTarget, + injectInto: ["boot", "connect"], + optional: false, + }, + ]), + }, + }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("nodePreloads[0].target must be a direct JavaScript file"); + expect(fs.readFileSync(victimPath, "utf-8")).toBe("preserve me\n"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("runs the manifest runtime setup in order (#8184)", () => { const result = spawnSync( "bash", From 8ea4a4d4f112e558e5a6f3bfa5ddf4eac0fbf0a0 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 4 Aug 2026 23:55:11 -0700 Subject: [PATCH 13/31] test(messaging): cover Hermes session preload wiring Signed-off-by: Apurv Kumaria --- .../runtime/whatsapp-hermes-session.ts | 10 +- test/hermes-messaging-runtime-setup.test.ts | 99 ++++++++++++++++++- 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts index 2e2088a0364..e9aef19877e 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts @@ -27,10 +27,12 @@ export function normalizeHermesWhatsappSessionArgv(argv: string[]): boolean { } // Hermes currently launches its dashboard and gateway bridge from different homes with - // independent --session values. The channel runtime preload is the shared boundary where - // both launchers can be reconciled; the rendered manifest session_path does not control - // those launcher arguments. Keep the two owned paths and this regression together, and - // remove the preload once both upstream launchers honor one manifest-owned session path. + // independent --session values. Both command-construction sites belong to the upstream + // Hermes distribution installed into the image rather than NemoClaw's manifest renderer, + // so the rendered manifest session_path cannot change those arguments here. The channel + // runtime preload is the shared boundary where both launchers can be reconciled. Keep the + // two owned paths and this regression together, and remove the preload once both upstream + // launchers honor one manifest-owned session path. argv[sessionIndex + 1] = HERMES_WHATSAPP_SESSION_PATH; return true; } diff --git a/test/hermes-messaging-runtime-setup.test.ts b/test/hermes-messaging-runtime-setup.test.ts index 96a8c9f27ac..64693f882c6 100644 --- a/test/hermes-messaging-runtime-setup.test.ts +++ b/test/hermes-messaging-runtime-setup.test.ts @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import ts from "typescript"; import { describe, expect, it } from "vitest"; import { extractShellFunction } from "./support/hermes-shell-harness"; @@ -20,17 +21,33 @@ function runtimeShellEnvFunction(source: string): string { return source.slice(start, end); } -function messagingRuntimeSetupSection(source: string, planPath: string): string { +function messagingRuntimeSetupSection( + source: string, + planPath: string, + preloadPaths?: { sourcePrefix: string; targetPrefix: string }, +): string { const start = source.indexOf("# ── Messaging runtime setup from manifest metadata"); const end = source.indexOf("# ── End messaging runtime setup", start); expect(start).toBeGreaterThanOrEqual(0); expect(end).toBeGreaterThan(start); - return source + let section = source .slice(start, end) .replace( '_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json"', `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(planPath)}`, ); + if (preloadPaths) { + section = section + .replace( + 'PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/"', + `PRELOAD_SOURCE_PREFIX = ${JSON.stringify(preloadPaths.sourcePrefix)}`, + ) + .replace( + 'PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-"', + `PRELOAD_TARGET_PREFIX = ${JSON.stringify(preloadPaths.targetPrefix)}`, + ); + } + return section; } function encodeRuntimePlan(nodePreloads: Array>): string { @@ -54,6 +71,84 @@ function encodeRuntimePlan(nodePreloads: Array>): string } describe("Hermes messaging runtime setup", () => { + it("installs the active WhatsApp preload and rewrites the Hermes bridge session path (#8229)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-whatsapp-plan-")); + const sourceDir = path.join(tmpDir, "preloads"); + const sourcePrefix = `${sourceDir}${path.sep}`; + const sourcePath = path.join(sourceDir, "whatsapp-hermes-session.js"); + const targetPrefix = path.join(tmpDir, "nemoclaw-"); + const targetPath = `${targetPrefix}whatsapp-hermes-session.js`; + const planPath = path.join(tmpDir, "runtime-plan.json"); + const runtimeSourcePath = path.join( + ROOT, + "src", + "lib", + "messaging", + "channels", + "whatsapp", + "runtime", + "whatsapp-hermes-session.ts", + ); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync( + sourcePath, + ts.transpileModule(fs.readFileSync(runtimeSourcePath, "utf-8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + fileName: runtimeSourcePath, + }).outputText, + ); + + try { + const result = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + "set -euo pipefail", + 'emit_sandbox_sourced_file() { local target="$1"; cat >"$target"; chmod 444 "$target"; }', + messagingRuntimeSetupSection(SANDBOX_INIT, planPath, { + sourcePrefix, + targetPrefix, + }), + "write_messaging_runtime_setup_plan", + "install_messaging_runtime_preloads", + "node -e 'process.stdout.write(JSON.stringify(process.argv))' /sandbox/.hermes/scripts/whatsapp-bridge/bridge.js --session /tmp/split-session", + ].join("\n"), + ], + { + encoding: "utf-8", + timeout: 5000, + env: { + ...process.env, + NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH: path.join(tmpDir, "missing.json"), + NEMOCLAW_MESSAGING_PLAN_B64: encodeRuntimePlan([ + { + source: sourcePath, + target: targetPath, + injectInto: ["boot", "connect"], + optional: false, + }, + ]), + NODE_OPTIONS: "", + }, + }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual([ + process.execPath, + "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", + "--session", + "/sandbox/.hermes/platforms/whatsapp/session", + ]); + expect(fs.readFileSync(targetPath, "utf-8")).toBe(fs.readFileSync(sourcePath, "utf-8")); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("rejects traversal-shaped preload targets before any destination write (#8229)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preload-traversal-")); const victimPath = path.join(tmpDir, "victim.js"); From 4fdb936fda17e04aa85671d55decaccb4a2f81e6 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 4 Aug 2026 23:59:48 -0700 Subject: [PATCH 14/31] test(messaging): keep runtime setup harness linear Signed-off-by: Apurv Kumaria --- test/hermes-messaging-runtime-setup.test.ts | 27 ++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/test/hermes-messaging-runtime-setup.test.ts b/test/hermes-messaging-runtime-setup.test.ts index 64693f882c6..2a3ba8a0c82 100644 --- a/test/hermes-messaging-runtime-setup.test.ts +++ b/test/hermes-messaging-runtime-setup.test.ts @@ -24,30 +24,29 @@ function runtimeShellEnvFunction(source: string): string { function messagingRuntimeSetupSection( source: string, planPath: string, - preloadPaths?: { sourcePrefix: string; targetPrefix: string }, + preloadPaths: { sourcePrefix: string; targetPrefix: string } = { + sourcePrefix: "/usr/local/lib/nemoclaw/preloads/", + targetPrefix: "/tmp/nemoclaw-", + }, ): string { const start = source.indexOf("# ── Messaging runtime setup from manifest metadata"); const end = source.indexOf("# ── End messaging runtime setup", start); expect(start).toBeGreaterThanOrEqual(0); expect(end).toBeGreaterThan(start); - let section = source + return source .slice(start, end) .replace( '_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json"', `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(planPath)}`, + ) + .replace( + 'PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/"', + `PRELOAD_SOURCE_PREFIX = ${JSON.stringify(preloadPaths.sourcePrefix)}`, + ) + .replace( + 'PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-"', + `PRELOAD_TARGET_PREFIX = ${JSON.stringify(preloadPaths.targetPrefix)}`, ); - if (preloadPaths) { - section = section - .replace( - 'PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/"', - `PRELOAD_SOURCE_PREFIX = ${JSON.stringify(preloadPaths.sourcePrefix)}`, - ) - .replace( - 'PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-"', - `PRELOAD_TARGET_PREFIX = ${JSON.stringify(preloadPaths.targetPrefix)}`, - ); - } - return section; } function encodeRuntimePlan(nodePreloads: Array>): string { From 24c4688a0fdf2eb8bc2975dc12f99043041a052a Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 5 Aug 2026 18:32:03 +0700 Subject: [PATCH 15/31] fix(messaging): preserve Hermes WhatsApp config integrity Signed-off-by: San Dang --- agents/hermes/start.sh | 13 +- docs/manage-sandboxes/set-up-whatsapp.mdx | 5 + .../check-messaging-plan-image-boundary.mts | 17 ++- scripts/lib/sandbox-init.sh | 121 +++++++++++++++++- .../snapshot-managed-clone-providers.test.ts | 2 +- src/lib/messaging/README.md | 4 +- .../applier/build/messaging-build-applier.mts | 9 +- .../messaging/applier/host-state-applier.ts | 1 + .../messaging/channels/whatsapp/manifest.ts | 7 + .../runtime/whatsapp-hermes-session.test.ts | 78 ++++++++++- .../runtime/whatsapp-hermes-session.ts | 105 +++++++++++++++ src/lib/messaging/clone-rebind.ts | 2 +- .../compiler/engines/runtime-setup-engine.ts | 35 ++++- .../compiler/manifest-compiler.test.ts | 14 +- .../compiler/workflow-planner.test.ts | 1 + .../messaging/compiler/workflow-planner.ts | 5 + src/lib/messaging/hydration.ts | 1 + src/lib/messaging/manifest/types.ts | 14 ++ src/lib/messaging/persistence.ts | 3 +- src/lib/messaging/plan-validation.test.ts | 20 +++ src/lib/messaging/plan-validation.ts | 3 +- .../handlers/sandbox-messaging.test.ts | 1 + .../machine/handlers/sandbox-messaging.ts | 9 +- .../managed-startup-clone-rebinder.test.ts | 2 +- .../managed-startup-profile-builder.test.ts | 2 +- .../managed-workload-clone-handoff.test.ts | 2 +- test/hermes-messaging-runtime-setup.test.ts | 112 ++++++++++++++-- test/hermes-runtime-api-key.test.ts | 8 +- test/messaging-build-applier.test.ts | 18 +++ test/messaging-plan-image-boundary.test.ts | 1 + 30 files changed, 573 insertions(+), 42 deletions(-) diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 777938187f1..405d6b7d97b 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -1781,6 +1781,9 @@ TUIENVEOF if type emit_messaging_connect_runtime_preload_exports >/dev/null 2>&1; then emit_messaging_connect_runtime_preload_exports fi + if type emit_messaging_connect_runtime_command_router >/dev/null 2>&1; then + emit_messaging_connect_runtime_command_router + fi for _ca_env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO NODE_EXTRA_CA_CERTS; do _ca_env_value="${!_ca_env_name:-}" if [ -n "$_ca_env_value" ]; then @@ -1790,7 +1793,7 @@ TUIENVEOF cat <<'GUARDENVEOF' # nemoclaw-configure-guard begin hermes() { - case "$1" in + case "${1:-}" in setup|doctor) echo "Error: 'hermes $1' cannot modify config inside the sandbox." >&2 echo "NemoClaw manages sandbox config from the host for integrity checks." >&2 @@ -1800,6 +1803,14 @@ hermes() { return 1 ;; esac + if type _nemoclaw_messaging_runtime_command_module >/dev/null 2>&1; then + local _nemoclaw_runtime_module + _nemoclaw_runtime_module="$(_nemoclaw_messaging_runtime_command_module hermes "$@")" || return $? + if [ -n "$_nemoclaw_runtime_module" ]; then + /usr/local/bin/node "$_nemoclaw_runtime_module" + return $? + fi + fi command hermes "$@" } # nemoclaw-configure-guard end diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index 59dbf03daa1..af6ede959da 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -23,9 +23,14 @@ openclaw channels login --channel whatsapp ``` +In the NemoClaw-managed connect shell, run: ```bash hermes whatsapp ``` +This command opens QR pairing directly at `/sandbox/.hermes/platforms/whatsapp/session`. +It reads the WhatsApp mode and sender allowlist from the NemoClaw-managed `/sandbox/.hermes/.env` file without modifying that file. +Pairing therefore does not cause configuration drift during later host-side `config set --restart` operations or gateway restarts. +This route applies only to the managed connect shell; direct Hermes executable invocation uses upstream behavior. diff --git a/scripts/check-messaging-plan-image-boundary.mts b/scripts/check-messaging-plan-image-boundary.mts index b28be6a1143..0b7d4e5901b 100644 --- a/scripts/check-messaging-plan-image-boundary.mts +++ b/scripts/check-messaging-plan-image-boundary.mts @@ -127,6 +127,7 @@ export function createMessagingBoundaryPlan(agent: unknown) { ], runtimeSetup: { nodePreloads: [OPENCLAW_RUNTIME_PRELOAD], + commandRoutes: [], envAliases: [], secretScans: [], }, @@ -175,7 +176,7 @@ export function createMessagingBoundaryPlan(agent: unknown) { value: { manager: "hermes-uv-pip", spec: HERMES_AIOHTTP_PACKAGE_SPEC }, }, ], - runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, }; } @@ -313,6 +314,7 @@ function assertReducedRuntimeArtifact(text: string, agent: MessagingBoundaryAgen if ( !isObject(runtimeSetup) || !Array.isArray(runtimeSetup.nodePreloads) || + !Array.isArray(runtimeSetup.commandRoutes) || !Array.isArray(runtimeSetup.envAliases) || !Array.isArray(runtimeSetup.secretScans) ) { @@ -320,7 +322,7 @@ function assertReducedRuntimeArtifact(text: string, agent: MessagingBoundaryAgen } assertAllowedKeys( runtimeSetup, - ["nodePreloads", "envAliases", "secretScans"], + ["nodePreloads", "commandRoutes", "envAliases", "secretScans"], "reduced runtime plan runtimeSetup", ); assertRuntimeSetupEntryAllowlist( @@ -336,6 +338,11 @@ function assertReducedRuntimeArtifact(text: string, agent: MessagingBoundaryAgen ], "nodePreloads", ); + assertRuntimeSetupEntryAllowlist( + runtimeSetup.commandRoutes, + ["channelId", "command", "args", "module", "source"], + "commandRoutes", + ); assertRuntimeSetupEntryAllowlist( runtimeSetup.envAliases, ["channelId", "envKey", "match", "value", "message"], @@ -346,7 +353,11 @@ function assertReducedRuntimeArtifact(text: string, agent: MessagingBoundaryAgen ["channelId", "path", "pattern", "message", "exitCode"], "secretScans", ); - if (runtimeSetup.envAliases.length !== 0 || runtimeSetup.secretScans.length !== 0) { + if ( + runtimeSetup.commandRoutes.length !== 0 || + runtimeSetup.envAliases.length !== 0 || + runtimeSetup.secretScans.length !== 0 + ) { throw new Error("reduced runtime plan contains unexpected Teams runtime setup entries"); } if (agent === "openclaw") { diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index c6d9f1c42b9..502d48139d7 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -826,10 +826,10 @@ PY # ── Messaging runtime setup from manifest metadata ─────────────── # Channel-owned runtime setup is compiled from manifests at image build time. -# Both agent entrypoints consume the same generic declarations: envAliases, -# nodePreloads, and secretScans. Prefer a forwarded env plan when present; -# otherwise load the reduced image artifact written by the messaging build -# applier. +# Both agent entrypoints consume the same generic declarations: commandRoutes, +# envAliases, nodePreloads, and secretScans. Prefer a forwarded env plan when +# present; otherwise load the reduced image artifact written by the messaging +# build applier. _MESSAGING_RUNTIME_PLAN_ARTIFACT="${NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH:-/usr/local/share/nemoclaw/messaging-runtime-plan.json}" _MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json" _MESSAGING_CONNECT_PRELOADS_FILE="/tmp/nemoclaw-messaging-connect-preloads.list" @@ -842,10 +842,11 @@ import os import re import sys -EMPTY = {"nodePreloads": [], "envAliases": [], "secretScans": []} +EMPTY = {"nodePreloads": [], "commandRoutes": [], "envAliases": [], "secretScans": []} PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/" PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-" ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$") +COMMAND_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") def fail(message): @@ -942,6 +943,38 @@ def clean_node_preload(entry, index): } +def clean_command_route(entry, index): + if not isinstance(entry, dict): + fail(f"commandRoutes[{index}] must be an object") + command = clean_string(entry.get("command"), f"commandRoutes[{index}].command") + if not COMMAND_RE.match(command): + fail(f"commandRoutes[{index}].command is not a safe command name") + args = entry.get("args") + if not isinstance(args, list) or not args: + fail(f"commandRoutes[{index}].args must be a non-empty list") + clean_args = [ + clean_string(value, f"commandRoutes[{index}].args[{arg_index}]") + for arg_index, value in enumerate(args) + ] + module = clean_string(entry.get("module"), f"commandRoutes[{index}].module") + if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", module): + fail(f"commandRoutes[{index}].module is not a safe module name") + source = clean_preload_path( + entry.get("source"), + f"commandRoutes[{index}].source", + PRELOAD_SOURCE_PREFIX, + "runtime JavaScript file", + ) + if source != f"{PRELOAD_SOURCE_PREFIX}{module}.js": + fail(f"commandRoutes[{index}].source does not match its module") + return { + "command": command, + "args": clean_args, + "module": module, + "source": source, + } + + def clean_env_alias(entry, index): if not isinstance(entry, dict): fail(f"envAliases[{index}] must be an object") @@ -1045,9 +1078,11 @@ def runtime_setup_entries(key): node_preloads = [] +command_routes = [] env_aliases = [] secret_scans = [] seen_node_preloads = set() +seen_command_routes = set() seen_aliases = set() seen_scans = set() @@ -1057,6 +1092,13 @@ for entry in runtime_setup_entries("nodePreloads"): if preload_key not in seen_node_preloads: seen_node_preloads.add(preload_key) node_preloads.append(preload) +for entry in runtime_setup_entries("commandRoutes"): + route = clean_command_route(entry, len(command_routes)) + route_key = (route["command"], tuple(route["args"])) + if route_key in seen_command_routes: + fail(f"commandRoutes contains duplicate route {route['command']} {route['args']!r}") + seen_command_routes.add(route_key) + command_routes.append(route) for entry in runtime_setup_entries("envAliases"): alias = clean_env_alias(entry, len(env_aliases)) alias_key = (alias["envKey"], alias["match"], alias["value"]) @@ -1070,7 +1112,7 @@ for entry in runtime_setup_entries("secretScans"): seen_scans.add(scan_key) secret_scans.append(scan) -print(json.dumps({"nodePreloads": node_preloads, "envAliases": env_aliases, "secretScans": secret_scans}, sort_keys=True)) +print(json.dumps({"nodePreloads": node_preloads, "commandRoutes": command_routes, "envAliases": env_aliases, "secretScans": secret_scans}, sort_keys=True)) PYMESSAGINGRUNTIME } @@ -1201,6 +1243,73 @@ fi CONNECTPRELOADSEOF } +emit_messaging_connect_runtime_command_router() { + cat < 2 else "" +args = sys.argv[3:] +if not os.path.isfile(plan_path): + raise SystemExit(0) +try: + if os.path.getsize(plan_path) > 4 * 1024 * 1024: + fail("runtime setup plan is oversized") + with open(plan_path, encoding="utf-8") as handle: + plan = json.load(handle) +except (OSError, UnicodeError, json.JSONDecodeError) as exc: + fail(f"runtime setup plan is unreadable ({exc.__class__.__name__})") +if not isinstance(plan, dict): + fail("runtime setup plan is not an object") + +matches = [] +routes = plan.get("commandRoutes", []) +if not isinstance(routes, list): + fail("commandRoutes is not a list") +for index, route in enumerate(routes): + if not isinstance(route, dict): + fail(f"commandRoutes[{index}] is not an object") + if route.get("command") != command or route.get("args") != args: + continue + source = route.get("source") + module = route.get("module") + if not isinstance(module, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", module): + fail(f"commandRoutes[{index}].module is invalid") + expected = f"/usr/local/lib/nemoclaw/preloads/{module}.js" + if source != expected: + fail(f"commandRoutes[{index}].source does not match its module") + try: + source_stat = os.lstat(source) + except OSError as exc: + fail(f"runtime command module is unavailable ({exc.__class__.__name__})") + if ( + not stat.S_ISREG(source_stat.st_mode) + or source_stat.st_uid != 0 + or source_stat.st_mode & 0o022 + ): + fail("runtime command module is not a root-owned read-only file") + matches.append(source) +if len(matches) > 1: + fail("runtime command route is ambiguous") +if matches: + print(matches[0]) +PYMESSAGINGCOMMAND +} +CONNECTCOMMANDSEOF +} + messaging_runtime_preload_targets() { printf '%s\n' "$_MESSAGING_RUNTIME_SETUP_PLAN" "$_MESSAGING_CONNECT_PRELOADS_FILE" [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 diff --git a/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts b/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts index cec4e673e20..2b27c22338e 100644 --- a/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts +++ b/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts @@ -106,7 +106,7 @@ function messagingPlan(sandboxName: string): SandboxMessagingPlan { networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], - runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, stateUpdates: [], healthChecks: [], } as unknown as SandboxMessagingPlan; diff --git a/src/lib/messaging/README.md b/src/lib/messaging/README.md index 3675050e0cb..6867868d4aa 100644 --- a/src/lib/messaging/README.md +++ b/src/lib/messaging/README.md @@ -180,7 +180,7 @@ Important plan sections are: | `networkPolicy` | `manifest.policyPresets`. | Policy application during onboard and channel lifecycle changes. | | `agentRender` | `manifest.render` after template and credential placeholder resolution. | Host config applier and build-time config applier. | | `buildSteps` | `manifest.agentPackages` and hook outputs of kind `build-arg`, `build-file`, or `package-install`. | Image build and post-agent-install application. | -| `runtimeSetup` | `manifest.runtime`. | Runtime preload, env alias, secret scan, and reduced runtime artifact generation. | +| `runtimeSetup` | `manifest.runtime`. | Runtime preload, exact command route, env alias, secret scan, and reduced runtime artifact generation. | | `stateUpdates` | Config inputs with `statePath`. | Registry persistence and rebuild hydration. | | `healthChecks` | Health-check hook declarations. | Lifecycle success gates. | @@ -293,7 +293,7 @@ It is a serializable declaration for one channel and one set of supported agents | `policyPresets` | Optional network policy presets and policy keys required by the channel. | | `render` | Agent config fragments or env-file lines to render when the channel is active. | | `hostForward` | Optional host-side forward for inbound webhooks. | -| `runtime` | Optional runtime visibility, preload, env alias, and secret scan metadata. | +| `runtime` | Optional runtime visibility, preload, exact command route, env alias, and secret scan metadata. | | `agentPackages` | Optional build-time agent package installs. | | `hooks` | Hook references for enrollment, checks, render, apply, status, and diagnostics. | diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 7f2f7cf33cc..9bb3a025a8e 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -40,7 +40,7 @@ type JsonObject = Record; type MessagingAgentId = "openclaw" | "hermes"; type MessagingHookPhase = "agent-install" | "post-agent-install"; type MessagingBuildCliPhase = MessagingBuildPhase | "managed-image-capability-union"; -type MessagingRuntimeSetupKey = "nodePreloads" | "envAliases" | "secretScans"; +type MessagingRuntimeSetupKey = "nodePreloads" | "commandRoutes" | "envAliases" | "secretScans"; type MessagingSerializableValue = | string | number @@ -425,6 +425,13 @@ function sanitizeRuntimeSetup( "installMessage", "installedMessage", ]), + commandRoutes: sanitizeRuntimeSetupEntries(setup?.commandRoutes, [ + "channelId", + "command", + "args", + "module", + "source", + ]), envAliases: sanitizeRuntimeSetupEntries(setup?.envAliases, [ "channelId", "envKey", diff --git a/src/lib/messaging/applier/host-state-applier.ts b/src/lib/messaging/applier/host-state-applier.ts index 93c21f4e267..9ad6f020a6f 100644 --- a/src/lib/messaging/applier/host-state-applier.ts +++ b/src/lib/messaging/applier/host-state-applier.ts @@ -119,6 +119,7 @@ function mergeRuntimeSetup( ): SandboxMessagingRuntimeSetupPlan { return { nodePreloads: mergeByChannelId(existing?.nodePreloads ?? [], incoming?.nodePreloads ?? []), + commandRoutes: mergeByChannelId(existing?.commandRoutes ?? [], incoming?.commandRoutes ?? []), envAliases: mergeByChannelId(existing?.envAliases ?? [], incoming?.envAliases ?? []), secretScans: mergeByChannelId(existing?.secretScans ?? [], incoming?.secretScans ?? []), }; diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index f93cf45ea93..3ff53544283 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -106,6 +106,13 @@ export const whatsappManifest = { ], }, hermes: { + commandRoutes: [ + { + command: "hermes", + args: ["whatsapp"], + module: "whatsapp-hermes-session", + }, + ], nodePreloads: [ { module: "whatsapp-hermes-session", diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts index 439ca4ae5b9..68b4e98a489 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts @@ -1,13 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; import { planRuntimeSetup } from "../../../compiler/engines/runtime-setup-engine"; import { whatsappManifest } from "../manifest"; import { applyHermesWhatsappSessionPatch, HERMES_WHATSAPP_SESSION_PATH, normalizeHermesWhatsappSessionArgv, + runHermesWhatsappPairing, } from "./whatsapp-hermes-session"; describe("Hermes WhatsApp session runtime preload", () => { @@ -108,5 +114,75 @@ describe("Hermes WhatsApp session runtime preload", () => { optional: false, }), ]); + expect(runtime.commandRoutes).toEqual([ + { + channelId: "whatsapp", + command: "hermes", + args: ["whatsapp"], + module: "whatsapp-hermes-session", + source: "/usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js", + }, + ]); + }); + + it("pairs with managed settings without invoking the Hermes config writer (#8184)", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-whatsapp-pair-")); + try { + const envPath = path.join(fixture, ".env"); + const bridgePath = path.join(fixture, "bridge.js"); + const sessionPath = path.join(fixture, "platforms", "whatsapp", "session"); + fs.writeFileSync( + envPath, + [ + "WHATSAPP_ENABLED=true", + "WHATSAPP_MODE=bot", + "WHATSAPP_ALLOWED_USERS=15550000001", + "UNRELATED_SECRET=do-not-forward", + "", + ].join("\n"), + ); + fs.writeFileSync(bridgePath, "// bridge fixture\n"); + const envModifiedAt = fs.statSync(envPath).mtimeMs; + const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })); + const modes: number[] = []; + const output: string[] = []; + + expect( + runHermesWhatsappPairing({ + envPath, + sessionPath, + bridgePaths: [bridgePath], + nodePath: "/trusted/node", + spawn: spawn as unknown as typeof spawnSync, + setUmask: (mode) => modes.push(mode), + write: (message) => output.push(message), + }), + ).toBe(0); + + expect(fs.statSync(envPath).isFile()).toBe(true); + expect(fs.statSync(envPath).mtimeMs).toBe(envModifiedAt); + expect(fs.readFileSync(envPath, "utf8")).toContain("UNRELATED_SECRET=do-not-forward"); + expect(fs.statSync(sessionPath).isDirectory()).toBe(true); + expect(modes).toEqual([0o007]); + expect(spawn).toHaveBeenCalledWith( + "/trusted/node", + [bridgePath, "--pair-only", "--session", sessionPath], + expect.objectContaining({ + cwd: fixture, + env: expect.objectContaining({ + WHATSAPP_MODE: "bot", + WHATSAPP_ALLOWED_USERS: "15550000001", + }), + stdio: "inherit", + }), + ); + const childEnvironment = (spawn.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined) + ?.env; + expect(childEnvironment).not.toHaveProperty("UNRELATED_SECRET"); + expect(output.join("")).toContain(`Session: ${sessionPath}`); + expect(output.join("")).not.toContain("15550000001"); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } }); }); diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts index e9aef19877e..cfeb3d57b9d 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts @@ -1,11 +1,21 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; +import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname } from "node:path"; + export const HERMES_WHATSAPP_SESSION_PATH = "/sandbox/.hermes/platforms/whatsapp/session"; +export const HERMES_MANAGED_ENV_PATH = "/sandbox/.hermes/.env"; const HERMES_WHATSAPP_BRIDGE_PATHS = new Set([ "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", + "/opt/hermes/scripts/whatsapp-bridge/bridge.js", ]); +const HERMES_WHATSAPP_PAIRING_BRIDGE_PATHS = [ + "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", + "/opt/hermes/scripts/whatsapp-bridge/bridge.js", +] as const; function isHermesWhatsappBridge(scriptPath: string | undefined): boolean { return scriptPath !== undefined && HERMES_WHATSAPP_BRIDGE_PATHS.has(scriptPath); @@ -48,4 +58,99 @@ export function applyHermesWhatsappSessionPatch( return true; } +export interface HermesWhatsappPairingDependencies { + readonly envPath?: string; + readonly sessionPath?: string; + readonly bridgePaths?: readonly string[]; + readonly nodePath?: string; + readonly spawn?: typeof spawnSync; + readonly setUmask?: (mode: number) => unknown; + readonly write?: (message: string) => void; +} + +function readManagedWhatsappEnvironment(envPath: string): NodeJS.ProcessEnv { + const file = lstatSync(envPath); + if (!file.isFile() || file.isSymbolicLink()) { + throw new Error("Hermes managed environment is not a regular file"); + } + const selected: NodeJS.ProcessEnv = {}; + for (const line of readFileSync(envPath, "utf8").split(/\r?\n/)) { + const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); + if (!match) continue; + const [, key, rawValue] = match; + if (key !== "WHATSAPP_MODE" && key !== "WHATSAPP_ALLOWED_USERS") continue; + let value = rawValue ?? ""; + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + selected[key] = value; + } + if (selected.WHATSAPP_MODE !== "bot" && selected.WHATSAPP_MODE !== "self-chat") { + throw new Error("Hermes managed WhatsApp mode is missing or invalid"); + } + return selected; +} + +function requireRegularPath(path: string, description: string): void { + const file = lstatSync(path); + if (!file.isFile() || file.isSymbolicLink()) { + throw new Error(`${description} is not a regular file`); + } +} + +export function runHermesWhatsappPairing( + dependencies: HermesWhatsappPairingDependencies = {}, +): number { + const envPath = dependencies.envPath ?? HERMES_MANAGED_ENV_PATH; + const sessionPath = dependencies.sessionPath ?? HERMES_WHATSAPP_SESSION_PATH; + const bridgePaths = dependencies.bridgePaths ?? HERMES_WHATSAPP_PAIRING_BRIDGE_PATHS; + const bridgePath = bridgePaths.find((candidate) => existsSync(candidate)); + if (!bridgePath) { + throw new Error("Hermes WhatsApp bridge is unavailable"); + } + requireRegularPath(bridgePath, "Hermes WhatsApp bridge"); + if (existsSync(sessionPath)) { + const session = lstatSync(sessionPath); + if (!session.isDirectory() || session.isSymbolicLink()) { + throw new Error("Hermes WhatsApp session path is not a directory"); + } + } else { + mkdirSync(sessionPath, { recursive: true, mode: 0o770 }); + } + + const whatsappEnvironment = readManagedWhatsappEnvironment(envPath); + const write = dependencies.write ?? ((message) => process.stdout.write(message)); + write("\n⚕ WhatsApp Setup\n==================================================\n\n"); + write("NemoClaw manages the WhatsApp mode and sender allowlist from the host.\n"); + write(`Session: ${sessionPath}\n\n`); + write("Open WhatsApp on the phone, then use Settings → Linked Devices → Link a Device.\n\n"); + + (dependencies.setUmask ?? ((mode) => process.umask(mode)))(0o007); + const result = (dependencies.spawn ?? spawnSync)( + dependencies.nodePath ?? process.execPath, + [bridgePath, "--pair-only", "--session", sessionPath], + { + cwd: dirname(bridgePath), + env: { ...process.env, ...whatsappEnvironment }, + stdio: "inherit", + }, + ); + if (result.error) throw result.error; + return result.status ?? 1; +} + applyHermesWhatsappSessionPatch(process.argv); + +if (require.main === module) { + try { + process.exitCode = runHermesWhatsappPairing(); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + process.stderr.write(`Hermes WhatsApp pairing failed: ${message}\n`); + process.exitCode = 1; + } +} diff --git a/src/lib/messaging/clone-rebind.ts b/src/lib/messaging/clone-rebind.ts index 68b1c9c6778..4c388b293cb 100644 --- a/src/lib/messaging/clone-rebind.ts +++ b/src/lib/messaging/clone-rebind.ts @@ -126,7 +126,7 @@ export function rebindSandboxMessagingPlanForClone( networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], - runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, stateUpdates: [], healthChecks: [], } as const; diff --git a/src/lib/messaging/compiler/engines/runtime-setup-engine.ts b/src/lib/messaging/compiler/engines/runtime-setup-engine.ts index 90bdf7401e6..5dc4ecd54fe 100644 --- a/src/lib/messaging/compiler/engines/runtime-setup-engine.ts +++ b/src/lib/messaging/compiler/engines/runtime-setup-engine.ts @@ -3,9 +3,11 @@ import type { ChannelManifest, + ChannelRuntimeCommandRouteSpec, ChannelRuntimeNodePreloadSpec, MessagingAgentId, SandboxMessagingChannelPlan, + SandboxMessagingRuntimeCommandRoutePlan, SandboxMessagingRuntimeEnvAliasPlan, SandboxMessagingRuntimeNodePreloadPlan, SandboxMessagingRuntimeSecretScanPlan, @@ -15,6 +17,7 @@ import type { const PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/"; const PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-"; const NODE_PRELOAD_MODULE_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +const COMMAND_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/; export function planRuntimeSetup( manifests: readonly ChannelManifest[], @@ -27,6 +30,7 @@ export function planRuntimeSetup( .map((channel) => channel.channelId), ); const nodePreloads: SandboxMessagingRuntimeNodePreloadPlan[] = []; + const commandRoutes: SandboxMessagingRuntimeCommandRoutePlan[] = []; const envAliases: SandboxMessagingRuntimeEnvAliasPlan[] = []; const secretScans: SandboxMessagingRuntimeSecretScanPlan[] = []; @@ -37,6 +41,9 @@ export function planRuntimeSetup( nodePreloads.push( ...(runtime.nodePreloads ?? []).map((entry) => resolveNodePreload(manifest, entry)), ); + commandRoutes.push( + ...(runtime.commandRoutes ?? []).map((entry) => resolveCommandRoute(manifest, entry)), + ); envAliases.push( ...(runtime.envAliases ?? []).map((entry) => ({ channelId: manifest.id, @@ -51,7 +58,33 @@ export function planRuntimeSetup( ); } - return { nodePreloads, envAliases, secretScans }; + return { nodePreloads, commandRoutes, envAliases, secretScans }; +} + +function resolveCommandRoute( + manifest: ChannelManifest, + entry: ChannelRuntimeCommandRouteSpec, +): SandboxMessagingRuntimeCommandRoutePlan { + if (!COMMAND_NAME_PATTERN.test(entry.command)) { + throw new Error( + `Channel manifest '${manifest.id}' declares invalid runtime command '${entry.command}'.`, + ); + } + if (!NODE_PRELOAD_MODULE_PATTERN.test(entry.module)) { + throw new Error( + `Channel manifest '${manifest.id}' declares invalid runtime command module '${entry.module}'.`, + ); + } + if (entry.args.length === 0 || entry.args.some((arg) => !arg || /[\0\r\n\t]/.test(arg))) { + throw new Error( + `Channel manifest '${manifest.id}' declares invalid runtime command arguments.`, + ); + } + return { + channelId: manifest.id, + ...entry, + source: `${PRELOAD_SOURCE_PREFIX}${entry.module}.js`, + }; } function resolveNodePreload( diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index cb911296bdf..a0b10884a29 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -825,7 +825,12 @@ describe("ManifestCompiler", () => { "telegram-gateway-conflict-status", "telegram-status-health", ]); - expect(plan.runtimeSetup).toEqual({ nodePreloads: [], envAliases: [], secretScans: [] }); + expect(plan.runtimeSetup).toEqual({ + nodePreloads: [], + commandRoutes: [], + envAliases: [], + secretScans: [], + }); expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual(["telegram"]); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["telegram"]); expect(plan.agentRender).toEqual([]); @@ -1282,7 +1287,12 @@ describe("ManifestCompiler", () => { "telegram-gateway-conflict-status", "telegram-status-health", ]); - expect(plan.runtimeSetup).toEqual({ nodePreloads: [], envAliases: [], secretScans: [] }); + expect(plan.runtimeSetup).toEqual({ + nodePreloads: [], + commandRoutes: [], + envAliases: [], + secretScans: [], + }); }); it("compiles a non-built-in channel manifest through the same generic path", async () => { diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 3b09821bfa5..02bf07183c1 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -745,6 +745,7 @@ describe("MessagingWorkflowPlanner", () => { ).toBe(true); expect(disabledRebuild?.runtimeSetup).toEqual({ nodePreloads: [], + commandRoutes: [], envAliases: [], secretScans: [], }); diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index 03492a50e26..e8244e083b5 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -435,6 +435,10 @@ function mergeRuntimeSetup( existing?.nodePreloads ?? [], incoming?.nodePreloads ?? [], ), + commandRoutes: mergePlanEntriesByChannel( + existing?.commandRoutes ?? [], + incoming?.commandRoutes ?? [], + ), envAliases: mergePlanEntriesByChannel(existing?.envAliases ?? [], incoming?.envAliases ?? []), secretScans: mergePlanEntriesByChannel( existing?.secretScans ?? [], @@ -449,6 +453,7 @@ function filterRuntimeSetup( ): SandboxMessagingRuntimeSetupPlan { return { nodePreloads: (setup?.nodePreloads ?? []).filter(keepEntry), + commandRoutes: (setup?.commandRoutes ?? []).filter(keepEntry), envAliases: (setup?.envAliases ?? []).filter(keepEntry), secretScans: (setup?.secretScans ?? []).filter(keepEntry), }; diff --git a/src/lib/messaging/hydration.ts b/src/lib/messaging/hydration.ts index 07d79f3dcfd..2bf7f087d8c 100644 --- a/src/lib/messaging/hydration.ts +++ b/src/lib/messaging/hydration.ts @@ -231,6 +231,7 @@ function runtimeSetupHasEntries(setup: SandboxMessagingRuntimeSetupPlan | undefi return Boolean( setup && (setup.nodePreloads.length > 0 || + setup.commandRoutes.length > 0 || setup.envAliases.length > 0 || setup.secretScans.length > 0), ); diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 0c0fa24a5a7..4b3618fc23a 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -177,6 +177,7 @@ export interface ChannelOpenClawRuntimeSpec extends ChannelRuntimeSpec { export interface ChannelRuntimeSpec { readonly visibility?: ChannelRuntimeVisibilitySpec; readonly nodePreloads?: readonly ChannelRuntimeNodePreloadSpec[]; + readonly commandRoutes?: readonly ChannelRuntimeCommandRouteSpec[]; readonly envAliases?: readonly ChannelRuntimeEnvAliasSpec[]; readonly secretScans?: readonly ChannelRuntimeSecretScanSpec[]; } @@ -198,6 +199,13 @@ export interface ChannelRuntimeNodePreloadSpec { readonly installedMessage?: string; } +/** Exact agent CLI invocation handled by a manifest-owned Node runtime module. */ +export interface ChannelRuntimeCommandRouteSpec { + readonly command: string; + readonly args: readonly string[]; + readonly module: string; +} + export interface ChannelRuntimeEnvAliasSpec { readonly envKey: string; readonly match: string; @@ -445,6 +453,7 @@ export interface SandboxMessagingPackageInstallStepPlan { export interface SandboxMessagingRuntimeSetupPlan { readonly nodePreloads: readonly SandboxMessagingRuntimeNodePreloadPlan[]; + readonly commandRoutes: readonly SandboxMessagingRuntimeCommandRoutePlan[]; readonly envAliases: readonly SandboxMessagingRuntimeEnvAliasPlan[]; readonly secretScans: readonly SandboxMessagingRuntimeSecretScanPlan[]; } @@ -455,6 +464,11 @@ export interface SandboxMessagingRuntimeNodePreloadPlan extends ChannelRuntimeNo readonly target: string; } +export interface SandboxMessagingRuntimeCommandRoutePlan extends ChannelRuntimeCommandRouteSpec { + readonly channelId: MessagingChannelId; + readonly source: string; +} + export interface SandboxMessagingRuntimeEnvAliasPlan extends ChannelRuntimeEnvAliasSpec { readonly channelId: MessagingChannelId; } diff --git a/src/lib/messaging/persistence.ts b/src/lib/messaging/persistence.ts index 5812031c143..4d1b8eeee5b 100644 --- a/src/lib/messaging/persistence.ts +++ b/src/lib/messaging/persistence.ts @@ -346,7 +346,7 @@ function normalizePersistedCredentialBindings( networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], - runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, stateUpdates: [], healthChecks: [], }; @@ -426,6 +426,7 @@ function normalizeRuntimeSetup( ): SandboxMessagingRuntimeSetupPlan { return { nodePreloads: Array.isArray(setup?.nodePreloads) ? [...setup.nodePreloads] : [], + commandRoutes: Array.isArray(setup?.commandRoutes) ? [...setup.commandRoutes] : [], envAliases: Array.isArray(setup?.envAliases) ? [...setup.envAliases] : [], secretScans: Array.isArray(setup?.secretScans) ? [...setup.secretScans] : [], }; diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts index f64bb7e932f..88dd4ecb490 100644 --- a/src/lib/messaging/plan-validation.test.ts +++ b/src/lib/messaging/plan-validation.test.ts @@ -66,6 +66,25 @@ describe("parseSandboxMessagingPlan", () => { expect(parsed).not.toBe(source); }); + it("normalizes command routes missing from an older runtime setup plan", () => { + const source = makePlan(); + const legacy = { + ...source, + runtimeSetup: { + nodePreloads: [], + envAliases: [], + secretScans: [], + }, + }; + + expect(parseSandboxMessagingPlan(legacy)?.runtimeSetup).toEqual({ + nodePreloads: [], + commandRoutes: [], + envAliases: [], + secretScans: [], + }); + }); + it("accepts compact persisted plans without manifest-derived sections", () => { const source = makePlan({ channels: [ @@ -182,6 +201,7 @@ describe("parseSandboxMessagingPlan", () => { ], runtimeSetup: { nodePreloads: [], + commandRoutes: [], envAliases: [], secretScans: [ { diff --git a/src/lib/messaging/plan-validation.ts b/src/lib/messaging/plan-validation.ts index f4a2a91e4ba..b16b2492fc6 100644 --- a/src/lib/messaging/plan-validation.ts +++ b/src/lib/messaging/plan-validation.ts @@ -236,6 +236,7 @@ function isRuntimeSetup(value: unknown): boolean { return ( isObjectRecord(value) && Array.isArray(value.nodePreloads) && + isOptionalObjectArray(value, "commandRoutes") && Array.isArray(value.envAliases) && Array.isArray(value.secretScans) && value.nodePreloads.every(isObjectRecord) && @@ -268,7 +269,7 @@ function hasCanonicalNetworkPolicyReferences(value: unknown): boolean { function hasCanonicalRuntimeSetupReferences(value: unknown): boolean { if (value === undefined) return true; if (!isObjectRecord(value)) return false; - return ["nodePreloads", "envAliases", "secretScans"].every((field) => + return ["nodePreloads", "commandRoutes", "envAliases", "secretScans"].every((field) => hasCanonicalChannelReferences(value[field]), ); } diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 4d04e481cb9..2c89d9d48d5 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -75,6 +75,7 @@ function mixedChannelPlan(): SandboxMessagingPlan { source: "manifest", target: "agent", })), + commandRoutes: [], envAliases: channelIds.map((channelId) => ({ channelId, envKey: `${channelId.toUpperCase()}_TOKEN`, diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index 0b5d279c61a..36f3a480310 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -8,14 +8,14 @@ import { tryGetMessagingAgentId, } from "../../../messaging"; import type { MessagingAgentId, SandboxMessagingPlan } from "../../../messaging/manifest"; -import { hashCredential } from "../../../security/credential-hash"; -import { isDecisionSelected, isDecisionUnset } from "../../../state/onboard-checkpoint-decision"; -import type { Session } from "../../../state/onboard-session"; -import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; import { type RegistryMessagingAuthority, resolveMessagingPlanAuthority, } from "../../../messaging/plan-authority"; +import { hashCredential } from "../../../security/credential-hash"; +import { isDecisionSelected, isDecisionUnset } from "../../../state/onboard-checkpoint-decision"; +import type { Session } from "../../../state/onboard-session"; +import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; import { getActiveChannelsFromPlan, getChannelsFromPlan } from "../../messaging-plan-session"; export { resolveMessagingPlanAuthority }; @@ -143,6 +143,7 @@ export function filterMessagingPlanForCurrentAgent( runtimeSetup: plan.runtimeSetup ? { nodePreloads: filterRuntimeSetup(plan.runtimeSetup.nodePreloads), + commandRoutes: filterRuntimeSetup(plan.runtimeSetup.commandRoutes), envAliases: filterRuntimeSetup(plan.runtimeSetup.envAliases), secretScans: filterRuntimeSetup(plan.runtimeSetup.secretScans), } diff --git a/src/lib/onboard/managed-startup-clone-rebinder.test.ts b/src/lib/onboard/managed-startup-clone-rebinder.test.ts index 2cc15bfc1e9..54e0641f88f 100644 --- a/src/lib/onboard/managed-startup-clone-rebinder.test.ts +++ b/src/lib/onboard/managed-startup-clone-rebinder.test.ts @@ -56,7 +56,7 @@ function messagingPlan(agent: "openclaw" | "hermes", sandboxName = "source"): Sa networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], - runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, stateUpdates: [], healthChecks: [], }; diff --git a/src/lib/onboard/managed-startup-profile-builder.test.ts b/src/lib/onboard/managed-startup-profile-builder.test.ts index 5e67b547c5a..d031e18d458 100644 --- a/src/lib/onboard/managed-startup-profile-builder.test.ts +++ b/src/lib/onboard/managed-startup-profile-builder.test.ts @@ -166,7 +166,7 @@ describe("buildManagedStartupProfile", () => { networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], - runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, stateUpdates: [], healthChecks: [], }); diff --git a/src/lib/onboard/managed-workload-clone-handoff.test.ts b/src/lib/onboard/managed-workload-clone-handoff.test.ts index 98bb6230a57..1b84368dae5 100644 --- a/src/lib/onboard/managed-workload-clone-handoff.test.ts +++ b/src/lib/onboard/managed-workload-clone-handoff.test.ts @@ -280,7 +280,7 @@ describe("prepareManagedWorkloadCloneHandoff", () => { networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], - runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, stateUpdates: [], healthChecks: [], }, diff --git a/test/hermes-messaging-runtime-setup.test.ts b/test/hermes-messaging-runtime-setup.test.ts index 2a3ba8a0c82..1af054a5380 100644 --- a/test/hermes-messaging-runtime-setup.test.ts +++ b/test/hermes-messaging-runtime-setup.test.ts @@ -33,7 +33,7 @@ function messagingRuntimeSetupSection( const end = source.indexOf("# ── End messaging runtime setup", start); expect(start).toBeGreaterThanOrEqual(0); expect(end).toBeGreaterThan(start); - return source + const section = source .slice(start, end) .replace( '_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json"', @@ -47,9 +47,16 @@ function messagingRuntimeSetupSection( 'PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-"', `PRELOAD_TARGET_PREFIX = ${JSON.stringify(preloadPaths.targetPrefix)}`, ); + if (preloadPaths.sourcePrefix === "/usr/local/lib/nemoclaw/preloads/") return section; + return section + .replaceAll("/usr/local/lib/nemoclaw/preloads/", preloadPaths.sourcePrefix) + .replace("source_stat.st_uid != 0", "source_stat.st_uid != os.getuid()"); } -function encodeRuntimePlan(nodePreloads: Array>): string { +function encodeRuntimePlan( + nodePreloads: Array>, + commandRoutes: Array> = [], +): string { return Buffer.from( JSON.stringify({ channels: [ @@ -62,6 +69,7 @@ function encodeRuntimePlan(nodePreloads: Array>): string disabledChannels: [], runtimeSetup: { nodePreloads: nodePreloads.map((entry) => ({ channelId: "whatsapp", ...entry })), + commandRoutes: commandRoutes.map((entry) => ({ channelId: "whatsapp", ...entry })), envAliases: [], secretScans: [], }, @@ -70,6 +78,66 @@ function encodeRuntimePlan(nodePreloads: Array>): string } describe("Hermes messaging runtime setup", () => { + it("runs exact Hermes command routes from the generated connect environment (#8184)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-command-route-")); + const envFile = path.join(tmpDir, "runtime-env.sh"); + const modulePath = path.join(tmpDir, "whatsapp-pair.js"); + const markerPath = path.join(tmpDir, "paired.txt"); + const fallbackPath = path.join(tmpDir, "fallback.txt"); + const binDir = path.join(tmpDir, "bin"); + const hermesPath = path.join(binDir, "hermes"); + fs.mkdirSync(binDir); + fs.writeFileSync( + modulePath, + `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "paired");\n`, + ); + fs.writeFileSync( + hermesPath, + `#!/bin/sh\nprintf '%s\\n' "$*" >${JSON.stringify(fallbackPath)}\n`, + { mode: 0o755 }, + ); + + try { + const result = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + "set -euo pipefail", + 'emit_sandbox_sourced_file() { cat >"$1"; chmod 444 "$1"; }', + `emit_messaging_connect_runtime_command_router() { printf '%s\n' '_nemoclaw_messaging_runtime_command_module() {' ' if [ "$#" -eq 2 ] && [ "$1" = hermes ] && [ "$2" = whatsapp ]; then' ' printf "%s\\n" ${JSON.stringify(modulePath)}' ' fi' '}'; }`, + "emit_messaging_connect_runtime_preload_exports() { :; }", + `_PROXY_ENV_FILE=${JSON.stringify(envFile)}`, + '_PROXY_URL="http://10.200.0.1:3128"', + '_NO_PROXY_VAL="localhost,127.0.0.1"', + `HERMES_DIR=${JSON.stringify(tmpDir)}`, + runtimeShellEnvFunction(HERMES_START).replace( + "/usr/local/bin/node", + JSON.stringify(process.execPath), + ), + "write_runtime_shell_env", + `source ${JSON.stringify(envFile)}`, + "hermes whatsapp", + "hermes whatsapp --repair", + ].join("\n"), + ], + { + encoding: "utf8", + timeout: 5000, + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` }, + }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(markerPath, "utf8")).toBe("paired"); + expect(fs.readFileSync(fallbackPath, "utf8")).toBe("whatsapp --repair\n"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("installs the active WhatsApp preload and rewrites the Hermes bridge session path (#8229)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-whatsapp-plan-")); const sourceDir = path.join(tmpDir, "preloads"); @@ -78,6 +146,8 @@ describe("Hermes messaging runtime setup", () => { const targetPrefix = path.join(tmpDir, "nemoclaw-"); const targetPath = `${targetPrefix}whatsapp-hermes-session.js`; const planPath = path.join(tmpDir, "runtime-plan.json"); + const commandRouterPath = path.join(tmpDir, "command-router.sh"); + const resolvedCommandPath = path.join(tmpDir, "resolved-command.txt"); const runtimeSourcePath = path.join( ROOT, "src", @@ -113,6 +183,9 @@ describe("Hermes messaging runtime setup", () => { }), "write_messaging_runtime_setup_plan", "install_messaging_runtime_preloads", + `emit_messaging_connect_runtime_command_router >${JSON.stringify(commandRouterPath)}`, + `source ${JSON.stringify(commandRouterPath)}`, + `_nemoclaw_messaging_runtime_command_module hermes whatsapp >${JSON.stringify(resolvedCommandPath)}`, "node -e 'process.stdout.write(JSON.stringify(process.argv))' /sandbox/.hermes/scripts/whatsapp-bridge/bridge.js --session /tmp/split-session", ].join("\n"), ], @@ -122,14 +195,24 @@ describe("Hermes messaging runtime setup", () => { env: { ...process.env, NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH: path.join(tmpDir, "missing.json"), - NEMOCLAW_MESSAGING_PLAN_B64: encodeRuntimePlan([ - { - source: sourcePath, - target: targetPath, - injectInto: ["boot", "connect"], - optional: false, - }, - ]), + NEMOCLAW_MESSAGING_PLAN_B64: encodeRuntimePlan( + [ + { + source: sourcePath, + target: targetPath, + injectInto: ["boot", "connect"], + optional: false, + }, + ], + [ + { + command: "hermes", + args: ["whatsapp"], + module: "whatsapp-hermes-session", + source: sourcePath, + }, + ], + ), NODE_OPTIONS: "", }, }, @@ -143,6 +226,15 @@ describe("Hermes messaging runtime setup", () => { "/sandbox/.hermes/platforms/whatsapp/session", ]); expect(fs.readFileSync(targetPath, "utf-8")).toBe(fs.readFileSync(sourcePath, "utf-8")); + expect(fs.readFileSync(resolvedCommandPath, "utf8").trim()).toBe(sourcePath); + expect(JSON.parse(fs.readFileSync(planPath, "utf8")).commandRoutes).toEqual([ + { + command: "hermes", + args: ["whatsapp"], + module: "whatsapp-hermes-session", + source: sourcePath, + }, + ]); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/test/hermes-runtime-api-key.test.ts b/test/hermes-runtime-api-key.test.ts index 2712a22aa01..d5a40e645f1 100644 --- a/test/hermes-runtime-api-key.test.ts +++ b/test/hermes-runtime-api-key.test.ts @@ -622,7 +622,7 @@ describe("agents/hermes/start.sh runtime API server key", () => { channels: [{ channelId: "discord", active: true, disabled: false }], disabledChannels: [], credentialBindings: [{ channelId: "discord", providerEnvKey: "DISCORD_BOT_TOKEN" }], - runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, }, }); @@ -652,7 +652,7 @@ describe("agents/hermes/start.sh runtime API server key", () => { channels: [{ channelId: "discord", active: true, disabled: false }], disabledChannels: [], credentialBindings: [{ channelId: "discord", providerEnvKey: "DISCORD_BOT_TOKEN" }], - runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, }, }); @@ -687,7 +687,7 @@ describe("agents/hermes/start.sh runtime API server key", () => { channels: [{ channelId: "discord", active: true, disabled: false }], disabledChannels: [], credentialBindings: [{ channelId: "discord", providerEnvKey: "DISCORD_BOT_TOKEN" }], - runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, }, }); @@ -716,7 +716,7 @@ describe("agents/hermes/start.sh runtime API server key", () => { channels: [{ channelId: "discord", active: true, disabled: false }], disabledChannels: [], credentialBindings: [{ channelId: "discord", providerEnvKey: "DISCORD_BOT_TOKEN" }], - runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, }, hashFileContent, }); diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 68c7dfe4e83..b174f252c7c 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -411,6 +411,15 @@ describe("messaging-build-applier.mts: agent-install", () => { installedMessage: "[channels] installed telegram diagnostics", }, ], + commandRoutes: [ + { + channelId: "telegram", + command: "hermes", + args: ["diagnose-telegram"], + module: "telegram-diagnostics", + source: "/usr/local/lib/nemoclaw/preloads/telegram-diagnostics.js", + }, + ], envAliases: [], secretScans: [], }, @@ -464,6 +473,15 @@ describe("messaging-build-applier.mts: agent-install", () => { optional: false, }, ], + commandRoutes: [ + { + channelId: "telegram", + command: "hermes", + args: ["diagnose-telegram"], + module: "telegram-diagnostics", + source: "/usr/local/lib/nemoclaw/preloads/telegram-diagnostics.js", + }, + ], envAliases: [], secretScans: [], }, diff --git a/test/messaging-plan-image-boundary.test.ts b/test/messaging-plan-image-boundary.test.ts index b130f232eac..28cdcc2bfbb 100644 --- a/test/messaging-plan-image-boundary.test.ts +++ b/test/messaging-plan-image-boundary.test.ts @@ -52,6 +52,7 @@ function reducedArtifact(agent: Agent): string { }, ] : [], + commandRoutes: [], envAliases: [], secretScans: [], }, From 0a3f3fef491b32e83eccde98f936678db4e39668 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 5 Aug 2026 18:43:08 +0700 Subject: [PATCH 16/31] test(messaging): keep command route fixture linear Signed-off-by: San Dang --- test/hermes-messaging-runtime-setup.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/hermes-messaging-runtime-setup.test.ts b/test/hermes-messaging-runtime-setup.test.ts index 1af054a5380..8206391f868 100644 --- a/test/hermes-messaging-runtime-setup.test.ts +++ b/test/hermes-messaging-runtime-setup.test.ts @@ -47,10 +47,11 @@ function messagingRuntimeSetupSection( 'PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-"', `PRELOAD_TARGET_PREFIX = ${JSON.stringify(preloadPaths.targetPrefix)}`, ); - if (preloadPaths.sourcePrefix === "/usr/local/lib/nemoclaw/preloads/") return section; - return section - .replaceAll("/usr/local/lib/nemoclaw/preloads/", preloadPaths.sourcePrefix) - .replace("source_stat.st_uid != 0", "source_stat.st_uid != os.getuid()"); + return preloadPaths.sourcePrefix === "/usr/local/lib/nemoclaw/preloads/" + ? section + : section + .replaceAll("/usr/local/lib/nemoclaw/preloads/", preloadPaths.sourcePrefix) + .replace("source_stat.st_uid != 0", "source_stat.st_uid != os.getuid()"); } function encodeRuntimePlan( From bf7a89275e09d1950f5953794b160d719f74ecbc Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 5 Aug 2026 20:56:16 +0700 Subject: [PATCH 17/31] fix(whatsapp): keep Hermes CLI pairing unchanged --- agents/hermes/start.sh | 13 +- docs/manage-sandboxes/set-up-whatsapp.mdx | 5 - .../check-messaging-plan-image-boundary.mts | 17 +-- scripts/lib/sandbox-init.sh | 121 +----------------- .../snapshot-managed-clone-providers.test.ts | 2 +- src/lib/messaging/README.md | 4 +- .../applier/build/messaging-build-applier.mts | 9 +- .../messaging/applier/host-state-applier.ts | 1 - .../messaging/channels/whatsapp/manifest.ts | 7 - .../runtime/whatsapp-hermes-session.test.ts | 78 +---------- .../runtime/whatsapp-hermes-session.ts | 105 --------------- src/lib/messaging/clone-rebind.ts | 2 +- .../compiler/engines/runtime-setup-engine.ts | 35 +---- .../compiler/manifest-compiler.test.ts | 14 +- .../compiler/workflow-planner.test.ts | 1 - .../messaging/compiler/workflow-planner.ts | 5 - src/lib/messaging/hydration.ts | 1 - src/lib/messaging/manifest/types.ts | 14 -- src/lib/messaging/persistence.ts | 3 +- src/lib/messaging/plan-validation.test.ts | 20 --- src/lib/messaging/plan-validation.ts | 3 +- .../handlers/sandbox-messaging.test.ts | 1 - .../machine/handlers/sandbox-messaging.ts | 9 +- .../managed-startup-clone-rebinder.test.ts | 2 +- .../managed-startup-profile-builder.test.ts | 2 +- .../managed-workload-clone-handoff.test.ts | 2 +- test/hermes-messaging-runtime-setup.test.ts | 113 ++-------------- test/hermes-runtime-api-key.test.ts | 8 +- test/messaging-build-applier.test.ts | 18 --- test/messaging-plan-image-boundary.test.ts | 1 - 30 files changed, 42 insertions(+), 574 deletions(-) diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 405d6b7d97b..777938187f1 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -1781,9 +1781,6 @@ TUIENVEOF if type emit_messaging_connect_runtime_preload_exports >/dev/null 2>&1; then emit_messaging_connect_runtime_preload_exports fi - if type emit_messaging_connect_runtime_command_router >/dev/null 2>&1; then - emit_messaging_connect_runtime_command_router - fi for _ca_env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO NODE_EXTRA_CA_CERTS; do _ca_env_value="${!_ca_env_name:-}" if [ -n "$_ca_env_value" ]; then @@ -1793,7 +1790,7 @@ TUIENVEOF cat <<'GUARDENVEOF' # nemoclaw-configure-guard begin hermes() { - case "${1:-}" in + case "$1" in setup|doctor) echo "Error: 'hermes $1' cannot modify config inside the sandbox." >&2 echo "NemoClaw manages sandbox config from the host for integrity checks." >&2 @@ -1803,14 +1800,6 @@ hermes() { return 1 ;; esac - if type _nemoclaw_messaging_runtime_command_module >/dev/null 2>&1; then - local _nemoclaw_runtime_module - _nemoclaw_runtime_module="$(_nemoclaw_messaging_runtime_command_module hermes "$@")" || return $? - if [ -n "$_nemoclaw_runtime_module" ]; then - /usr/local/bin/node "$_nemoclaw_runtime_module" - return $? - fi - fi command hermes "$@" } # nemoclaw-configure-guard end diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index af6ede959da..59dbf03daa1 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -23,14 +23,9 @@ openclaw channels login --channel whatsapp ``` -In the NemoClaw-managed connect shell, run: ```bash hermes whatsapp ``` -This command opens QR pairing directly at `/sandbox/.hermes/platforms/whatsapp/session`. -It reads the WhatsApp mode and sender allowlist from the NemoClaw-managed `/sandbox/.hermes/.env` file without modifying that file. -Pairing therefore does not cause configuration drift during later host-side `config set --restart` operations or gateway restarts. -This route applies only to the managed connect shell; direct Hermes executable invocation uses upstream behavior. diff --git a/scripts/check-messaging-plan-image-boundary.mts b/scripts/check-messaging-plan-image-boundary.mts index 0b7d4e5901b..b28be6a1143 100644 --- a/scripts/check-messaging-plan-image-boundary.mts +++ b/scripts/check-messaging-plan-image-boundary.mts @@ -127,7 +127,6 @@ export function createMessagingBoundaryPlan(agent: unknown) { ], runtimeSetup: { nodePreloads: [OPENCLAW_RUNTIME_PRELOAD], - commandRoutes: [], envAliases: [], secretScans: [], }, @@ -176,7 +175,7 @@ export function createMessagingBoundaryPlan(agent: unknown) { value: { manager: "hermes-uv-pip", spec: HERMES_AIOHTTP_PACKAGE_SPEC }, }, ], - runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, }; } @@ -314,7 +313,6 @@ function assertReducedRuntimeArtifact(text: string, agent: MessagingBoundaryAgen if ( !isObject(runtimeSetup) || !Array.isArray(runtimeSetup.nodePreloads) || - !Array.isArray(runtimeSetup.commandRoutes) || !Array.isArray(runtimeSetup.envAliases) || !Array.isArray(runtimeSetup.secretScans) ) { @@ -322,7 +320,7 @@ function assertReducedRuntimeArtifact(text: string, agent: MessagingBoundaryAgen } assertAllowedKeys( runtimeSetup, - ["nodePreloads", "commandRoutes", "envAliases", "secretScans"], + ["nodePreloads", "envAliases", "secretScans"], "reduced runtime plan runtimeSetup", ); assertRuntimeSetupEntryAllowlist( @@ -338,11 +336,6 @@ function assertReducedRuntimeArtifact(text: string, agent: MessagingBoundaryAgen ], "nodePreloads", ); - assertRuntimeSetupEntryAllowlist( - runtimeSetup.commandRoutes, - ["channelId", "command", "args", "module", "source"], - "commandRoutes", - ); assertRuntimeSetupEntryAllowlist( runtimeSetup.envAliases, ["channelId", "envKey", "match", "value", "message"], @@ -353,11 +346,7 @@ function assertReducedRuntimeArtifact(text: string, agent: MessagingBoundaryAgen ["channelId", "path", "pattern", "message", "exitCode"], "secretScans", ); - if ( - runtimeSetup.commandRoutes.length !== 0 || - runtimeSetup.envAliases.length !== 0 || - runtimeSetup.secretScans.length !== 0 - ) { + if (runtimeSetup.envAliases.length !== 0 || runtimeSetup.secretScans.length !== 0) { throw new Error("reduced runtime plan contains unexpected Teams runtime setup entries"); } if (agent === "openclaw") { diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 502d48139d7..c6d9f1c42b9 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -826,10 +826,10 @@ PY # ── Messaging runtime setup from manifest metadata ─────────────── # Channel-owned runtime setup is compiled from manifests at image build time. -# Both agent entrypoints consume the same generic declarations: commandRoutes, -# envAliases, nodePreloads, and secretScans. Prefer a forwarded env plan when -# present; otherwise load the reduced image artifact written by the messaging -# build applier. +# Both agent entrypoints consume the same generic declarations: envAliases, +# nodePreloads, and secretScans. Prefer a forwarded env plan when present; +# otherwise load the reduced image artifact written by the messaging build +# applier. _MESSAGING_RUNTIME_PLAN_ARTIFACT="${NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH:-/usr/local/share/nemoclaw/messaging-runtime-plan.json}" _MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json" _MESSAGING_CONNECT_PRELOADS_FILE="/tmp/nemoclaw-messaging-connect-preloads.list" @@ -842,11 +842,10 @@ import os import re import sys -EMPTY = {"nodePreloads": [], "commandRoutes": [], "envAliases": [], "secretScans": []} +EMPTY = {"nodePreloads": [], "envAliases": [], "secretScans": []} PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/" PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-" ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$") -COMMAND_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") def fail(message): @@ -943,38 +942,6 @@ def clean_node_preload(entry, index): } -def clean_command_route(entry, index): - if not isinstance(entry, dict): - fail(f"commandRoutes[{index}] must be an object") - command = clean_string(entry.get("command"), f"commandRoutes[{index}].command") - if not COMMAND_RE.match(command): - fail(f"commandRoutes[{index}].command is not a safe command name") - args = entry.get("args") - if not isinstance(args, list) or not args: - fail(f"commandRoutes[{index}].args must be a non-empty list") - clean_args = [ - clean_string(value, f"commandRoutes[{index}].args[{arg_index}]") - for arg_index, value in enumerate(args) - ] - module = clean_string(entry.get("module"), f"commandRoutes[{index}].module") - if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", module): - fail(f"commandRoutes[{index}].module is not a safe module name") - source = clean_preload_path( - entry.get("source"), - f"commandRoutes[{index}].source", - PRELOAD_SOURCE_PREFIX, - "runtime JavaScript file", - ) - if source != f"{PRELOAD_SOURCE_PREFIX}{module}.js": - fail(f"commandRoutes[{index}].source does not match its module") - return { - "command": command, - "args": clean_args, - "module": module, - "source": source, - } - - def clean_env_alias(entry, index): if not isinstance(entry, dict): fail(f"envAliases[{index}] must be an object") @@ -1078,11 +1045,9 @@ def runtime_setup_entries(key): node_preloads = [] -command_routes = [] env_aliases = [] secret_scans = [] seen_node_preloads = set() -seen_command_routes = set() seen_aliases = set() seen_scans = set() @@ -1092,13 +1057,6 @@ for entry in runtime_setup_entries("nodePreloads"): if preload_key not in seen_node_preloads: seen_node_preloads.add(preload_key) node_preloads.append(preload) -for entry in runtime_setup_entries("commandRoutes"): - route = clean_command_route(entry, len(command_routes)) - route_key = (route["command"], tuple(route["args"])) - if route_key in seen_command_routes: - fail(f"commandRoutes contains duplicate route {route['command']} {route['args']!r}") - seen_command_routes.add(route_key) - command_routes.append(route) for entry in runtime_setup_entries("envAliases"): alias = clean_env_alias(entry, len(env_aliases)) alias_key = (alias["envKey"], alias["match"], alias["value"]) @@ -1112,7 +1070,7 @@ for entry in runtime_setup_entries("secretScans"): seen_scans.add(scan_key) secret_scans.append(scan) -print(json.dumps({"nodePreloads": node_preloads, "commandRoutes": command_routes, "envAliases": env_aliases, "secretScans": secret_scans}, sort_keys=True)) +print(json.dumps({"nodePreloads": node_preloads, "envAliases": env_aliases, "secretScans": secret_scans}, sort_keys=True)) PYMESSAGINGRUNTIME } @@ -1243,73 +1201,6 @@ fi CONNECTPRELOADSEOF } -emit_messaging_connect_runtime_command_router() { - cat < 2 else "" -args = sys.argv[3:] -if not os.path.isfile(plan_path): - raise SystemExit(0) -try: - if os.path.getsize(plan_path) > 4 * 1024 * 1024: - fail("runtime setup plan is oversized") - with open(plan_path, encoding="utf-8") as handle: - plan = json.load(handle) -except (OSError, UnicodeError, json.JSONDecodeError) as exc: - fail(f"runtime setup plan is unreadable ({exc.__class__.__name__})") -if not isinstance(plan, dict): - fail("runtime setup plan is not an object") - -matches = [] -routes = plan.get("commandRoutes", []) -if not isinstance(routes, list): - fail("commandRoutes is not a list") -for index, route in enumerate(routes): - if not isinstance(route, dict): - fail(f"commandRoutes[{index}] is not an object") - if route.get("command") != command or route.get("args") != args: - continue - source = route.get("source") - module = route.get("module") - if not isinstance(module, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", module): - fail(f"commandRoutes[{index}].module is invalid") - expected = f"/usr/local/lib/nemoclaw/preloads/{module}.js" - if source != expected: - fail(f"commandRoutes[{index}].source does not match its module") - try: - source_stat = os.lstat(source) - except OSError as exc: - fail(f"runtime command module is unavailable ({exc.__class__.__name__})") - if ( - not stat.S_ISREG(source_stat.st_mode) - or source_stat.st_uid != 0 - or source_stat.st_mode & 0o022 - ): - fail("runtime command module is not a root-owned read-only file") - matches.append(source) -if len(matches) > 1: - fail("runtime command route is ambiguous") -if matches: - print(matches[0]) -PYMESSAGINGCOMMAND -} -CONNECTCOMMANDSEOF -} - messaging_runtime_preload_targets() { printf '%s\n' "$_MESSAGING_RUNTIME_SETUP_PLAN" "$_MESSAGING_CONNECT_PRELOADS_FILE" [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 diff --git a/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts b/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts index 2b27c22338e..cec4e673e20 100644 --- a/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts +++ b/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts @@ -106,7 +106,7 @@ function messagingPlan(sandboxName: string): SandboxMessagingPlan { networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], - runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, stateUpdates: [], healthChecks: [], } as unknown as SandboxMessagingPlan; diff --git a/src/lib/messaging/README.md b/src/lib/messaging/README.md index 6867868d4aa..3675050e0cb 100644 --- a/src/lib/messaging/README.md +++ b/src/lib/messaging/README.md @@ -180,7 +180,7 @@ Important plan sections are: | `networkPolicy` | `manifest.policyPresets`. | Policy application during onboard and channel lifecycle changes. | | `agentRender` | `manifest.render` after template and credential placeholder resolution. | Host config applier and build-time config applier. | | `buildSteps` | `manifest.agentPackages` and hook outputs of kind `build-arg`, `build-file`, or `package-install`. | Image build and post-agent-install application. | -| `runtimeSetup` | `manifest.runtime`. | Runtime preload, exact command route, env alias, secret scan, and reduced runtime artifact generation. | +| `runtimeSetup` | `manifest.runtime`. | Runtime preload, env alias, secret scan, and reduced runtime artifact generation. | | `stateUpdates` | Config inputs with `statePath`. | Registry persistence and rebuild hydration. | | `healthChecks` | Health-check hook declarations. | Lifecycle success gates. | @@ -293,7 +293,7 @@ It is a serializable declaration for one channel and one set of supported agents | `policyPresets` | Optional network policy presets and policy keys required by the channel. | | `render` | Agent config fragments or env-file lines to render when the channel is active. | | `hostForward` | Optional host-side forward for inbound webhooks. | -| `runtime` | Optional runtime visibility, preload, exact command route, env alias, and secret scan metadata. | +| `runtime` | Optional runtime visibility, preload, env alias, and secret scan metadata. | | `agentPackages` | Optional build-time agent package installs. | | `hooks` | Hook references for enrollment, checks, render, apply, status, and diagnostics. | diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 9bb3a025a8e..7f2f7cf33cc 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -40,7 +40,7 @@ type JsonObject = Record; type MessagingAgentId = "openclaw" | "hermes"; type MessagingHookPhase = "agent-install" | "post-agent-install"; type MessagingBuildCliPhase = MessagingBuildPhase | "managed-image-capability-union"; -type MessagingRuntimeSetupKey = "nodePreloads" | "commandRoutes" | "envAliases" | "secretScans"; +type MessagingRuntimeSetupKey = "nodePreloads" | "envAliases" | "secretScans"; type MessagingSerializableValue = | string | number @@ -425,13 +425,6 @@ function sanitizeRuntimeSetup( "installMessage", "installedMessage", ]), - commandRoutes: sanitizeRuntimeSetupEntries(setup?.commandRoutes, [ - "channelId", - "command", - "args", - "module", - "source", - ]), envAliases: sanitizeRuntimeSetupEntries(setup?.envAliases, [ "channelId", "envKey", diff --git a/src/lib/messaging/applier/host-state-applier.ts b/src/lib/messaging/applier/host-state-applier.ts index 9ad6f020a6f..93c21f4e267 100644 --- a/src/lib/messaging/applier/host-state-applier.ts +++ b/src/lib/messaging/applier/host-state-applier.ts @@ -119,7 +119,6 @@ function mergeRuntimeSetup( ): SandboxMessagingRuntimeSetupPlan { return { nodePreloads: mergeByChannelId(existing?.nodePreloads ?? [], incoming?.nodePreloads ?? []), - commandRoutes: mergeByChannelId(existing?.commandRoutes ?? [], incoming?.commandRoutes ?? []), envAliases: mergeByChannelId(existing?.envAliases ?? [], incoming?.envAliases ?? []), secretScans: mergeByChannelId(existing?.secretScans ?? [], incoming?.secretScans ?? []), }; diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index 3ff53544283..f93cf45ea93 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -106,13 +106,6 @@ export const whatsappManifest = { ], }, hermes: { - commandRoutes: [ - { - command: "hermes", - args: ["whatsapp"], - module: "whatsapp-hermes-session", - }, - ], nodePreloads: [ { module: "whatsapp-hermes-session", diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts index 68b4e98a489..439ca4ae5b9 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts @@ -1,19 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { planRuntimeSetup } from "../../../compiler/engines/runtime-setup-engine"; import { whatsappManifest } from "../manifest"; import { applyHermesWhatsappSessionPatch, HERMES_WHATSAPP_SESSION_PATH, normalizeHermesWhatsappSessionArgv, - runHermesWhatsappPairing, } from "./whatsapp-hermes-session"; describe("Hermes WhatsApp session runtime preload", () => { @@ -114,75 +108,5 @@ describe("Hermes WhatsApp session runtime preload", () => { optional: false, }), ]); - expect(runtime.commandRoutes).toEqual([ - { - channelId: "whatsapp", - command: "hermes", - args: ["whatsapp"], - module: "whatsapp-hermes-session", - source: "/usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js", - }, - ]); - }); - - it("pairs with managed settings without invoking the Hermes config writer (#8184)", () => { - const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-whatsapp-pair-")); - try { - const envPath = path.join(fixture, ".env"); - const bridgePath = path.join(fixture, "bridge.js"); - const sessionPath = path.join(fixture, "platforms", "whatsapp", "session"); - fs.writeFileSync( - envPath, - [ - "WHATSAPP_ENABLED=true", - "WHATSAPP_MODE=bot", - "WHATSAPP_ALLOWED_USERS=15550000001", - "UNRELATED_SECRET=do-not-forward", - "", - ].join("\n"), - ); - fs.writeFileSync(bridgePath, "// bridge fixture\n"); - const envModifiedAt = fs.statSync(envPath).mtimeMs; - const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })); - const modes: number[] = []; - const output: string[] = []; - - expect( - runHermesWhatsappPairing({ - envPath, - sessionPath, - bridgePaths: [bridgePath], - nodePath: "/trusted/node", - spawn: spawn as unknown as typeof spawnSync, - setUmask: (mode) => modes.push(mode), - write: (message) => output.push(message), - }), - ).toBe(0); - - expect(fs.statSync(envPath).isFile()).toBe(true); - expect(fs.statSync(envPath).mtimeMs).toBe(envModifiedAt); - expect(fs.readFileSync(envPath, "utf8")).toContain("UNRELATED_SECRET=do-not-forward"); - expect(fs.statSync(sessionPath).isDirectory()).toBe(true); - expect(modes).toEqual([0o007]); - expect(spawn).toHaveBeenCalledWith( - "/trusted/node", - [bridgePath, "--pair-only", "--session", sessionPath], - expect.objectContaining({ - cwd: fixture, - env: expect.objectContaining({ - WHATSAPP_MODE: "bot", - WHATSAPP_ALLOWED_USERS: "15550000001", - }), - stdio: "inherit", - }), - ); - const childEnvironment = (spawn.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined) - ?.env; - expect(childEnvironment).not.toHaveProperty("UNRELATED_SECRET"); - expect(output.join("")).toContain(`Session: ${sessionPath}`); - expect(output.join("")).not.toContain("15550000001"); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } }); }); diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts index cfeb3d57b9d..e9aef19877e 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts @@ -1,21 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; -import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs"; -import { dirname } from "node:path"; - export const HERMES_WHATSAPP_SESSION_PATH = "/sandbox/.hermes/platforms/whatsapp/session"; -export const HERMES_MANAGED_ENV_PATH = "/sandbox/.hermes/.env"; const HERMES_WHATSAPP_BRIDGE_PATHS = new Set([ "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", - "/opt/hermes/scripts/whatsapp-bridge/bridge.js", ]); -const HERMES_WHATSAPP_PAIRING_BRIDGE_PATHS = [ - "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", - "/opt/hermes/scripts/whatsapp-bridge/bridge.js", -] as const; function isHermesWhatsappBridge(scriptPath: string | undefined): boolean { return scriptPath !== undefined && HERMES_WHATSAPP_BRIDGE_PATHS.has(scriptPath); @@ -58,99 +48,4 @@ export function applyHermesWhatsappSessionPatch( return true; } -export interface HermesWhatsappPairingDependencies { - readonly envPath?: string; - readonly sessionPath?: string; - readonly bridgePaths?: readonly string[]; - readonly nodePath?: string; - readonly spawn?: typeof spawnSync; - readonly setUmask?: (mode: number) => unknown; - readonly write?: (message: string) => void; -} - -function readManagedWhatsappEnvironment(envPath: string): NodeJS.ProcessEnv { - const file = lstatSync(envPath); - if (!file.isFile() || file.isSymbolicLink()) { - throw new Error("Hermes managed environment is not a regular file"); - } - const selected: NodeJS.ProcessEnv = {}; - for (const line of readFileSync(envPath, "utf8").split(/\r?\n/)) { - const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); - if (!match) continue; - const [, key, rawValue] = match; - if (key !== "WHATSAPP_MODE" && key !== "WHATSAPP_ALLOWED_USERS") continue; - let value = rawValue ?? ""; - if ( - value.length >= 2 && - ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) - ) { - value = value.slice(1, -1); - } - selected[key] = value; - } - if (selected.WHATSAPP_MODE !== "bot" && selected.WHATSAPP_MODE !== "self-chat") { - throw new Error("Hermes managed WhatsApp mode is missing or invalid"); - } - return selected; -} - -function requireRegularPath(path: string, description: string): void { - const file = lstatSync(path); - if (!file.isFile() || file.isSymbolicLink()) { - throw new Error(`${description} is not a regular file`); - } -} - -export function runHermesWhatsappPairing( - dependencies: HermesWhatsappPairingDependencies = {}, -): number { - const envPath = dependencies.envPath ?? HERMES_MANAGED_ENV_PATH; - const sessionPath = dependencies.sessionPath ?? HERMES_WHATSAPP_SESSION_PATH; - const bridgePaths = dependencies.bridgePaths ?? HERMES_WHATSAPP_PAIRING_BRIDGE_PATHS; - const bridgePath = bridgePaths.find((candidate) => existsSync(candidate)); - if (!bridgePath) { - throw new Error("Hermes WhatsApp bridge is unavailable"); - } - requireRegularPath(bridgePath, "Hermes WhatsApp bridge"); - if (existsSync(sessionPath)) { - const session = lstatSync(sessionPath); - if (!session.isDirectory() || session.isSymbolicLink()) { - throw new Error("Hermes WhatsApp session path is not a directory"); - } - } else { - mkdirSync(sessionPath, { recursive: true, mode: 0o770 }); - } - - const whatsappEnvironment = readManagedWhatsappEnvironment(envPath); - const write = dependencies.write ?? ((message) => process.stdout.write(message)); - write("\n⚕ WhatsApp Setup\n==================================================\n\n"); - write("NemoClaw manages the WhatsApp mode and sender allowlist from the host.\n"); - write(`Session: ${sessionPath}\n\n`); - write("Open WhatsApp on the phone, then use Settings → Linked Devices → Link a Device.\n\n"); - - (dependencies.setUmask ?? ((mode) => process.umask(mode)))(0o007); - const result = (dependencies.spawn ?? spawnSync)( - dependencies.nodePath ?? process.execPath, - [bridgePath, "--pair-only", "--session", sessionPath], - { - cwd: dirname(bridgePath), - env: { ...process.env, ...whatsappEnvironment }, - stdio: "inherit", - }, - ); - if (result.error) throw result.error; - return result.status ?? 1; -} - applyHermesWhatsappSessionPatch(process.argv); - -if (require.main === module) { - try { - process.exitCode = runHermesWhatsappPairing(); - } catch (error) { - const message = error instanceof Error ? error.message : "unknown error"; - process.stderr.write(`Hermes WhatsApp pairing failed: ${message}\n`); - process.exitCode = 1; - } -} diff --git a/src/lib/messaging/clone-rebind.ts b/src/lib/messaging/clone-rebind.ts index 4c388b293cb..68b1c9c6778 100644 --- a/src/lib/messaging/clone-rebind.ts +++ b/src/lib/messaging/clone-rebind.ts @@ -126,7 +126,7 @@ export function rebindSandboxMessagingPlanForClone( networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], - runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, stateUpdates: [], healthChecks: [], } as const; diff --git a/src/lib/messaging/compiler/engines/runtime-setup-engine.ts b/src/lib/messaging/compiler/engines/runtime-setup-engine.ts index 5dc4ecd54fe..90bdf7401e6 100644 --- a/src/lib/messaging/compiler/engines/runtime-setup-engine.ts +++ b/src/lib/messaging/compiler/engines/runtime-setup-engine.ts @@ -3,11 +3,9 @@ import type { ChannelManifest, - ChannelRuntimeCommandRouteSpec, ChannelRuntimeNodePreloadSpec, MessagingAgentId, SandboxMessagingChannelPlan, - SandboxMessagingRuntimeCommandRoutePlan, SandboxMessagingRuntimeEnvAliasPlan, SandboxMessagingRuntimeNodePreloadPlan, SandboxMessagingRuntimeSecretScanPlan, @@ -17,7 +15,6 @@ import type { const PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/"; const PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-"; const NODE_PRELOAD_MODULE_PATTERN = /^[a-z0-9][a-z0-9-]*$/; -const COMMAND_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/; export function planRuntimeSetup( manifests: readonly ChannelManifest[], @@ -30,7 +27,6 @@ export function planRuntimeSetup( .map((channel) => channel.channelId), ); const nodePreloads: SandboxMessagingRuntimeNodePreloadPlan[] = []; - const commandRoutes: SandboxMessagingRuntimeCommandRoutePlan[] = []; const envAliases: SandboxMessagingRuntimeEnvAliasPlan[] = []; const secretScans: SandboxMessagingRuntimeSecretScanPlan[] = []; @@ -41,9 +37,6 @@ export function planRuntimeSetup( nodePreloads.push( ...(runtime.nodePreloads ?? []).map((entry) => resolveNodePreload(manifest, entry)), ); - commandRoutes.push( - ...(runtime.commandRoutes ?? []).map((entry) => resolveCommandRoute(manifest, entry)), - ); envAliases.push( ...(runtime.envAliases ?? []).map((entry) => ({ channelId: manifest.id, @@ -58,33 +51,7 @@ export function planRuntimeSetup( ); } - return { nodePreloads, commandRoutes, envAliases, secretScans }; -} - -function resolveCommandRoute( - manifest: ChannelManifest, - entry: ChannelRuntimeCommandRouteSpec, -): SandboxMessagingRuntimeCommandRoutePlan { - if (!COMMAND_NAME_PATTERN.test(entry.command)) { - throw new Error( - `Channel manifest '${manifest.id}' declares invalid runtime command '${entry.command}'.`, - ); - } - if (!NODE_PRELOAD_MODULE_PATTERN.test(entry.module)) { - throw new Error( - `Channel manifest '${manifest.id}' declares invalid runtime command module '${entry.module}'.`, - ); - } - if (entry.args.length === 0 || entry.args.some((arg) => !arg || /[\0\r\n\t]/.test(arg))) { - throw new Error( - `Channel manifest '${manifest.id}' declares invalid runtime command arguments.`, - ); - } - return { - channelId: manifest.id, - ...entry, - source: `${PRELOAD_SOURCE_PREFIX}${entry.module}.js`, - }; + return { nodePreloads, envAliases, secretScans }; } function resolveNodePreload( diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index a0b10884a29..cb911296bdf 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -825,12 +825,7 @@ describe("ManifestCompiler", () => { "telegram-gateway-conflict-status", "telegram-status-health", ]); - expect(plan.runtimeSetup).toEqual({ - nodePreloads: [], - commandRoutes: [], - envAliases: [], - secretScans: [], - }); + expect(plan.runtimeSetup).toEqual({ nodePreloads: [], envAliases: [], secretScans: [] }); expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual(["telegram"]); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["telegram"]); expect(plan.agentRender).toEqual([]); @@ -1287,12 +1282,7 @@ describe("ManifestCompiler", () => { "telegram-gateway-conflict-status", "telegram-status-health", ]); - expect(plan.runtimeSetup).toEqual({ - nodePreloads: [], - commandRoutes: [], - envAliases: [], - secretScans: [], - }); + expect(plan.runtimeSetup).toEqual({ nodePreloads: [], envAliases: [], secretScans: [] }); }); it("compiles a non-built-in channel manifest through the same generic path", async () => { diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 02bf07183c1..3b09821bfa5 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -745,7 +745,6 @@ describe("MessagingWorkflowPlanner", () => { ).toBe(true); expect(disabledRebuild?.runtimeSetup).toEqual({ nodePreloads: [], - commandRoutes: [], envAliases: [], secretScans: [], }); diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index e8244e083b5..03492a50e26 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -435,10 +435,6 @@ function mergeRuntimeSetup( existing?.nodePreloads ?? [], incoming?.nodePreloads ?? [], ), - commandRoutes: mergePlanEntriesByChannel( - existing?.commandRoutes ?? [], - incoming?.commandRoutes ?? [], - ), envAliases: mergePlanEntriesByChannel(existing?.envAliases ?? [], incoming?.envAliases ?? []), secretScans: mergePlanEntriesByChannel( existing?.secretScans ?? [], @@ -453,7 +449,6 @@ function filterRuntimeSetup( ): SandboxMessagingRuntimeSetupPlan { return { nodePreloads: (setup?.nodePreloads ?? []).filter(keepEntry), - commandRoutes: (setup?.commandRoutes ?? []).filter(keepEntry), envAliases: (setup?.envAliases ?? []).filter(keepEntry), secretScans: (setup?.secretScans ?? []).filter(keepEntry), }; diff --git a/src/lib/messaging/hydration.ts b/src/lib/messaging/hydration.ts index 2bf7f087d8c..07d79f3dcfd 100644 --- a/src/lib/messaging/hydration.ts +++ b/src/lib/messaging/hydration.ts @@ -231,7 +231,6 @@ function runtimeSetupHasEntries(setup: SandboxMessagingRuntimeSetupPlan | undefi return Boolean( setup && (setup.nodePreloads.length > 0 || - setup.commandRoutes.length > 0 || setup.envAliases.length > 0 || setup.secretScans.length > 0), ); diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 4b3618fc23a..0c0fa24a5a7 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -177,7 +177,6 @@ export interface ChannelOpenClawRuntimeSpec extends ChannelRuntimeSpec { export interface ChannelRuntimeSpec { readonly visibility?: ChannelRuntimeVisibilitySpec; readonly nodePreloads?: readonly ChannelRuntimeNodePreloadSpec[]; - readonly commandRoutes?: readonly ChannelRuntimeCommandRouteSpec[]; readonly envAliases?: readonly ChannelRuntimeEnvAliasSpec[]; readonly secretScans?: readonly ChannelRuntimeSecretScanSpec[]; } @@ -199,13 +198,6 @@ export interface ChannelRuntimeNodePreloadSpec { readonly installedMessage?: string; } -/** Exact agent CLI invocation handled by a manifest-owned Node runtime module. */ -export interface ChannelRuntimeCommandRouteSpec { - readonly command: string; - readonly args: readonly string[]; - readonly module: string; -} - export interface ChannelRuntimeEnvAliasSpec { readonly envKey: string; readonly match: string; @@ -453,7 +445,6 @@ export interface SandboxMessagingPackageInstallStepPlan { export interface SandboxMessagingRuntimeSetupPlan { readonly nodePreloads: readonly SandboxMessagingRuntimeNodePreloadPlan[]; - readonly commandRoutes: readonly SandboxMessagingRuntimeCommandRoutePlan[]; readonly envAliases: readonly SandboxMessagingRuntimeEnvAliasPlan[]; readonly secretScans: readonly SandboxMessagingRuntimeSecretScanPlan[]; } @@ -464,11 +455,6 @@ export interface SandboxMessagingRuntimeNodePreloadPlan extends ChannelRuntimeNo readonly target: string; } -export interface SandboxMessagingRuntimeCommandRoutePlan extends ChannelRuntimeCommandRouteSpec { - readonly channelId: MessagingChannelId; - readonly source: string; -} - export interface SandboxMessagingRuntimeEnvAliasPlan extends ChannelRuntimeEnvAliasSpec { readonly channelId: MessagingChannelId; } diff --git a/src/lib/messaging/persistence.ts b/src/lib/messaging/persistence.ts index 4d1b8eeee5b..5812031c143 100644 --- a/src/lib/messaging/persistence.ts +++ b/src/lib/messaging/persistence.ts @@ -346,7 +346,7 @@ function normalizePersistedCredentialBindings( networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], - runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, stateUpdates: [], healthChecks: [], }; @@ -426,7 +426,6 @@ function normalizeRuntimeSetup( ): SandboxMessagingRuntimeSetupPlan { return { nodePreloads: Array.isArray(setup?.nodePreloads) ? [...setup.nodePreloads] : [], - commandRoutes: Array.isArray(setup?.commandRoutes) ? [...setup.commandRoutes] : [], envAliases: Array.isArray(setup?.envAliases) ? [...setup.envAliases] : [], secretScans: Array.isArray(setup?.secretScans) ? [...setup.secretScans] : [], }; diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts index 88dd4ecb490..f64bb7e932f 100644 --- a/src/lib/messaging/plan-validation.test.ts +++ b/src/lib/messaging/plan-validation.test.ts @@ -66,25 +66,6 @@ describe("parseSandboxMessagingPlan", () => { expect(parsed).not.toBe(source); }); - it("normalizes command routes missing from an older runtime setup plan", () => { - const source = makePlan(); - const legacy = { - ...source, - runtimeSetup: { - nodePreloads: [], - envAliases: [], - secretScans: [], - }, - }; - - expect(parseSandboxMessagingPlan(legacy)?.runtimeSetup).toEqual({ - nodePreloads: [], - commandRoutes: [], - envAliases: [], - secretScans: [], - }); - }); - it("accepts compact persisted plans without manifest-derived sections", () => { const source = makePlan({ channels: [ @@ -201,7 +182,6 @@ describe("parseSandboxMessagingPlan", () => { ], runtimeSetup: { nodePreloads: [], - commandRoutes: [], envAliases: [], secretScans: [ { diff --git a/src/lib/messaging/plan-validation.ts b/src/lib/messaging/plan-validation.ts index b16b2492fc6..f4a2a91e4ba 100644 --- a/src/lib/messaging/plan-validation.ts +++ b/src/lib/messaging/plan-validation.ts @@ -236,7 +236,6 @@ function isRuntimeSetup(value: unknown): boolean { return ( isObjectRecord(value) && Array.isArray(value.nodePreloads) && - isOptionalObjectArray(value, "commandRoutes") && Array.isArray(value.envAliases) && Array.isArray(value.secretScans) && value.nodePreloads.every(isObjectRecord) && @@ -269,7 +268,7 @@ function hasCanonicalNetworkPolicyReferences(value: unknown): boolean { function hasCanonicalRuntimeSetupReferences(value: unknown): boolean { if (value === undefined) return true; if (!isObjectRecord(value)) return false; - return ["nodePreloads", "commandRoutes", "envAliases", "secretScans"].every((field) => + return ["nodePreloads", "envAliases", "secretScans"].every((field) => hasCanonicalChannelReferences(value[field]), ); } diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 2c89d9d48d5..4d04e481cb9 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -75,7 +75,6 @@ function mixedChannelPlan(): SandboxMessagingPlan { source: "manifest", target: "agent", })), - commandRoutes: [], envAliases: channelIds.map((channelId) => ({ channelId, envKey: `${channelId.toUpperCase()}_TOKEN`, diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index 36f3a480310..0b5d279c61a 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -8,14 +8,14 @@ import { tryGetMessagingAgentId, } from "../../../messaging"; import type { MessagingAgentId, SandboxMessagingPlan } from "../../../messaging/manifest"; -import { - type RegistryMessagingAuthority, - resolveMessagingPlanAuthority, -} from "../../../messaging/plan-authority"; import { hashCredential } from "../../../security/credential-hash"; import { isDecisionSelected, isDecisionUnset } from "../../../state/onboard-checkpoint-decision"; import type { Session } from "../../../state/onboard-session"; import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; +import { + type RegistryMessagingAuthority, + resolveMessagingPlanAuthority, +} from "../../../messaging/plan-authority"; import { getActiveChannelsFromPlan, getChannelsFromPlan } from "../../messaging-plan-session"; export { resolveMessagingPlanAuthority }; @@ -143,7 +143,6 @@ export function filterMessagingPlanForCurrentAgent( runtimeSetup: plan.runtimeSetup ? { nodePreloads: filterRuntimeSetup(plan.runtimeSetup.nodePreloads), - commandRoutes: filterRuntimeSetup(plan.runtimeSetup.commandRoutes), envAliases: filterRuntimeSetup(plan.runtimeSetup.envAliases), secretScans: filterRuntimeSetup(plan.runtimeSetup.secretScans), } diff --git a/src/lib/onboard/managed-startup-clone-rebinder.test.ts b/src/lib/onboard/managed-startup-clone-rebinder.test.ts index 54e0641f88f..2cc15bfc1e9 100644 --- a/src/lib/onboard/managed-startup-clone-rebinder.test.ts +++ b/src/lib/onboard/managed-startup-clone-rebinder.test.ts @@ -56,7 +56,7 @@ function messagingPlan(agent: "openclaw" | "hermes", sandboxName = "source"): Sa networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], - runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, stateUpdates: [], healthChecks: [], }; diff --git a/src/lib/onboard/managed-startup-profile-builder.test.ts b/src/lib/onboard/managed-startup-profile-builder.test.ts index d031e18d458..5e67b547c5a 100644 --- a/src/lib/onboard/managed-startup-profile-builder.test.ts +++ b/src/lib/onboard/managed-startup-profile-builder.test.ts @@ -166,7 +166,7 @@ describe("buildManagedStartupProfile", () => { networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], - runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, stateUpdates: [], healthChecks: [], }); diff --git a/src/lib/onboard/managed-workload-clone-handoff.test.ts b/src/lib/onboard/managed-workload-clone-handoff.test.ts index 1b84368dae5..98bb6230a57 100644 --- a/src/lib/onboard/managed-workload-clone-handoff.test.ts +++ b/src/lib/onboard/managed-workload-clone-handoff.test.ts @@ -280,7 +280,7 @@ describe("prepareManagedWorkloadCloneHandoff", () => { networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], - runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, stateUpdates: [], healthChecks: [], }, diff --git a/test/hermes-messaging-runtime-setup.test.ts b/test/hermes-messaging-runtime-setup.test.ts index 8206391f868..2a3ba8a0c82 100644 --- a/test/hermes-messaging-runtime-setup.test.ts +++ b/test/hermes-messaging-runtime-setup.test.ts @@ -33,7 +33,7 @@ function messagingRuntimeSetupSection( const end = source.indexOf("# ── End messaging runtime setup", start); expect(start).toBeGreaterThanOrEqual(0); expect(end).toBeGreaterThan(start); - const section = source + return source .slice(start, end) .replace( '_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json"', @@ -47,17 +47,9 @@ function messagingRuntimeSetupSection( 'PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-"', `PRELOAD_TARGET_PREFIX = ${JSON.stringify(preloadPaths.targetPrefix)}`, ); - return preloadPaths.sourcePrefix === "/usr/local/lib/nemoclaw/preloads/" - ? section - : section - .replaceAll("/usr/local/lib/nemoclaw/preloads/", preloadPaths.sourcePrefix) - .replace("source_stat.st_uid != 0", "source_stat.st_uid != os.getuid()"); } -function encodeRuntimePlan( - nodePreloads: Array>, - commandRoutes: Array> = [], -): string { +function encodeRuntimePlan(nodePreloads: Array>): string { return Buffer.from( JSON.stringify({ channels: [ @@ -70,7 +62,6 @@ function encodeRuntimePlan( disabledChannels: [], runtimeSetup: { nodePreloads: nodePreloads.map((entry) => ({ channelId: "whatsapp", ...entry })), - commandRoutes: commandRoutes.map((entry) => ({ channelId: "whatsapp", ...entry })), envAliases: [], secretScans: [], }, @@ -79,66 +70,6 @@ function encodeRuntimePlan( } describe("Hermes messaging runtime setup", () => { - it("runs exact Hermes command routes from the generated connect environment (#8184)", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-command-route-")); - const envFile = path.join(tmpDir, "runtime-env.sh"); - const modulePath = path.join(tmpDir, "whatsapp-pair.js"); - const markerPath = path.join(tmpDir, "paired.txt"); - const fallbackPath = path.join(tmpDir, "fallback.txt"); - const binDir = path.join(tmpDir, "bin"); - const hermesPath = path.join(binDir, "hermes"); - fs.mkdirSync(binDir); - fs.writeFileSync( - modulePath, - `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "paired");\n`, - ); - fs.writeFileSync( - hermesPath, - `#!/bin/sh\nprintf '%s\\n' "$*" >${JSON.stringify(fallbackPath)}\n`, - { mode: 0o755 }, - ); - - try { - const result = spawnSync( - "bash", - [ - "--noprofile", - "--norc", - "-c", - [ - "set -euo pipefail", - 'emit_sandbox_sourced_file() { cat >"$1"; chmod 444 "$1"; }', - `emit_messaging_connect_runtime_command_router() { printf '%s\n' '_nemoclaw_messaging_runtime_command_module() {' ' if [ "$#" -eq 2 ] && [ "$1" = hermes ] && [ "$2" = whatsapp ]; then' ' printf "%s\\n" ${JSON.stringify(modulePath)}' ' fi' '}'; }`, - "emit_messaging_connect_runtime_preload_exports() { :; }", - `_PROXY_ENV_FILE=${JSON.stringify(envFile)}`, - '_PROXY_URL="http://10.200.0.1:3128"', - '_NO_PROXY_VAL="localhost,127.0.0.1"', - `HERMES_DIR=${JSON.stringify(tmpDir)}`, - runtimeShellEnvFunction(HERMES_START).replace( - "/usr/local/bin/node", - JSON.stringify(process.execPath), - ), - "write_runtime_shell_env", - `source ${JSON.stringify(envFile)}`, - "hermes whatsapp", - "hermes whatsapp --repair", - ].join("\n"), - ], - { - encoding: "utf8", - timeout: 5000, - env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` }, - }, - ); - - expect(result.status, result.stderr).toBe(0); - expect(fs.readFileSync(markerPath, "utf8")).toBe("paired"); - expect(fs.readFileSync(fallbackPath, "utf8")).toBe("whatsapp --repair\n"); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - it("installs the active WhatsApp preload and rewrites the Hermes bridge session path (#8229)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-whatsapp-plan-")); const sourceDir = path.join(tmpDir, "preloads"); @@ -147,8 +78,6 @@ describe("Hermes messaging runtime setup", () => { const targetPrefix = path.join(tmpDir, "nemoclaw-"); const targetPath = `${targetPrefix}whatsapp-hermes-session.js`; const planPath = path.join(tmpDir, "runtime-plan.json"); - const commandRouterPath = path.join(tmpDir, "command-router.sh"); - const resolvedCommandPath = path.join(tmpDir, "resolved-command.txt"); const runtimeSourcePath = path.join( ROOT, "src", @@ -184,9 +113,6 @@ describe("Hermes messaging runtime setup", () => { }), "write_messaging_runtime_setup_plan", "install_messaging_runtime_preloads", - `emit_messaging_connect_runtime_command_router >${JSON.stringify(commandRouterPath)}`, - `source ${JSON.stringify(commandRouterPath)}`, - `_nemoclaw_messaging_runtime_command_module hermes whatsapp >${JSON.stringify(resolvedCommandPath)}`, "node -e 'process.stdout.write(JSON.stringify(process.argv))' /sandbox/.hermes/scripts/whatsapp-bridge/bridge.js --session /tmp/split-session", ].join("\n"), ], @@ -196,24 +122,14 @@ describe("Hermes messaging runtime setup", () => { env: { ...process.env, NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH: path.join(tmpDir, "missing.json"), - NEMOCLAW_MESSAGING_PLAN_B64: encodeRuntimePlan( - [ - { - source: sourcePath, - target: targetPath, - injectInto: ["boot", "connect"], - optional: false, - }, - ], - [ - { - command: "hermes", - args: ["whatsapp"], - module: "whatsapp-hermes-session", - source: sourcePath, - }, - ], - ), + NEMOCLAW_MESSAGING_PLAN_B64: encodeRuntimePlan([ + { + source: sourcePath, + target: targetPath, + injectInto: ["boot", "connect"], + optional: false, + }, + ]), NODE_OPTIONS: "", }, }, @@ -227,15 +143,6 @@ describe("Hermes messaging runtime setup", () => { "/sandbox/.hermes/platforms/whatsapp/session", ]); expect(fs.readFileSync(targetPath, "utf-8")).toBe(fs.readFileSync(sourcePath, "utf-8")); - expect(fs.readFileSync(resolvedCommandPath, "utf8").trim()).toBe(sourcePath); - expect(JSON.parse(fs.readFileSync(planPath, "utf8")).commandRoutes).toEqual([ - { - command: "hermes", - args: ["whatsapp"], - module: "whatsapp-hermes-session", - source: sourcePath, - }, - ]); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/test/hermes-runtime-api-key.test.ts b/test/hermes-runtime-api-key.test.ts index d5a40e645f1..2712a22aa01 100644 --- a/test/hermes-runtime-api-key.test.ts +++ b/test/hermes-runtime-api-key.test.ts @@ -622,7 +622,7 @@ describe("agents/hermes/start.sh runtime API server key", () => { channels: [{ channelId: "discord", active: true, disabled: false }], disabledChannels: [], credentialBindings: [{ channelId: "discord", providerEnvKey: "DISCORD_BOT_TOKEN" }], - runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, }, }); @@ -652,7 +652,7 @@ describe("agents/hermes/start.sh runtime API server key", () => { channels: [{ channelId: "discord", active: true, disabled: false }], disabledChannels: [], credentialBindings: [{ channelId: "discord", providerEnvKey: "DISCORD_BOT_TOKEN" }], - runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, }, }); @@ -687,7 +687,7 @@ describe("agents/hermes/start.sh runtime API server key", () => { channels: [{ channelId: "discord", active: true, disabled: false }], disabledChannels: [], credentialBindings: [{ channelId: "discord", providerEnvKey: "DISCORD_BOT_TOKEN" }], - runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, }, }); @@ -716,7 +716,7 @@ describe("agents/hermes/start.sh runtime API server key", () => { channels: [{ channelId: "discord", active: true, disabled: false }], disabledChannels: [], credentialBindings: [{ channelId: "discord", providerEnvKey: "DISCORD_BOT_TOKEN" }], - runtimeSetup: { nodePreloads: [], commandRoutes: [], envAliases: [], secretScans: [] }, + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, }, hashFileContent, }); diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index b174f252c7c..68c7dfe4e83 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -411,15 +411,6 @@ describe("messaging-build-applier.mts: agent-install", () => { installedMessage: "[channels] installed telegram diagnostics", }, ], - commandRoutes: [ - { - channelId: "telegram", - command: "hermes", - args: ["diagnose-telegram"], - module: "telegram-diagnostics", - source: "/usr/local/lib/nemoclaw/preloads/telegram-diagnostics.js", - }, - ], envAliases: [], secretScans: [], }, @@ -473,15 +464,6 @@ describe("messaging-build-applier.mts: agent-install", () => { optional: false, }, ], - commandRoutes: [ - { - channelId: "telegram", - command: "hermes", - args: ["diagnose-telegram"], - module: "telegram-diagnostics", - source: "/usr/local/lib/nemoclaw/preloads/telegram-diagnostics.js", - }, - ], envAliases: [], secretScans: [], }, diff --git a/test/messaging-plan-image-boundary.test.ts b/test/messaging-plan-image-boundary.test.ts index 28cdcc2bfbb..b130f232eac 100644 --- a/test/messaging-plan-image-boundary.test.ts +++ b/test/messaging-plan-image-boundary.test.ts @@ -52,7 +52,6 @@ function reducedArtifact(agent: Agent): string { }, ] : [], - commandRoutes: [], envAliases: [], secretScans: [], }, From 2e84a3e4806096e77376888fc19d062a8e33e64f Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 5 Aug 2026 20:58:18 +0700 Subject: [PATCH 18/31] docs(whatsapp): describe dashboard session storage --- docs/manage-sandboxes/set-up-whatsapp.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index 59dbf03daa1..06c9e6aa23e 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -26,6 +26,8 @@ openclaw channels login --channel whatsapp ```bash hermes whatsapp ``` +The Hermes dashboard runs under `/sandbox/.hermes/dashboard-home`. +When you pair WhatsApp from the dashboard, the WhatsApp bridge stores session credentials in `/sandbox/.hermes/platforms/whatsapp/session` for the gateway to read. From 1ff42e8d5142c13f334058459ac7101888be397d Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 5 Aug 2026 21:03:18 +0700 Subject: [PATCH 19/31] fix(whatsapp): scope session patch to dashboard --- .../messaging/channels/whatsapp/manifest.ts | 11 ++---- .../runtime/whatsapp-hermes-session.test.ts | 35 ++++++++++++------- .../runtime/whatsapp-hermes-session.ts | 22 +++++------- test/generate-hermes-config.test.ts | 7 +--- test/hermes-messaging-runtime-setup.test.ts | 32 ++++++++++++----- 5 files changed, 57 insertions(+), 50 deletions(-) diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index f93cf45ea93..d178a68c3bb 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -81,9 +81,6 @@ export const whatsappManifest = { path: "platforms.whatsapp", value: { enabled: true, - extra: { - session_path: "/sandbox/.hermes/platforms/whatsapp/session", - }, }, }, }, @@ -109,12 +106,10 @@ export const whatsappManifest = { nodePreloads: [ { module: "whatsapp-hermes-session", - injectInto: ["boot", "connect"], + injectInto: ["boot"], optional: false, - installMessage: - "[channels] Installing Hermes WhatsApp session path patch (durable pairing state)", - installedMessage: - "[channels] Hermes WhatsApp session path patch installed (NODE_OPTIONS updated)", + installMessage: "[channels] Installing Hermes dashboard WhatsApp session path patch", + installedMessage: "[channels] Hermes dashboard WhatsApp session path patch installed", }, ], }, diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts index 439ca4ae5b9..c886b247ea6 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts @@ -11,18 +11,24 @@ import { } from "./whatsapp-hermes-session"; describe("Hermes WhatsApp session runtime preload", () => { - it.each([ - "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", - "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", - ])("forces %s to the manifest-owned durable session path (#8184)", (bridgePath) => { - const argv = ["/usr/local/bin/node", bridgePath, "--session", "/split/session"]; + it("moves dashboard pairing to the gateway session path (#8184)", () => { + const argv = [ + "/usr/local/bin/node", + "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", + "--session", + "/sandbox/.hermes/dashboard-home/platforms/whatsapp/session", + ]; expect(normalizeHermesWhatsappSessionArgv(argv)).toBe(true); expect(argv[3]).toBe(HERMES_WHATSAPP_SESSION_PATH); }); - it("leaves unrelated Node processes unchanged (#8184)", () => { - const argv = ["/usr/local/bin/node", "/sandbox/tool.js", "--session", "/keep"]; + it.each([ + ["CLI and gateway", "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js"], + ["installed gateway", "/opt/hermes/scripts/whatsapp-bridge/bridge.js"], + ["unrelated Node process", "/sandbox/tool.js"], + ])("leaves the %s bridge session path unchanged (#8184)", (_case, bridgePath) => { + const argv = ["/usr/local/bin/node", bridgePath, "--session", "/keep"]; expect(normalizeHermesWhatsappSessionArgv(argv)).toBe(false); expect(argv[3]).toBe("/keep"); @@ -46,7 +52,7 @@ describe("Hermes WhatsApp session runtime preload", () => { const modes: number[] = []; const argv = [ "/usr/local/bin/node", - "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", + "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", "--session", "/split/session", ]; @@ -56,12 +62,15 @@ describe("Hermes WhatsApp session runtime preload", () => { }); it.each([ - ["missing", ["/usr/local/bin/node", "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js"]], + [ + "missing", + ["/usr/local/bin/node", "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js"], + ], [ "missing before another option", [ "/usr/local/bin/node", - "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", + "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", "--session", "--mode", "bot", @@ -71,7 +80,7 @@ describe("Hermes WhatsApp session runtime preload", () => { "duplicate", [ "/usr/local/bin/node", - "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", + "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", "--session", "/one", "--session", @@ -84,7 +93,7 @@ describe("Hermes WhatsApp session runtime preload", () => { ); }); - it("declares the mandatory preload for Hermes boot and connect (#8184)", () => { + it("declares the mandatory preload only for Hermes boot (#8184)", () => { const runtime = planRuntimeSetup([whatsappManifest], "hermes", [ { channelId: "whatsapp", @@ -104,7 +113,7 @@ describe("Hermes WhatsApp session runtime preload", () => { channelId: "whatsapp", source: "/usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js", target: "/tmp/nemoclaw-whatsapp-hermes-session.js", - injectInto: ["boot", "connect"], + injectInto: ["boot"], optional: false, }), ]); diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts index e9aef19877e..56447968715 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts @@ -2,13 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 export const HERMES_WHATSAPP_SESSION_PATH = "/sandbox/.hermes/platforms/whatsapp/session"; -const HERMES_WHATSAPP_BRIDGE_PATHS = new Set([ - "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", - "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", -]); +const HERMES_DASHBOARD_WHATSAPP_BRIDGE_PATH = + "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js"; function isHermesWhatsappBridge(scriptPath: string | undefined): boolean { - return scriptPath !== undefined && HERMES_WHATSAPP_BRIDGE_PATHS.has(scriptPath); + return scriptPath === HERMES_DASHBOARD_WHATSAPP_BRIDGE_PATH; } export function normalizeHermesWhatsappSessionArgv(argv: string[]): boolean { @@ -26,13 +24,9 @@ export function normalizeHermesWhatsappSessionArgv(argv: string[]): boolean { ); } - // Hermes currently launches its dashboard and gateway bridge from different homes with - // independent --session values. Both command-construction sites belong to the upstream - // Hermes distribution installed into the image rather than NemoClaw's manifest renderer, - // so the rendered manifest session_path cannot change those arguments here. The channel - // runtime preload is the shared boundary where both launchers can be reconciled. Keep the - // two owned paths and this regression together, and remove the preload once both upstream - // launchers honor one manifest-owned session path. + // The dashboard runs with an isolated HERMES_HOME, but its paired credentials must be + // available to the gateway under the primary Hermes home. Keep upstream CLI and gateway + // bridge arguments unchanged because Hermes already provides legacy-path compatibility. argv[sessionIndex + 1] = HERMES_WHATSAPP_SESSION_PATH; return true; } @@ -42,8 +36,8 @@ export function applyHermesWhatsappSessionPatch( setUmask: (mode: number) => unknown = (mode) => process.umask(mode), ): boolean { if (!normalizeHermesWhatsappSessionArgv(argv)) return false; - // The dashboard and gateway run as separate users in the shared sandbox - // group. Keep pairing state read-write for that group without granting world access. + // The dashboard and gateway run as separate users in the shared sandbox group. + // Keep dashboard-paired credentials group-readable without granting world access. setUmask(0o007); return true; } diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index 49cad5ad74a..5a913d7dd4a 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -1082,12 +1082,7 @@ describe("agents/hermes/generate-config.ts", () => { }); expect(config.whatsapp).toBeUndefined(); - expect(config.platforms.whatsapp).toEqual({ - enabled: true, - extra: { - session_path: "/sandbox/.hermes/platforms/whatsapp/session", - }, - }); + expect(config.platforms.whatsapp).toEqual({ enabled: true }); expectRemotePlatformToolsets(config.platform_toolsets.whatsapp); expect(envFile).toContain("WHATSAPP_ENABLED=true\n"); expect(envFile).toContain("WHATSAPP_MODE=bot\n"); diff --git a/test/hermes-messaging-runtime-setup.test.ts b/test/hermes-messaging-runtime-setup.test.ts index 2a3ba8a0c82..d9421008fe8 100644 --- a/test/hermes-messaging-runtime-setup.test.ts +++ b/test/hermes-messaging-runtime-setup.test.ts @@ -70,7 +70,7 @@ function encodeRuntimePlan(nodePreloads: Array>): string } describe("Hermes messaging runtime setup", () => { - it("installs the active WhatsApp preload and rewrites the Hermes bridge session path (#8229)", () => { + it("rewrites only the dashboard bridge session path (#8184)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-whatsapp-plan-")); const sourceDir = path.join(tmpDir, "preloads"); const sourcePrefix = `${sourceDir}${path.sep}`; @@ -113,7 +113,8 @@ describe("Hermes messaging runtime setup", () => { }), "write_messaging_runtime_setup_plan", "install_messaging_runtime_preloads", - "node -e 'process.stdout.write(JSON.stringify(process.argv))' /sandbox/.hermes/scripts/whatsapp-bridge/bridge.js --session /tmp/split-session", + "node -e 'process.stdout.write(JSON.stringify(process.argv) + \"\\n\")' /sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js --session /tmp/dashboard-session", + "node -e 'process.stdout.write(JSON.stringify(process.argv) + \"\\n\")' /sandbox/.hermes/scripts/whatsapp-bridge/bridge.js --session /tmp/cli-session", ].join("\n"), ], { @@ -126,7 +127,7 @@ describe("Hermes messaging runtime setup", () => { { source: sourcePath, target: targetPath, - injectInto: ["boot", "connect"], + injectInto: ["boot"], optional: false, }, ]), @@ -136,11 +137,24 @@ describe("Hermes messaging runtime setup", () => { ); expect(result.status, result.stderr).toBe(0); - expect(JSON.parse(result.stdout)).toEqual([ - process.execPath, - "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", - "--session", - "/sandbox/.hermes/platforms/whatsapp/session", + expect( + result.stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)), + ).toEqual([ + [ + process.execPath, + "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", + "--session", + "/sandbox/.hermes/platforms/whatsapp/session", + ], + [ + process.execPath, + "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", + "--session", + "/tmp/cli-session", + ], ]); expect(fs.readFileSync(targetPath, "utf-8")).toBe(fs.readFileSync(sourcePath, "utf-8")); } finally { @@ -148,7 +162,7 @@ describe("Hermes messaging runtime setup", () => { } }); - it("rejects traversal-shaped preload targets before any destination write (#8229)", () => { + it("rejects traversal-shaped preload targets before any destination write", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preload-traversal-")); const victimPath = path.join(tmpDir, "victim.js"); const planPath = path.join(tmpDir, "runtime-plan.json"); From a74299774e56bde71f87f9f4e3df3440a7d86f4d Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 5 Aug 2026 21:51:20 +0700 Subject: [PATCH 20/31] chore(whatsapp): revert broad session implementation --- agents/hermes/Dockerfile | 15 - agents/hermes/start.sh | 19 +- ci/test-file-size-budget.json | 2 +- docs/manage-sandboxes/set-up-whatsapp.mdx | 2 - scripts/lib/sandbox-init.sh | 428 ------------------ scripts/nemoclaw-start.sh | 383 +++++++++++++++- .../messaging/channels/whatsapp/manifest.ts | 11 - .../runtime/whatsapp-hermes-session.test.ts | 121 ----- .../runtime/whatsapp-hermes-session.ts | 45 -- src/lib/messaging/manifest/types.ts | 2 +- test/hermes-doctor-config-hash.test.ts | 9 - test/hermes-final-image-layout.test.ts | 2 - test/hermes-messaging-runtime-setup.test.ts | 409 ----------------- test/hermes-start.test.ts | 6 +- test/hermes-tirith-retry-finalization.test.ts | 1 - test/http-proxy-fix-sync.test.ts | 3 +- ...essaging-runtime-preload-packaging.test.ts | 29 -- test/messaging-runtime-secret-scan.test.ts | 80 ---- test/nemoclaw-start-guard-recovery.test.ts | 3 +- test/nemoclaw-start-runtime-env-alias.test.ts | 49 +- test/nemoclaw-start-slack-runtime.test.ts | 5 +- test/nemoclaw-start-telegram-runtime.test.ts | 5 +- test/nemoclaw-start.test.ts | 62 ++- test/nemotron-inference-fix.test.ts | 3 +- test/sandbox-provisioning.test.ts | 20 +- test/sandbox-rlimit-hooks.test.ts | 17 - test/support/entrypoint-script-fixture.ts | 12 - 27 files changed, 456 insertions(+), 1287 deletions(-) delete mode 100644 src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.test.ts delete mode 100644 src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts delete mode 100644 test/hermes-messaging-runtime-setup.test.ts delete mode 100644 test/messaging-runtime-secret-scan.test.ts diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index d3bb7b2cfdc..635b8dc159e 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -21,13 +21,6 @@ RUN ./install-reviewed-runtime.sh \ RUN chown -R root:root /opt/mcp-tool-discovery-runtime \ && chmod -R a=rX /opt/mcp-tool-discovery-runtime -FROM mcp-tool-discovery-runtime AS runtime-preload-builder -WORKDIR /opt/nemoclaw-root -COPY tsconfig.runtime-preloads.json /opt/nemoclaw-root/ -COPY src/lib/messaging/channels/ /opt/nemoclaw-root/src/lib/messaging/channels/ -RUN ln -s /opt/mcp-tool-discovery-runtime/node_modules /opt/nemoclaw-root/node_modules \ - && /opt/mcp-tool-discovery-runtime/node_modules/.bin/tsc -p tsconfig.runtime-preloads.json - FROM mcp-tool-discovery-runtime AS managed-startup-runtime-builder WORKDIR /opt/nemoclaw-managed-startup-build COPY src/lib/core/json-types.ts src/lib/core/ports.ts ./src/lib/core/ @@ -143,7 +136,6 @@ COPY agents/hermes/mcp-config-transaction.py /usr/local/lib/nemoclaw/hermes-mcp- COPY src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.85.json /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json COPY scripts/state-dir-guard.py /usr/local/lib/nemoclaw/state-dir-guard.py COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ -COPY --from=runtime-preload-builder /opt/nemoclaw-root/dist/lib/messaging/channels/ /usr/local/lib/nemoclaw/preloads-compiled-channels/ FROM scratch AS hermes-wrapper-payload @@ -371,12 +363,6 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-managed-star && chmod 444 /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py \ && chmod 444 /usr/local/lib/nemoclaw/patch-hermes-langfuse-credentials.mts \ && chmod 444 /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ - && test -d /usr/local/lib/nemoclaw/preloads-compiled-channels \ - && runtime_preload_found="$(find /usr/local/lib/nemoclaw/preloads-compiled-channels -path '*/runtime/*.js' -type f -print -quit)" \ - && test -n "$runtime_preload_found" \ - && find /usr/local/lib/nemoclaw/preloads-compiled-channels -path '*/runtime/*.js' -type f \ - -exec sh -c 'for file do target="/usr/local/lib/nemoclaw/preloads/$(basename "$file")"; [ ! -e "$target" ] || { echo "ERROR: duplicate messaging runtime preload basename: $target" >&2; exit 1; }; cp "$file" "$target"; done' sh {} + \ - && rm -rf /usr/local/lib/nemoclaw/preloads-compiled-channels \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then \ chown -R 0:0 /usr/local/lib/nemoclaw/preloads \ && find /usr/local/lib/nemoclaw/preloads -type f -exec chmod 444 {} + \ @@ -1122,7 +1108,6 @@ RUN check_metadata() { \ && check_metadata /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh 'root:root 444' \ && check_metadata /usr/local/bin/nemoclaw-gateway-control 'root:root 700' \ && check_metadata /usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root 444' \ - && check_metadata /usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js 'root:root 444' \ && check_metadata /usr/local/lib/nemoclaw/hermes-wrapper.py 'root:root 755' \ && check_metadata /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py 'root:root 755' \ && check_metadata /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json 'root:root 444' \ diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 777938187f1..a251b4d155b 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -359,14 +359,6 @@ verify_hermes_config_integrity() { # configure_messaging_channels is provided by sandbox-init.sh (shared). -prepare_hermes_messaging_runtime() { - write_messaging_runtime_setup_plan || return 1 - apply_messaging_runtime_env_aliases || return 1 - install_messaging_runtime_preloads || return 1 - verify_messaging_runtime_secret_scans || return 1 - write_runtime_shell_env -} - print_dashboard_urls() { local api_url dashboard_url api_url="http://127.0.0.1:${PUBLIC_PORT}/v1" @@ -1778,9 +1770,6 @@ if [ -f /opt/hermes/ui-tui/dist/entry.js ]; then export HERMES_TUI_DIR="/opt/hermes/ui-tui" fi TUIENVEOF - if type emit_messaging_connect_runtime_preload_exports >/dev/null 2>&1; then - emit_messaging_connect_runtime_preload_exports - fi for _ca_env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO NODE_EXTRA_CA_CERTS; do _ca_env_value="${!_ca_env_name:-}" if [ -n "$_ca_env_value" ]; then @@ -2172,7 +2161,7 @@ validate_running_hermes_boundary() { validate_hermes_runtime_env_secret_boundary || return 1 HERMES_RESTART_FAILURE_CODE=preload-missing # shellcheck disable=SC2119 - validate_messaging_runtime_tmp_permissions || return 1 + validate_tmp_permissions || return 1 } prepare_hermes_gateway_restart() { @@ -2886,7 +2875,6 @@ prepare_hermes_nonroot_runtime() { refresh_hermes_runtime_config_hashes compat || return 1 inspect_hermes_mcp_integrity "${HERMES_DIR}/.config-hash" || return 1 configure_messaging_channels || return 1 - prepare_hermes_messaging_runtime || return 1 prepare_tirith_marker_retry || return 1 } @@ -2899,7 +2887,6 @@ prepare_hermes_root_runtime() { validate_hermes_runtime_env_secret_boundary || return 1 refresh_hermes_provider_placeholders both || return 1 configure_messaging_channels || return 1 - prepare_hermes_messaging_runtime || return 1 prepare_tirith_marker_retry || return 1 } @@ -3254,7 +3241,7 @@ if [ "$(id -u)" -ne 0 ]; then # Defence-in-depth: verify /tmp file permissions before launching services. # shellcheck disable=SC2119 - validate_messaging_runtime_tmp_permissions + validate_tmp_permissions # Start Hermes gateway. Messaging egress goes directly through OpenShell. umask 0007 @@ -3297,7 +3284,7 @@ prepare_restricted_log /tmp/gateway.log gateway:gateway 600 # Defence-in-depth: verify /tmp file permissions before launching services. # shellcheck disable=SC2119 -validate_messaging_runtime_tmp_permissions +validate_tmp_permissions # Start Hermes gateway. Messaging egress goes directly through OpenShell. launch_hermes_gateway diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index ed975db4e2b..596906e3c02 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -7,7 +7,7 @@ "src/lib/onboard/preflight.test.ts": 1904, "test/generate-openclaw-config.test.ts": 1941, "test/install-preflight.test.ts": 3921, - "test/nemoclaw-start.test.ts": 4765, + "test/nemoclaw-start.test.ts": 4817, "test/onboard-messaging.test.ts": 2043, "test/onboard-selection.test.ts": 4769 } diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index 06c9e6aa23e..59dbf03daa1 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -26,8 +26,6 @@ openclaw channels login --channel whatsapp ```bash hermes whatsapp ``` -The Hermes dashboard runs under `/sandbox/.hermes/dashboard-home`. -When you pair WhatsApp from the dashboard, the WhatsApp bridge stores session credentials in `/sandbox/.hermes/platforms/whatsapp/session` for the gateway to read. diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index c6d9f1c42b9..7cf01a99059 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -823,431 +823,3 @@ for item in plan.get("channels", []): print(channel) PY } - -# ── Messaging runtime setup from manifest metadata ─────────────── -# Channel-owned runtime setup is compiled from manifests at image build time. -# Both agent entrypoints consume the same generic declarations: envAliases, -# nodePreloads, and secretScans. Prefer a forwarded env plan when present; -# otherwise load the reduced image artifact written by the messaging build -# applier. -_MESSAGING_RUNTIME_PLAN_ARTIFACT="${NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH:-/usr/local/share/nemoclaw/messaging-runtime-plan.json}" -_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json" -_MESSAGING_CONNECT_PRELOADS_FILE="/tmp/nemoclaw-messaging-connect-preloads.list" - -write_messaging_runtime_setup_plan() { - python3 - "$_MESSAGING_RUNTIME_PLAN_ARTIFACT" <<'PYMESSAGINGRUNTIME' | emit_sandbox_sourced_file "$_MESSAGING_RUNTIME_SETUP_PLAN" -import base64 -import json -import os -import re -import sys - -EMPTY = {"nodePreloads": [], "envAliases": [], "secretScans": []} -PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/" -PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-" -ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$") - - -def fail(message): - print(f"[channels] Invalid messaging runtime setup plan: {message}", file=sys.stderr) - raise SystemExit(1) - - -def clean_string(value, field, *, allow_empty=False): - if not isinstance(value, str): - fail(f"{field} must be a string") - if not allow_empty and not value: - fail(f"{field} must not be empty") - if any(ch in value for ch in "\x00\r\n\t"): - fail(f"{field} contains a control character") - return value - - -def clean_message(value, field): - if value is None: - return "" - if not isinstance(value, str): - fail(f"{field} must be a string") - if any(ch in value for ch in "\x00\r\n\t"): - fail(f"{field} contains a control character") - return value - - -def clean_preload_path(value, field, prefix, description): - path = clean_string(value, field) - if not path.startswith(prefix) or not path.endswith(".js"): - fail(f"{field} must be a {description} under {prefix}*") - - prefix_without_slash = prefix.rstrip("/") - if prefix.endswith("/"): - allowed_directory = prefix_without_slash - basename_prefix = "" - else: - allowed_directory = os.path.dirname(prefix_without_slash) - basename_prefix = os.path.basename(prefix_without_slash) - - normalized = os.path.normpath(path) - basename = os.path.basename(normalized) - if ( - normalized != path - or os.path.dirname(normalized) != allowed_directory - or not basename.startswith(basename_prefix) - ): - fail(f"{field} must be a direct {description} under {prefix}*") - - # Reject an existing symlink that escapes the approved directory. The - # atomic writer replaces an in-directory final symlink instead of following - # it, while this check prevents either source or target from resolving into - # another filesystem location. - resolved_directory = os.path.realpath(allowed_directory) - if os.path.dirname(os.path.realpath(path)) != resolved_directory: - fail(f"{field} must not resolve outside {prefix}*") - return path - - -def clean_node_preload(entry, index): - if not isinstance(entry, dict): - fail(f"nodePreloads[{index}] must be an object") - source = clean_preload_path( - entry.get("source"), - f"nodePreloads[{index}].source", - PRELOAD_SOURCE_PREFIX, - "preload JavaScript file", - ) - target = clean_preload_path( - entry.get("target"), - f"nodePreloads[{index}].target", - PRELOAD_TARGET_PREFIX, - "JavaScript file", - ) - inject_into = entry.get("injectInto", []) - if not isinstance(inject_into, list): - fail(f"nodePreloads[{index}].injectInto must be a list") - normalized_scopes = [] - for scope in inject_into: - if scope not in ("boot", "connect"): - fail(f"nodePreloads[{index}].injectInto contains unsupported value {scope!r}") - if scope not in normalized_scopes: - normalized_scopes.append(scope) - optional = entry.get("optional", False) - if not isinstance(optional, bool): - fail(f"nodePreloads[{index}].optional must be a boolean") - return { - "source": source, - "target": target, - "injectInto": normalized_scopes, - "optional": optional, - "installMessage": clean_message(entry.get("installMessage"), f"nodePreloads[{index}].installMessage"), - "installedMessage": clean_message(entry.get("installedMessage"), f"nodePreloads[{index}].installedMessage"), - } - - -def clean_env_alias(entry, index): - if not isinstance(entry, dict): - fail(f"envAliases[{index}] must be an object") - env_key = clean_string(entry.get("envKey"), f"envAliases[{index}].envKey") - if not ENV_KEY_RE.match(env_key): - fail(f"envAliases[{index}].envKey is not a safe environment key") - pattern = clean_string(entry.get("match"), f"envAliases[{index}].match") - try: - re.compile(pattern) - except re.error as exc: - fail(f"envAliases[{index}].match is not a valid regex: {exc}") - return { - "envKey": env_key, - "match": pattern, - "value": clean_string(entry.get("value"), f"envAliases[{index}].value", allow_empty=True), - "message": clean_message(entry.get("message"), f"envAliases[{index}].message"), - } - - -def clean_secret_scan(entry, index): - if not isinstance(entry, dict): - fail(f"secretScans[{index}] must be an object") - path = clean_string(entry.get("path"), f"secretScans[{index}].path") - if not path.startswith("/sandbox/"): - fail(f"secretScans[{index}].path must be under /sandbox") - pattern = clean_string(entry.get("pattern"), f"secretScans[{index}].pattern") - try: - re.compile(pattern) - except re.error as exc: - fail(f"secretScans[{index}].pattern is not a valid regex: {exc}") - exit_code = entry.get("exitCode", 78) - if not isinstance(exit_code, int) or exit_code < 1 or exit_code > 255: - fail(f"secretScans[{index}].exitCode must be an integer from 1 to 255") - return { - "path": path, - "pattern": pattern, - "message": clean_message(entry.get("message"), f"secretScans[{index}].message") or "[SECURITY] Runtime secret scan failed for {path}", - "exitCode": exit_code, - } - - -def load_messaging_plan(): - raw_plan = os.environ.get("NEMOCLAW_MESSAGING_PLAN_B64", "").strip() - if raw_plan: - try: - return json.loads(base64.b64decode(raw_plan, validate=True).decode("utf-8")) - except Exception as exc: - fail(f"NEMOCLAW_MESSAGING_PLAN_B64 is not valid base64 JSON: {exc}") - artifact_path = sys.argv[1] if len(sys.argv) > 1 else "" - if not artifact_path or not os.path.isfile(artifact_path): - return None - try: - with open(artifact_path, encoding="utf-8") as handle: - return json.load(handle) - except Exception as exc: - fail(f"messaging runtime plan artifact {artifact_path} is not valid JSON: {exc}") - - -plan = load_messaging_plan() -if plan is None: - print(json.dumps(EMPTY, sort_keys=True)) - raise SystemExit(0) -if not isinstance(plan, dict): - fail("decoded plan must be an object") - -disabled_channels = { - channel_id - for channel_id in plan.get("disabledChannels", []) - if isinstance(channel_id, str) -} -active_channel_ids = set() -for channel in plan.get("channels", []): - if not isinstance(channel, dict): - continue - channel_id = channel.get("channelId") - if not isinstance(channel_id, str): - continue - if channel.get("active") is True and channel.get("disabled") is not True and channel_id not in disabled_channels: - active_channel_ids.add(channel_id) - -runtime_setup = plan.get("runtimeSetup", EMPTY) -if runtime_setup is None: - runtime_setup = EMPTY -if not isinstance(runtime_setup, dict): - fail("runtimeSetup must be an object") - - -def runtime_setup_entries(key): - entries = runtime_setup.get(key, []) - if not isinstance(entries, list): - fail(f"runtimeSetup.{key} must be a list") - for index, entry in enumerate(entries): - if not isinstance(entry, dict): - fail(f"runtimeSetup.{key}[{index}] must be an object") - channel_id = entry.get("channelId") - if not isinstance(channel_id, str) or not channel_id: - fail(f"runtimeSetup.{key}[{index}].channelId must be a string") - if channel_id not in active_channel_ids: - continue - yield entry - - -node_preloads = [] -env_aliases = [] -secret_scans = [] -seen_node_preloads = set() -seen_aliases = set() -seen_scans = set() - -for entry in runtime_setup_entries("nodePreloads"): - preload = clean_node_preload(entry, len(node_preloads)) - preload_key = (preload["source"], preload["target"]) - if preload_key not in seen_node_preloads: - seen_node_preloads.add(preload_key) - node_preloads.append(preload) -for entry in runtime_setup_entries("envAliases"): - alias = clean_env_alias(entry, len(env_aliases)) - alias_key = (alias["envKey"], alias["match"], alias["value"]) - if alias_key not in seen_aliases: - seen_aliases.add(alias_key) - env_aliases.append(alias) -for entry in runtime_setup_entries("secretScans"): - scan = clean_secret_scan(entry, len(secret_scans)) - scan_key = (scan["path"], scan["pattern"]) - if scan_key not in seen_scans: - seen_scans.add(scan_key) - secret_scans.append(scan) - -print(json.dumps({"nodePreloads": node_preloads, "envAliases": env_aliases, "secretScans": secret_scans}, sort_keys=True)) -PYMESSAGINGRUNTIME -} - -apply_messaging_runtime_env_aliases() { - [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 - local _rows - _rows="$( - python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGALIASES' -import json -import os -import re -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - plan = json.load(handle) -for alias in plan.get("envAliases", []): - if not re.search(alias["match"], os.environ.get(alias["envKey"], "")): - continue - print("\t".join([ - alias["envKey"], - alias["value"], - alias.get("message", ""), - ])) -PYMESSAGINGALIASES - )" || return $? - [ -n "$_rows" ] || return 0 - - local _env_key _value _message - while IFS=$'\t' read -r _env_key _value _message; do - export "$_env_key=$_value" - if [ -n "$_message" ]; then - printf '%s\n' "$_message" >&2 - fi - done <<<"$_rows" - return 0 -} - -node_options_has_require() { - local wanted="$1" - local previous="" - local token - local tokens=() - IFS=$' \t\n' read -r -a tokens <<<"${NODE_OPTIONS:-}" - # Iterating "${tokens[@]}" on an empty array trips `set -u` on bash 3.2 - # (macOS default); guard so the local unit harnesses run there too. - [ "${#tokens[@]}" -gt 0 ] || return 1 - for token in "${tokens[@]}"; do - if [ "$previous" = "--require" ] && [ "$token" = "$wanted" ]; then - return 0 - fi - [ "$token" = "--require=$wanted" ] && return 0 - previous="$token" - done - return 1 -} - -append_node_require_once() { - local wanted="$1" - if ! node_options_has_require "$wanted"; then - export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $wanted" - fi -} - -install_messaging_runtime_preloads() { - [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 - local _rows - _rows="$( - python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGPRELOADS' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - plan = json.load(handle) -for preload in plan.get("nodePreloads", []): - print("\t".join([ - preload["source"], - preload["target"], - ",".join(preload.get("injectInto", [])), - "1" if preload.get("optional") else "0", - preload.get("installMessage", ""), - preload.get("installedMessage", ""), - ])) -PYMESSAGINGPRELOADS - )" || return $? - - local _connect_preloads=() - if [ -n "$_rows" ]; then - local _source _target _inject_into _optional _install_message _installed_message - while IFS=$'\t' read -r _source _target _inject_into _optional _install_message _installed_message; do - if [ ! -f "$_source" ]; then - [ "$_optional" = "1" ] && continue - printf '[channels] Missing runtime preload source: %s\n' "$_source" >&2 - return 1 - fi - [ -n "$_install_message" ] && printf '%s\n' "$_install_message" >&2 - emit_sandbox_sourced_file "$_target" <"$_source" || return 1 - case ",$_inject_into," in - *,boot,*) - append_node_require_once "$_target" - ;; - esac - case ",$_inject_into," in - *,connect,*) - _connect_preloads+=("$_target") - ;; - esac - [ -n "$_installed_message" ] && printf '%s\n' "$_installed_message" >&2 - done <<<"$_rows" - fi - - if [ "${#_connect_preloads[@]}" -gt 0 ]; then - printf '%s\n' "${_connect_preloads[@]}" \ - | emit_sandbox_sourced_file "$_MESSAGING_CONNECT_PRELOADS_FILE" || return 1 - else - : | emit_sandbox_sourced_file "$_MESSAGING_CONNECT_PRELOADS_FILE" || return 1 - fi -} - -emit_messaging_connect_runtime_preload_exports() { - cat < 255: + fail(f"secretScans[{index}].exitCode must be an integer from 1 to 255") + return { + "path": path, + "pattern": pattern, + "message": clean_message(entry.get("message"), f"secretScans[{index}].message") or "[SECURITY] Runtime secret scan failed for {path}", + "exitCode": exit_code, + } + + +def load_messaging_plan(): + raw_plan = os.environ.get("NEMOCLAW_MESSAGING_PLAN_B64", "").strip() + if raw_plan: + try: + return json.loads(base64.b64decode(raw_plan, validate=True).decode("utf-8")) + except Exception as exc: + fail(f"NEMOCLAW_MESSAGING_PLAN_B64 is not valid base64 JSON: {exc}") + artifact_path = sys.argv[1] if len(sys.argv) > 1 else "" + if not artifact_path or not os.path.isfile(artifact_path): + return None + try: + with open(artifact_path, encoding="utf-8") as handle: + return json.load(handle) + except Exception as exc: + fail(f"messaging runtime plan artifact {artifact_path} is not valid JSON: {exc}") + + +plan = load_messaging_plan() +if plan is None: + print(json.dumps(EMPTY, sort_keys=True)) + raise SystemExit(0) +if not isinstance(plan, dict): + fail("decoded plan must be an object") + +disabled_channels = { + channel_id + for channel_id in plan.get("disabledChannels", []) + if isinstance(channel_id, str) +} +active_channel_ids = set() +for channel in plan.get("channels", []): + if not isinstance(channel, dict): + continue + channel_id = channel.get("channelId") + if not isinstance(channel_id, str): + continue + if channel.get("active") is True and channel.get("disabled") is not True and channel_id not in disabled_channels: + active_channel_ids.add(channel_id) + +runtime_setup = plan.get("runtimeSetup", EMPTY) +if runtime_setup is None: + runtime_setup = EMPTY +if not isinstance(runtime_setup, dict): + fail("runtimeSetup must be an object") + + +def runtime_setup_entries(key): + entries = runtime_setup.get(key, []) + if not isinstance(entries, list): + fail(f"runtimeSetup.{key} must be a list") + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + fail(f"runtimeSetup.{key}[{index}] must be an object") + channel_id = entry.get("channelId") + if not isinstance(channel_id, str) or not channel_id: + fail(f"runtimeSetup.{key}[{index}].channelId must be a string") + if channel_id not in active_channel_ids: + continue + yield entry + + +node_preloads = [] +env_aliases = [] +secret_scans = [] +seen_node_preloads = set() +seen_aliases = set() +seen_scans = set() + +for entry in runtime_setup_entries("nodePreloads"): + preload = clean_node_preload(entry, len(node_preloads)) + preload_key = (preload["source"], preload["target"]) + if preload_key not in seen_node_preloads: + seen_node_preloads.add(preload_key) + node_preloads.append(preload) +for entry in runtime_setup_entries("envAliases"): + alias = clean_env_alias(entry, len(env_aliases)) + alias_key = (alias["envKey"], alias["match"], alias["value"]) + if alias_key not in seen_aliases: + seen_aliases.add(alias_key) + env_aliases.append(alias) +for entry in runtime_setup_entries("secretScans"): + scan = clean_secret_scan(entry, len(secret_scans)) + scan_key = (scan["path"], scan["pattern"]) + if scan_key not in seen_scans: + seen_scans.add(scan_key) + secret_scans.append(scan) + +print(json.dumps({"nodePreloads": node_preloads, "envAliases": env_aliases, "secretScans": secret_scans}, sort_keys=True)) +PYMESSAGINGRUNTIME +} + +apply_messaging_runtime_env_aliases() { + [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 + local _rows + _rows="$( + python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGALIASES' +import json +import os +import re +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + plan = json.load(handle) +for alias in plan.get("envAliases", []): + if not re.search(alias["match"], os.environ.get(alias["envKey"], "")): + continue + print("\t".join([ + alias["envKey"], + alias["value"], + alias.get("message", ""), + ])) +PYMESSAGINGALIASES + )" || return $? + [ -n "$_rows" ] || return 0 + + local _env_key _value _message + while IFS=$'\t' read -r _env_key _value _message; do + export "$_env_key=$_value" + [ -n "$_message" ] && printf '%s\n' "$_message" >&2 + done <<<"$_rows" +} + +node_options_has_require() { + local wanted="$1" + local previous="" + local token + local tokens=() + IFS=$' \t\n' read -r -a tokens <<<"${NODE_OPTIONS:-}" + # Iterating "${tokens[@]}" on an empty array trips `set -u` on bash 3.2 + # (macOS default); guard so the local unit harnesses run there too. + [ "${#tokens[@]}" -gt 0 ] || return 1 + for token in "${tokens[@]}"; do + if [ "$previous" = "--require" ] && [ "$token" = "$wanted" ]; then + return 0 + fi + [ "$token" = "--require=$wanted" ] && return 0 + previous="$token" + done + return 1 +} + +append_node_require_once() { + local wanted="$1" + if ! node_options_has_require "$wanted"; then + export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $wanted" + fi +} + +install_messaging_runtime_preloads() { + [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 + local _rows + _rows="$( + python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGPRELOADS' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + plan = json.load(handle) +for preload in plan.get("nodePreloads", []): + print("\t".join([ + preload["source"], + preload["target"], + ",".join(preload.get("injectInto", [])), + "1" if preload.get("optional") else "0", + preload.get("installMessage", ""), + preload.get("installedMessage", ""), + ])) +PYMESSAGINGPRELOADS + )" || return $? + + local _connect_preloads=() + if [ -n "$_rows" ]; then + local _source _target _inject_into _optional _install_message _installed_message + while IFS=$'\t' read -r _source _target _inject_into _optional _install_message _installed_message; do + if [ ! -f "$_source" ]; then + [ "$_optional" = "1" ] && continue + printf '[channels] Missing runtime preload source: %s\n' "$_source" >&2 + return 1 + fi + [ -n "$_install_message" ] && printf '%s\n' "$_install_message" >&2 + emit_sandbox_sourced_file "$_target" <"$_source" || return 1 + case ",$_inject_into," in + *,boot,*) + append_node_require_once "$_target" + ;; + esac + case ",$_inject_into," in + *,connect,*) + _connect_preloads+=("$_target") + ;; + esac + [ -n "$_installed_message" ] && printf '%s\n' "$_installed_message" >&2 + done <<<"$_rows" + fi + + if [ "${#_connect_preloads[@]}" -gt 0 ]; then + printf '%s\n' "${_connect_preloads[@]}" \ + | emit_sandbox_sourced_file "$_MESSAGING_CONNECT_PRELOADS_FILE" || return 1 + else + : | emit_sandbox_sourced_file "$_MESSAGING_CONNECT_PRELOADS_FILE" || return 1 + fi +} + +emit_messaging_connect_runtime_preload_exports() { + cat < { - it("moves dashboard pairing to the gateway session path (#8184)", () => { - const argv = [ - "/usr/local/bin/node", - "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", - "--session", - "/sandbox/.hermes/dashboard-home/platforms/whatsapp/session", - ]; - - expect(normalizeHermesWhatsappSessionArgv(argv)).toBe(true); - expect(argv[3]).toBe(HERMES_WHATSAPP_SESSION_PATH); - }); - - it.each([ - ["CLI and gateway", "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js"], - ["installed gateway", "/opt/hermes/scripts/whatsapp-bridge/bridge.js"], - ["unrelated Node process", "/sandbox/tool.js"], - ])("leaves the %s bridge session path unchanged (#8184)", (_case, bridgePath) => { - const argv = ["/usr/local/bin/node", bridgePath, "--session", "/keep"]; - - expect(normalizeHermesWhatsappSessionArgv(argv)).toBe(false); - expect(argv[3]).toBe("/keep"); - }); - - it("leaves a path-shaped unrelated bridge and its umask unchanged (#8184)", () => { - const modes: number[] = []; - const argv = [ - "/usr/local/bin/node", - "/sandbox/unrelated/whatsapp-bridge/bridge.js", - "--session", - "/keep", - ]; - - expect(applyHermesWhatsappSessionPatch(argv, (mode) => modes.push(mode))).toBe(false); - expect(argv[3]).toBe("/keep"); - expect(modes).toEqual([]); - }); - - it("keeps paired credentials shared with the Hermes gateway group (#8184)", () => { - const modes: number[] = []; - const argv = [ - "/usr/local/bin/node", - "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", - "--session", - "/split/session", - ]; - - expect(applyHermesWhatsappSessionPatch(argv, (mode) => modes.push(mode))).toBe(true); - expect(modes).toEqual([0o007]); - }); - - it.each([ - [ - "missing", - ["/usr/local/bin/node", "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js"], - ], - [ - "missing before another option", - [ - "/usr/local/bin/node", - "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", - "--session", - "--mode", - "bot", - ], - ], - [ - "duplicate", - [ - "/usr/local/bin/node", - "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", - "--session", - "/one", - "--session", - "/two", - ], - ], - ])("refuses a %s Hermes bridge session argument (#8184)", (_case, argv) => { - expect(() => normalizeHermesWhatsappSessionArgv(argv)).toThrow( - "did not provide exactly one session path", - ); - }); - - it("declares the mandatory preload only for Hermes boot (#8184)", () => { - const runtime = planRuntimeSetup([whatsappManifest], "hermes", [ - { - channelId: "whatsapp", - displayName: "WhatsApp", - authMode: "in-sandbox-qr", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ]); - - expect(runtime.nodePreloads).toEqual([ - expect.objectContaining({ - channelId: "whatsapp", - source: "/usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js", - target: "/tmp/nemoclaw-whatsapp-hermes-session.js", - injectInto: ["boot"], - optional: false, - }), - ]); - }); -}); diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts deleted file mode 100644 index 56447968715..00000000000 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-hermes-session.ts +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -export const HERMES_WHATSAPP_SESSION_PATH = "/sandbox/.hermes/platforms/whatsapp/session"; -const HERMES_DASHBOARD_WHATSAPP_BRIDGE_PATH = - "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js"; - -function isHermesWhatsappBridge(scriptPath: string | undefined): boolean { - return scriptPath === HERMES_DASHBOARD_WHATSAPP_BRIDGE_PATH; -} - -export function normalizeHermesWhatsappSessionArgv(argv: string[]): boolean { - if (!isHermesWhatsappBridge(argv[1])) return false; - - const sessionIndexes = argv.reduce((indexes, value, index) => { - if (value === "--session") indexes.push(index); - return indexes; - }, []); - const sessionIndex = sessionIndexes.length === 1 ? sessionIndexes[0] : undefined; - const sessionValue = sessionIndex === undefined ? undefined : argv[sessionIndex + 1]; - if (sessionIndex === undefined || !sessionValue || sessionValue.startsWith("-")) { - throw new Error( - "Hermes WhatsApp bridge did not provide exactly one session path; refusing split session state", - ); - } - - // The dashboard runs with an isolated HERMES_HOME, but its paired credentials must be - // available to the gateway under the primary Hermes home. Keep upstream CLI and gateway - // bridge arguments unchanged because Hermes already provides legacy-path compatibility. - argv[sessionIndex + 1] = HERMES_WHATSAPP_SESSION_PATH; - return true; -} - -export function applyHermesWhatsappSessionPatch( - argv: string[], - setUmask: (mode: number) => unknown = (mode) => process.umask(mode), -): boolean { - if (!normalizeHermesWhatsappSessionArgv(argv)) return false; - // The dashboard and gateway run as separate users in the shared sandbox group. - // Keep dashboard-paired credentials group-readable without granting world access. - setUmask(0o007); - return true; -} - -applyHermesWhatsappSessionPatch(process.argv); diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 0c0fa24a5a7..848ca4a61bd 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -189,7 +189,7 @@ export interface ChannelRuntimeVisibilitySpec { export type ChannelRuntimeNodePreloadScope = "boot" | "connect"; -/** Node preload module to inject inside the selected agent runtime process. */ +/** Node preload module to inject inside the OpenClaw runtime process. */ export interface ChannelRuntimeNodePreloadSpec { readonly module: string; readonly injectInto?: readonly ChannelRuntimeNodePreloadScope[]; diff --git a/test/hermes-doctor-config-hash.test.ts b/test/hermes-doctor-config-hash.test.ts index ad454df4e45..7be8f2f420e 100644 --- a/test/hermes-doctor-config-hash.test.ts +++ b/test/hermes-doctor-config-hash.test.ts @@ -65,13 +65,6 @@ describe("Hermes doctor and config hash boundary", () => { const binDir = path.join(tmp, "usr-local-bin"); const libDir = path.join(tmp, "usr-local-lib-nemoclaw"); const preloadsDir = path.join(libDir, "preloads"); - const compiledRuntimePreload = path.join( - libDir, - "preloads-compiled-channels", - "whatsapp", - "runtime", - "whatsapp-hermes-session.js", - ); const buildMcpDigestPath = path.join(libDir, "build-hermes-mcp-digest.py"); const mcpConfigTransactionPath = path.join(libDir, "hermes-mcp-config-transaction.py"); const langfuseCredentialPatcherPath = path.join( @@ -119,7 +112,6 @@ describe("Hermes doctor and config hash boundary", () => { path.join(libDir, "state-dir-guard.py"), path.join(libDir, "managed-gateway-control.py"), path.join(libDir, "sandbox-rlimits.sh"), - compiledRuntimePreload, path.join(preloadsDir, "gateway-safety-net.js"), path.join(nestedDir, "ciao-preload.js"), bashrcPath, @@ -172,7 +164,6 @@ describe("Hermes doctor and config hash boundary", () => { expect(mode(preloadsDir)).toBe("755"); expect(mode(nestedDir)).toBe("755"); expect(mode(path.join(preloadsDir, "gateway-safety-net.js"))).toBe("444"); - expect(mode(path.join(preloadsDir, "whatsapp-hermes-session.js"))).toBe("444"); expect(mode(path.join(nestedDir, "ciao-preload.js"))).toBe("444"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 34fe87c4640..0ed2c5aa3bf 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -276,7 +276,6 @@ describe("Hermes final image layout", () => { "COPY src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.85.json /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json", "COPY scripts/state-dir-guard.py /usr/local/lib/nemoclaw/state-dir-guard.py", "COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/", - "COPY --from=runtime-preload-builder /opt/nemoclaw-root/dist/lib/messaging/channels/ /usr/local/lib/nemoclaw/preloads-compiled-channels/", ], }, { @@ -406,7 +405,6 @@ describe("Hermes final image layout", () => { "/usr/local/bin/nemoclaw-gateway-control 'root:root 700'", "/sandbox/.nemoclaw 'root:root 1755'", "/usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root 444'", - "/usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js 'root:root 444'", "/usr/local/lib/nemoclaw/hermes-wrapper.py 'root:root 755'", "/usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py 'root:root 755'", "/usr/local/share/nemoclaw/hermes-cli-adapter-v1.json 'root:root 444'", diff --git a/test/hermes-messaging-runtime-setup.test.ts b/test/hermes-messaging-runtime-setup.test.ts deleted file mode 100644 index d9421008fe8..00000000000 --- a/test/hermes-messaging-runtime-setup.test.ts +++ /dev/null @@ -1,409 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import ts from "typescript"; -import { describe, expect, it } from "vitest"; -import { extractShellFunction } from "./support/hermes-shell-harness"; - -const ROOT = path.join(import.meta.dirname, ".."); -const HERMES_START = fs.readFileSync(path.join(ROOT, "agents", "hermes", "start.sh"), "utf-8"); -const SANDBOX_INIT = fs.readFileSync(path.join(ROOT, "scripts", "lib", "sandbox-init.sh"), "utf-8"); - -function runtimeShellEnvFunction(source: string): string { - const start = source.indexOf("write_runtime_shell_env() {"); - const end = source.indexOf("\nwrite_runtime_shell_env\n", start); - expect(start, "expected write_runtime_shell_env").toBeGreaterThanOrEqual(0); - expect(end, "expected write_runtime_shell_env invocation").toBeGreaterThan(start); - return source.slice(start, end); -} - -function messagingRuntimeSetupSection( - source: string, - planPath: string, - preloadPaths: { sourcePrefix: string; targetPrefix: string } = { - sourcePrefix: "/usr/local/lib/nemoclaw/preloads/", - targetPrefix: "/tmp/nemoclaw-", - }, -): string { - const start = source.indexOf("# ── Messaging runtime setup from manifest metadata"); - const end = source.indexOf("# ── End messaging runtime setup", start); - expect(start).toBeGreaterThanOrEqual(0); - expect(end).toBeGreaterThan(start); - return source - .slice(start, end) - .replace( - '_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json"', - `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(planPath)}`, - ) - .replace( - 'PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/"', - `PRELOAD_SOURCE_PREFIX = ${JSON.stringify(preloadPaths.sourcePrefix)}`, - ) - .replace( - 'PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-"', - `PRELOAD_TARGET_PREFIX = ${JSON.stringify(preloadPaths.targetPrefix)}`, - ); -} - -function encodeRuntimePlan(nodePreloads: Array>): string { - return Buffer.from( - JSON.stringify({ - channels: [ - { - channelId: "whatsapp", - active: true, - disabled: false, - }, - ], - disabledChannels: [], - runtimeSetup: { - nodePreloads: nodePreloads.map((entry) => ({ channelId: "whatsapp", ...entry })), - envAliases: [], - secretScans: [], - }, - }), - ).toString("base64"); -} - -describe("Hermes messaging runtime setup", () => { - it("rewrites only the dashboard bridge session path (#8184)", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-whatsapp-plan-")); - const sourceDir = path.join(tmpDir, "preloads"); - const sourcePrefix = `${sourceDir}${path.sep}`; - const sourcePath = path.join(sourceDir, "whatsapp-hermes-session.js"); - const targetPrefix = path.join(tmpDir, "nemoclaw-"); - const targetPath = `${targetPrefix}whatsapp-hermes-session.js`; - const planPath = path.join(tmpDir, "runtime-plan.json"); - const runtimeSourcePath = path.join( - ROOT, - "src", - "lib", - "messaging", - "channels", - "whatsapp", - "runtime", - "whatsapp-hermes-session.ts", - ); - fs.mkdirSync(sourceDir, { recursive: true }); - fs.writeFileSync( - sourcePath, - ts.transpileModule(fs.readFileSync(runtimeSourcePath, "utf-8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, - fileName: runtimeSourcePath, - }).outputText, - ); - - try { - const result = spawnSync( - "bash", - [ - "--noprofile", - "--norc", - "-c", - [ - "set -euo pipefail", - 'emit_sandbox_sourced_file() { local target="$1"; cat >"$target"; chmod 444 "$target"; }', - messagingRuntimeSetupSection(SANDBOX_INIT, planPath, { - sourcePrefix, - targetPrefix, - }), - "write_messaging_runtime_setup_plan", - "install_messaging_runtime_preloads", - "node -e 'process.stdout.write(JSON.stringify(process.argv) + \"\\n\")' /sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js --session /tmp/dashboard-session", - "node -e 'process.stdout.write(JSON.stringify(process.argv) + \"\\n\")' /sandbox/.hermes/scripts/whatsapp-bridge/bridge.js --session /tmp/cli-session", - ].join("\n"), - ], - { - encoding: "utf-8", - timeout: 5000, - env: { - ...process.env, - NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH: path.join(tmpDir, "missing.json"), - NEMOCLAW_MESSAGING_PLAN_B64: encodeRuntimePlan([ - { - source: sourcePath, - target: targetPath, - injectInto: ["boot"], - optional: false, - }, - ]), - NODE_OPTIONS: "", - }, - }, - ); - - expect(result.status, result.stderr).toBe(0); - expect( - result.stdout - .trim() - .split("\n") - .map((line) => JSON.parse(line)), - ).toEqual([ - [ - process.execPath, - "/sandbox/.hermes/dashboard-home/scripts/whatsapp-bridge/bridge.js", - "--session", - "/sandbox/.hermes/platforms/whatsapp/session", - ], - [ - process.execPath, - "/sandbox/.hermes/scripts/whatsapp-bridge/bridge.js", - "--session", - "/tmp/cli-session", - ], - ]); - expect(fs.readFileSync(targetPath, "utf-8")).toBe(fs.readFileSync(sourcePath, "utf-8")); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("rejects traversal-shaped preload targets before any destination write", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preload-traversal-")); - const victimPath = path.join(tmpDir, "victim.js"); - const planPath = path.join(tmpDir, "runtime-plan.json"); - const traversalTarget = `/tmp/nemoclaw-/../..${victimPath}`; - fs.writeFileSync(victimPath, "preserve me\n"); - expect(path.resolve(traversalTarget)).toBe(path.resolve(victimPath)); - - try { - const result = spawnSync( - "bash", - [ - "--noprofile", - "--norc", - "-c", - [ - "set -euo pipefail", - 'emit_sandbox_sourced_file() { local target="$1"; cat >"$target"; }', - messagingRuntimeSetupSection(SANDBOX_INIT, planPath), - "write_messaging_runtime_setup_plan", - ].join("\n"), - ], - { - encoding: "utf-8", - timeout: 5000, - env: { - ...process.env, - NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH: path.join(tmpDir, "missing.json"), - NEMOCLAW_MESSAGING_PLAN_B64: encodeRuntimePlan([ - { - source: "/usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js", - target: traversalTarget, - injectInto: ["boot", "connect"], - optional: false, - }, - ]), - }, - }, - ); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("nodePreloads[0].target must be a direct JavaScript file"); - expect(fs.readFileSync(victimPath, "utf-8")).toBe("preserve me\n"); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("runs the manifest runtime setup in order (#8184)", () => { - const result = spawnSync( - "bash", - [ - "--noprofile", - "--norc", - "-c", - [ - "set -euo pipefail", - 'write_messaging_runtime_setup_plan() { printf "plan\\n"; }', - 'apply_messaging_runtime_env_aliases() { printf "alias\\n"; }', - 'install_messaging_runtime_preloads() { printf "install\\n"; }', - 'verify_messaging_runtime_secret_scans() { printf "scan\\n"; }', - 'write_runtime_shell_env() { printf "env\\n"; }', - extractShellFunction(HERMES_START, "prepare_hermes_messaging_runtime"), - "prepare_hermes_messaging_runtime", - ].join("\n"), - ], - { encoding: "utf-8", timeout: 5000 }, - ); - - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toBe("plan\nalias\ninstall\nscan\nenv\n"); - }); - - it("publishes manifest connect preloads through the trusted runtime environment (#8184)", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-connect-preload-")); - const preloadPath = path.join(tmpDir, "manifest-connect.js"); - const preloadListPath = path.join(tmpDir, "connect-preloads"); - const runtimeEnvPath = path.join(tmpDir, "runtime-env.sh"); - fs.writeFileSync(preloadPath, "module.exports = {};\n"); - fs.writeFileSync(preloadListPath, `${preloadPath}\n`); - - try { - const result = spawnSync( - "bash", - [ - "--noprofile", - "--norc", - "-c", - [ - "set -euo pipefail", - 'emit_sandbox_sourced_file() { local target="$1"; cat >"$target"; chmod 444 "$target"; }', - extractShellFunction(SANDBOX_INIT, "emit_messaging_connect_runtime_preload_exports"), - `_MESSAGING_CONNECT_PRELOADS_FILE=${JSON.stringify(preloadListPath)}`, - `_PROXY_ENV_FILE=${JSON.stringify(runtimeEnvPath)}`, - '_PROXY_URL="http://10.200.0.1:3128"', - '_NO_PROXY_VAL="localhost,127.0.0.1"', - 'HERMES_DIR="/sandbox/.hermes"', - runtimeShellEnvFunction(HERMES_START), - "write_runtime_shell_env", - `source ${JSON.stringify(runtimeEnvPath)}`, - 'printf "NODE_OPTIONS=%s\\n" "$NODE_OPTIONS"', - ].join("\n"), - ], - { encoding: "utf-8", timeout: 5000, env: { ...process.env, NODE_OPTIONS: "" } }, - ); - - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain(`NODE_OPTIONS=--require ${preloadPath}`); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it.each([ - { - name: "prepare_hermes_nonroot_runtime", - stubs: [ - "verify_config_integrity_if_locked", - "validate_hermes_env_secret_boundary", - "inspect_hermes_mcp_integrity", - "ensure_hermes_runtime_api_server_key", - "apply_shields_up_runtime_env", - "validate_hermes_runtime_env_secret_boundary", - "refresh_hermes_provider_placeholders", - "refresh_hermes_runtime_config_hashes", - "configure_messaging_channels", - "apply_messaging_runtime_env_aliases", - "write_runtime_shell_env", - "prepare_tirith_marker_retry", - ], - expectedTail: - "configure_messaging_channels\nplan\napply_messaging_runtime_env_aliases\ninstall\nscan\nwrite_runtime_shell_env\nprepare_tirith_marker_retry\n", - }, - { - name: "prepare_hermes_root_runtime", - stubs: [ - "verify_hermes_config_integrity", - "ensure_hermes_config_root_mode", - "ensure_hermes_runtime_api_server_key", - "apply_shields_up_runtime_env", - "validate_hermes_env_secret_boundary", - "validate_hermes_runtime_env_secret_boundary", - "refresh_hermes_provider_placeholders", - "configure_messaging_channels", - "apply_messaging_runtime_env_aliases", - "write_runtime_shell_env", - "prepare_tirith_marker_retry", - ], - expectedTail: - "configure_messaging_channels\nplan\napply_messaging_runtime_env_aliases\ninstall\nscan\nwrite_runtime_shell_env\nprepare_tirith_marker_retry\n", - }, - ])("installs manifest runtime setup during $name (#8184)", ({ name, stubs, expectedTail }) => { - const stubFunctions = stubs.map((stub) => `${stub}() { printf '${stub}\\n'; }`).join("\n"); - const result = spawnSync( - "bash", - [ - "--noprofile", - "--norc", - "-c", - [ - "set -euo pipefail", - 'HERMES_DIR="/sandbox/.hermes"', - stubFunctions, - 'write_messaging_runtime_setup_plan() { printf "plan\\n"; }', - 'install_messaging_runtime_preloads() { printf "install\\n"; }', - 'verify_messaging_runtime_secret_scans() { printf "scan\\n"; }', - extractShellFunction(HERMES_START, "prepare_hermes_messaging_runtime"), - extractShellFunction(HERMES_START, name), - name, - ].join("\n"), - ], - { encoding: "utf-8", timeout: 5000 }, - ); - - expect(result.status, result.stderr).toBe(0); - expect(result.stdout.endsWith(expectedTail)).toBe(true); - }); - - it.each([ - "prepare_hermes_nonroot_runtime", - "prepare_hermes_root_runtime", - ])("applies active Slack runtime aliases during %s (#8184)", (name) => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-env-alias-")); - const planPath = path.join(tmpDir, "runtime-plan.json"); - const runtimePlan = JSON.stringify({ - envAliases: [ - { - envKey: "SLACK_BOT_TOKEN", - match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$", - value: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", - }, - ], - }); - const prerequisites = [ - "verify_config_integrity_if_locked", - "validate_hermes_env_secret_boundary", - "inspect_hermes_mcp_integrity", - "ensure_hermes_runtime_api_server_key", - "apply_shields_up_runtime_env", - "validate_hermes_runtime_env_secret_boundary", - "refresh_hermes_provider_placeholders", - "refresh_hermes_runtime_config_hashes", - "verify_hermes_config_integrity", - "ensure_hermes_config_root_mode", - "configure_messaging_channels", - "install_messaging_runtime_preloads", - "verify_messaging_runtime_secret_scans", - "write_runtime_shell_env", - "prepare_tirith_marker_retry", - ]; - - try { - const result = spawnSync( - "bash", - [ - "--noprofile", - "--norc", - "-c", - [ - "set -euo pipefail", - `HERMES_DIR=${JSON.stringify(tmpDir)}`, - `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(planPath)}`, - ...prerequisites.map((stub) => `${stub}() { return 0; }`), - `write_messaging_runtime_setup_plan() { printf '%s' ${JSON.stringify(runtimePlan)} >"$_MESSAGING_RUNTIME_SETUP_PLAN"; }`, - extractShellFunction(SANDBOX_INIT, "apply_messaging_runtime_env_aliases"), - extractShellFunction(HERMES_START, "prepare_hermes_messaging_runtime"), - extractShellFunction(HERMES_START, name), - name, - 'printf "%s\\n" "$SLACK_BOT_TOKEN"', - ].join("\n"), - ], - { - encoding: "utf-8", - timeout: 5000, - env: { ...process.env, SLACK_BOT_TOKEN: "openshell:resolve:env:v42_SLACK_BOT_TOKEN" }, - }, - ); - - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toBe("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN\n"); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); -}); diff --git a/test/hermes-start.test.ts b/test/hermes-start.test.ts index 3144d30c9ad..02b157063ac 100644 --- a/test/hermes-start.test.ts +++ b/test/hermes-start.test.ts @@ -341,7 +341,7 @@ function runHermesRootStartupMutableRootPreflight() { "validate_hermes_runtime_env_secret_boundary() { :; }", "refresh_hermes_provider_placeholders() { :; }", "refresh_hermes_runtime_config_hashes() { :; }", - "configure_messaging_channels() { :; }; prepare_hermes_messaging_runtime() { :; }", + "configure_messaging_channels() { :; }", 'retry_tirith_marker_if_needed() { printf "tirith-state=%s\\n" "$TIRITH_RETRY_MARKER_CLEARED"; }', "prepare_tirith_marker_retry() { TIRITH_RETRY_MARKER_CLEARED=0; retry_tirith_marker_if_needed; }", extractShellFunctionFromSource(src, "prepare_hermes_root_runtime"), @@ -1047,7 +1047,7 @@ describe("agents/hermes/start.sh env secret boundary", () => { "validate_hermes_runtime_env_secret_boundary() { trace runtime-boundary; }", "refresh_hermes_provider_placeholders() { trace placeholders; }", "refresh_hermes_runtime_config_hashes() { trace hashes; }", - "configure_messaging_channels() { trace channels; }; prepare_hermes_messaging_runtime() { trace messaging-runtime; }", + "configure_messaging_channels() { trace channels; }", "retry_tirith_marker_if_needed() { trace tirith; }", extractShellFunctionFromSource(source, "prepare_tirith_marker_retry"), extractShellFunctionFromSource(source, "prepare_hermes_nonroot_runtime"), @@ -1069,7 +1069,7 @@ describe("agents/hermes/start.sh env secret boundary", () => { "placeholders", "hashes", "mcp-integrity", - ...["channels", "messaging-runtime"], + "channels", "tirith", ]); }); diff --git a/test/hermes-tirith-retry-finalization.test.ts b/test/hermes-tirith-retry-finalization.test.ts index 7e3dcc0b258..0eeb2d4d3bf 100644 --- a/test/hermes-tirith-retry-finalization.test.ts +++ b/test/hermes-tirith-retry-finalization.test.ts @@ -177,7 +177,6 @@ describe("agents/hermes/start.sh Tirith retry finalization", () => { "validate_hermes_runtime_env_secret_boundary() { :; }", "refresh_hermes_provider_placeholders() { :; }", "configure_messaging_channels() { :; }", - "prepare_hermes_messaging_runtime() { :; }", "TIRITH_RETRY_MARKER_CLEARED=1", 'rm -f "$MARKER"', "prepare_hermes_root_runtime", diff --git a/test/http-proxy-fix-sync.test.ts b/test/http-proxy-fix-sync.test.ts index 9c868331ace..ab0a1fda4b6 100644 --- a/test/http-proxy-fix-sync.test.ts +++ b/test/http-proxy-fix-sync.test.ts @@ -10,7 +10,6 @@ import { describe, expect, it } from "vitest"; const ROOT = path.join(import.meta.dirname, ".."); const CANONICAL_FIX = path.join(ROOT, "nemoclaw-blueprint", "scripts", "http-proxy-fix.js"); const START_SCRIPT = path.join(ROOT, "scripts", "nemoclaw-start.sh"); -const SANDBOX_INIT = path.join(ROOT, "scripts", "lib", "sandbox-init.sh"); function extractShellFunction(source: string, name: string): string { const header = `${name}() {`; @@ -24,7 +23,7 @@ function extractShellFunction(source: string, name: string): string { describe("http-proxy-fix preload sync (#2109)", () => { it("entrypoint emits the proxy fix preload and registers it in NODE_OPTIONS", () => { - const startScript = `${fs.readFileSync(SANDBOX_INIT, "utf-8")}\n${fs.readFileSync(START_SCRIPT, "utf-8")}`; + const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); const start = startScript.indexOf('_PROXY_FIX_SCRIPT="/tmp/nemoclaw-http-proxy-fix.js"'); const end = startScript.indexOf( "# NVIDIA endpoint model-specific inference parameter injection", diff --git a/test/messaging-runtime-preload-packaging.test.ts b/test/messaging-runtime-preload-packaging.test.ts index 197548019c8..9374c00007d 100644 --- a/test/messaging-runtime-preload-packaging.test.ts +++ b/test/messaging-runtime-preload-packaging.test.ts @@ -7,10 +7,6 @@ import { describe, expect, it } from "vitest"; const repoRoot = path.join(import.meta.dirname, ".."); const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); -const hermesDockerfile = fs.readFileSync( - path.join(repoRoot, "agents", "hermes", "Dockerfile"), - "utf8", -); describe("messaging runtime preload packaging", () => { it("packages preload JavaScript compiled from TypeScript without requiring root npm metadata", () => { @@ -34,29 +30,4 @@ describe("messaging runtime preload packaging", () => { ); expect(dockerfile).not.toContain('basename "$file" .ts'); }); - - it("packages the same manifest-owned runtime preloads for Hermes", () => { - expect(hermesDockerfile).toContain( - "FROM mcp-tool-discovery-runtime AS runtime-preload-builder", - ); - expect(hermesDockerfile).toContain("COPY tsconfig.runtime-preloads.json /opt/nemoclaw-root/"); - expect(hermesDockerfile).toContain( - "COPY src/lib/messaging/channels/ /opt/nemoclaw-root/src/lib/messaging/channels/", - ); - expect(hermesDockerfile).toContain( - "ln -s /opt/mcp-tool-discovery-runtime/node_modules /opt/nemoclaw-root/node_modules", - ); - expect(hermesDockerfile).toContain( - "/opt/mcp-tool-discovery-runtime/node_modules/.bin/tsc -p tsconfig.runtime-preloads.json", - ); - expect(hermesDockerfile).toContain( - "COPY --from=runtime-preload-builder /opt/nemoclaw-root/dist/lib/messaging/channels/", - ); - expect(hermesDockerfile).toContain('runtime_preload_found="$(find'); - expect(hermesDockerfile).toContain("duplicate messaging runtime preload basename"); - expect(hermesDockerfile).toContain("-path '*/runtime/*.js'"); - expect(hermesDockerfile).toContain( - "check_metadata /usr/local/lib/nemoclaw/preloads/whatsapp-hermes-session.js 'root:root 444'", - ); - }); }); diff --git a/test/messaging-runtime-secret-scan.test.ts b/test/messaging-runtime-secret-scan.test.ts deleted file mode 100644 index 2f968bc1389..00000000000 --- a/test/messaging-runtime-secret-scan.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { extractShellFunction } from "./support/hermes-shell-harness"; - -const SANDBOX_INIT = fs.readFileSync( - path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"), - "utf-8", -); - -function writeConfig(content: string): (configPath: string) => void { - return (configPath) => fs.writeFileSync(configPath, content); -} - -function runSecretScan(prepareConfig: (configPath: string) => void) { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-secret-scan-")); - const configPath = path.join(tmpDir, "openclaw.json"); - const planPath = path.join(tmpDir, "runtime-plan.json"); - const scriptPath = path.join(tmpDir, "run.sh"); - try { - prepareConfig(configPath); - fs.writeFileSync( - planPath, - JSON.stringify({ - secretScans: [ - { - path: configPath, - pattern: "(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)", - message: "[SECURITY] Slack token leaked into {path} - refusing to serve", - exitCode: 78, - }, - ], - }), - ); - fs.writeFileSync( - scriptPath, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(planPath)}`, - extractShellFunction(SANDBOX_INIT, "verify_messaging_runtime_secret_scans"), - "verify_messaging_runtime_secret_scans", - ].join("\n"), - { mode: 0o700 }, - ); - const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); - return { configPath, result }; - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } -} - -describe("messaging runtime secret scans", () => { - it.each([ - "xoxb-real-token", - "xapp-real-token", - ])("rejects %s without exposing the secret (#2085)", (secret) => { - const { configPath, result } = runSecretScan(writeConfig(JSON.stringify({ token: secret }))); - - expect(result.status).toBe(78); - expect(result.stderr).toContain(configPath); - expect(result.stderr).not.toContain(secret); - }); - - it.each([ - [ - "approved placeholder", - writeConfig('{"botToken":"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"}\n'), - ], - ["OpenShell reference", writeConfig('{"token":"openshell:resolve:env:SLACK_BOT_TOKEN"}\n')], - ["missing file", () => undefined], - ])("skips the %s without failure (#2085)", (_case, prepareConfig) => { - expect(runSecretScan(prepareConfig).result.status).toBe(0); - }); -}); diff --git a/test/nemoclaw-start-guard-recovery.test.ts b/test/nemoclaw-start-guard-recovery.test.ts index 60bf2f56855..62f0365c69c 100644 --- a/test/nemoclaw-start-guard-recovery.test.ts +++ b/test/nemoclaw-start-guard-recovery.test.ts @@ -8,7 +8,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); -const SANDBOX_INIT = path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"); function extractShellFunction(source: string, name: string): string { const header = `${name}() {`; @@ -70,7 +69,7 @@ function runRecoveryHarness({ gatewayLogKind = "regular", missingCiaoSource = false, }: RecoveryHarnessOptions = {}): Harness { - const source = `${fs.readFileSync(SANDBOX_INIT, "utf8")}\n${fs.readFileSync(START_SCRIPT, "utf8")}`; + const source = fs.readFileSync(START_SCRIPT, "utf8"); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-guard-recovery-")); const eventLog = path.join(tmpDir, "events.log"); const gatewayLog = path.join(tmpDir, "gateway.log"); diff --git a/test/nemoclaw-start-runtime-env-alias.test.ts b/test/nemoclaw-start-runtime-env-alias.test.ts index 8126c55d5ae..eb960438f85 100644 --- a/test/nemoclaw-start-runtime-env-alias.test.ts +++ b/test/nemoclaw-start-runtime-env-alias.test.ts @@ -8,11 +8,10 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); -const SANDBOX_INIT = path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"); function messagingRuntimeSetupSection(src: string, planPath: string): string { const start = src.indexOf("# ── Messaging runtime setup from manifest metadata"); - const end = src.indexOf("# ── End messaging runtime setup", start); + const end = src.indexOf("_read_gateway_token()", start); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); return src @@ -65,7 +64,7 @@ function encodeRuntimeSetupPlan(channelId: string, value: Record { it("uses Python regex semantics consistently when applying aliases", () => { - const src = `${fs.readFileSync(SANDBOX_INIT, "utf-8")}\n${fs.readFileSync(START_SCRIPT, "utf-8")}`; + const src = fs.readFileSync(START_SCRIPT, "utf-8"); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-alias-")); const planPath = path.join(tmpDir, "runtime-plan.json"); const scriptPath = path.join(tmpDir, "run.sh"); @@ -111,48 +110,4 @@ describe("messaging runtime env aliases", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); - - it("returns success when a matching alias has no message", () => { - const src = `${fs.readFileSync(SANDBOX_INIT, "utf-8")}\n${fs.readFileSync(START_SCRIPT, "utf-8")}`; - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-alias-empty-message-")); - const planPath = path.join(tmpDir, "runtime-plan.json"); - const scriptPath = path.join(tmpDir, "run.sh"); - const runtimeValue = { - envAliases: [ - { - envKey: "TEST_ALIAS", - match: "^original$", - value: "normalized", - }, - ], - }; - fs.writeFileSync( - scriptPath, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', - 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', - `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimeSetupPlan("test", runtimeValue))}`, - messagingRuntimeSetupSection(src, planPath), - "write_messaging_runtime_setup_plan", - "apply_messaging_runtime_env_aliases", - 'printf "TEST_ALIAS=%s\\n" "$TEST_ALIAS"', - ].join("\n"), - { mode: 0o700 }, - ); - - try { - const result = spawnSync("bash", [scriptPath], { - encoding: "utf-8", - env: { ...process.env, TEST_ALIAS: "original" }, - timeout: 5000, - }); - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain("TEST_ALIAS=normalized"); - expect(result.stderr).toBe(""); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); }); diff --git a/test/nemoclaw-start-slack-runtime.test.ts b/test/nemoclaw-start-slack-runtime.test.ts index 8cfaddc3e37..702f587014a 100644 --- a/test/nemoclaw-start-slack-runtime.test.ts +++ b/test/nemoclaw-start-slack-runtime.test.ts @@ -9,11 +9,10 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); -const SANDBOX_INIT = path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"); function messagingRuntimeSetupSection(src: string, planPath: string): string { const start = src.indexOf("# ── Messaging runtime setup from manifest metadata"); - const end = src.indexOf("# ── End messaging runtime setup", start); + const end = src.indexOf("_read_gateway_token()", start); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); return src @@ -65,7 +64,7 @@ function encodeRuntimeSetupPlan(channelId: string, value: Record { - const src = `${fs.readFileSync(SANDBOX_INIT, "utf-8")}\n${fs.readFileSync(START_SCRIPT, "utf-8")}`; + const src = fs.readFileSync(START_SCRIPT, "utf-8"); function runNormalize(env: Record = {}): { bot: string; diff --git a/test/nemoclaw-start-telegram-runtime.test.ts b/test/nemoclaw-start-telegram-runtime.test.ts index b1213224886..6d7862c71ad 100644 --- a/test/nemoclaw-start-telegram-runtime.test.ts +++ b/test/nemoclaw-start-telegram-runtime.test.ts @@ -9,7 +9,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); -const SANDBOX_INIT = path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"); const TELEGRAM_RUNTIME_PRELOAD = path.join( import.meta.dirname, "..", @@ -26,7 +25,7 @@ function messagingRuntimeSetupSection( }, ): string { const start = src.indexOf("# ── Messaging runtime setup from manifest metadata"); - const end = src.indexOf("# ── End messaging runtime setup", start); + const end = src.indexOf("_read_gateway_token()", start); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); return src @@ -96,7 +95,7 @@ function encodeRuntimeSetupPlan( } describe("Telegram runtime preload installation", () => { - const src = `${fs.readFileSync(SANDBOX_INIT, "utf-8")}\n${fs.readFileSync(START_SCRIPT, "utf-8")}`; + const src = fs.readFileSync(START_SCRIPT, "utf-8"); it("installs Telegram diagnostics only when Telegram is configured", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-telegram-install-")); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 40fb4aa51e1..f8446051898 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -8,7 +8,7 @@ import os from "node:os"; import path from "node:path"; import * as ts from "typescript"; import { describe, expect, it } from "vitest"; -import * as entrypointFixture from "./support/entrypoint-script-fixture"; +import { openClawBootstrapSnippet } from "./support/entrypoint-script-fixture"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); const APPROVAL_POLICY_DIR = path.join(import.meta.dirname, "..", "scripts", "lib"); @@ -36,7 +36,7 @@ function messagingRuntimeSetupSection( } = {}, ): string { const start = src.indexOf("# ── Messaging runtime setup from manifest metadata"); - const end = src.indexOf("# ── End messaging runtime setup", start); + const end = src.indexOf("_read_gateway_token()", start); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); let section = src.slice(start, end); @@ -353,7 +353,7 @@ describe("nemoclaw-start non-root fallback", () => { }); it("unwraps the sandbox-create env self-wrapper and applies dashboard port defaults", () => { - const snippet = entrypointFixture.openClawBootstrapSnippet( + const snippet = openClawBootstrapSnippet( START_SCRIPT, path.join(import.meta.dirname, "..", "scripts", "lib", "entrypoint-env-wrapper.sh"), ); @@ -1337,7 +1337,7 @@ describe("runtime CORS origin override (#719)", () => { }); describe("Slack channel guard — unhandled-rejection safety net (#2340)", () => { - const src = entrypointFixture.readOpenClawSources(START_SCRIPT); + const src = fs.readFileSync(START_SCRIPT, "utf-8"); const extractGuardScript = () => startScriptHeredoc(src, "SLACK_GUARD_EOF"); function runSlackGuardHarness(body: string): ReturnType { @@ -2792,6 +2792,58 @@ describe("seed_default_workspace_templates (#3240)", () => { }); }); +describe("Slack secrets-on-disk tripwire (#2085)", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + it("refuses to serve when real Slack tokens leak to disk", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-secret-")); + const configPath = path.join(tmpDir, "openclaw.json"); + const planPath = path.join(tmpDir, "runtime-plan.json"); + const runtimeValue = { + secretScans: [ + { + path: configPath, + pattern: "(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)", + message: "[SECURITY] Slack token leaked into {path} - refusing to serve", + exitCode: 78, + }, + ], + }; + const scriptPath = path.join(tmpDir, "run.sh"); + const run = (config: string) => { + fs.writeFileSync(configPath, config); + fs.rmSync(planPath, { force: true }); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimeSetupPlan("slack", runtimeValue))}`, + messagingRuntimeSetupSection(src, { + planPath, + secretScanPrefix: tmpDir + path.sep, + }), + "write_messaging_runtime_setup_plan", + "verify_messaging_runtime_secret_scans", + ].join("\n"), + { mode: 0o700 }, + ); + return spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + }; + + try { + expect(run('{"botToken":"xoxb-real-token"}\n').status).toBe(78); + expect(run('{"appToken":"xapp-real-token"}\n').status).toBe(78); + expect(run('{"botToken":"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"}\n').status).toBe(0); + expect(run('{"token":"openshell:resolve:env:SLACK_BOT_TOKEN"}\n').status).toBe(0); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + describe("provider placeholder refresh (#4251)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); @@ -3405,7 +3457,7 @@ describe("provider placeholder refresh (#4251)", () => { }); describe("Telegram diagnostics (#2766)", () => { - const src = entrypointFixture.readOpenClawSources(START_SCRIPT); + const src = fs.readFileSync(START_SCRIPT, "utf-8"); const telegramDiagnosticsScript = startScriptHeredoc(src, "TELEGRAM_DIAGNOSTICS_EOF"); function preGatewaySetupBlock( diff --git a/test/nemotron-inference-fix.test.ts b/test/nemotron-inference-fix.test.ts index 0b37622f48a..fc202b01528 100644 --- a/test/nemotron-inference-fix.test.ts +++ b/test/nemotron-inference-fix.test.ts @@ -9,7 +9,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); -const SANDBOX_INIT = path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"); const NEMOTRON_FIX_SOURCE = path.join( import.meta.dirname, "..", @@ -36,7 +35,7 @@ function extractShellFunction(source, name) { } describe("NVIDIA endpoint inference fix preload (#1193, #2051, #4063)", () => { - const src = `${fs.readFileSync(SANDBOX_INIT, "utf-8")}\n${fs.readFileSync(START_SCRIPT, "utf-8")}`; + const src = fs.readFileSync(START_SCRIPT, "utf-8"); it("entrypoint writes the preload and registers it in NODE_OPTIONS", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-nemotron-entrypoint-")); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 1b0d84c87cd..2b79002f099 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1101,14 +1101,6 @@ describe("Hermes sandbox provisioning", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-helper-modes-")); const localBin = path.join(tmp, "usr", "local", "bin"); const localLib = path.join(tmp, "usr", "local", "lib", "nemoclaw"); - const preloadsDir = path.join(localLib, "preloads"); - const compiledRuntimePreload = path.join( - localLib, - "preloads-compiled-channels", - "whatsapp", - "runtime", - "whatsapp-hermes-session.js", - ); const etcDir = path.join(tmp, "etc"); const profileDir = path.join(etcDir, "profile.d"); const bashrcPath = path.join(etcDir, "bash.bashrc"); @@ -1145,7 +1137,6 @@ describe("Hermes sandbox provisioning", () => { stateDirGuardPath, managedGatewayControlPath, path.join(localLib, "sandbox-rlimits.sh"), - compiledRuntimePreload, ]; const command = dockerRunCommandBetween( dockerfile, @@ -1159,13 +1150,9 @@ describe("Hermes sandbox provisioning", () => { try { fs.mkdirSync(localBin, { recursive: true }); fs.mkdirSync(localLib, { recursive: true }); - fs.mkdirSync(preloadsDir, { recursive: true }); fs.mkdirSync(etcDir, { recursive: true }); fs.writeFileSync(bashrcPath, "# fixture\n", { mode: 0o600 }); - for (const file of files) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, "# fixture\n", { mode: 0o600 }); - } + for (const file of files) fs.writeFileSync(file, "# fixture\n", { mode: 0o600 }); const { result, calls } = runLoggedDockerShell(command, tmp, [ 'chown() { printf "chown %s\\n" "$*" >> "$call_log"; }', ]); @@ -1182,11 +1169,6 @@ describe("Hermes sandbox provisioning", () => { expect((fs.statSync(gatewaySupervisorPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(stateDirGuardPath).mode & 0o777).toString(8)).toBe("500"); expect((fs.statSync(managedGatewayControlPath).mode & 0o777).toString(8)).toBe("500"); - expect( - (fs.statSync(path.join(preloadsDir, "whatsapp-hermes-session.js")).mode & 0o777).toString( - 8, - ), - ).toBe("444"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/test/sandbox-rlimit-hooks.test.ts b/test/sandbox-rlimit-hooks.test.ts index d1c3027b22b..c4c5ae690ad 100644 --- a/test/sandbox-rlimit-hooks.test.ts +++ b/test/sandbox-rlimit-hooks.test.ts @@ -570,13 +570,6 @@ describe("sandbox rlimit system hooks (#2173)", () => { "openshell-child-visible-credentials.v0.0.85.json", ); const preloadDir = path.join(localLib, "preloads"); - const compiledRuntimePreload = path.join( - localLib, - "preloads-compiled-channels", - "whatsapp", - "runtime", - "whatsapp-hermes-session.js", - ); const safetyNet = path.join(preloadDir, "sandbox-safety-net.js"); const ciaoGuard = path.join(preloadDir, "ciao-network-guard.js"); const gatewaySupervisor = path.join(localLib, "gateway-supervisor.sh"); @@ -606,10 +599,6 @@ describe("sandbox rlimit system hooks (#2173)", () => { fs.writeFileSync(buildMcpDigest, "# build MCP digest fixture\n"); fs.writeFileSync(mcpTransaction, "# MCP transaction fixture\n"); fs.writeFileSync(mcpCredentialBoundary, "{}\n"); - fs.mkdirSync(path.dirname(compiledRuntimePreload), { recursive: true }); - fs.writeFileSync(compiledRuntimePreload, "module.exports = 'runtime preload fixture';\n", { - mode: 0o666, - }); fs.mkdirSync(preloadDir, { mode: 0o777 }); fs.writeFileSync(safetyNet, "module.exports = 'safety net fixture';\n", { mode: 0o666 }); fs.writeFileSync(ciaoGuard, "module.exports = 'ciao guard fixture';\n", { mode: 0o666 }); @@ -687,13 +676,9 @@ describe("sandbox rlimit system hooks (#2173)", () => { const hardenedDir = fs.statSync(preloadDir); const hardenedSafetyNet = fs.statSync(safetyNet); const hardenedCiaoGuard = fs.statSync(ciaoGuard); - const hardenedRuntimePreload = fs.statSync( - path.join(preloadDir, "whatsapp-hermes-session.js"), - ); expect(hardenedDir.mode & 0o777).toBe(0o755); expect(hardenedSafetyNet.mode & 0o777).toBe(0o444); expect(hardenedCiaoGuard.mode & 0o777).toBe(0o444); - expect(hardenedRuntimePreload.mode & 0o777).toBe(0o444); expect(fs.statSync(discordRecoveryPatcher).mode & 0o777).toBe(0o755); expect(fs.statSync(profilePolicyPatcher).mode & 0o777).toBe(0o755); expect(fs.statSync(langfuseCredentialPatcher).mode & 0o777).toBe(0o444); @@ -705,8 +690,6 @@ describe("sandbox rlimit system hooks (#2173)", () => { expect(hardenedSafetyNet.gid).toBe(fixtureOwner.gid); expect(hardenedCiaoGuard.uid).toBe(fixtureOwner.uid); expect(hardenedCiaoGuard.gid).toBe(fixtureOwner.gid); - expect(hardenedRuntimePreload.uid).toBe(fixtureOwner.uid); - expect(hardenedRuntimePreload.gid).toBe(fixtureOwner.gid); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/test/support/entrypoint-script-fixture.ts b/test/support/entrypoint-script-fixture.ts index 2ee6605b207..f6ad838a7d0 100644 --- a/test/support/entrypoint-script-fixture.ts +++ b/test/support/entrypoint-script-fixture.ts @@ -2,18 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; -import path from "node:path"; - -export function readEntrypointSources(...sourcePaths: string[]): string { - return sourcePaths.map((sourcePath) => fs.readFileSync(sourcePath, "utf8")).join("\n"); -} - -export function readOpenClawSources(startScriptPath: string): string { - return readEntrypointSources( - path.join(path.dirname(startScriptPath), "lib", "sandbox-init.sh"), - startScriptPath, - ); -} export function openClawBootstrapSnippet( startScriptPath: string, From a7a7f3e470a75c404d316d2054445e16bb63b48c Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 5 Aug 2026 21:42:30 +0700 Subject: [PATCH 21/31] fix(hermes): align dashboard WhatsApp session path --- agents/hermes/whatsapp-proxy.patch | 16 ++++++- ...s-whatsapp-dashboard-session-patch.test.ts | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 test/hermes-whatsapp-dashboard-session-patch.test.ts diff --git a/agents/hermes/whatsapp-proxy.patch b/agents/hermes/whatsapp-proxy.patch index c154223e380..341f162b7a5 100644 --- a/agents/hermes/whatsapp-proxy.patch +++ b/agents/hermes/whatsapp-proxy.patch @@ -2,8 +2,20 @@ # SPDX-License-Identifier: Apache-2.0 # # Routes the pinned Hermes v2026.7.20 WhatsApp bridge through the OpenShell -# proxy. Remove this patch when the minimum supported Hermes release passes -# an HTTPS proxy agent to Baileys natively. +# proxy and keeps dashboard pairing state in the gateway's session directory. +# Remove the relevant hunk when the minimum supported Hermes release provides +# that behavior natively. +diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py +index d0c78a6b3..5f6d801b5 100644 +--- a/hermes_cli/web_server.py ++++ b/hermes_cli/web_server.py +@@ -8110,5 +8110,3 @@ def _normalize_whatsapp_allowed_users(value: Any) -> str: + def _whatsapp_session_path() -> Path: +- from hermes_constants import get_hermes_dir +- +- return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") ++ return Path("/sandbox/.hermes/platforms/whatsapp/session") + diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 4b5733d16..22f1ec3e1 100644 --- a/scripts/whatsapp-bridge/bridge.js diff --git a/test/hermes-whatsapp-dashboard-session-patch.test.ts b/test/hermes-whatsapp-dashboard-session-patch.test.ts new file mode 100644 index 00000000000..3a61d666701 --- /dev/null +++ b/test/hermes-whatsapp-dashboard-session-patch.test.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { expect, it } from "vitest"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const PATCH = path.join(ROOT, "agents", "hermes", "whatsapp-proxy.patch"); + +it("stores Hermes dashboard pairing state in the gateway session directory (#8184)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-whatsapp-dashboard-")); + const source = path.join(tmp, "hermes_cli", "web_server.py"); + fs.mkdirSync(path.dirname(source), { recursive: true }); + fs.writeFileSync( + source, + `${"\n".repeat(8109)}def _whatsapp_session_path() -> Path:\n` + + " from hermes_constants import get_hermes_dir\n\n" + + ' return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session")\n\n', + ); + + try { + const applied = spawnSync("git", ["apply", "--include=hermes_cli/web_server.py", PATCH], { + cwd: tmp, + encoding: "utf8", + }); + expect(applied.status, applied.stderr).toBe(0); + const patched = fs.readFileSync(source, "utf8"); + expect(patched).toContain('return Path("/sandbox/.hermes/platforms/whatsapp/session")'); + expect(patched).not.toContain("get_hermes_dir"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +it("leaves the Hermes CLI and gateway unpatched", () => { + const patch = fs.readFileSync(PATCH, "utf8"); + expect(patch).not.toContain("diff --git a/hermes_cli/main.py"); + expect(patch).not.toContain("diff --git a/gateway/"); +}); From 545c490c81570029feef6b1799dcae777d5e6ec3 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 6 Aug 2026 00:56:12 +0700 Subject: [PATCH 22/31] fix(hermes): pin WhatsApp session base image Signed-off-by: San Dang --- agents/hermes/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 635b8dc159e..4d296e468a1 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -6,7 +6,7 @@ # Layers PR-specific code (plugin, config, startup script) on top of the # pre-built Hermes base image. Mirrors the OpenClaw Dockerfile structure. -ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:956c3d0c812ee6caa56f3b6e307819925d920604adcf73c4a9e6229788967634 +ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:3d54b928baef9df403227e846f73079d13ca8424a27cd5268ca97bac3f030b27 ARG NEMOCLAW_CORPORATE_CA_B64= FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba AS mcp-tool-discovery-runtime From 9dcef9107fca348ec61991b36fed8e505c3dbbe6 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 6 Aug 2026 00:58:22 +0700 Subject: [PATCH 23/31] docs(security): record Hermes WhatsApp base image Signed-off-by: San Dang --- docs/security/hermes-0.19.0-dependency-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/security/hermes-0.19.0-dependency-review.md b/docs/security/hermes-0.19.0-dependency-review.md index 650a9bb1371..781376d8aaa 100644 --- a/docs/security/hermes-0.19.0-dependency-review.md +++ b/docs/security/hermes-0.19.0-dependency-review.md @@ -233,7 +233,7 @@ Artifact scanning must therefore inspect the assembled image and record the down | `HERMES-9` | High | Pin and test | The selected Python delta adds no advisory regression, and the affected multipart parser is replaced with attested `0.0.32` plus hash and runtime probes. | | `HERMES-10` | High | Pin and test | The exact-source patch updates Hermes metadata and its frozen lock together, selects `aiohttp==3.14.3`, `cryptography==50.0.0`, `mcp==1.28.1`, `Pillow==12.3.0`, `starlette==1.3.1`, and `tornado==6.5.7`, and fails the image build on dependency inconsistency or installed-version drift. The base image separately checksum-pins Node.js `24.18.1` and asserts uv `0.11.33`. | | `HERMES-11` | High | Migrate, test, and runtime-proof | Root npm audit reports zero production findings and the WhatsApp bridge removes its current critical, high, and medium advisory entries, while both architectures still require native bridge and message-path evidence. | -| `HERMES-12` | High | Pin and runtime-proof | Trusted workflow run `30779271312`, attempt 1, built source commit `340c47857596e7cc347541a0b32fe9e24f201bcd` and published amd64 digest `sha256:faf96b115049c2ae3e7c10be66ae4916cd05ff72900ef5b0642f9f90b6dd834d` plus arm64 digest `sha256:61a7356020832392692347d2510fe8817c8a9f3ff3d5c050fbcec6182787eb4d` under OCI index `sha256:956c3d0c812ee6caa56f3b6e307819925d920604adcf73c4a9e6229788967634`. The final Dockerfile pins that index. Both base-image builds passed the exact-source patch guard, locked bridge install, bridge-to-Baileys option assertions, and the controlled-proxy WebSocket `CONNECT` regression; live final-image WhatsApp evidence remains under `HERMES-22`. | +| `HERMES-12` | High | Pin and runtime-proof | Trusted workflow run `30779271312`, attempt 1, built source commit `340c47857596e7cc347541a0b32fe9e24f201bcd` and published amd64 digest `sha256:faf96b115049c2ae3e7c10be66ae4916cd05ff72900ef5b0642f9f90b6dd834d` plus arm64 digest `sha256:61a7356020832392692347d2510fe8817c8a9f3ff3d5c050fbcec6182787eb4d` under OCI index `sha256:956c3d0c812ee6caa56f3b6e307819925d920604adcf73c4a9e6229788967634`. Trusted workflow run `31031662054`, attempt 1, rebuilt source commit `a7a7f3e470a75c404d316d2054445e16bb63b48c` with the exact-source dashboard WhatsApp session-path patch and published replacement OCI index `sha256:3d54b928baef9df403227e846f73079d13ca8424a27cd5268ca97bac3f030b27`. The final Dockerfile pins the replacement index. The amd64 and arm64 base-image builds in both runs passed the exact-source patch guard, locked bridge install, bridge-to-Baileys option assertions, and the controlled-proxy WebSocket `CONNECT` regression; live final-image WhatsApp evidence remains under `HERMES-22`. | | `HERMES-13` | Medium | Document bounded residual | Static `state_files` entries online-back up the default profile only. Cron or Discord ledgers created by a process launched under `profiles/` remain in the raw `profiles` tar capture and can be inconsistent during a concurrent snapshot. Dynamic profile-local SQLite discovery is generic snapshot work outside this upgrade PR. | | `HERMES-14` | High | Migrate and test | The browser evaluation denylist changed from default-on to opt-in. Generated configuration explicitly writes `browser.restrict_evaluate: true`, including when managed browser-gateway settings are merged, so the upgrade does not broaden page-context access. | | `HERMES-15` | Medium | Migrate and test | The omitted gateway session-reset policy changed from bounded daily and idle expiry to no automatic reset. Generated configuration explicitly writes the complete outgoing reset and notification policy to preserve the retention bound without inheriting mutable dependency defaults. | From 3c1f7fd4eab14dba56afd0f91ecf9f2b25c6ff37 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 6 Aug 2026 09:45:26 -0700 Subject: [PATCH 24/31] docs(whatsapp): remove obsolete Hermes session workaround Signed-off-by: Apurv Kumaria --- docs/manage-sandboxes/set-up-whatsapp.mdx | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index 8d9954da76e..b8de0c2e4ef 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -33,25 +33,8 @@ 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 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. +Hermes Dashboard pairing and the gateway share `/sandbox/.hermes/platforms/whatsapp/session`. +After scanning a dashboard QR code, run `$$nemoclaw channels status --channel whatsapp` to confirm that the shared session is available to the gateway. ## Protect Paired Session State @@ -60,7 +43,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 for the gateway and `profiles/dashboard-home/platforms/whatsapp` state for Dashboard pairing. +The sandbox stores mutable session credentials in durable `platforms/whatsapp` state for both Dashboard pairing and the gateway. 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 6d8bb3d7d77a69331cc36ece821bead282b555d2 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 6 Aug 2026 09:50:40 -0700 Subject: [PATCH 25/31] docs(whatsapp): document legacy Hermes re-pairing Signed-off-by: Apurv Kumaria --- docs/manage-sandboxes/set-up-whatsapp.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index b8de0c2e4ef..1cabe530bd6 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -35,6 +35,10 @@ If pairing exits with a gateway close such as `1008`, rerun the login command on Hermes Dashboard pairing and the gateway share `/sandbox/.hermes/platforms/whatsapp/session`. After scanning a dashboard QR code, run `$$nemoclaw channels status --channel whatsapp` to confirm that the shared session is available to the gateway. + +If you paired an existing sandbox before this shared-path fix, `channels status` can report credentials only under the legacy `profiles/dashboard-home` path. +Update or rebuild the sandbox, then pair again from Dashboard so the credentials are written to `/sandbox/.hermes/platforms/whatsapp/session`. +Run `channels status` again to confirm that the gateway path contains the new credentials. ## Protect Paired Session State From 96ee9e00878bdeea254b3f965b67b7d7c7dc7f88 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 7 Aug 2026 12:27:43 +0700 Subject: [PATCH 26/31] test(docs): align Hermes WhatsApp rendering contract --- .../security/hermes-0.19.0-dependency-review.md | 2 +- test/agent-variant-docs.test.ts | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/docs/security/hermes-0.19.0-dependency-review.md b/docs/security/hermes-0.19.0-dependency-review.md index a06257bedd9..e3cde8e3a61 100644 --- a/docs/security/hermes-0.19.0-dependency-review.md +++ b/docs/security/hermes-0.19.0-dependency-review.md @@ -237,7 +237,7 @@ The `BASE_IMAGE` argument in `agents/hermes/Dockerfile` pins the following publi | Repository | `NVIDIA/NemoClaw` | | Workflow | `Images / Base Images` | | Workflow path | `.github/workflows/base-image.yaml` | -| Trigger | Push to `main` | +| Trigger | `workflow_dispatch` from `fix/hermes-whatsapp-session-path` | | Producer run | `31031662054`, attempt 1, completed successfully | | Source commit | `a7a7f3e470a75c404d316d2054445e16bb63b48c` | | OCI index | `sha256:3d54b928baef9df403227e846f73079d13ca8424a27cd5268ca97bac3f030b27` | diff --git a/test/agent-variant-docs.test.ts b/test/agent-variant-docs.test.ts index 5c40fd7eac1..38ee2346e04 100644 --- a/test/agent-variant-docs.test.ts +++ b/test/agent-variant-docs.test.ts @@ -330,7 +330,7 @@ import { AgentOnly } from "../_components/AgentGuide"; expect(hermes.split(baselineExplanation)).toHaveLength(2); }); - it("keeps the Hermes WhatsApp repair inside a shields maintenance window (#8184)", () => { + it("renders the shared Hermes WhatsApp session path without a configuration repair (#8184)", () => { const whatsapp = readFileSync( new URL("../docs/manage-sandboxes/set-up-whatsapp.mdx", import.meta.url), "utf8", @@ -338,17 +338,12 @@ import { AgentOnly } from "../_components/AgentGuide"; 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); + expect(hermes).toContain( + "Hermes Dashboard pairing and the gateway share `/sandbox/.hermes/platforms/whatsapp/session`", + ); + expect(hermes).toContain("nemohermes channels status --channel whatsapp"); + expect(hermes).not.toContain("platforms.whatsapp.extra.session_path"); }); it("keeps the troubleshooting security review link within each agent guide (#6558)", () => { From 9060fc604cda61c1cc64b1bd511100ba6b457e9c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 7 Aug 2026 00:17:32 -0700 Subject: [PATCH 27/31] fix(messaging): update Hermes session recovery hint Signed-off-by: Apurv Kumaria --- .../whatsapp/hooks/status-health-eval.test.ts | 16 +++++++++------- .../whatsapp/hooks/status-health-eval.ts | 9 ++++++--- .../whatsapp/hooks/status-health.test.ts | 13 ++++++------- 3 files changed, 21 insertions(+), 17 deletions(-) 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 09da557c522..862a3f8054a 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,7 +77,7 @@ describe("evaluateWhatsappDiagnostics", () => { expect(report.hints.join(" ")).toMatch(/hermes whatsapp/); }); - it("reports the Hermes dashboard-only session path split with the supported repair", () => { + it("guides legacy Hermes dashboard sessions through rebuild and re-pairing (#8184)", () => { const report = evaluateWhatsappDiagnostics( baseInput({ agent: "hermes", @@ -89,15 +89,17 @@ describe("evaluateWhatsappDiagnostics", () => { }), ); 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(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"); + expect(pairing?.hint).toMatch(/Update or rebuild/); + expect(session?.hint).toMatch(/pair again from Dashboard/); + expect(hints).toContain("/sandbox/.hermes/platforms/whatsapp/session"); + expect(hints).toContain("re-run channels status"); + 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", () => { 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 d0a92183e68..9a1727e617d 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts @@ -130,6 +130,9 @@ function isParseableTimestamp(value: string | null): value is string { return value !== null && Number.isFinite(Date.parse(value)); } +const HERMES_LEGACY_DASHBOARD_SESSION_HINT = + "Update or rebuild the sandbox, pair again from Dashboard so credentials are stored in `/sandbox/.hermes/platforms/whatsapp/session`, then re-run channels status."; + function pairingSignal(input: WhatsappProbeInput): DiagnosticSignal { if (!input.probeReachable) { return { @@ -154,7 +157,7 @@ function pairingSignal(input: WhatsappProbeInput): DiagnosticSignal { 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" + ? HERMES_LEGACY_DASHBOARD_SESSION_HINT : "run `hermes whatsapp` inside the sandbox to display a QR code", }; } @@ -195,7 +198,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/profiles/dashboard-home/platforms/whatsapp/session --restart --config-accept-new-path`", + hint: HERMES_LEGACY_DASHBOARD_SESSION_HINT, }; } if (gateway === true && dashboard === false) { @@ -473,7 +476,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/profiles/dashboard-home/platforms/whatsapp/session --restart --config-accept-new-path` to point the gateway at that session.", + HERMES_LEGACY_DASHBOARD_SESSION_HINT, ); 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 ab27d6bad51..b214b4804e0 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -392,7 +392,7 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { expect(reportOf(run())?.verdict).toBeDefined(); }); - it("reports the Hermes dashboard-only session path split from fixed session roots", () => { + it("guides the Hermes dashboard-only session split through re-pairing (#8184)", () => { const exec = makeExec({ status: 0, stdout: hermesSessionProbeOutput({ @@ -406,13 +406,12 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { ); const report = reportOf(result); const command = String(exec.mock.calls[0]?.[1] ?? ""); + const hint = report?.signals.find((s) => s.label === "Session location")?.hint; 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(hint).toMatch(/Update or rebuild/); + expect(hint).toMatch(/pair again from Dashboard/); + expect(hint).toContain("/sandbox/.hermes/platforms/whatsapp/session"); + expect(hint).not.toContain("platforms.whatsapp.extra.session_path"); expect(command).toContain("/sandbox/.hermes/platforms/whatsapp/session/creds.json"); expect(command).toContain( "/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session/creds.json", From e9b1eadd7cc15c7af47d608e8e5509da5ed39217 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 7 Aug 2026 00:23:47 -0700 Subject: [PATCH 28/31] docs(messaging): align Hermes session recovery guidance Signed-off-by: Apurv Kumaria --- docs/reference/commands.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index e94d7476f4c..daf4487b1f0 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2475,7 +2475,8 @@ For an OpenClaw WhatsApp sandbox, `--channel whatsapp` probes the sandbox to sep 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 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. +If the Dashboard path has credentials and the gateway path is empty, the report recommends updating or rebuilding the sandbox. +It also tells you to pair again from Dashboard so credentials use `/sandbox/.hermes/platforms/whatsapp/session`, then rerun `channels status`. 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. From f39d67e20f1e672e6e0c5590cc0d85a908c43286 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 7 Aug 2026 15:58:06 +0700 Subject: [PATCH 29/31] fix(hermes): address WhatsApp review findings Signed-off-by: San Dang --- agents/hermes/whatsapp-proxy.patch | 6 +++-- docs/manage-sandboxes/set-up-whatsapp.mdx | 7 +++--- docs/reference/commands.mdx | 5 +++-- .../hermes-0.19.0-dependency-review.md | 22 +++++++++++++++++-- .../whatsapp/hooks/status-health-eval.test.ts | 6 ++--- .../whatsapp/hooks/status-health-eval.ts | 2 +- .../whatsapp/hooks/status-health.test.ts | 5 +++-- ...s-whatsapp-dashboard-session-patch.test.ts | 2 +- 8 files changed, 39 insertions(+), 16 deletions(-) diff --git a/agents/hermes/whatsapp-proxy.patch b/agents/hermes/whatsapp-proxy.patch index 341f162b7a5..5c53947f6af 100644 --- a/agents/hermes/whatsapp-proxy.patch +++ b/agents/hermes/whatsapp-proxy.patch @@ -3,8 +3,10 @@ # # Routes the pinned Hermes v2026.7.20 WhatsApp bridge through the OpenShell # proxy and keeps dashboard pairing state in the gateway's session directory. -# Remove the relevant hunk when the minimum supported Hermes release provides -# that behavior natively. +# Remove the web_server.py hunk when the minimum supported Hermes release stores +# Dashboard pairing state in the gateway session directory natively. +# Remove the scripts/whatsapp-bridge hunks when that release routes the WhatsApp +# bridge through HTTPS_PROXY natively. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d0c78a6b3..5f6d801b5 100644 --- a/hermes_cli/web_server.py diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index 1cabe530bd6..4444d1c72ce 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -36,9 +36,10 @@ If pairing exits with a gateway close such as `1008`, rerun the login command on Hermes Dashboard pairing and the gateway share `/sandbox/.hermes/platforms/whatsapp/session`. After scanning a dashboard QR code, run `$$nemoclaw channels status --channel whatsapp` to confirm that the shared session is available to the gateway. -If you paired an existing sandbox before this shared-path fix, `channels status` can report credentials only under the legacy `profiles/dashboard-home` path. -Update or rebuild the sandbox, then pair again from Dashboard so the credentials are written to `/sandbox/.hermes/platforms/whatsapp/session`. -Run `channels status` again to confirm that the gateway path contains the new credentials. +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. +Run `$$nemoclaw rebuild --yes`. +Pair again from Dashboard so the credentials are written to `/sandbox/.hermes/platforms/whatsapp/session`. +Rerun `$$nemoclaw channels status --channel whatsapp` to confirm that the gateway path contains the new credentials. ## Protect Paired Session State diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 7bbb445bc81..904467ae24f 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2475,8 +2475,9 @@ For an OpenClaw WhatsApp sandbox, `--channel whatsapp` probes the sandbox to sep 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 the `profiles/dashboard-home` session path contain `creds.json`. -If the Dashboard path has credentials and the gateway path is empty, the report recommends updating or rebuilding the sandbox. -It also tells you to pair again from Dashboard so credentials use `/sandbox/.hermes/platforms/whatsapp/session`, then rerun `channels status`. +If the Dashboard path has credentials and the gateway path is empty, the report tells you to run `$$nemoclaw rebuild --yes`. +Pair again from Dashboard so credentials use `/sandbox/.hermes/platforms/whatsapp/session`. +Rerun `$$nemoclaw channels status --channel whatsapp`. 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. diff --git a/docs/security/hermes-0.19.0-dependency-review.md b/docs/security/hermes-0.19.0-dependency-review.md index e3cde8e3a61..369d937d870 100644 --- a/docs/security/hermes-0.19.0-dependency-review.md +++ b/docs/security/hermes-0.19.0-dependency-review.md @@ -242,6 +242,24 @@ The `BASE_IMAGE` argument in `agents/hermes/Dockerfile` pins the following publi | Source commit | `a7a7f3e470a75c404d316d2054445e16bb63b48c` | | OCI index | `sha256:3d54b928baef9df403227e846f73079d13ca8424a27cd5268ca97bac3f030b27` | +The selected index resolves to these platform manifests and image-configuration labels: + +| Platform | Child manifest | `org.opencontainers.image.source` | `org.opencontainers.image.revision` | +| --- | --- | --- | --- | +| `linux/amd64` | `sha256:63b5a1944a4613f90fa8f9f92f6a3ccb2ceac6df23bcefad0282ed4167e6c09c` | `https://github.com/NVIDIA/NemoClaw` | `a7a7f3e470a75c404d316d2054445e16bb63b48c` | +| `linux/arm64` | `sha256:f30b5c2529bbb5c06de38d0c1a7018c27d6c08d5a917503fa354300f12e5568c` | `https://github.com/NVIDIA/NemoClaw` | `a7a7f3e470a75c404d316d2054445e16bb63b48c` | + +Each child manifest has the following per-platform Supply-chain Levels for Software Artifacts (SLSA) provenance: + +| Platform | Attestation manifest | SLSA provenance layer | Builder ID | +| --- | --- | --- | --- | +| `linux/amd64` | `sha256:6797db760d6c894b624a4e40614b33a611ca02ed772323dd0b097f8ca782beab` | `sha256:f95cea632fa5006fc78c193f7e550d869423818e8ba3ab2b48a6518746a7a43c` | `https://github.com/NVIDIA/NemoClaw/actions/runs/31031662054/attempts/1` | +| `linux/arm64` | `sha256:3237354f9fe66b71b2e5e2fb9f4c1983c9c9151c53ecfdf7cf3ecbfee4029944` | `sha256:1ca19a6f81b3709df3e05fc39fc40a0425ce6473036909be61fa274569e858c5` | `https://github.com/NVIDIA/NemoClaw/actions/runs/31031662054/attempts/1` | + +Both in-toto layers use predicate type `https://slsa.dev/provenance/v1` and bind source `https://github.com/NVIDIA/NemoClaw` to revision `a7a7f3e470a75c404d316d2054445e16bb63b48c`. +The selected index has no index-level attestation manifest. +Each platform attestation manifest contains only its SLSA provenance layer and has no software bill of materials (SBOM) layer. + The selected build includes the exact-source dashboard WhatsApp session-path patch. Its amd64 and arm64 base-image builds passed the exact-source patch guard, locked bridge install, bridge-to-Baileys option assertions, and controlled-proxy WebSocket `CONNECT` regression. The replacement follows the security-refreshed multi-platform index published by run `31006872948`, attempt 1, from source commit `bd668121e918e7b1dda13062bed728f18150360e`: @@ -344,8 +362,8 @@ The review records the following publication and registry evidence. - GitHub Actions workflow `.github/workflows/base-image.yaml` run `31031662054`, attempt 1, published the selected patched `linux/amd64` and `linux/arm64` base images; run `31006872948`, attempt 1, published the preceding security-refreshed index. - PyPI Trusted Publisher attestations bind both `hermes-agent==0.19.0` artifacts to source commit `3ef6bbd201263d354fd83ec55b3c306ded2eb72a`. - The npm registry-integrity check matches the `hermes-agent==0.19.0` cross-check value recorded in this review. -- OCI inspection records the immutable index, image-configuration source and revision labels, SLSA provenance, and build histories. -- The OCI index has no SBOM attestation. +- OCI inspection records the immutable index, both child manifests, image-configuration source and revision labels, per-platform SLSA provenance, and build histories. +- The selected index has no index-level attestation manifest, and neither platform attestation manifest contains an SBOM layer. Before merge, these checks must pass: 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 862a3f8054a..8e8fc3a3bc7 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 @@ -94,10 +94,10 @@ describe("evaluateWhatsappDiagnostics", () => { expect(report.verdict).toBe("unpaired"); expect(session?.severity).toBe("warn"); expect(session?.detail).toMatch(/dashboard-home has WhatsApp credentials/); - expect(pairing?.hint).toMatch(/Update or rebuild/); - expect(session?.hint).toMatch(/pair again from Dashboard/); + expect(pairing?.hint).toContain("`nemoclaw rebuild --yes`"); + expect(session?.hint).toMatch(/Pair again from Dashboard/); expect(hints).toContain("/sandbox/.hermes/platforms/whatsapp/session"); - expect(hints).toContain("re-run channels status"); + expect(hints).toContain("`nemoclaw channels status --channel whatsapp`"); expect(hints).not.toContain("platforms.whatsapp.extra.session_path"); expect(hints).not.toContain("profiles/dashboard-home/platforms/whatsapp/session"); }); 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 9a1727e617d..53ca07a0ac4 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts @@ -131,7 +131,7 @@ function isParseableTimestamp(value: string | null): value is string { } const HERMES_LEGACY_DASHBOARD_SESSION_HINT = - "Update or rebuild the sandbox, pair again from Dashboard so credentials are stored in `/sandbox/.hermes/platforms/whatsapp/session`, then re-run channels status."; + "Run `nemoclaw rebuild --yes`. Pair again from Dashboard so credentials are stored in `/sandbox/.hermes/platforms/whatsapp/session`. Rerun `nemoclaw channels status --channel whatsapp`."; function pairingSignal(input: WhatsappProbeInput): DiagnosticSignal { if (!input.probeReachable) { 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 b214b4804e0..bc8a643d3ac 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -408,9 +408,10 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { const command = String(exec.mock.calls[0]?.[1] ?? ""); const hint = report?.signals.find((s) => s.label === "Session location")?.hint; expect(report?.verdict).toBe("unpaired"); - expect(hint).toMatch(/Update or rebuild/); - expect(hint).toMatch(/pair again from Dashboard/); + expect(hint).toContain("`nemoclaw rebuild --yes`"); + expect(hint).toMatch(/Pair again from Dashboard/); expect(hint).toContain("/sandbox/.hermes/platforms/whatsapp/session"); + expect(hint).toContain("`nemoclaw channels status --channel whatsapp`"); expect(hint).not.toContain("platforms.whatsapp.extra.session_path"); expect(command).toContain("/sandbox/.hermes/platforms/whatsapp/session/creds.json"); expect(command).toContain( diff --git a/test/hermes-whatsapp-dashboard-session-patch.test.ts b/test/hermes-whatsapp-dashboard-session-patch.test.ts index 3a61d666701..ee5182bb87c 100644 --- a/test/hermes-whatsapp-dashboard-session-patch.test.ts +++ b/test/hermes-whatsapp-dashboard-session-patch.test.ts @@ -35,7 +35,7 @@ it("stores Hermes dashboard pairing state in the gateway session directory (#818 } }); -it("leaves the Hermes CLI and gateway unpatched", () => { +it("leaves the Hermes CLI and gateway unpatched (#8184)", () => { const patch = fs.readFileSync(PATCH, "utf8"); expect(patch).not.toContain("diff --git a/hermes_cli/main.py"); expect(patch).not.toContain("diff --git a/gateway/"); From b84247e1c688c0ddcdde34268389125ec26bbaca Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 11 Aug 2026 05:45:10 +0530 Subject: [PATCH 30/31] docs(messaging): name the supported cleanup for legacy WhatsApp sessions The guide told an operator with credentials under the legacy dashboard profile to rebuild and pair again. That writes new credentials to the shared path but never removes the old ones: `profiles` and `dashboard-home` are both durable state that a rebuild restores, and startup treats the legacy copy as migration input, so the sandbox keeps two credential sets. Point the operator at `channels remove whatsapp`, which clears `platforms/whatsapp`, `profiles/dashboard-home/platforms/whatsapp/session`, and the legacy `dashboard-home/platforms/whatsapp/session` before the channel is added again, and qualify the durable-state claim for a sandbox that still holds both copies. Separate the completed manual HERMES-22 evidence from the check that is still outstanding: the live pairing, connection, reply, and proxy-audit results are recorded on the pull request, and no target in this repository pairs a live WhatsApp account, so the trusted manual E2E run remains the merge gate. Use lowercase `dashboard` for the technical noun, as the controlled word list requires, and extend the agent-variant documentation contract to cover the new cleanup command. Signed-off-by: Hung Le --- docs/manage-sandboxes/set-up-whatsapp.mdx | 25 ++++++++++++++----- .../hermes-0.19.0-dependency-review.md | 4 +-- test/agent-variant-docs.test.ts | 3 ++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/manage-sandboxes/set-up-whatsapp.mdx b/docs/manage-sandboxes/set-up-whatsapp.mdx index 4444d1c72ce..ba91730b594 100644 --- a/docs/manage-sandboxes/set-up-whatsapp.mdx +++ b/docs/manage-sandboxes/set-up-whatsapp.mdx @@ -33,13 +33,22 @@ 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 Dashboard pairing and the gateway share `/sandbox/.hermes/platforms/whatsapp/session`. +Hermes dashboard pairing and the gateway share `/sandbox/.hermes/platforms/whatsapp/session`. After scanning a dashboard QR code, run `$$nemoclaw 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. -Run `$$nemoclaw rebuild --yes`. -Pair again from Dashboard so the credentials are written to `/sandbox/.hermes/platforms/whatsapp/session`. -Rerun `$$nemoclaw channels status --channel whatsapp` to confirm that the gateway path contains the new credentials. +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 channels remove whatsapp +$$nemoclaw 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 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. ## Protect Paired Session State @@ -48,9 +57,13 @@ Rerun `$$nemoclaw channels status --channel whatsapp` to confirm that The sandbox stores mutable session credentials in durable `whatsapp` state. -The sandbox stores mutable session credentials in durable `platforms/whatsapp` state for both Dashboard pairing and the gateway. +The sandbox stores mutable session credentials in durable `platforms/whatsapp` state for both dashboard pairing and the gateway. The credentials survive rebuilds without re-pairing. + +A sandbox that also holds legacy `profiles/dashboard-home` or `dashboard-home` credentials keeps both copies across a rebuild, because both paths are part of the durable state that a rebuild restores. +Run `$$nemoclaw channels remove whatsapp` to clear every WhatsApp session path, then re-add the channel and pair again. + 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. NemoClaw cannot detect cross-sandbox WhatsApp conflicts the way it does for token-based channels. diff --git a/docs/security/hermes-0.19.0-dependency-review.md b/docs/security/hermes-0.19.0-dependency-review.md index 67ff10c24a6..7d666a6fa80 100644 --- a/docs/security/hermes-0.19.0-dependency-review.md +++ b/docs/security/hermes-0.19.0-dependency-review.md @@ -330,7 +330,7 @@ Each reviewed commit in the following table is an ancestor of `bd668121e918e7b1d | `HERMES-9` | High | Pin and test | The selected Python delta adds no advisory regression, and the affected multipart parser is replaced with attested `0.0.32` plus hash and runtime probes. | | `HERMES-10` | High | Pin and test | The exact-source patch updates Hermes metadata and its frozen lock together, selects `aiohttp==3.14.3`, `cryptography==50.0.0`, `mcp==1.28.1`, `Pillow==12.3.0`, `starlette==1.3.1`, and `tornado==6.5.7`, and fails the base image build on dependency inconsistency or installed-version drift. The `agents/hermes/Dockerfile` build checks `aiohttp==3.14.3` and `cryptography==50.0.0` in the Hermes sandbox image after messaging package installation. The check runs when `NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION` is `0` or `1`. The base image separately checksum-pins Node.js `24.18.1` and checks uv `0.11.33`. | | `HERMES-11` | High | Migrate, test, and runtime-proof | Root npm audit reports zero production findings, and the WhatsApp bridge removes the Baileys RC9 critical, high, and medium advisory entries. Both architectures still require native bridge and message-path evidence. | -| `HERMES-12` | High | Pin and runtime-proof | Trusted workflow run `30779271312`, attempt 1, built source commit `340c47857596e7cc347541a0b32fe9e24f201bcd` and published OCI index `sha256:956c3d0c812ee6caa56f3b6e307819925d920604adcf73c4a9e6229788967634`. Run `31006872948`, attempt 1, published security-refreshed index `sha256:57c091ab9b31c924eac0050e66c834c37df875154a254964302a31b119b50b96` from source commit `bd668121e918e7b1dda13062bed728f18150360e`, whose platform histories check `aiohttp==3.14.3` and `cryptography==50.0.0`. Trusted workflow run `31031662054`, attempt 1, rebuilt source commit `a7a7f3e470a75c404d316d2054445e16bb63b48c` with the exact-source dashboard WhatsApp session-path patch and published replacement OCI index `sha256:3d54b928baef9df403227e846f73079d13ca8424a27cd5268ca97bac3f030b27`. The final Dockerfile pins that replacement index. The amd64 and arm64 base-image builds in the reviewed runs passed the exact-source patch guard, locked bridge install, bridge-to-Baileys option assertions, and the controlled-proxy WebSocket `CONNECT` regression; live final-image WhatsApp evidence remains under `HERMES-22`. | +| `HERMES-12` | High | Pin and runtime-proof | Trusted workflow run `30779271312`, attempt 1, built source commit `340c47857596e7cc347541a0b32fe9e24f201bcd` and published OCI index `sha256:956c3d0c812ee6caa56f3b6e307819925d920604adcf73c4a9e6229788967634`. Run `31006872948`, attempt 1, published security-refreshed index `sha256:57c091ab9b31c924eac0050e66c834c37df875154a254964302a31b119b50b96` from source commit `bd668121e918e7b1dda13062bed728f18150360e`, whose platform histories check `aiohttp==3.14.3` and `cryptography==50.0.0`. Trusted workflow run `31031662054`, attempt 1, rebuilt source commit `a7a7f3e470a75c404d316d2054445e16bb63b48c` with the exact-source dashboard WhatsApp session-path patch and published replacement OCI index `sha256:3d54b928baef9df403227e846f73079d13ca8424a27cd5268ca97bac3f030b27`. The final Dockerfile pins that replacement index. The amd64 and arm64 base-image builds in the reviewed runs passed the exact-source patch guard, locked bridge install, bridge-to-Baileys option assertions, and the controlled-proxy WebSocket `CONNECT` regression; the live final-image WhatsApp evidence is recorded under `HERMES-22`. | | `HERMES-13` | Medium | Document bounded residual | Static `state_files` entries online-back up the default profile only. Cron or Discord ledgers created by a process launched under `profiles/` remain in the raw `profiles` tar capture and can be inconsistent during a concurrent snapshot. Dynamic profile-local SQLite discovery is generic snapshot work outside this upgrade PR. | | `HERMES-14` | High | Migrate and test | The browser evaluation denylist changed from default-on to opt-in. Generated configuration explicitly writes `browser.restrict_evaluate: true`, including when managed browser-gateway settings are merged, so the upgrade does not broaden page-context access. | | `HERMES-15` | Medium | Migrate and test | The omitted gateway session-reset policy changed from bounded daily and idle expiry to no automatic reset. Generated configuration explicitly writes the complete outgoing reset and notification policy to preserve the retention bound without inheriting mutable dependency defaults. | @@ -340,7 +340,7 @@ Each reviewed commit in the following table is an ancestor of `bd668121e918e7b1d | `HERMES-19` | High | Migrate and test | The dashboard has an isolated `HERMES_HOME`, so its allowlisted routing and policy mirror is a startup security boundary. A missing gateway config remains a benign cold-start no-op, while malformed, non-mapping, unreadable, or routing-free source config and invalid existing dashboard config fail startup without changing stale dashboard bytes. Sanitized errors never include raw PyYAML parser context or credential-bearing source lines. | | `HERMES-20` | High | Retarget, guard, test, and runtime-proof | Base SHA `fa96c91f` adds a Hermes 0.18 gateway-runtime-metadata patch whose central helper shape does not match Hermes 0.19. The retargeted exact-source guard preserves `_get_process_hermes_home()` while moving the managed default gateway's central PID, lock, and status helpers below `runtime`, hash-binds the patcher, and adds unit and Hermes sandbox image probes. The managed-gateway restart E2E remains the PR SHA runtime gate. | | `HERMES-21` | Medium | Document inherited bounded residual | The base workaround does not retarget direct upstream `--replace` cleanup, planned-stop/takeover markers, named-profile and multiplexer readers, service/boot/web/Windows consumers, or upstream backup and Docker paths. With Shields up, those direct paths can fail or observe stale state, but the same limitation exists on base SHA `fa96c91f`; the 0.19 selector retarget adds no regression to NemoClaw's supported host-managed default-gateway lifecycle. A complete relocation needs separate exact-source patches and runtime proof for every explicit consumer. | -| `HERMES-22` | High | Patch, pin, test, and runtime-proof | Issue `#8087` showed that the Hermes WhatsApp WebSocket ignored the injected `HTTPS_PROXY`, attempted direct DNS resolution, and failed before OpenShell produced an Open Cybersecurity Schema Framework (OCSF) record. NemoClaw exact-source patches both Baileys proxy fields, locks the added proxy dependency graph, and fails the base image build when the patch drifts, a bridge-level `makeWASocket` mock does not receive the same proxy agent as `agent` and `fetchAgent`, or the pinned Baileys WebSocket transport does not send a `CONNECT web.whatsapp.com:443` request to a controlled HTTPS proxy. The mock also proves that both options remain unset without `HTTPS_PROXY`. Live Hermes WhatsApp E2E evidence for QR pairing, connected status, and audited WebSocket traffic through the OpenShell proxy remains a merge gate. | +| `HERMES-22` | High | Patch, pin, test, and runtime-proof | Issue `#8087` showed that the Hermes WhatsApp WebSocket ignored the injected `HTTPS_PROXY`, attempted direct DNS resolution, and failed before OpenShell produced an Open Cybersecurity Schema Framework (OCSF) record. NemoClaw exact-source patches both Baileys proxy fields, locks the added proxy dependency graph, and fails the base image build when the patch drifts, a bridge-level `makeWASocket` mock does not receive the same proxy agent as `agent` and `fetchAgent`, or the pinned Baileys WebSocket transport does not send a `CONNECT web.whatsapp.com:443` request to a controlled HTTPS proxy. The mock also proves that both options remain unset without `HTTPS_PROXY`. Live Hermes WhatsApp evidence was captured manually on a final image built from this branch: dashboard QR pairing wrote credentials to `/sandbox/.hermes/platforms/whatsapp/session`, the bridge reported `{"status":"connected"}`, an inbound message from an allowlisted sender received an agent reply, and the OpenShell proxy audit admitted every WhatsApp flow under `policy:whatsapp`. Pull request `#8229` records that run and its reproduction steps. No target in this repository pairs a live WhatsApp account, so the trusted manual pull request E2E run remains the merge gate for that evidence. | Unresolved upgrade-created high-impact concerns: `0`. One Medium upgrade-created instance of the pre-existing named-profile raw-capture limitation and one inherited Medium direct-runtime-consumer limitation remain explicitly accepted for this upgrade scope. diff --git a/test/agent-variant-docs.test.ts b/test/agent-variant-docs.test.ts index 38ee2346e04..da942947723 100644 --- a/test/agent-variant-docs.test.ts +++ b/test/agent-variant-docs.test.ts @@ -340,9 +340,10 @@ import { AgentOnly } from "../_components/AgentGuide"; }); expect(hermes).toContain( - "Hermes Dashboard pairing and the gateway share `/sandbox/.hermes/platforms/whatsapp/session`", + "Hermes dashboard pairing and the gateway share `/sandbox/.hermes/platforms/whatsapp/session`", ); expect(hermes).toContain("nemohermes channels status --channel whatsapp"); + expect(hermes).toContain("nemohermes channels remove whatsapp"); expect(hermes).not.toContain("platforms.whatsapp.extra.session_path"); }); From d9a381d833ef4d302bb6efe69032a03d484a00cc Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 11 Aug 2026 06:14:08 +0530 Subject: [PATCH 31/31] fix(messaging): send the WhatsApp session split through channel removal `channels status` told an operator with credentials only under the legacy dashboard profile to rebuild and pair again. A rebuild restores that profile rather than dropping it, so the sandbox ends up holding two credential sets and the split it reported is still there. Point the hint at `channels remove whatsapp`, which clears every WhatsApp session path, followed by `channels add whatsapp`. The command reference now describes the same procedure, so the report and the documentation give one answer. Signed-off-by: Hung Le --- docs/reference/commands.mdx | 4 ++-- .../channels/whatsapp/hooks/status-health-eval.test.ts | 7 ++++--- .../channels/whatsapp/hooks/status-health-eval.ts | 5 ++++- .../channels/whatsapp/hooks/status-health.test.ts | 5 +++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 7035b0163cd..8f88f428246 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2494,8 +2494,8 @@ For an OpenClaw WhatsApp sandbox, `--channel whatsapp` probes the sandbox to sep 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 the `profiles/dashboard-home` session path contain `creds.json`. -If the Dashboard path has credentials and the gateway path is empty, the report tells you to run `$$nemoclaw rebuild --yes`. -Pair again from Dashboard so credentials use `/sandbox/.hermes/platforms/whatsapp/session`. +If the dashboard path has credentials and the gateway path is empty, the report tells you to run `$$nemoclaw channels remove whatsapp` and then `$$nemoclaw 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 channels status --channel whatsapp`. NemoClaw does not treat a Hermes session file as live inbound-health evidence. 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 8e8fc3a3bc7..f29844870c3 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,7 +77,7 @@ describe("evaluateWhatsappDiagnostics", () => { expect(report.hints.join(" ")).toMatch(/hermes whatsapp/); }); - it("guides legacy Hermes dashboard sessions through rebuild and re-pairing (#8184)", () => { + it("guides legacy Hermes dashboard sessions through channel removal and re-pairing (#8184)", () => { const report = evaluateWhatsappDiagnostics( baseInput({ agent: "hermes", @@ -94,8 +94,9 @@ describe("evaluateWhatsappDiagnostics", () => { expect(report.verdict).toBe("unpaired"); expect(session?.severity).toBe("warn"); expect(session?.detail).toMatch(/dashboard-home has WhatsApp credentials/); - expect(pairing?.hint).toContain("`nemoclaw rebuild --yes`"); - expect(session?.hint).toMatch(/Pair again from Dashboard/); + expect(pairing?.hint).toContain("`nemoclaw channels remove whatsapp`"); + expect(session?.hint).toContain("`nemoclaw channels add whatsapp`"); + expect(session?.hint).toMatch(/Pair again from the dashboard/); expect(hints).toContain("/sandbox/.hermes/platforms/whatsapp/session"); expect(hints).toContain("`nemoclaw channels status --channel whatsapp`"); expect(hints).not.toContain("platforms.whatsapp.extra.session_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 53ca07a0ac4..b16a2afdf5b 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts @@ -130,8 +130,11 @@ 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 rebuild --yes`. Pair again from Dashboard so credentials are stored in `/sandbox/.hermes/platforms/whatsapp/session`. Rerun `nemoclaw channels status --channel whatsapp`."; + "Run `nemoclaw channels remove whatsapp` to clear every WhatsApp session path, then `nemoclaw channels add whatsapp`. Pair again from the dashboard so credentials are stored in `/sandbox/.hermes/platforms/whatsapp/session`. Rerun `nemoclaw channels status --channel whatsapp`."; function pairingSignal(input: WhatsappProbeInput): DiagnosticSignal { if (!input.probeReachable) { 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 bc8a643d3ac..a65582ee96d 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -408,8 +408,9 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { const command = String(exec.mock.calls[0]?.[1] ?? ""); const hint = report?.signals.find((s) => s.label === "Session location")?.hint; expect(report?.verdict).toBe("unpaired"); - expect(hint).toContain("`nemoclaw rebuild --yes`"); - expect(hint).toMatch(/Pair again from Dashboard/); + expect(hint).toContain("`nemoclaw channels remove whatsapp`"); + expect(hint).toContain("`nemoclaw channels add whatsapp`"); + expect(hint).toMatch(/Pair again from the dashboard/); expect(hint).toContain("/sandbox/.hermes/platforms/whatsapp/session"); expect(hint).toContain("`nemoclaw channels status --channel whatsapp`"); expect(hint).not.toContain("platforms.whatsapp.extra.session_path");