From c5423e8c1dd7d4e625f0f620d8156389b32e0946 Mon Sep 17 00:00:00 2001 From: harjoth Date: Wed, 8 Jul 2026 13:37:59 -0700 Subject: [PATCH 1/4] fix(whatsapp): reconcile post-pair gateway restart without operator.admin (#6413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenClaw's `channels login --channel whatsapp` saves credentials locally and then asks the running gateway to restart the channel via the `channels.start` RPC, which is gated behind `operator.admin`. Since #6291 stopped ordinary in-sandbox argv from inheriting the gateway token, the login runs on device auth, the device approval policy deliberately never grants `operator.admin`, and the post-pair restart is denied — credentials are saved but the running channel keeps its old session ("missing scope: operator.admin"). After a successful token-less WhatsApp login, re-issue that same bounded `channels.start` as a NemoClaw-owned one-shot authenticated with the gateway token (read from the mutable config), scoped to a single fixed-argv child and sent only to NemoClaw's trusted private gateway URL. This mirrors the existing sessions.reset/delete admin RPC path; it does not auto-approve operator.admin or reintroduce ambient token auth, preserving the #6291 boundary. A failed or ambient-token login is left untouched, and reconcile failures downgrade to host-side recovery guidance without changing the login's exit code. The WhatsApp login subprocess now also strips an ambient gateway token when the login targets a caller-supplied (non-trusted) URL, closing a token exfiltration path, and runs under saved/restored errexit so its exit status is always captured. Adds test/repro-6413-whatsapp-postpair-start.test.ts covering the reconcile, trusted-URL gating, token non-exfiltration (login and reconcile), account-id handling, and the errexit path. Signed-off-by: harjoth --- scripts/nemoclaw-start.sh | 174 ++++++++- ...repro-6413-whatsapp-postpair-start.test.ts | 343 ++++++++++++++++++ 2 files changed, 508 insertions(+), 9 deletions(-) create mode 100644 test/repro-6413-whatsapp-postpair-start.test.ts diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 9d9411a104..3a1890d884 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3342,6 +3342,103 @@ _nemoclaw_messaging_connect_node_options() { done < "/tmp/nemoclaw-messaging-connect-preloads.list" printf '%s' "$_nemoclaw_options" } +# Read gateway.auth.token from the mutable OpenClaw config. Mirrors the +# entrypoint's _read_gateway_token, which is not emitted into this sourced +# file; keep the JSON5 fallback in sync with it. +_nemoclaw_whatsapp_gateway_token() { + node - "${OPENCLAW_STATE_DIR:-/sandbox/.openclaw}/openclaw.json" <<'NODEWATOKEN' +const fs = require("fs"); + +const configPath = process.argv[2]; + +function loadJson5() { + try { + const JSON5 = require("/opt/nemoclaw/node_modules/json5"); + if (JSON5 && typeof JSON5.parse === "function") { + return JSON5; + } + } catch { + // Fall through to the caller's empty-token behavior. + } + return undefined; +} + +try { + const text = fs.readFileSync(configPath, "utf8"); + let cfg; + try { + cfg = JSON.parse(text); + } catch (jsonError) { + const JSON5 = loadJson5(); + if (!JSON5) { + throw jsonError; + } + cfg = JSON5.parse(text); + } + console.log(cfg?.gateway?.auth?.token || ""); +} catch { + console.log(""); +} +NODEWATOKEN +} +# NemoClaw#6413: OpenClaw's `channels login` saves WhatsApp credentials +# locally and then asks the running gateway to restart the channel through the +# `channels.start` RPC, which the gateway gates behind `operator.admin`. When +# the login runs without the ambient gateway token, the client falls back to +# device auth, the device approval policy deliberately never grants +# `operator.admin`, and the post-pair restart is denied: credentials are saved +# but the running channel keeps its old session. Re-issue that same bounded +# RPC as a NemoClaw-owned one-shot with gateway-token auth after a successful +# login. The token stays scoped to this single fixed-argv child; per the +# runtime-env contract, removing the token from ordinary caller argv "is not a +# secrecy boundary against a command that deliberately reads the file", and +# this helper is exactly such an owned reader. Auto-approving `operator.admin` +# for ordinary devices would weaken the reviewed #6291 boundary and is +# deliberately not done here. Never fails the login: credentials are already +# saved, so a reconcile failure downgrades to host-side recovery guidance. +# Removal condition: drop this helper when the pinned OpenClaw lets a local +# login (re)start its own channel account without `operator.admin`, or ships +# its own bounded post-login reconcile. +_nemoclaw_whatsapp_postpair_start() { + local _nemoclaw_wa_gateway_url="$1" _nemoclaw_wa_insecure_ws="$2" _nemoclaw_wa_account="$3" + local _nemoclaw_wa_token _nemoclaw_wa_params _nemoclaw_wa_call_output + # The account id is caller input embedded in JSON params; allowlist it + # instead of quoting it (mirrors the approval policy's request-id pattern). + # Evaluate the range under the C locale so [A-Za-z0-9._:-] stays ASCII and is + # not widened by the caller's LC_COLLATE/LC_CTYPE. Scoped to this function + # subshell-style: the assignment cannot leak into the interactive shell + # because the helper only runs inside command substitution / the reconcile. + local LC_ALL=C + case "$_nemoclaw_wa_account" in + "") _nemoclaw_wa_params='{"channel":"whatsapp"}' ;; + *[!A-Za-z0-9._:-]*) + echo "[whatsapp] Credentials saved, but the account id contains unsupported characters, so the" >&2 + echo "[whatsapp] running gateway was not asked to restart the channel. Exit the sandbox and run" >&2 + echo "[whatsapp] 'nemoclaw channels status --channel whatsapp' to confirm it reconnects." >&2 + return 0 + ;; + *) _nemoclaw_wa_params='{"channel":"whatsapp","accountId":"'"$_nemoclaw_wa_account"'"}' ;; + esac + _nemoclaw_wa_token="$(_nemoclaw_whatsapp_gateway_token)" + if [ -z "$_nemoclaw_wa_token" ]; then + echo "[whatsapp] Credentials saved, but the gateway token is unavailable in this shell, so the" >&2 + echo "[whatsapp] running gateway was not asked to restart the channel. Exit the sandbox and run" >&2 + echo "[whatsapp] 'nemoclaw channels status --channel whatsapp' to confirm it reconnects." >&2 + return 0 + fi + if _nemoclaw_wa_call_output="$(OPENCLAW_GATEWAY_URL="$_nemoclaw_wa_gateway_url" \ + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_wa_insecure_ws" \ + OPENCLAW_GATEWAY_TOKEN="$_nemoclaw_wa_token" \ + command openclaw gateway call channels.start --params "$_nemoclaw_wa_params" --json 2>&1)"; then + echo "[whatsapp] Restarted the WhatsApp channel on the running gateway with the new credentials." >&2 + else + echo "[whatsapp] Credentials saved, but restarting the channel on the running gateway failed:" >&2 + printf '%s\n' "$_nemoclaw_wa_call_output" | tail -n 3 | sed 's/^/[whatsapp] /' >&2 + echo "[whatsapp] Exit the sandbox and run 'nemoclaw channels status --channel whatsapp'" >&2 + echo "[whatsapp] to check the channel; re-run the login from a fresh connect shell if it stays down." >&2 + fi + return 0 +} openclaw() { # NemoClaw#4462: approval calls temporarily drop the gateway URL/port/token # so OpenClaw resolves the local loopback gateway and device token. The @@ -3392,8 +3489,10 @@ openclaw() { list | status | "" | -h | --help) ;; login) _login_channel="" + _login_account="" _login_help=0 _prev_arg_was_channel_flag=0 + _prev_arg_was_account_flag=0 _seen_login_subcommand=0 for _arg in "$@"; do if [ "$_seen_login_subcommand" = "0" ]; then @@ -3405,6 +3504,11 @@ openclaw() { _prev_arg_was_channel_flag=0 continue fi + if [ "$_prev_arg_was_account_flag" = "1" ]; then + _login_account="$_arg" + _prev_arg_was_account_flag=0 + continue + fi case "$_arg" in --channel) _prev_arg_was_channel_flag=1 @@ -3412,6 +3516,12 @@ openclaw() { --channel=*) _login_channel="${_arg#--channel=}" ;; + --account) + _prev_arg_was_account_flag=1 + ;; + --account=*) + _login_account="${_arg#--account=}" + ;; -h | --help) _login_help=1 ;; @@ -3482,16 +3592,47 @@ openclaw() { # injecting them again here covers non-connect shells. Runtime # preload modules are idempotent, so a double --require is harmless. _nemoclaw_connect_node_options="$(_nemoclaw_messaging_connect_node_options)" - if [ -n "$_nemoclaw_connect_node_options" ]; then - OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" \ - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" \ - NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$_nemoclaw_connect_node_options" \ - command openclaw "$@" - else - OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" \ - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" \ - command openclaw "$@" + # The shared gateway token (ambient in connect shells; see the + # runtime env at OPENCLAW_GATEWAY_TOKEN) must never reach a + # caller-selected gateway. NemoClaw injects its trusted private URL + # as NEMOCLAW_OPENCLAW_GATEWAY_URL; the login tolerates a + # caller-supplied OPENCLAW_GATEWAY_URL override, so classify whether + # this login is aimed at the trusted URL. Only the trusted URL may + # carry the token — for the login itself and for the reconcile. + _nemoclaw_whatsapp_trusted_url="${NEMOCLAW_OPENCLAW_GATEWAY_URL:-}" + _nemoclaw_whatsapp_url_is_trusted=0 + if [ -n "$_nemoclaw_whatsapp_trusted_url" ] && + [ "$_nemoclaw_whatsapp_gateway_url" = "$_nemoclaw_whatsapp_trusted_url" ]; then + _nemoclaw_whatsapp_url_is_trusted=1 + fi + # Whether the login could authenticate with the gateway token (only + # when it is both present and pointed at the trusted URL). If so, + # OpenClaw's own post-pair `channels.start` succeeds and no NemoClaw + # reconcile is needed; otherwise that restart is denied for lack of + # operator.admin and NemoClaw reconciles it below (NemoClaw#6413). + _nemoclaw_whatsapp_login_had_token=0 + if [ "$_nemoclaw_whatsapp_url_is_trusted" = "1" ] && [ -n "${OPENCLAW_GATEWAY_TOKEN:-}" ]; then + _nemoclaw_whatsapp_login_had_token=1 fi + # Run the login with errexit disabled so its exit status is always + # captured (and the post-login guidance/reconcile always runs) even + # when the caller shell has `set -e`; mirrors the devices-approve + # and configure-guard branches. Restored before returning. + _nemoclaw_whatsapp_login_errexit=0 + case $- in *e*) _nemoclaw_whatsapp_login_errexit=1 ;; esac + set +e + ( + # A non-trusted (caller-selected) target must not receive the + # shared gateway token; strip it so the login there falls back to + # device auth, matching the #6291 WhatsApp-login boundary. + [ "$_nemoclaw_whatsapp_url_is_trusted" = "1" ] || builtin unset OPENCLAW_GATEWAY_TOKEN + export OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" + export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" + if [ -n "$_nemoclaw_connect_node_options" ]; then + export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$_nemoclaw_connect_node_options" + fi + command openclaw "$@" + ) _whatsapp_login_exit=$? if [ "$_whatsapp_login_exit" -ne 0 ]; then echo "" >&2 @@ -3500,7 +3641,22 @@ openclaw() { echo "issue, not a QR-size issue — the QR above rendered independently of the gateway." >&2 echo "[whatsapp] Re-run 'openclaw channels login --channel whatsapp' to retry. If it keeps" >&2 echo "closing, exit the sandbox and run 'nemoclaw channels status --channel whatsapp'." >&2 + elif [ "$_nemoclaw_whatsapp_url_is_trusted" != "1" ]; then + # Login targeted a caller-supplied gateway URL rather than + # NemoClaw's injected private URL; the token was withheld above, + # and the reconcile must not send it there either. + echo "[whatsapp] Credentials saved. The channel was not auto-restarted because pairing used a" >&2 + echo "[whatsapp] custom gateway URL; exit the sandbox and run" >&2 + echo "[whatsapp] 'nemoclaw channels status --channel whatsapp' to confirm it reconnects." >&2 + elif [ "$_nemoclaw_whatsapp_login_had_token" = "1" ]; then + : # Login authenticated with the gateway token, so its own + # post-pair channels.start already restarted the channel; a + # second restart would only bounce the freshly started session. + else + _nemoclaw_whatsapp_postpair_start "$_nemoclaw_whatsapp_trusted_url" \ + "$_nemoclaw_whatsapp_insecure_ws" "$_login_account" fi + [ "$_nemoclaw_whatsapp_login_errexit" = "1" ] && set -e return $_whatsapp_login_exit fi ;; diff --git a/test/repro-6413-whatsapp-postpair-start.test.ts b/test/repro-6413-whatsapp-postpair-start.test.ts new file mode 100644 index 0000000000..3d732f1ab0 --- /dev/null +++ b/test/repro-6413-whatsapp-postpair-start.test.ts @@ -0,0 +1,343 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Repro for NemoClaw#6413: OpenClaw's `channels login --channel whatsapp` +// saves credentials locally and then asks the running gateway to restart the +// channel via the `channels.start` RPC, which the gateway gates behind +// `operator.admin` (OpenClaw 2026.6.10 core-descriptors). When the login runs +// without the ambient gateway token (ordinary exec/one-shot argv drop it; see +// src/lib/actions/sandbox/runtime-env.ts), the client falls back to device +// auth, NemoClaw's device approval policy deliberately never grants +// `operator.admin`, and the post-pair restart is always denied: +// +// Local login saved auth for whatsapp/default, but the running gateway did +// not restart it: missing scope: operator.admin +// +// The guard must re-issue that same bounded RPC as a NemoClaw-owned one-shot +// with gateway-token auth after a successful token-less login, without +// auto-approving `operator.admin` and without failing the login when the +// reconcile itself cannot run. + +import assert from "node:assert"; +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"; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const START_SCRIPT = path.join(REPO_ROOT, "scripts", "nemoclaw-start.sh"); + +const PRIVATE_GATEWAY_URL = "ws://10.222.0.2:18789"; +const GATEWAY_TOKEN = "unit-test-gateway-token"; + +// The upstream line the reconcile repairs, verbatim from the OpenClaw +// 2026.6.10 dist (channel-auth reconcileGatewayRuntimeAfterLocalLogin). +const UPSTREAM_DENIAL_LINE = + "Local login saved auth for whatsapp/default, but the running gateway did not restart it: missing scope: operator.admin"; + +function extractGuardFunction(src: string): string { + const beginMarker = "# nemoclaw-configure-guard begin"; + const endMarker = "# nemoclaw-configure-guard end"; + const begin = src.indexOf(beginMarker); + const end = src.indexOf(endMarker); + assert( + begin !== -1 && end !== -1 && begin < end, + "Expected nemoclaw-configure-guard markers in scripts/nemoclaw-start.sh", + ); + return src.slice(begin, end); +} + +interface ReconcileRunOptions { + loginArgs?: string[]; + ambientToken?: string; + configuredToken?: string | undefined; + fakeLoginExit?: number; + fakeGatewayCallExit?: number; + // Caller-supplied OPENCLAW_GATEWAY_URL override. When set and different from + // the trusted private URL, the token-bearing reconcile must be skipped. + callerGatewayUrl?: string; + // Set `set -e` in the wrapper before invoking the login, to prove the login + // exit status is still captured and the reconcile guarantees hold. + errexit?: boolean; +} + +interface ReconcileRunResult { + status: number; + stdout: string; + stderr: string; + calls: string[]; +} + +describe("WhatsApp post-pair gateway channel start (#6413)", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const guard = extractGuardFunction(src); + + function runLoginThroughGuard(opts: ReconcileRunOptions): ReconcileRunResult { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wa-postpair-")); + try { + const binDir = path.join(tempDir, "bin"); + const stateDir = path.join(tempDir, "state"); + const callLog = path.join(tempDir, "openclaw-calls.log"); + fs.mkdirSync(binDir); + fs.mkdirSync(stateDir, { recursive: true }); + + // The mutable OpenClaw config the token reader must consult; the env + // unset in ordinary argv paths is documented as "not a secrecy boundary + // against a command that deliberately reads the file". + const config = + opts.configuredToken === undefined + ? { gateway: { auth: {} } } + : { gateway: { auth: { token: opts.configuredToken } } }; + fs.writeFileSync(path.join(stateDir, "openclaw.json"), JSON.stringify(config)); + + // Fake `openclaw` records every invocation with the env that matters + // (gateway URL, private-WS marker, token) and replays the real + // 2026.6.10 post-pair denial on token-less logins. + fs.writeFileSync( + path.join(binDir, "openclaw"), + [ + "#!/usr/bin/env bash", + `printf 'ARGS=%s URL=%s WS=%s TOKEN=%s\\n' "$*" "\${OPENCLAW_GATEWAY_URL:-unset}" "\${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-unset}" "\${OPENCLAW_GATEWAY_TOKEN:-unset}" >> ${JSON.stringify(callLog)}`, + 'if [ "$1" = "channels" ] && [ "$2" = "login" ]; then', + ' if [ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]; then', + ` echo ${JSON.stringify(UPSTREAM_DENIAL_LINE)} >&2`, + " fi", + ` exit ${opts.fakeLoginExit ?? 0}`, + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "call" ]; then', + ` if [ ${JSON.stringify(String(opts.fakeGatewayCallExit ?? 0))} != "0" ]; then`, + ' echo "Gateway call failed: gateway unreachable" >&2', + ` exit ${opts.fakeGatewayCallExit ?? 0}`, + " fi", + ' echo "{}"', + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const wrapperLines = [ + "#!/usr/bin/env bash", + `export PATH=${JSON.stringify(binDir)}:"$PATH"`, + `export OPENCLAW_STATE_DIR=${JSON.stringify(stateDir)}`, + opts.callerGatewayUrl === undefined + ? "unset OPENCLAW_GATEWAY_URL" + : `export OPENCLAW_GATEWAY_URL=${JSON.stringify(opts.callerGatewayUrl)}`, + "unset OPENCLAW_ALLOW_INSECURE_PRIVATE_WS", + `export NEMOCLAW_OPENCLAW_GATEWAY_URL=${JSON.stringify(PRIVATE_GATEWAY_URL)}`, + "export NEMOCLAW_OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", + opts.ambientToken === undefined + ? "unset OPENCLAW_GATEWAY_TOKEN" + : `export OPENCLAW_GATEWAY_TOKEN=${JSON.stringify(opts.ambientToken)}`, + guard, + ...(opts.errexit ? ["set -e"] : []), + `openclaw ${(opts.loginArgs ?? ["channels", "login", "--channel", "whatsapp"]) + .map((arg) => JSON.stringify(arg)) + .join(" ")}`, + // Under `set -e` a nonzero login aborts the wrapper here (the caller + // shell's own contract), so this line only runs on success; assert on + // stderr for the failing-login-under-errexit case instead. + "__rc=$?", + ...(opts.errexit ? ["set +e"] : []), + 'echo "GUARD_EXIT=$__rc"', + ]; + const wrapperPath = path.join(tempDir, "run.sh"); + fs.writeFileSync(wrapperPath, wrapperLines.join("\n"), { mode: 0o700 }); + + const r = spawnSync("bash", [wrapperPath], { encoding: "utf-8", timeout: 15000 }); + const calls = fs.existsSync(callLog) + ? fs.readFileSync(callLog, "utf-8").split("\n").filter(Boolean) + : []; + return { + status: r.status ?? -1, + stdout: r.stdout ?? "", + stderr: r.stderr ?? "", + calls, + }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + + function gatewayCallLines(calls: string[]): string[] { + return calls.filter((line) => line.startsWith("ARGS=gateway call ")); + } + + it("restarts the channel with gateway-token auth after a token-less login (#6413)", () => { + const r = runLoginThroughGuard({ configuredToken: GATEWAY_TOKEN }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + const reconcile = gatewayCallLines(r.calls); + expect(reconcile).toHaveLength(1); + expect(reconcile[0]).toContain( + 'ARGS=gateway call channels.start --params {"channel":"whatsapp"} --json', + ); + expect(reconcile[0]).toContain(`TOKEN=${GATEWAY_TOKEN}`); + expect(reconcile[0]).toContain(`URL=${PRIVATE_GATEWAY_URL}`); + expect(reconcile[0]).toContain("WS=1"); + expect(r.stderr).toContain( + "Restarted the WhatsApp channel on the running gateway with the new credentials.", + ); + }); + + it("passes the login --account id through to channels.start", () => { + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + loginArgs: ["channels", "login", "--channel", "whatsapp", "--account", "biz"], + }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + const reconcile = gatewayCallLines(r.calls); + expect(reconcile).toHaveLength(1); + expect(reconcile[0]).toContain('--params {"channel":"whatsapp","accountId":"biz"}'); + }); + + it("supports the --account= spelling", () => { + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + loginArgs: ["channels", "login", "--channel=whatsapp", "--account=biz.2"], + }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + const reconcile = gatewayCallLines(r.calls); + expect(reconcile).toHaveLength(1); + expect(reconcile[0]).toContain('--params {"channel":"whatsapp","accountId":"biz.2"}'); + }); + + it("refuses to embed an unsafe account id in the RPC params", () => { + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + loginArgs: ["channels", "login", "--channel", "whatsapp", "--account", 'biz"},"x":"y'], + }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + expect(gatewayCallLines(r.calls)).toHaveLength(0); + expect(r.stderr).toContain("account id contains unsupported characters"); + expect(r.stderr).toContain("nemoclaw channels status --channel whatsapp"); + }); + + it("does NOT send the gateway token to a caller-supplied gateway URL — login or reconcile (#6413)", () => { + // A login can carry a caller-chosen OPENCLAW_GATEWAY_URL. Neither the login + // subprocess nor the reconcile may hand the gateway token to that endpoint, + // or the token is exfiltrated. Here the token is configured but NOT ambient. + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + callerGatewayUrl: "ws://attacker.example.test:1234", + }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + expect(gatewayCallLines(r.calls)).toHaveLength(0); + // The token must not appear in ANY recorded invocation env (login included). + expect(r.calls.every((line) => !line.includes(GATEWAY_TOKEN))).toBe(true); + expect(r.stderr).toContain("custom gateway URL"); + expect(r.stderr).toContain("nemoclaw channels status --channel whatsapp"); + }); + + it("strips an AMBIENT gateway token before a login to a caller-supplied URL (#6413)", () => { + // The connect shell exports OPENCLAW_GATEWAY_TOKEN ambiently. A caller that + // redirects the login to an attacker URL must not have that ambient token + // forwarded to the login subprocess. Covers the ambient + non-trusted case. + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + ambientToken: GATEWAY_TOKEN, + callerGatewayUrl: "ws://attacker.example.test:1234", + }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + // Login ran, but with the token stripped, and no reconcile fired. + const loginCalls = r.calls.filter((line) => line.startsWith("ARGS=channels login ")); + expect(loginCalls).toHaveLength(1); + expect(loginCalls[0]).toContain("TOKEN=unset"); + expect(gatewayCallLines(r.calls)).toHaveLength(0); + expect(r.calls.every((line) => !line.includes(GATEWAY_TOKEN))).toBe(true); + expect(r.stderr).toContain("custom gateway URL"); + }); + + it("reconciles against the trusted URL when the caller URL matches it", () => { + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + callerGatewayUrl: PRIVATE_GATEWAY_URL, + }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + const reconcile = gatewayCallLines(r.calls); + expect(reconcile).toHaveLength(1); + expect(reconcile[0]).toContain(`URL=${PRIVATE_GATEWAY_URL}`); + expect(reconcile[0]).toContain(`TOKEN=${GATEWAY_TOKEN}`); + }); + + it("captures the login exit and skips reconcile on a failed login under set -e (#6413)", () => { + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + fakeLoginExit: 5, + errexit: true, + }); + + // The function body must run to completion under the caller's `set -e`: + // the failure guidance is printed and no reconcile is attempted. + expect(r.stderr).toContain("Pairing exited with code 5 before it completed"); + expect(gatewayCallLines(r.calls)).toHaveLength(0); + expect(r.status).toBe(5); + }); + + it("reconciles normally when the caller shell has set -e and the login succeeds", () => { + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + errexit: true, + }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + expect(gatewayCallLines(r.calls)).toHaveLength(1); + }); + + it("keeps the token for a trusted-URL login and does not double-reconcile", () => { + // Trusted URL + ambient token: the login authenticates with the token and + // its own post-pair channels.start succeeds, so NemoClaw must NOT issue a + // second restart (it would only bounce the freshly started session). The + // token legitimately reaches the trusted URL here. + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + ambientToken: GATEWAY_TOKEN, + }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + const loginCalls = r.calls.filter((line) => line.startsWith("ARGS=channels login ")); + expect(loginCalls).toHaveLength(1); + expect(loginCalls[0]).toContain(`TOKEN=${GATEWAY_TOKEN}`); + expect(gatewayCallLines(r.calls)).toHaveLength(0); + }); + + it("does not reconcile after a failed login and preserves its exit code", () => { + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + fakeLoginExit: 7, + }); + + expect(r.stdout).toContain("GUARD_EXIT=7"); + expect(gatewayCallLines(r.calls)).toHaveLength(0); + }); + + it("downgrades to host-side guidance when no gateway token is configured", () => { + const r = runLoginThroughGuard({ configuredToken: undefined }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + expect(gatewayCallLines(r.calls)).toHaveLength(0); + expect(r.stderr).toContain("the gateway token is unavailable in this shell"); + expect(r.stderr).toContain("nemoclaw channels status --channel whatsapp"); + }); + + it("keeps the login successful and prints recovery guidance when the restart RPC fails", () => { + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + fakeGatewayCallExit: 1, + }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + expect(gatewayCallLines(r.calls)).toHaveLength(1); + expect(r.stderr).toContain("restarting the channel on the running gateway failed"); + expect(r.stderr).toContain("Gateway call failed: gateway unreachable"); + expect(r.stderr).toContain("nemoclaw channels status --channel whatsapp"); + }); +}); From 9a83e492979805a366ecfe856f6dc54300e65a15 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 8 Jul 2026 14:48:58 -0700 Subject: [PATCH 2/4] fix(whatsapp): bind post-pair restart to trusted gateway Co-authored-by: harjoth Signed-off-by: Apurv Kumaria --- docs/manage-sandboxes/messaging-channels.mdx | 11 +++++-- scripts/nemoclaw-start.sh | 33 +++++++++++++------ test/nemoclaw-start-gateway-ws-host.test.ts | 2 ++ ...repro-6413-whatsapp-postpair-start.test.ts | 29 ++++++++++++++++ 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx index 934dc8559e..37030e88a5 100644 --- a/docs/manage-sandboxes/messaging-channels.mdx +++ b/docs/manage-sandboxes/messaging-channels.mdx @@ -148,8 +148,15 @@ openclaw channels login --channel whatsapp # OpenClaw sandboxes hermes whatsapp # Hermes sandboxes ``` -For OpenClaw sandboxes, NemoClaw validates the gateway URL before pairing and renders the WhatsApp QR code in a compact terminal form so it fits in smaller terminal windows. -If pairing exits with a gateway close such as `1008`, rerun the login command one time and then check `nemoclaw channels status --channel whatsapp` so you can diagnose the gateway/session path separately from QR rendering. + +NemoClaw validates the gateway URL before pairing and renders the WhatsApp QR code in a compact terminal form so it fits in smaller terminal windows. +After a successful QR login, NemoClaw restarts the WhatsApp channel through a bounded call to the sandbox's generated private gateway URL so the new session takes effect without granting `operator.admin` to the pairing device. +NemoClaw sends the gateway token only to that generated private URL. +If you set a different `OPENCLAW_GATEWAY_URL`, the login can save credentials, but NemoClaw does not automatically restart the channel. +Exit the sandbox and run `$$nemoclaw channels status --channel whatsapp` to confirm that it reconnects. +If the bounded restart fails for another reason, the pairing remains saved and the same status command reports the channel state. +If pairing exits with a gateway close such as `1008`, rerun the login command one time and then check `$$nemoclaw channels status --channel whatsapp` so you can diagnose the gateway/session path separately from QR rendering. + The sandbox generates and stores session credentials inside durable agent state (`whatsapp` for OpenClaw, `platforms/whatsapp` for Hermes), so they 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. diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 3a1890d884..a2511c74e0 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3265,6 +3265,14 @@ PROXYEOF # Preserve NemoClaw's sandbox-interface dial-back URL for the few # NemoClaw-owned commands that require it without forcing ordinary # OpenClaw CLI clients onto the explicit remote-gateway pairing path. + # Keep a separate readonly anchor for token-bearing helpers. The exported + # compatibility alias is intentionally caller-mutable, so it must never + # decide where a gateway token may be sent. The conditional assignment + # makes repeated sourcing idempotent; a conflicting preexisting readonly + # value fails closed instead of becoming the trust anchor. + printf "if [ \"\${_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL:-}\" != '%s' ]; then\\n" "$_escaped_gateway_url" + printf " _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL='%s'\\n" "$_escaped_gateway_url" + printf "fi\\nbuiltin readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL\\n" printf "export NEMOCLAW_OPENCLAW_GATEWAY_URL='%s'\n" "$_escaped_gateway_url" cat <<'GATEWAYURLENVEOF' # Equality identifies NemoClaw's inherited private-interface value. A different @@ -3594,12 +3602,13 @@ openclaw() { _nemoclaw_connect_node_options="$(_nemoclaw_messaging_connect_node_options)" # The shared gateway token (ambient in connect shells; see the # runtime env at OPENCLAW_GATEWAY_TOKEN) must never reach a - # caller-selected gateway. NemoClaw injects its trusted private URL - # as NEMOCLAW_OPENCLAW_GATEWAY_URL; the login tolerates a - # caller-supplied OPENCLAW_GATEWAY_URL override, so classify whether - # this login is aimed at the trusted URL. Only the trusted URL may - # carry the token — for the login itself and for the reconcile. - _nemoclaw_whatsapp_trusted_url="${NEMOCLAW_OPENCLAW_GATEWAY_URL:-}" + # caller-selected gateway. The public NEMOCLAW_* compatibility + # alias is caller-mutable, so only the readonly value baked into the + # generated root-owned runtime file may act as the trust anchor. + # The login tolerates a caller-supplied OPENCLAW_GATEWAY_URL + # override; only the baked URL may carry the token, for either the + # login itself or the reconcile. + _nemoclaw_whatsapp_trusted_url="${_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL:-}" _nemoclaw_whatsapp_url_is_trusted=0 if [ -n "$_nemoclaw_whatsapp_trusted_url" ] && [ "$_nemoclaw_whatsapp_gateway_url" = "$_nemoclaw_whatsapp_trusted_url" ]; then @@ -3626,12 +3635,16 @@ openclaw() { # shared gateway token; strip it so the login there falls back to # device auth, matching the #6291 WhatsApp-login boundary. [ "$_nemoclaw_whatsapp_url_is_trusted" = "1" ] || builtin unset OPENCLAW_GATEWAY_TOKEN - export OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" - export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" if [ -n "$_nemoclaw_connect_node_options" ]; then - export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$_nemoclaw_connect_node_options" + OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" \ + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" \ + NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$_nemoclaw_connect_node_options" \ + command openclaw "$@" + else + OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" \ + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" \ + command openclaw "$@" fi - command openclaw "$@" ) _whatsapp_login_exit=$? if [ "$_whatsapp_login_exit" -ne 0 ]; then diff --git a/test/nemoclaw-start-gateway-ws-host.test.ts b/test/nemoclaw-start-gateway-ws-host.test.ts index c6c20c0e95..1cde84b069 100644 --- a/test/nemoclaw-start-gateway-ws-host.test.ts +++ b/test/nemoclaw-start-gateway-ws-host.test.ts @@ -183,6 +183,8 @@ describe("gateway websocket url host derivation", () => { expect(result.status, result.stderr).toBe(0); const envFile = fs.readFileSync(envFilePath, "utf-8"); expect(envFile).toContain("export NEMOCLAW_OPENCLAW_GATEWAY_URL='ws://10.200.0.2:18790'"); + expect(envFile).toContain("_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL='ws://10.200.0.2:18790'"); + expect(envFile).toContain("builtin readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL"); expect(envFile).toContain("export NEMOCLAW_OPENCLAW_ALLOW_INSECURE_PRIVATE_WS='1'"); expect(envFile).not.toContain("export OPENCLAW_GATEWAY_URL="); expect(envFile).not.toContain("export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="); diff --git a/test/repro-6413-whatsapp-postpair-start.test.ts b/test/repro-6413-whatsapp-postpair-start.test.ts index 3d732f1ab0..3b125e352c 100644 --- a/test/repro-6413-whatsapp-postpair-start.test.ts +++ b/test/repro-6413-whatsapp-postpair-start.test.ts @@ -57,6 +57,9 @@ interface ReconcileRunOptions { // Caller-supplied OPENCLAW_GATEWAY_URL override. When set and different from // the trusted private URL, the token-bearing reconcile must be skipped. callerGatewayUrl?: string; + // Spoof the caller-mutable NEMOCLAW_* compatibility alias after the readonly + // trust anchor has been installed. Security decisions must ignore it. + callerPrivateGatewayAlias?: string; // Set `set -e` in the wrapper before invoking the login, to prove the login // exit status is still captured and the reconcile guarantees hold. errexit?: boolean; @@ -127,11 +130,18 @@ describe("WhatsApp post-pair gateway channel start (#6413)", () => { : `export OPENCLAW_GATEWAY_URL=${JSON.stringify(opts.callerGatewayUrl)}`, "unset OPENCLAW_ALLOW_INSECURE_PRIVATE_WS", `export NEMOCLAW_OPENCLAW_GATEWAY_URL=${JSON.stringify(PRIVATE_GATEWAY_URL)}`, + `_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL=${JSON.stringify(PRIVATE_GATEWAY_URL)}`, + "builtin readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL", "export NEMOCLAW_OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", opts.ambientToken === undefined ? "unset OPENCLAW_GATEWAY_TOKEN" : `export OPENCLAW_GATEWAY_TOKEN=${JSON.stringify(opts.ambientToken)}`, guard, + ...(opts.callerPrivateGatewayAlias === undefined + ? [] + : [ + `export NEMOCLAW_OPENCLAW_GATEWAY_URL=${JSON.stringify(opts.callerPrivateGatewayAlias)}`, + ]), ...(opts.errexit ? ["set -e"] : []), `openclaw ${(opts.loginArgs ?? ["channels", "login", "--channel", "whatsapp"]) .map((arg) => JSON.stringify(arg)) @@ -255,6 +265,25 @@ describe("WhatsApp post-pair gateway channel start (#6413)", () => { expect(r.stderr).toContain("custom gateway URL"); }); + it("rejects a caller that spoofs both mutable gateway URL aliases (#6413)", () => { + const attackerUrl = "ws://attacker.example.test:1234"; + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + ambientToken: GATEWAY_TOKEN, + callerGatewayUrl: attackerUrl, + callerPrivateGatewayAlias: attackerUrl, + }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + const loginCalls = r.calls.filter((line) => line.startsWith("ARGS=channels login ")); + expect(loginCalls).toHaveLength(1); + expect(loginCalls[0]).toContain(`URL=${attackerUrl}`); + expect(loginCalls[0]).toContain("TOKEN=unset"); + expect(gatewayCallLines(r.calls)).toHaveLength(0); + expect(r.calls.every((line) => !line.includes(GATEWAY_TOKEN))).toBe(true); + expect(r.stderr).toContain("custom gateway URL"); + }); + it("reconciles against the trusted URL when the caller URL matches it", () => { const r = runLoginThroughGuard({ configuredToken: GATEWAY_TOKEN, From 8df9f738f7a5f52e415d85d91f2c84d7963c7250 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 8 Jul 2026 15:16:41 -0700 Subject: [PATCH 3/4] test(whatsapp): verify trusted gateway binding behavior Co-authored-by: harjoth Signed-off-by: Apurv Kumaria --- test/nemoclaw-start-gateway-ws-host.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/nemoclaw-start-gateway-ws-host.test.ts b/test/nemoclaw-start-gateway-ws-host.test.ts index 1cde84b069..012e72715b 100644 --- a/test/nemoclaw-start-gateway-ws-host.test.ts +++ b/test/nemoclaw-start-gateway-ws-host.test.ts @@ -183,8 +183,6 @@ describe("gateway websocket url host derivation", () => { expect(result.status, result.stderr).toBe(0); const envFile = fs.readFileSync(envFilePath, "utf-8"); expect(envFile).toContain("export NEMOCLAW_OPENCLAW_GATEWAY_URL='ws://10.200.0.2:18790'"); - expect(envFile).toContain("_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL='ws://10.200.0.2:18790'"); - expect(envFile).toContain("builtin readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL"); expect(envFile).toContain("export NEMOCLAW_OPENCLAW_ALLOW_INSECURE_PRIVATE_WS='1'"); expect(envFile).not.toContain("export OPENCLAW_GATEWAY_URL="); expect(envFile).not.toContain("export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="); @@ -199,6 +197,8 @@ describe("gateway websocket url host derivation", () => { `. ${JSON.stringify(envFilePath)}`, 'printf "PUBLIC_URL=%s\\n" "${OPENCLAW_GATEWAY_URL-unset}"', 'printf "PRIVATE_URL=%s\\n" "${NEMOCLAW_OPENCLAW_GATEWAY_URL-unset}"', + 'printf "TRUSTED_URL=%s\\n" "${_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL-unset}"', + 'if ( _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL=ws://attacker.invalid ) 2>/dev/null; then printf "TRUSTED_READONLY=no\\n"; else printf "TRUSTED_READONLY=yes\\n"; fi', 'printf "PUBLIC_INSECURE=%s\\n" "${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS-unset}"', 'printf "PRIVATE_INSECURE=%s\\n" "${NEMOCLAW_OPENCLAW_ALLOW_INSECURE_PRIVATE_WS-unset}"', 'printf "PORT=%s\\n" "${OPENCLAW_GATEWAY_PORT-unset}"', @@ -218,6 +218,8 @@ describe("gateway websocket url host derivation", () => { expect(sourced.status, sourced.stderr).toBe(0); expect(sourced.stdout).toContain("PUBLIC_URL=unset"); expect(sourced.stdout).toContain("PRIVATE_URL=ws://10.200.0.2:18790"); + expect(sourced.stdout).toContain("TRUSTED_URL=ws://10.200.0.2:18790"); + expect(sourced.stdout).toContain("TRUSTED_READONLY=yes"); expect(sourced.stdout).toContain("PUBLIC_INSECURE=unset"); expect(sourced.stdout).toContain("PRIVATE_INSECURE=1"); expect(sourced.stdout).toContain("PORT=18790"); From b8c47c2ffcb63a238966997070f1beac61957dc6 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 8 Jul 2026 23:24:40 -0700 Subject: [PATCH 4/4] fix(whatsapp): harden post-pair restart reconciliation Fail closed on conflicting readonly gateway anchors. Bound account identifiers before constructing the channel-start RPC. Co-authored-by: harjoth Signed-off-by: Apurv Kumaria --- scripts/nemoclaw-start.sh | 28 +++++++-- test/nemoclaw-start-gateway-ws-host.test.ts | 60 +++++++++++++++++++ ...repro-6413-whatsapp-postpair-start.test.ts | 25 ++++++++ 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index a2511c74e0..b7dba00e18 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3267,11 +3267,19 @@ PROXYEOF # OpenClaw CLI clients onto the explicit remote-gateway pairing path. # Keep a separate readonly anchor for token-bearing helpers. The exported # compatibility alias is intentionally caller-mutable, so it must never - # decide where a gateway token may be sent. The conditional assignment - # makes repeated sourcing idempotent; a conflicting preexisting readonly - # value fails closed instead of becoming the trust anchor. - printf "if [ \"\${_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL:-}\" != '%s' ]; then\\n" "$_escaped_gateway_url" - printf " _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL='%s'\\n" "$_escaped_gateway_url" + # decide where a gateway token may be sent. Assign and verify the anchor + # in one fail-closed gate while keeping repeated sourcing idempotent. If + # the caller predeclared a conflicting readonly value, clear any ambient + # token and stop before token-bearing helpers are installed. + printf "if { [ \"\${_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL:-}\" != '%s' ] &&\\n" "$_escaped_gateway_url" + printf " ! builtin readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL='%s' 2>/dev/null; } ||\\n" "$_escaped_gateway_url" + printf " [ \"\${_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL:-}\" != '%s' ]; then\\n" "$_escaped_gateway_url" + printf " if ! builtin unset OPENCLAW_GATEWAY_TOKEN 2>/dev/null; then\\n" + printf " echo 'Error: NemoClaw rejected a conflicting gateway trust anchor, and the ambient gateway token could not be cleared.' >&2\\n" + printf " exit 1\\n" + printf " fi\\n" + printf " echo 'Error: NemoClaw rejected a conflicting gateway trust anchor; gateway-token helpers were disabled.' >&2\\n" + printf " return 1 2>/dev/null || exit 1\\n" printf "fi\\nbuiltin readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL\\n" printf "export NEMOCLAW_OPENCLAW_GATEWAY_URL='%s'\n" "$_escaped_gateway_url" cat <<'GATEWAYURLENVEOF' @@ -3425,7 +3433,15 @@ _nemoclaw_whatsapp_postpair_start() { echo "[whatsapp] 'nemoclaw channels status --channel whatsapp' to confirm it reconnects." >&2 return 0 ;; - *) _nemoclaw_wa_params='{"channel":"whatsapp","accountId":"'"$_nemoclaw_wa_account"'"}' ;; + *) + if [ "${#_nemoclaw_wa_account}" -gt 128 ]; then + echo "[whatsapp] Credentials saved, but the account id exceeds 128 characters, so the" >&2 + echo "[whatsapp] running gateway was not asked to restart the channel. Exit the sandbox and run" >&2 + echo "[whatsapp] 'nemoclaw channels status --channel whatsapp' to confirm it reconnects." >&2 + return 0 + fi + _nemoclaw_wa_params='{"channel":"whatsapp","accountId":"'"$_nemoclaw_wa_account"'"}' + ;; esac _nemoclaw_wa_token="$(_nemoclaw_whatsapp_gateway_token)" if [ -z "$_nemoclaw_wa_token" ]; then diff --git a/test/nemoclaw-start-gateway-ws-host.test.ts b/test/nemoclaw-start-gateway-ws-host.test.ts index 012e72715b..d8fcc33593 100644 --- a/test/nemoclaw-start-gateway-ws-host.test.ts +++ b/test/nemoclaw-start-gateway-ws-host.test.ts @@ -251,6 +251,66 @@ describe("gateway websocket url host derivation", () => { } }); + it("clears the gateway token when a readonly caller value conflicts with the trust anchor (#6413)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gwenv-conflict-")); + try { + const envFilePath = writeRuntimeShellEnv(tmpDir); + const fakeBin = path.join(tmpDir, "bin"); + const callLog = path.join(tmpDir, "openclaw-calls.log"); + fs.mkdirSync(fakeBin); + fs.writeFileSync( + path.join(fakeBin, "openclaw"), + [ + "#!/usr/bin/env bash", + `printf 'ARGS=%s TOKEN=%s\\n' "$*" "\${OPENCLAW_GATEWAY_TOKEN:-unset}" >> ${JSON.stringify(callLog)}`, + ].join("\n"), + { mode: 0o755 }, + ); + + const probe = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + "_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL=ws://attacker.invalid:18790", + "builtin readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL", + `. ${JSON.stringify(envFilePath)} && echo SOURCE_STATUS=unexpected || echo SOURCE_STATUS=blocked`, + "if declare -F openclaw >/dev/null; then echo WHATSAPP_WRAPPER=installed; else echo WHATSAPP_WRAPPER=disabled; fi", + "if declare -F _nemoclaw_whatsapp_postpair_start >/dev/null; then echo TOKEN_HELPER=installed; else echo TOKEN_HELPER=disabled; fi", + "openclaw channels login --channel whatsapp", + 'openclaw gateway call channels.start --params \'{"channel":"whatsapp"}\' --json', + ].join("\n"), + ], + { + encoding: "utf-8", + timeout: 5000, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", + OPENCLAW_GATEWAY_URL: "ws://attacker.invalid:18790", + }, + }, + ); + expect(probe.status, probe.stderr).toBe(0); + expect(probe.stdout).toContain("SOURCE_STATUS=blocked"); + expect(probe.stdout).toContain("WHATSAPP_WRAPPER=disabled"); + expect(probe.stdout).toContain("TOKEN_HELPER=disabled"); + expect(probe.stderr).toContain("gateway-token helpers were disabled"); + + const calls = fs.readFileSync(callLog, "utf-8").split("\n").filter(Boolean); + expect(calls).toHaveLength(2); + expect(calls[0]).toContain("ARGS=channels login --channel whatsapp TOKEN=unset"); + expect(calls[1]).toContain("ARGS=gateway call channels.start"); + expect(calls[1]).toContain("TOKEN=unset"); + expect(calls.every((line) => !line.includes("ambient-gateway-token"))).toBe(true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("sources the trusted runtime env for the auto-pair watcher child only (#4504)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-autopair-env-")); try { diff --git a/test/repro-6413-whatsapp-postpair-start.test.ts b/test/repro-6413-whatsapp-postpair-start.test.ts index 3b125e352c..dd5b6552ef 100644 --- a/test/repro-6413-whatsapp-postpair-start.test.ts +++ b/test/repro-6413-whatsapp-postpair-start.test.ts @@ -216,6 +216,31 @@ describe("WhatsApp post-pair gateway channel start (#6413)", () => { expect(reconcile[0]).toContain('--params {"channel":"whatsapp","accountId":"biz.2"}'); }); + it("accepts a 128-character account id at the RPC boundary (#6413)", () => { + const accountId = "a".repeat(128); + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + loginArgs: ["channels", "login", "--channel", "whatsapp", "--account", accountId], + }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + const reconcile = gatewayCallLines(r.calls); + expect(reconcile).toHaveLength(1); + expect(reconcile[0]).toContain(`--params {"channel":"whatsapp","accountId":"${accountId}"}`); + }); + + it("refuses an account id longer than 128 characters (#6413)", () => { + const r = runLoginThroughGuard({ + configuredToken: GATEWAY_TOKEN, + loginArgs: ["channels", "login", "--channel", "whatsapp", "--account", "a".repeat(129)], + }); + + expect(r.stdout).toContain("GUARD_EXIT=0"); + expect(gatewayCallLines(r.calls)).toHaveLength(0); + expect(r.stderr).toContain("account id exceeds 128 characters"); + expect(r.stderr).toContain("nemoclaw channels status --channel whatsapp"); + }); + it("refuses to embed an unsafe account id in the RPC params", () => { const r = runLoginThroughGuard({ configuredToken: GATEWAY_TOKEN,