From 1a598da6a24bbd2fe54020e52f64981c7a60fc8e Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 9 Jul 2026 12:20:18 +0530 Subject: [PATCH 01/13] fix(sandbox): pair WhatsApp login over the loopback gateway Signed-off-by: Hung Le --- scripts/nemoclaw-start.sh | 92 +++++++++++++++++--------------- test/whatsapp-qr-compact.test.ts | 24 ++++++--- 2 files changed, 65 insertions(+), 51 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 9d9411a104..9eff09517b 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3441,57 +3441,63 @@ openclaw() { # "1008 abnormal closure") is diagnosed separately from QR rendering, # and force compact QR output so the code fits on the screen. if [ "$_login_help" != "1" ] && [ "$_login_channel" = "whatsapp" ]; then - # Keep an explicit override coupled to its own opt-in. The private - # veth URL may inherit only NemoClaw's matching private-WS marker. - if [ -n "${OPENCLAW_GATEWAY_URL:-}" ]; then - _nemoclaw_whatsapp_gateway_url="$OPENCLAW_GATEWAY_URL" - _nemoclaw_whatsapp_insecure_ws="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" + # NemoClaw#6413: pair over the in-sandbox loopback gateway. Do NOT + # re-inject the stashed private veth URL + # (NEMOCLAW_OPENCLAW_GATEWAY_URL): a private-IP origin makes the + # gateway's locality check strip operator scopes, so the post-pair + # channels.start restart is denied with "missing scope: + # operator.admin". With no URL in the environment OpenClaw resolves + # ws://127.0.0.1: from its own config — the same loopback + # resolution the `devices approve` wrapper (NemoClaw#4462) relies + # on. That single change dictates the rest of this block's shape: + # an unset URL is the healthy default rather than an error (the + # old "gateway URL is not set" refusal is gone), the ws:// scheme + # check and the pairing banner apply only to an explicitly + # exported OPENCLAW_GATEWAY_URL (kept as an operator escape + # hatch), and the login runs in a subshell that exports the + # override env only when present — an empty-but-set + # OPENCLAW_GATEWAY_URL is not equivalent to an unset one for + # OpenClaw's config resolution. + _nemoclaw_whatsapp_gateway_url="${OPENCLAW_GATEWAY_URL:-}" + _nemoclaw_whatsapp_insecure_ws="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" + if [ -n "$_nemoclaw_whatsapp_gateway_url" ]; then + # The OpenClaw gateway is a WebSocket endpoint (set to + # ws://127.0.0.1: at boot). Reject a malformed scheme up + # front so a typo'd/clobbered URL is reported as a gateway/env + # problem rather than failing inside the login as an ambiguous + # close. + case "$_nemoclaw_whatsapp_gateway_url" in + ws://*|wss://*) ;; + *) + echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' is not a ws:// gateway URL." >&2 + echo "The OpenClaw gateway is a WebSocket endpoint (e.g. ws://127.0.0.1:); a malformed value" >&2 + echo "would fail the login in a way that looks like a QR/pairing problem (this is a gateway/env problem)." >&2 + echo "" >&2 + echo "Reconnect with 'openshell sandbox connect ' and retry. If it persists," >&2 + echo "exit the sandbox and rebuild with 'nemoclaw rebuild'." >&2 + return 1 + ;; + esac + echo "[whatsapp] Pairing via gateway ${_nemoclaw_whatsapp_gateway_url} (explicit override)." >&2 else - _nemoclaw_whatsapp_gateway_url="${NEMOCLAW_OPENCLAW_GATEWAY_URL:-}" - _nemoclaw_whatsapp_insecure_ws="${NEMOCLAW_OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" + echo "[whatsapp] Pairing via the in-sandbox gateway (loopback)." >&2 fi - if [ -z "$_nemoclaw_whatsapp_gateway_url" ]; then - echo "Error: WhatsApp pairing cannot start — gateway URL is not set in this shell." >&2 - echo "Pairing talks to the OpenClaw gateway; without the gateway URL the login will" >&2 - echo "close immediately (this is a gateway/env problem, not a QR problem)." >&2 - echo "" >&2 - echo "Reconnect with 'openshell sandbox connect ' and retry. If it persists," >&2 - echo "exit the sandbox and rebuild with 'nemoclaw rebuild'." >&2 - return 1 - fi - # The OpenClaw gateway is a WebSocket endpoint (set to - # ws://127.0.0.1: at boot). Reject a malformed scheme up front - # so a typo'd/clobbered URL is reported as a gateway/env problem - # rather than failing inside the login as an ambiguous close. - case "$_nemoclaw_whatsapp_gateway_url" in - ws://*|wss://*) ;; - *) - echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' is not a ws:// gateway URL." >&2 - echo "The OpenClaw gateway is a WebSocket endpoint (e.g. ws://127.0.0.1:); a malformed value" >&2 - echo "would fail the login in a way that looks like a QR/pairing problem (this is a gateway/env problem)." >&2 - echo "" >&2 - echo "Reconnect with 'openshell sandbox connect ' and retry. If it persists," >&2 - echo "exit the sandbox and rebuild with 'nemoclaw rebuild'." >&2 - return 1 - ;; - esac - echo "[whatsapp] Pairing via gateway ${_nemoclaw_whatsapp_gateway_url}." >&2 echo "[whatsapp] On your phone: WhatsApp > Linked devices > Link a device, then scan the QR below." >&2 # Defense-in-depth: connect-session NODE_OPTIONS already wires # manifest-declared connect preloads for every openclaw invocation; # 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 "$@" - fi + ( + if [ -n "$_nemoclaw_whatsapp_gateway_url" ]; then + export OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" + export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" + fi + 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 diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index 3561294327..fe4ccc2b12 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -377,14 +377,18 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); }); - it("refuses to pair when no public or private gateway URL is available (#4504)", () => { + it("pairs over loopback config resolution when no gateway URL is set (#6413)", () => { const r = runGuard(["channels", "login", "--channel", "whatsapp"], { preloadPresent: true, }); - expect(r.stderr).toContain("gateway URL is not set"); - expect(r.stdout).toContain("GUARD_EXIT=1"); - // Must not attempt the login when the gateway env is missing. - expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); + expect(r.stderr).toContain("Pairing via the in-sandbox gateway (loopback)"); + expect(r.stdout).toContain("FAKE_OPENCLAW_ARGS=channels login --channel whatsapp"); + // No URL in the environment: OpenClaw resolves ws://127.0.0.1: from + // its own config, keeping the pairing origin local so operator scopes + // survive the post-pair channels.start restart. + expect(r.stdout).toContain("FAKE_OPENCLAW_GATEWAY_URL=unset"); + expect(r.stdout).toContain("FAKE_OPENCLAW_INSECURE_WS=unset"); + expect(r.stdout).toContain("GUARD_EXIT=0"); }); it.each([ @@ -414,15 +418,19 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).toContain("GUARD_EXIT=0"); }); - it("reinjects the NemoClaw-private gateway URL and private-WS flag for WhatsApp (#4504)", () => { + it("does not re-inject the stashed private gateway URL for WhatsApp (#6413)", () => { const r = runGuard(["channels", "login", "--channel", "whatsapp"], { privateGatewayUrl: "ws://10.200.0.2:18790", insecurePrivateWs: "1", preloadPresent: true, }); expect(r.stdout).toContain("FAKE_OPENCLAW_ARGS=channels login --channel whatsapp"); - expect(r.stdout).toContain("FAKE_OPENCLAW_GATEWAY_URL=ws://10.200.0.2:18790"); - expect(r.stdout).toContain("FAKE_OPENCLAW_INSECURE_WS=1"); + // The stashed private veth URL must stay out of the login environment: a + // private-IP origin makes the gateway's locality check strip operator + // scopes and the post-pair restart fails with "missing scope: + // operator.admin". + expect(r.stdout).toContain("FAKE_OPENCLAW_GATEWAY_URL=unset"); + expect(r.stdout).toContain("FAKE_OPENCLAW_INSECURE_WS=unset"); expect(r.stdout).toContain("GUARD_EXIT=0"); }); From 078a1fec9a7dfc532d9455d3c89cb563a36460f7 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Fri, 10 Jul 2026 16:11:14 +0530 Subject: [PATCH 02/13] fix(whatsapp): pair login over the loopback gateway instead of post-pair reconcile Signed-off-by: Hung Le --- docs/manage-sandboxes/messaging-channels.mdx | 11 +- scripts/nemoclaw-start.sh | 387 ++++------------- test/nemoclaw-start-gateway-ws-host.test.ts | 402 ++--------------- ...repro-6413-whatsapp-postpair-start.test.ts | 408 ------------------ test/whatsapp-qr-compact.test.ts | 19 +- 5 files changed, 117 insertions(+), 1110 deletions(-) delete mode 100644 test/repro-6413-whatsapp-postpair-start.test.ts diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx index 782f16ed1a..918a31b481 100644 --- a/docs/manage-sandboxes/messaging-channels.mdx +++ b/docs/manage-sandboxes/messaging-channels.mdx @@ -148,15 +148,8 @@ openclaw channels login --channel whatsapp # OpenClaw sandboxes hermes whatsapp # Hermes 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. -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. - +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. 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 d91a3cb6d3..21469ff20d 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3260,43 +3260,11 @@ PROXYEOF _escaped_gateway_port="$(printf '%s' "$OPENCLAW_GATEWAY_PORT" | sed "s/'/'\\\\''/g")" printf "export OPENCLAW_GATEWAY_PORT='%s'\n" "$_escaped_gateway_port" fi - _escaped_gateway_url="" if [ -n "${OPENCLAW_GATEWAY_URL:-}" ]; then _escaped_gateway_url="$(printf '%s' "$OPENCLAW_GATEWAY_URL" | sed "s/'/'\\\\''/g")" # 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 as a source-time integrity check. The - # exported compatibility alias is intentionally caller-mutable, so it - # must never decide where a gateway token may be sent. Case-based value - # checks cannot be shadowed by imported shell functions; the structural - # re-export gate below withholds the token after any anchor failure even - # if a shadowed return continues sourcing. Use `command` instead of the - # Bash-only `builtin`: profile hooks also source this file through sh. - printf "case \"\${_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL:-}\" in\\n" - printf " '%s') ;;\\n" "$_escaped_gateway_url" - printf " *) command readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL='%s' 2>/dev/null || : ;;\\n" "$_escaped_gateway_url" - printf "esac\\ncase \"\${_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL:-}\" in\\n" - printf " '%s') ;;\\n" "$_escaped_gateway_url" - printf " *)\\n" - printf " command unset OPENCLAW_GATEWAY_TOKEN 2>/dev/null || :\\n" - printf " case \"\${OPENCLAW_GATEWAY_TOKEN+x}\" in\\n" - printf " x) echo 'Error: NemoClaw rejected a conflicting gateway trust anchor, and the ambient gateway token could not be cleared.' >&2; exit 1 ;;\\n" - printf " esac\\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 " ;;\\nesac\\ncommand readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL\\n" - # Verify the shell actually enforced readonly state before installing - # token-bearing helpers. A caller-controlled `command` function can - # otherwise turn the portable builtin dispatch into a no-op. - printf "if ( _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL= ) 2>/dev/null; then\\n" - printf " command unset OPENCLAW_GATEWAY_TOKEN 2>/dev/null || :\\n" - printf " case \"\${OPENCLAW_GATEWAY_TOKEN+x}\" in\\n" - printf " x) echo 'Error: NemoClaw gateway trust anchor did not become readonly, and the ambient gateway token could not be cleared.' >&2; exit 1 ;;\\n" - printf " esac\\n" - printf " echo 'Error: NemoClaw gateway trust anchor did not become readonly; gateway-token helpers were disabled.' >&2\\n" - printf " return 1 2>/dev/null || exit 1\\n" - printf "fi\\n" printf "export NEMOCLAW_OPENCLAW_GATEWAY_URL='%s'\n" "$_escaped_gateway_url" cat <<'GATEWAYURLENVEOF' # Equality identifies NemoClaw's inherited private-interface value. A different @@ -3310,15 +3278,7 @@ GATEWAYURLENVEOF fi if [ -n "${OPENCLAW_GATEWAY_TOKEN:-}" ]; then _escaped_gateway_token="$(printf '%s' "$OPENCLAW_GATEWAY_TOKEN" | sed "s/'/'\\\\''/g")" - # Re-export the generated token only when the anchor has the exact baked - # value and is actually readonly. This structurally guards against a - # shadowed return continuing after either source-time rejection path. - printf "case \"\${_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL:-}\" in\\n" - printf " '%s')\\n" "$_escaped_gateway_url" - printf " if ! ( _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL= ) 2>/dev/null; then\\n" - printf " export OPENCLAW_GATEWAY_TOKEN='%s'\\n" "$_escaped_gateway_token" - printf " fi\\n" - printf " ;;\\nesac\\n" + printf "export OPENCLAW_GATEWAY_TOKEN='%s'\n" "$_escaped_gateway_token" fi if [ -n "${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" ]; then # Retain the matching break-glass under the same private namespace. @@ -3382,111 +3342,6 @@ _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 - ;; - *) - 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 - 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() { local _nemoclaw_guard_request_handled=0 _nemoclaw_guard_request_status=0 # NemoClaw#4462: approval calls temporarily drop the gateway URL/port/token @@ -3538,10 +3393,8 @@ 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 @@ -3553,11 +3406,6 @@ 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 @@ -3565,12 +3413,6 @@ openclaw() { --channel=*) _login_channel="${_arg#--channel=}" ;; - --account) - _prev_arg_was_account_flag=1 - ;; - --account=*) - _login_account="${_arg#--account=}" - ;; -h | --help) _login_help=1 ;; @@ -3604,160 +3446,97 @@ openclaw() { _nemoclaw_guard_request_status=1 ;; esac - # NemoClaw-supported WhatsApp pairing (NemoClaw#4522): validate the - # gateway environment up front so a gateway close (e.g. the reported - # "1008 abnormal closure") is diagnosed separately from QR rendering, - # and force compact QR output so the code fits on the screen. + # NemoClaw-supported WhatsApp pairing (NemoClaw#4522): pair over + # the in-sandbox loopback gateway and force compact QR output so the + # code fits on the screen. case "$_login_help:$_login_channel" in 0:whatsapp) - # Keep an explicit override coupled to its own opt-in. The private - # veth URL may inherit only NemoClaw's matching private-WS marker. - if [ -n "${OPENCLAW_GATEWAY_URL:-}" ]; then - _nemoclaw_whatsapp_gateway_url="$OPENCLAW_GATEWAY_URL" + # NemoClaw#6413: do NOT re-inject the stashed private veth URL + # (NEMOCLAW_OPENCLAW_GATEWAY_URL): a private-IP origin makes the + # gateway's locality check strip operator scopes regardless of + # token auth, so the login's own post-pair channels.start restart + # is denied with "missing scope: operator.admin". With no URL in + # the environment OpenClaw resolves ws://127.0.0.1: from + # its own config — the same loopback resolution the `devices + # approve` wrapper (NemoClaw#4462) relies on — and the post-pair + # restart succeeds without any token-bearing reconcile. That + # single change dictates this block's shape: an unset URL is the + # healthy default rather than an error, the ws:// scheme check + # and the pairing banner apply only to an explicitly exported + # OPENCLAW_GATEWAY_URL (kept as an operator escape hatch), and + # the login runs in a subshell that exports the override env + # only when present — an empty-but-set OPENCLAW_GATEWAY_URL is + # not equivalent to an unset one for OpenClaw's config + # resolution. + _nemoclaw_whatsapp_gateway_url="${OPENCLAW_GATEWAY_URL:-}" _nemoclaw_whatsapp_insecure_ws="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" - else - _nemoclaw_whatsapp_gateway_url="${NEMOCLAW_OPENCLAW_GATEWAY_URL:-}" - _nemoclaw_whatsapp_insecure_ws="${NEMOCLAW_OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" - fi - if [ -z "$_nemoclaw_whatsapp_gateway_url" ]; then - echo "Error: WhatsApp pairing cannot start — gateway URL is not set in this shell." >&2 - echo "Pairing talks to the OpenClaw gateway; without the gateway URL the login will" >&2 - echo "close immediately (this is a gateway/env problem, not a QR problem)." >&2 - echo "" >&2 - echo "Reconnect with 'openshell sandbox connect ' and retry. If it persists," >&2 - echo "exit the sandbox and rebuild with 'nemoclaw rebuild'." >&2 - return 1 - fi - # The OpenClaw gateway is a WebSocket endpoint (set to - # ws://127.0.0.1: at boot). Reject a malformed scheme up front - # so a typo'd/clobbered URL is reported as a gateway/env problem - # rather than failing inside the login as an ambiguous close. - case "$_nemoclaw_whatsapp_gateway_url" in - ws://*|wss://*) ;; - *) - echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' is not a ws:// gateway URL." >&2 - echo "The OpenClaw gateway is a WebSocket endpoint (e.g. ws://127.0.0.1:); a malformed value" >&2 - echo "would fail the login in a way that looks like a QR/pairing problem (this is a gateway/env problem)." >&2 - echo "" >&2 - echo "Reconnect with 'openshell sandbox connect ' and retry. If it persists," >&2 - echo "exit the sandbox and rebuild with 'nemoclaw rebuild'." >&2 - return 1 - ;; - esac - echo "[whatsapp] Pairing via gateway ${_nemoclaw_whatsapp_gateway_url}." >&2 - echo "[whatsapp] On your phone: WhatsApp > Linked devices > Link a device, then scan the QR below." >&2 - # Defense-in-depth: connect-session NODE_OPTIONS already wires - # manifest-declared connect preloads for every openclaw invocation; - # 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)" - # The shared gateway token (ambient in connect shells; see the - # runtime env at OPENCLAW_GATEWAY_TOKEN) must never reach a - # caller-selected gateway. The public NEMOCLAW_* compatibility - # alias and readonly shell variable are caller-preseedable, so the - # root-owned runtime file bakes the expected URL directly into this - # helper instead of trusting either variable for this decision. - # 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. -GUARDENVEOF - # nemoclaw-trusted-gateway-literal-injection begin - # Keep the security decision independent of a caller-preseedable variable: - # this literal is generated into the root-owned runtime file at boot. - printf " _nemoclaw_whatsapp_trusted_url='%s'\n" "${_escaped_gateway_url:-}" - # nemoclaw-trusted-gateway-literal-injection end - cat <<'GUARDENVEOF' - _nemoclaw_whatsapp_url_is_trusted=0 - # Use shell syntax rather than `[` for this security decision: a - # caller may have an exported function named `[` in its Bash - # environment, but cannot shadow `case`. The quoted case pattern - # is an exact literal match, not a glob. - case "$_nemoclaw_whatsapp_trusted_url" in - "") ;; - "$_nemoclaw_whatsapp_gateway_url") _nemoclaw_whatsapp_url_is_trusted=1 ;; - esac - # 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 - case "$_nemoclaw_whatsapp_url_is_trusted:${OPENCLAW_GATEWAY_TOKEN+x}" in - 1:x) _nemoclaw_whatsapp_login_had_token=1 ;; - esac - # 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. Invoke through the absolute env utility - # so readonly variables and ordinary imported functions named - # `command`, `unset`, or `exit` cannot defeat token removal - # (Bash does not import slash-named functions). This also leaves - # the parent shell's token untouched. - case "$_nemoclaw_whatsapp_url_is_trusted:$_nemoclaw_connect_node_options" in - 1:) - OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" \ - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" \ - /usr/bin/env openclaw "$@" - ;; - 1:*) - 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" \ - /usr/bin/env openclaw "$@" - ;; - *:) - OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" \ - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" \ - /usr/bin/env -u OPENCLAW_GATEWAY_TOKEN openclaw "$@" - ;; - *) - 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" \ - /usr/bin/env -u OPENCLAW_GATEWAY_TOKEN openclaw "$@" - ;; - esac - ) - _whatsapp_login_exit=$? - case "$_whatsapp_login_exit:$_nemoclaw_whatsapp_url_is_trusted:$_nemoclaw_whatsapp_login_had_token" in - 0:1:1) - : # 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. - ;; - 0:1:*) - _nemoclaw_whatsapp_postpair_start "$_nemoclaw_whatsapp_trusted_url" \ - "$_nemoclaw_whatsapp_insecure_ws" "$_login_account" - ;; - 0:*) - # 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 - ;; - *) + if [ -n "$_nemoclaw_whatsapp_gateway_url" ]; then + # The OpenClaw gateway is a WebSocket endpoint (set to + # ws://127.0.0.1: at boot). Reject a malformed scheme up + # front so a typo'd/clobbered URL is reported as a gateway/env + # problem rather than failing inside the login as an ambiguous + # close. + case "$_nemoclaw_whatsapp_gateway_url" in + ws://* | wss://*) ;; + *) + echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' is not a ws:// gateway URL." >&2 + echo "The OpenClaw gateway is a WebSocket endpoint (e.g. ws://127.0.0.1:); a malformed value" >&2 + echo "would fail the login in a way that looks like a QR/pairing problem (this is a gateway/env problem)." >&2 + echo "" >&2 + echo "Reconnect with 'openshell sandbox connect ' and retry. If it persists," >&2 + echo "exit the sandbox and rebuild with 'nemoclaw rebuild'." >&2 + return 1 + ;; + esac + echo "[whatsapp] Pairing via gateway ${_nemoclaw_whatsapp_gateway_url} (explicit override)." >&2 + else + echo "[whatsapp] Pairing via the in-sandbox gateway (loopback)." >&2 + fi + echo "[whatsapp] On your phone: WhatsApp > Linked devices > Link a device, then scan the QR below." >&2 + # Defense-in-depth: connect-session NODE_OPTIONS already wires + # manifest-declared connect preloads for every openclaw + # invocation; 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)" + # Run the login with errexit disabled so its exit status is + # always captured (and the post-login guidance 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 + ( + if [ -n "$_nemoclaw_whatsapp_gateway_url" ]; then + # Assign-then-export: the generated runtime env file embeds + # this guard body, and its emission contract forbids ambient + # single-line gateway-URL export statements anywhere in that + # file. These exports live only inside this login subshell. + OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" + export OPENCLAW_GATEWAY_URL OPENCLAW_ALLOW_INSECURE_PRIVATE_WS + fi + 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 echo "[whatsapp] Pairing exited with code ${_whatsapp_login_exit} before it completed." >&2 echo "[whatsapp] A gateway close (e.g. '1008 abnormal closure') is a gateway/session" >&2 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 - ;; - esac - [ "$_nemoclaw_whatsapp_login_errexit" = "1" ] && set -e - # Defer the return to the final dispatch below. If a caller has an - # imported function named `return`, it may alter the status but it - # cannot fall through into the token-preserving generic invocation. - _nemoclaw_guard_request_handled=1 - _nemoclaw_guard_request_status=$_whatsapp_login_exit + fi + [ "$_nemoclaw_whatsapp_login_errexit" = "1" ] && set -e + # Defer the return to the final dispatch below. If a caller has + # an imported function named `return`, it may alter the status + # but it cannot fall through into the token-preserving generic + # invocation. + _nemoclaw_guard_request_handled=1 + _nemoclaw_guard_request_status=$_whatsapp_login_exit ;; esac ;; diff --git a/test/nemoclaw-start-gateway-ws-host.test.ts b/test/nemoclaw-start-gateway-ws-host.test.ts index 95825044c3..c6c20c0e95 100644 --- a/test/nemoclaw-start-gateway-ws-host.test.ts +++ b/test/nemoclaw-start-gateway-ws-host.test.ts @@ -13,7 +13,6 @@ const requireForTest = createRequire(import.meta.url); const YAML = requireForTest("yaml"); const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); -const RUNTIME_ENV_SHELLS = ["bash", "sh"] as const; const startScriptSource = fs.readFileSync(START_SCRIPT, "utf-8"); @@ -188,405 +187,60 @@ describe("gateway websocket url host derivation", () => { expect(envFile).not.toContain("export OPENCLAW_GATEWAY_URL="); expect(envFile).not.toContain("export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="); - for (const shell of RUNTIME_ENV_SHELLS) { - const sourced = spawnSync( - shell, - [ - "-c", - [ - `. ${JSON.stringify(envFilePath)}`, - `. ${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}"', - ].join("; "), - ], - { - encoding: "utf-8", - timeout: 5000, - env: { - ...process.env, - OPENCLAW_GATEWAY_PORT: "18790", - OPENCLAW_GATEWAY_URL: "ws://10.200.0.2:18790", - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1", - }, - }, - ); - expect(sourced.status, `${shell}: ${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"); - - const explicitOverride = spawnSync( - shell, - [ - "-c", - `. ${JSON.stringify(envFilePath)}; printf "URL=%s INSECURE=%s\\n" "$OPENCLAW_GATEWAY_URL" "$OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"`, - ], - { - encoding: "utf-8", - timeout: 5000, - env: { - ...process.env, - OPENCLAW_GATEWAY_URL: "wss://gateway.example.test:443", - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "explicit-marker", - }, - }, - ); - expect(explicitOverride.status, `${shell}: ${explicitOverride.stderr}`).toBe(0); - expect(explicitOverride.stdout).toContain( - "URL=wss://gateway.example.test:443 INSECURE=explicit-marker", - ); - } - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - 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"), - [ - "#!/bin/sh", - `printf 'ARGS=%s TOKEN=%s\\n' "$*" "\${OPENCLAW_GATEWAY_TOKEN:-unset}" >> ${JSON.stringify(callLog)}`, - ].join("\n"), - { mode: 0o755 }, - ); - - for (const shell of RUNTIME_ENV_SHELLS) { - fs.rmSync(callLog, { force: true }); - const probe = spawnSync( - shell, - [ - "-c", - [ - "_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL=ws://attacker.invalid:18790", - "command readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL", - `. ${JSON.stringify(envFilePath)} && echo SOURCE_STATUS=unexpected || echo SOURCE_STATUS=blocked`, - "if command -v _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, `${shell}: ${probe.stderr}`).toBe(0); - expect(probe.stdout).toContain("SOURCE_STATUS=blocked"); - 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("refuses a conflicting anchor when shadowed dispatch cannot clear the token (#6413)", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gwenv-conflict-shadow-")); - 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"), - '#!/bin/sh\nprintf invoked > "$CALL_LOG"\n', - { - mode: 0o755, - }, - ); - - for (const shell of RUNTIME_ENV_SHELLS) { - fs.writeFileSync(callLog, ""); - const probe = spawnSync( - shell, - [ - "-c", - [ - "_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL=ws://attacker.invalid:18790", - "readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL", - "command() { :; }", - `. ${JSON.stringify(envFilePath)}`, - "openclaw channels login --channel whatsapp", - ].join("\n"), - ], - { - encoding: "utf-8", - timeout: 5000, - env: { - ...process.env, - CALL_LOG: callLog, - PATH: `${fakeBin}:${process.env.PATH ?? ""}`, - OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", - }, - }, - ); - expect(probe.status, `${shell}: ${probe.stderr}`).toBe(1); - expect(probe.stderr).toContain( - "conflicting gateway trust anchor, and the ambient gateway token could not be cleared", - ); - expect(fs.readFileSync(callLog, "utf-8"), shell).toBe(""); - } - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("fails closed when shell dispatch cannot make the trust anchor readonly (#6413)", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gwenv-mutable-anchor-")); - try { - const envFilePath = writeRuntimeShellEnv(tmpDir); - - for (const shell of RUNTIME_ENV_SHELLS) { - const probe = spawnSync( - shell, - [ - "-c", - [ - "command() { :; }", - "_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL=ws://10.200.0.2:18790", - `. ${JSON.stringify(envFilePath)}`, - ].join("\n"), - ], - { - encoding: "utf-8", - timeout: 5000, - env: { - ...process.env, - OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", - }, - }, - ); - expect(probe.status, `${shell}: ${probe.stderr}`).toBe(1); - expect(probe.stderr).toContain( - "gateway trust anchor did not become readonly, and the ambient gateway token could not be cleared", - ); - } - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("strips the gateway token from caller-selected WhatsApp URLs in Bash and POSIX sh (#6413)", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gwenv-whatsapp-sh-")); - 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"), - [ - "#!/bin/sh", - `printf 'URL=%s TOKEN=%s\\n' "\${OPENCLAW_GATEWAY_URL:-unset}" "\${OPENCLAW_GATEWAY_TOKEN:-unset}" > ${JSON.stringify(callLog)}`, - ].join("\n"), - { mode: 0o755 }, - ); - - for (const shell of RUNTIME_ENV_SHELLS) { - const probe = spawnSync( - shell, - ["-c", `. ${JSON.stringify(envFilePath)}; openclaw channels login --channel whatsapp`], - { - encoding: "utf-8", - timeout: 5000, - env: { - ...process.env, - PATH: `${fakeBin}:${process.env.PATH ?? ""}`, - OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", - OPENCLAW_GATEWAY_URL: "wss://caller-selected.example.test:443", - }, - }, - ); - expect(probe.status, `${shell}: ${probe.stderr}`).toBe(0); - expect(fs.readFileSync(callLog, "utf-8")).toBe( - "URL=wss://caller-selected.example.test:443 TOKEN=unset\n", - ); - } - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("strips a readonly token from caller-selected WhatsApp URLs without trusting exit (#6413)", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gwenv-whatsapp-readonly-")); - 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"), - [ - "#!/bin/sh", - `printf 'TOKEN=%s\\n' "\${OPENCLAW_GATEWAY_TOKEN:-unset}" > ${JSON.stringify(callLog)}`, - ].join("\n"), - { mode: 0o755 }, - ); - - for (const shell of RUNTIME_ENV_SHELLS) { - fs.writeFileSync(callLog, ""); - const probe = spawnSync( - shell, - [ - "-c", - [ - `. ${JSON.stringify(envFilePath)}`, - "command readonly OPENCLAW_GATEWAY_TOKEN", - ...(shell === "bash" ? ["exit() { :; }"] : []), - "openclaw channels login --channel whatsapp", - ].join("\n"), - ], - { - encoding: "utf-8", - timeout: 5000, - env: { - ...process.env, - PATH: `${fakeBin}:${process.env.PATH ?? ""}`, - OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", - OPENCLAW_GATEWAY_URL: "wss://caller-selected.example.test:443", - }, - }, - ); - expect(probe.status, `${shell}: ${probe.stderr}`).toBe(0); - expect(fs.readFileSync(callLog, "utf-8"), shell).toBe("TOKEN=unset\n"); - } - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("fails closed when an imported bracket function breaks login parsing (#6413)", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gwenv-whatsapp-bracket-")); - 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"), - '#!/bin/sh\nprintf invoked > "$CALL_LOG"\n', - { mode: 0o755 }, - ); - fs.writeFileSync(callLog, ""); - - const probe = spawnSync( + const sourced = spawnSync( "bash", [ + "--noprofile", + "--norc", "-c", [ `. ${JSON.stringify(envFilePath)}`, - "function [ { return 1; }", - "export OPENCLAW_GATEWAY_URL=wss://caller-selected.example.test:443", - "openclaw channels login --channel whatsapp", - ].join("\n"), + 'printf "PUBLIC_URL=%s\\n" "${OPENCLAW_GATEWAY_URL-unset}"', + 'printf "PRIVATE_URL=%s\\n" "${NEMOCLAW_OPENCLAW_GATEWAY_URL-unset}"', + '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}"', + ].join("; "), ], { encoding: "utf-8", timeout: 5000, env: { ...process.env, - CALL_LOG: callLog, - PATH: `${fakeBin}:${process.env.PATH ?? ""}`, - OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", + OPENCLAW_GATEWAY_PORT: "18790", + OPENCLAW_GATEWAY_URL: "ws://10.200.0.2:18790", + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1", }, }, ); - expect(probe.status, probe.stderr).toBe(1); - expect(probe.stderr).toContain( - "'openclaw channels login' is only supported inside the sandbox for WhatsApp", - ); - expect(fs.readFileSync(callLog, "utf-8")).toBe(""); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it.each([ - { - scenario: "a selective bracket function poisons source-time comparisons", - setup: [ - "_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL=wss://caller-selected.example.test:443", - 'function [ { case "$1:$2:$3" in wss://caller-selected.example.test:443:!=:ws://10.200.0.2:18790) return 1 ;; esac; builtin [ "$@"; }', - ], - }, - { - scenario: "a return function continues after a conflicting readonly anchor", - setup: [ - "_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL=wss://caller-selected.example.test:443", - "readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL", - "function return { :; }", - ], - }, - ])("strips the token when $scenario (#6413)", ({ setup }) => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gwenv-source-poison-")); - 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"), - [ - "#!/bin/sh", - `printf 'URL=%s TOKEN=%s\\n' "\${OPENCLAW_GATEWAY_URL:-unset}" "\${OPENCLAW_GATEWAY_TOKEN:-unset}" > ${JSON.stringify(callLog)}`, - ].join("\n"), - { mode: 0o755 }, - ); - - const probe = spawnSync( + 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("PUBLIC_INSECURE=unset"); + expect(sourced.stdout).toContain("PRIVATE_INSECURE=1"); + expect(sourced.stdout).toContain("PORT=18790"); + + const explicitOverride = spawnSync( "bash", [ + "--noprofile", + "--norc", "-c", - [ - ...setup, - `. ${JSON.stringify(envFilePath)}`, - "export OPENCLAW_GATEWAY_URL=wss://caller-selected.example.test:443", - "openclaw channels login --channel whatsapp", - ].join("\n"), + `. ${JSON.stringify(envFilePath)}; printf "URL=%s INSECURE=%s\\n" "$OPENCLAW_GATEWAY_URL" "$OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"`, ], { encoding: "utf-8", timeout: 5000, env: { ...process.env, - PATH: `${fakeBin}:${process.env.PATH ?? ""}`, - OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", + OPENCLAW_GATEWAY_URL: "wss://gateway.example.test:443", + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "explicit-marker", }, }, ); - expect(probe.status, probe.stderr).toBe(0); - expect(fs.readFileSync(callLog, "utf-8")).toBe( - "URL=wss://caller-selected.example.test:443 TOKEN=unset\n", + expect(explicitOverride.status, explicitOverride.stderr).toBe(0); + expect(explicitOverride.stdout).toContain( + "URL=wss://gateway.example.test:443 INSECURE=explicit-marker", ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/test/repro-6413-whatsapp-postpair-start.test.ts b/test/repro-6413-whatsapp-postpair-start.test.ts deleted file mode 100644 index d11cf9ebe7..0000000000 --- a/test/repro-6413-whatsapp-postpair-start.test.ts +++ /dev/null @@ -1,408 +0,0 @@ -// 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, trustedGatewayUrl: 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", - ); - const guardSource = src.slice(begin, end); - const injectionStartMarker = - "GUARDENVEOF\n # nemoclaw-trusted-gateway-literal-injection begin"; - const injectionEndMarker = - " # nemoclaw-trusted-gateway-literal-injection end\n cat <<'GUARDENVEOF'\n"; - const injectionStart = guardSource.indexOf(injectionStartMarker); - const injectionEnd = guardSource.indexOf(injectionEndMarker, injectionStart); - assert( - injectionStart !== -1 && injectionEnd !== -1, - "Expected the generated trusted gateway literal injection markers", - ); - return `${guardSource.slice(0, injectionStart)} _nemoclaw_whatsapp_trusted_url=${JSON.stringify(trustedGatewayUrl)}\n${guardSource.slice(injectionEnd + injectionEndMarker.length)}`; -} - -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; - // 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; -} - -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, PRIVATE_GATEWAY_URL); - - 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)}`, - `_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)) - .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("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, - 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("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, - 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"); - }); -}); diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index c9a81b292f..fe4ccc2b12 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -2,7 +2,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import assert from "node:assert"; import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; @@ -275,28 +274,18 @@ describe("WhatsApp compact-QR preload (qrcode package)", () => { // Extract the sandbox-side `openclaw()` guard function from the single-quoted // heredoc so we can exercise the WhatsApp login branch without a live sandbox. -function extractGuardFunction(src: string, trustedGatewayUrl: string): string { +function extractGuardFunction(src: string): string { const begin = src.indexOf("# nemoclaw-configure-guard begin"); const end = src.indexOf("# nemoclaw-configure-guard end"); if (begin === -1 || end === -1 || end <= begin) { throw new Error("Expected nemoclaw-configure-guard markers in scripts/nemoclaw-start.sh"); } - const guardSource = src.slice(begin, end); - const injectionStartMarker = - "GUARDENVEOF\n # nemoclaw-trusted-gateway-literal-injection begin"; - const injectionEndMarker = - " # nemoclaw-trusted-gateway-literal-injection end\n cat <<'GUARDENVEOF'\n"; - const injectionStart = guardSource.indexOf(injectionStartMarker); - const injectionEnd = guardSource.indexOf(injectionEndMarker, injectionStart); - assert( - injectionStart !== -1 && injectionEnd !== -1, - "Expected the generated trusted gateway literal injection markers", - ); - return `${guardSource.slice(0, injectionStart)} _nemoclaw_whatsapp_trusted_url=${JSON.stringify(trustedGatewayUrl)}\n${guardSource.slice(injectionEnd + injectionEndMarker.length)}`; + return src.slice(begin, end); } describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const guard = extractGuardFunction(src); function runGuard( args: string[], @@ -335,7 +324,7 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { // The guard body hardcodes literal /tmp paths (single-quoted heredoc); // redirect them to temp files for the test. - const guardBody = extractGuardFunction(src, opts.privateGatewayUrl ?? "") + const guardBody = guard .replaceAll("/tmp/nemoclaw-whatsapp-qr-compact.js", preloadPath) .replaceAll("/tmp/nemoclaw-messaging-connect-preloads.list", connectPreloadsPath); From f65abc3c430aad9e8ac49236285e7442f98701e7 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Fri, 10 Jul 2026 22:00:26 +0530 Subject: [PATCH 03/13] fix(messaging): install OpenClaw plugins via npm-pack spec to preserve npm provenance Signed-off-by: Hung Le --- Dockerfile | 6 +++++- .../applier/build/messaging-build-applier.mts | 20 +++++++++++-------- test/openclaw-dependency-review.test.ts | 4 ++-- test/openclaw-lifecycle-policy.test.ts | 5 +++-- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8e670b0699..4e4580d82d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -983,6 +983,10 @@ RUN set -eu; \ # Install non-messaging OpenClaw plugins that need to match the runtime. # Reviewed-archive invariants (#5896): registry SRI, packed-byte SRI, contained # basename in a fresh directory, local-archive-only install, and cleanup. +# The verified tarball installs through the `npm-pack:` spec so OpenClaw +# records npm provenance; bare archive-path installs record archive +# provenance, which fails the trusted-official-install check gating +# openKeyedStore on OpenClaw >= 2026.6.10. # hadolint ignore=DL3059,DL4006 RUN set -eu; \ verify_openclaw_plugin_integrity() { \ @@ -1032,7 +1036,7 @@ RUN set -eu; \ plugin_spec="${1}@${OPENCLAW_VERSION}"; \ plugin_archive="$(verify_openclaw_plugin_integrity "$plugin_spec")"; \ NPM_CONFIG_IGNORE_SCRIPTS=true npm_config_ignore_scripts=true \ - openclaw plugins install "$plugin_archive" --pin; \ + openclaw plugins install "npm-pack:${plugin_archive}"; \ }; \ NEMOCLAW_OPENCLAW_PLUGIN_PACK_DIR="$(mktemp -d)"; \ if [ "$NEMOCLAW_OPENCLAW_OTEL" = "1" ] || [ "$NEMOCLAW_WEB_SEARCH_ENABLED" = "1" ]; then \ diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 7062defa4e..1e5460f1b6 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -661,14 +661,18 @@ export function installOpenClawMessagingPlugins(plan: MessagingBuildPlan | null, for (const install of collectOpenClawMessagingPluginInstalls(plan, env)) { const packed = packVerifiedOpenClawPluginArchive(install, env); try { - runCommand( - ["openclaw", "plugins", "install", packed.archivePath, ...(install.pin ? ["--pin"] : [])], - { - ...env, - NPM_CONFIG_IGNORE_SCRIPTS: "true", - npm_config_ignore_scripts: "true", - }, - ); + // Install through the `npm-pack:` spec so OpenClaw records npm + // provenance (source, resolved name/version, integrity) for the + // verified tarball. A bare archive path records archive provenance, + // which fails the trusted-official-install check gating openKeyedStore + // on OpenClaw >= 2026.6.10 and crash-loops channel plugins that use + // keyed state (e.g. WhatsApp). npm-pack installs always record the + // exact resolved version, so `--pin` is not needed. + runCommand(["openclaw", "plugins", "install", `npm-pack:${packed.archivePath}`], { + ...env, + NPM_CONFIG_IGNORE_SCRIPTS: "true", + npm_config_ignore_scripts: "true", + }); } finally { rmSync(packed.rootDir, { recursive: true, force: true }); } diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index 7fe1acf983..2f78dd930b 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -387,13 +387,13 @@ optional_plugin_block="$(sed -n '/# Install non-messaging OpenClaw plugins that check_contains "$optional_plugin_block" 'npm view "$plugin_spec" dist.integrity' "optional plugin registry integrity" check_contains "$optional_plugin_block" 'npm view "$plugin_spec" dist.tarball' "optional plugin registry tarball" check_contains "$optional_plugin_block" 'npm pack "$expected_tarball" --pack-destination "$NEMOCLAW_OPENCLAW_PLUGIN_PACK_DIR" --json' "optional plugin pack" -check_contains "$optional_plugin_block" 'openclaw plugins install "$plugin_archive" --pin' "optional plugin archive install" +check_contains "$optional_plugin_block" 'openclaw plugins install "npm-pack:\${plugin_archive}"' "optional plugin npm-pack install" check_contains "$optional_plugin_block" 'reported unsafe archive filename' "optional plugin unsafe filename guard" check_contains "$optional_plugin_block" 'NEMOCLAW_OPENCLAW_PLUGIN_PACK_DIR="$(mktemp -d)"' "optional plugin fresh pack directory" check_contains "$optional_plugin_block" 'rm -rf "$NEMOCLAW_OPENCLAW_PLUGIN_PACK_DIR"' "optional plugin cleanup" grep -Fq 'spawnSync("npm", ["pack", packageSpec, "--pack-destination", rootDir, "--json"]' "$messaging_build_applier" - grep -Fq '["openclaw", "plugins", "install", packed.archivePath, ...(install.pin ? ["--pin"] : [])]' "$messaging_build_applier" + grep -Fq '["openclaw", "plugins", "install", \`npm-pack:\${packed.archivePath}\`]' "$messaging_build_applier" grep -Fq 'OPENCLAW_MESSAGING_PLUGIN_ARCHIVE_PROVENANCE_POLICY.registryIntegrityField' "$messaging_build_applier" grep -Fq 'downloaded tarball integrity mismatch' "$messaging_build_applier" grep -Fq 'mkdtempSync(join(tmpdir(), "nemoclaw-openclaw-plugin-pack-"))' "$messaging_build_applier" diff --git a/test/openclaw-lifecycle-policy.test.ts b/test/openclaw-lifecycle-policy.test.ts index a0296f4f82..7b6bbaf347 100644 --- a/test/openclaw-lifecycle-policy.test.ts +++ b/test/openclaw-lifecycle-policy.test.ts @@ -82,9 +82,10 @@ console.log(JSON.stringify({ codex: /npm install -g --no-audit --no-fund --no-progress --ignore-scripts\s+\\\s*"\$CODEX_ACP_PACK_PATH"/.test(codexBlock), runtime: /npm install -g --no-audit --no-fund --no-progress --ignore-scripts "\$OPENCLAW_PACK_PATH"/.test(runtimeBlock), base: /npm install -g --ignore-scripts "\$OPENCLAW_PACK_PATH"/.test(baseBlock), - optionalPlugin: /NPM_CONFIG_IGNORE_SCRIPTS=true npm_config_ignore_scripts=true\s+\\\s*openclaw plugins install "\$plugin_archive" --pin/.test(optionalPluginBlock), + optionalPlugin: /NPM_CONFIG_IGNORE_SCRIPTS=true npm_config_ignore_scripts=true\s+\\\s*openclaw plugins install "npm-pack:/.test(optionalPluginBlock) && + optionalPluginBlock.includes('openclaw plugins install "npm-pack:\${plugin_archive}"'), messagingPlugin: [ - '["openclaw", "plugins", "install", packed.archivePath', + '["openclaw", "plugins", "install", \`npm-pack:\${packed.archivePath}\`]', 'NPM_CONFIG_IGNORE_SCRIPTS: "true"', 'npm_config_ignore_scripts: "true"', ].every((marker) => messagingInstallBlock.includes(marker)), From afdebd2ae26bc48a9f4790cb98766841daec32cb Mon Sep 17 00:00:00 2001 From: Hung Le Date: Sat, 11 Jul 2026 01:26:29 +0530 Subject: [PATCH 04/13] fix(messaging): align contract tests with npm-pack install and restored guard Signed-off-by: Hung Le --- .../messaging-build-applier-integrity.test.ts | 4 ++-- test/messaging-build-applier.test.ts | 24 +++++++++---------- test/openclaw-integrity-pin.test.ts | 6 +++-- test/repro-4538-raw-doctor-perms.test.ts | 21 ++++------------ test/sandbox-provisioning.test.ts | 4 ++-- 5 files changed, 24 insertions(+), 35 deletions(-) diff --git a/test/messaging-build-applier-integrity.test.ts b/test/messaging-build-applier-integrity.test.ts index 7c3885c3a7..988bdb0900 100644 --- a/test/messaging-build-applier-integrity.test.ts +++ b/test/messaging-build-applier-integrity.test.ts @@ -108,8 +108,8 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity"); expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.tarball"); expect(trace).toContain("npm|pack|@openclaw/slack@2026.6.10|--pack-destination"); - expect(trace).toContain("openclaw|plugins|install"); - expect(trace).toContain("slack-2026.6.10.tgz|--pin"); + expect(trace).toContain("openclaw|plugins|install|npm-pack:"); + expect(trace).toContain("slack-2026.6.10.tgz|"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 73bb841b6c..8c05a6cf0c 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -606,9 +606,8 @@ describe("messaging-build-applier.mts: agent-install", () => { const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/discord@2026.6.10|dist.integrity"); expect(trace).toContain("npm|pack|@openclaw/discord@2026.6.10|--pack-destination"); - expect(trace).toContain("plugins|install|"); - expect(trace).toContain("discord-2026.6.10.tgz|--pin"); - expect(trace).toContain("ignore-scripts=true/true"); + expect(trace).toContain("plugins|install|npm-pack:"); + expect(trace).toContain("discord-2026.6.10.tgz|ignore-scripts=true/true"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -675,8 +674,8 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(trace).toContain("npm|view|@openclaw/msteams@2026.6.10|dist.integrity"); expect(trace).toContain("npm|view|@openclaw/msteams@2026.6.10|dist.tarball"); expect(trace).toContain("npm|pack|@openclaw/msteams@2026.6.10|--pack-destination"); - expect(trace).toContain("openclaw|plugins|install|"); - expect(trace).toContain("msteams-2026.6.10.tgz|--pin"); + expect(trace).toContain("openclaw|plugins|install|npm-pack:"); + expect(trace).toContain("msteams-2026.6.10.tgz|"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -866,7 +865,8 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(trace).toContain(`npm|view|${packageSpec}|dist.integrity`); expect(trace).toContain(`npm|view|${packageSpec}|dist.tarball`); expect(trace).toContain(`npm|pack|${packageSpec}|--pack-destination`); - expect(trace).toContain(`${archiveName}|--pin|||`); + expect(trace).toContain("plugins|install|npm-pack:"); + expect(trace).toContain(`${archiveName}||||`); } } finally { fs.rmSync(tmp, { recursive: true, force: true }); @@ -919,8 +919,8 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity"); expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.tarball"); expect(trace).toContain("npm|pack|@openclaw/slack@2026.6.10|--pack-destination"); - expect(trace).toContain("openclaw|plugins|install|"); - expect(trace).toContain("slack-2026.6.10.tgz|--pin"); + expect(trace).toContain("openclaw|plugins|install|npm-pack:"); + expect(trace).toContain("slack-2026.6.10.tgz|"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -1065,8 +1065,8 @@ describe("messaging-build-applier.mts: agent-install", () => { "const args = process.argv.slice(2);", 'fs.appendFileSync(process.env.OPENCLAW_TRACE, `${args.join("|")}|${process.env.DISCORD_BOT_TOKEN || ""}|${process.env.BRAVE_API_KEY || ""}\\n`);', 'if (args[0] === "plugins" && args[1] === "install") {', - ' if (!args[2].endsWith("discord-2026.6.10.tgz")) process.exit(41);', - ' if (args[3] !== "--pin") process.exit(47);', + ' if (!args[2].startsWith("npm-pack:") || !args[2].endsWith("discord-2026.6.10.tgz")) process.exit(41);', + " if (args.length !== 3) process.exit(47);", " process.exit(0);", "}", 'if (args[0] === "doctor" && args[1] === "--fix" && args[2] === "--non-interactive") {', @@ -1133,8 +1133,8 @@ describe("messaging-build-applier.mts: agent-install", () => { const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/discord@2026.6.10|dist.integrity||"); expect(trace).toContain("npm|pack|@openclaw/discord@2026.6.10|--pack-destination||"); - expect(trace).toContain("plugins|install|"); - expect(trace).toContain("discord-2026.6.10.tgz|--pin||"); + expect(trace).toContain("plugins|install|npm-pack:"); + expect(trace).toContain("discord-2026.6.10.tgz||"); expect(trace).toContain( "doctor|--fix|--non-interactive|openshell:resolve:env:DISCORD_BOT_TOKEN|openshell:resolve:env:BRAVE_API_KEY", ); diff --git a/test/openclaw-integrity-pin.test.ts b/test/openclaw-integrity-pin.test.ts index ab9b8871fe..2be4da0b21 100644 --- a/test/openclaw-integrity-pin.test.ts +++ b/test/openclaw-integrity-pin.test.ts @@ -532,7 +532,9 @@ describe("OpenClaw npm integrity pins", () => { expect(calls).toContain( "npm pack https://registry.npmjs.org/@openclaw/diagnostics-otel/-/diagnostics-otel-2026.6.10.tgz --pack-destination", ); - expect(calls).toContain("diagnostics-otel-2026.6.10.tgz --pin"); + expect(calls).toMatch( + /openclaw plugins install npm-pack:\S*\/diagnostics-otel-2026\.6\.10\.tgz\n/, + ); expect(calls).toContain( `npm view @openclaw/brave-plugin@${PINNED_OPENCLAW_VERSION} dist.integrity`, ); @@ -542,7 +544,7 @@ describe("OpenClaw npm integrity pins", () => { expect(calls).toContain( "npm pack https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.6.10.tgz --pack-destination", ); - expect(calls).toContain("brave-plugin-2026.6.10.tgz --pin"); + expect(calls).toMatch(/openclaw plugins install npm-pack:\S*\/brave-plugin-2026\.6\.10\.tgz\n/); expect(calls).toContain("openclaw-env true true"); }); diff --git a/test/repro-4538-raw-doctor-perms.test.ts b/test/repro-4538-raw-doctor-perms.test.ts index 38719f7b48..efa643f513 100644 --- a/test/repro-4538-raw-doctor-perms.test.ts +++ b/test/repro-4538-raw-doctor-perms.test.ts @@ -30,7 +30,6 @@ * real sandbox image and is gated behind NEMOCLAW_RUN_DOCTOR_PERMS_DOCKER_E2E=1. */ -import assert from "node:assert"; import { execFileSync, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; @@ -51,28 +50,16 @@ function extractShellFunctionFromSource(src: string, name: string): string { /** * Extract the literal guard block emitted into /tmp/nemoclaw-proxy-env.sh — the * region between `# nemoclaw-configure-guard begin` and `# nemoclaw-configure- - * guard end`. Most of the block lives inside single-quoted heredocs, while the - * trusted gateway URL is generated between them. Materialize that generated - * line so the test sources the same shell a connect session receives. + * guard end`. The block lives inside a single-quoted heredoc, so what the test + * sources is byte-identical to what a connect shell sources at runtime. */ -function extractGuardBlock(src: string, trustedGatewayUrl = "ws://127.0.0.1:18789"): string { +function extractGuardBlock(src: string): string { const begin = src.indexOf("# nemoclaw-configure-guard begin"); const end = src.indexOf("# nemoclaw-configure-guard end"); if (begin < 0 || end < 0 || end < begin) { throw new Error("Expected nemoclaw-configure-guard begin/end markers in nemoclaw-start.sh"); } - const guardSource = src.slice(begin, src.indexOf("\n", end) + 1); - const injectionStartMarker = - "GUARDENVEOF\n # nemoclaw-trusted-gateway-literal-injection begin"; - const injectionEndMarker = - " # nemoclaw-trusted-gateway-literal-injection end\n cat <<'GUARDENVEOF'\n"; - const injectionStart = guardSource.indexOf(injectionStartMarker); - const injectionEnd = guardSource.indexOf(injectionEndMarker, injectionStart); - assert( - injectionStart !== -1 && injectionEnd !== -1, - "Expected the generated trusted gateway literal injection markers", - ); - return `${guardSource.slice(0, injectionStart)} _nemoclaw_whatsapp_trusted_url=${JSON.stringify(trustedGatewayUrl)}\n${guardSource.slice(injectionEnd + injectionEndMarker.length)}`; + return src.slice(begin, src.indexOf("\n", end) + 1); } function modeBits(filePath: string): number { diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index d0cd904d95..a2a3b1d56d 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -341,8 +341,8 @@ describe("sandbox provisioning: non-messaging OpenClaw plugins", () => { expect(calls).toContain( "npm pack https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.6.10.tgz --pack-destination", ); - expect(calls).toContain("plugins install "); - expect(calls).toContain("brave-plugin-2026.6.10.tgz --pin|BRAVE_API_KEY="); + expect(calls).toContain("plugins install npm-pack:"); + expect(calls).toContain("brave-plugin-2026.6.10.tgz|BRAVE_API_KEY="); expect(calls).toContain( "doctor --fix --non-interactive|BRAVE_API_KEY=openshell:resolve:env:BRAVE_API_KEY", ); From ac08021597fcd959c8a59a65f54dc9c27a98fdb0 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Sat, 11 Jul 2026 02:20:01 +0530 Subject: [PATCH 05/13] fix(whatsapp): restrict explicit login gateway override to loopback hosts Signed-off-by: Hung Le --- scripts/nemoclaw-start.sh | 38 +++++++++++++++++++++------ test/whatsapp-qr-compact.test.ts | 44 ++++++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 95f5aaf1ef..8cc2f1666d 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3548,9 +3548,10 @@ openclaw() { # approve` wrapper (NemoClaw#4462) relies on — and the post-pair # restart succeeds without any token-bearing reconcile. That # single change dictates this block's shape: an unset URL is the - # healthy default rather than an error, the ws:// scheme check - # and the pairing banner apply only to an explicitly exported - # OPENCLAW_GATEWAY_URL (kept as an operator escape hatch), and + # healthy default rather than an error, the ws:// scheme and + # loopback-host checks plus the pairing banner apply only to an + # explicitly exported OPENCLAW_GATEWAY_URL (kept as a + # loopback-only operator escape hatch), and # the login runs in a subshell that exports the override env # only when present — an empty-but-set OPENCLAW_GATEWAY_URL is # not equivalent to an unset one for OpenClaw's config @@ -3559,12 +3560,33 @@ openclaw() { _nemoclaw_whatsapp_insecure_ws="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" if [ -n "$_nemoclaw_whatsapp_gateway_url" ]; then # The OpenClaw gateway is a WebSocket endpoint (set to - # ws://127.0.0.1: at boot). Reject a malformed scheme up - # front so a typo'd/clobbered URL is reported as a gateway/env - # problem rather than failing inside the login as an ambiguous - # close. + # ws://127.0.0.1: at boot). Dispatch on the override: + # + # ws(s):// on 127.0.0.1 | localhost | [::1] -> honor + # ws(s):// on any other host -> reject + # anything else (not a ws:// URL) -> reject + # + # Non-loopback hosts: the connect shell exports the gateway + # token, so a caller-selected endpoint would receive it — + # reject rather than silently strip (stripping would flip the + # login into device-pairing auth against a foreign host). + # Malformed values fail fast as a gateway/env problem instead + # of an ambiguous close inside the login. case "$_nemoclaw_whatsapp_gateway_url" in - ws://* | wss://*) ;; + ws://127.0.0.1 | ws://127.0.0.1:* | ws://127.0.0.1/* | \ + wss://127.0.0.1 | wss://127.0.0.1:* | wss://127.0.0.1/* | \ + ws://localhost | ws://localhost:* | ws://localhost/* | \ + wss://localhost | wss://localhost:* | wss://localhost/* | \ + "ws://[::1]" | "ws://[::1]:"* | "ws://[::1]/"* | \ + "wss://[::1]" | "wss://[::1]:"* | "wss://[::1]/"*) ;; + ws://* | wss://*) + echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' is not a loopback gateway URL." >&2 + echo "Explicit overrides are honored only for the in-sandbox loopback gateway (ws://127.0.0.1:," >&2 + echo "ws://localhost:, or ws://[::1]:) so the connect shell's gateway token is never" >&2 + echo "presented to a non-local endpoint. Unset OPENCLAW_GATEWAY_URL to pair via the supported" >&2 + echo "in-sandbox loopback resolution." >&2 + return 1 + ;; *) echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' is not a ws:// gateway URL." >&2 echo "The OpenClaw gateway is a WebSocket endpoint (e.g. ws://127.0.0.1:); a malformed value" >&2 diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index fe4ccc2b12..c6c11a30d5 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -291,6 +291,7 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { args: string[], opts: { gatewayUrl?: string; + gatewayToken?: string; insecurePublicWs?: string; privateGatewayUrl?: string; insecurePrivateWs?: string; @@ -312,6 +313,7 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { 'echo "FAKE_OPENCLAW_NODE_OPTIONS=${NODE_OPTIONS:-}"', 'echo "FAKE_OPENCLAW_GATEWAY_URL=${OPENCLAW_GATEWAY_URL:-unset}"', 'echo "FAKE_OPENCLAW_INSECURE_WS=${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-unset}"', + 'echo "FAKE_OPENCLAW_TOKEN=${OPENCLAW_GATEWAY_TOKEN:-unset}"', `exit ${opts.fakeExit ?? 0}`, ].join("\n"), { mode: 0o755 }, @@ -337,6 +339,11 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { } else { wrapperLines.push("unset OPENCLAW_GATEWAY_URL"); } + if (opts.gatewayToken !== undefined) { + wrapperLines.push(`export OPENCLAW_GATEWAY_TOKEN=${JSON.stringify(opts.gatewayToken)}`); + } else { + wrapperLines.push("unset OPENCLAW_GATEWAY_TOKEN"); + } wrapperLines.push( opts.insecurePublicWs !== undefined ? `export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=${JSON.stringify(opts.insecurePublicWs)}` @@ -407,8 +414,9 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { it.each([ "ws://127.0.0.1:18789", - "wss://gateway.internal:443", - ])("accepts ws:// and wss:// gateway URLs (%s)", (goodUrl) => { + "wss://localhost:443", + "ws://[::1]:18789", + ])("accepts loopback ws:// and wss:// gateway URL overrides (%s)", (goodUrl) => { const r = runGuard(["channels", "login", "--channel", "whatsapp"], { gatewayUrl: goodUrl, preloadPresent: true, @@ -418,6 +426,26 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).toContain("GUARD_EXIT=0"); }); + it.each([ + "wss://attacker.example.test:443", + "ws://10.200.0.2:18790", + "ws://gateway.internal:18789", + "ws://127.0.0.1.evil.example:18789", + "ws://localhost.evil.example:18789", + ])("fails closed on a non-loopback gateway URL override without invoking openclaw (%s)", (badUrl) => { + const r = runGuard(["channels", "login", "--channel", "whatsapp"], { + gatewayUrl: badUrl, + gatewayToken: "guard-secret-token", + preloadPresent: true, + }); + expect(r.stderr).toContain("is not a loopback gateway URL"); + expect(r.stdout).toContain("GUARD_EXIT=1"); + // The child never runs, so the connect-shell gateway token is never + // presented to the caller-selected endpoint. + expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); + expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); + }); + it("does not re-inject the stashed private gateway URL for WhatsApp (#6413)", () => { const r = runGuard(["channels", "login", "--channel", "whatsapp"], { privateGatewayUrl: "ws://10.200.0.2:18790", @@ -434,26 +462,26 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).toContain("GUARD_EXIT=0"); }); - it("preserves an explicit public gateway override without borrowing the private opt-in (#4504)", () => { + it("preserves an explicit loopback override without borrowing the private opt-in (#4504)", () => { const r = runGuard(["channels", "login", "--channel", "whatsapp"], { - gatewayUrl: "wss://explicit.example.test:443", + gatewayUrl: "wss://127.0.0.1:443", privateGatewayUrl: "ws://10.200.0.2:18790", insecurePrivateWs: "1", preloadPresent: true, }); - expect(r.stdout).toContain("FAKE_OPENCLAW_GATEWAY_URL=wss://explicit.example.test:443"); + expect(r.stdout).toContain("FAKE_OPENCLAW_GATEWAY_URL=wss://127.0.0.1:443"); expect(r.stdout).toContain("FAKE_OPENCLAW_INSECURE_WS=unset"); }); - it("preserves the insecure-WS marker explicitly coupled to a public override (#4504)", () => { + it("preserves the insecure-WS marker explicitly coupled to a loopback override (#4504)", () => { const r = runGuard(["channels", "login", "--channel", "whatsapp"], { - gatewayUrl: "ws://explicit.example.test:18790", + gatewayUrl: "ws://localhost:18790", insecurePublicWs: "explicit-marker", privateGatewayUrl: "ws://10.200.0.2:18790", insecurePrivateWs: "1", preloadPresent: true, }); - expect(r.stdout).toContain("FAKE_OPENCLAW_GATEWAY_URL=ws://explicit.example.test:18790"); + expect(r.stdout).toContain("FAKE_OPENCLAW_GATEWAY_URL=ws://localhost:18790"); expect(r.stdout).toContain("FAKE_OPENCLAW_INSECURE_WS=explicit-marker"); }); From 341f8ebe41bf7287db278a088d4f8df3603df492 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Sat, 11 Jul 2026 02:30:23 +0530 Subject: [PATCH 06/13] fix(whatsapp): state root cause ownership and removal condition for the loopback workaround Signed-off-by: Hung Le --- scripts/nemoclaw-start.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 8cc2f1666d..2dfa049633 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3556,6 +3556,16 @@ openclaw() { # only when present — an empty-but-set OPENCLAW_GATEWAY_URL is # not equivalent to an unset one for OpenClaw's config # resolution. + # + # Root cause + removal condition: the scope-strip is OpenClaw + # gateway locality behavior. "Fixing" it here would mean + # patching gateway auth to trust private-veth origins — erasing + # the same-device signal the #4462 bounded-approval patch + # deliberately preserves — so this wrapper sides with the + # locality model instead. Remove once the pinned OpenClaw keeps + # operator scopes for a token-authed post-pair channels.start + # over the stashed private URL (re-run the #6413 fresh-install + # repro to confirm before deleting). _nemoclaw_whatsapp_gateway_url="${OPENCLAW_GATEWAY_URL:-}" _nemoclaw_whatsapp_insecure_ws="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" if [ -n "$_nemoclaw_whatsapp_gateway_url" ]; then From 16776c401b35524dc4700e6f3658042bbdfd0008 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Sat, 11 Jul 2026 06:26:31 +0530 Subject: [PATCH 07/13] fix(whatsapp): reject userinfo in the loopback gateway override allowlist Signed-off-by: Hung Le --- scripts/nemoclaw-start.sh | 16 ++++++++++++++++ test/whatsapp-qr-compact.test.ts | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 2dfa049633..5a5f74c9f1 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3572,10 +3572,18 @@ openclaw() { # The OpenClaw gateway is a WebSocket endpoint (set to # ws://127.0.0.1: at boot). Dispatch on the override: # + # URL containing '@' (userinfo) -> reject # ws(s):// on 127.0.0.1 | localhost | [::1] -> honor # ws(s):// on any other host -> reject # anything else (not a ws:// URL) -> reject # + # Reject userinfo first: a glob like ws://127.0.0.1:* also + # matches ws://127.0.0.1:1@evil.example, but the WHATWG URL + # parser reads 127.0.0.1:1 as user:password and connects to + # evil.example — so a raw host-prefix match would leak the + # token to a non-loopback host. A real loopback gateway URL + # never carries userinfo, so any '@' is rejected outright. + # # Non-loopback hosts: the connect shell exports the gateway # token, so a caller-selected endpoint would receive it — # reject rather than silently strip (stripping would flip the @@ -3583,6 +3591,14 @@ openclaw() { # Malformed values fail fast as a gateway/env problem instead # of an ambiguous close inside the login. case "$_nemoclaw_whatsapp_gateway_url" in + *@*) + echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' must not contain '@' (userinfo)." >&2 + echo "A userinfo component (user:pass@host) makes the URL parser connect to the host after '@'," >&2 + echo "which would present the connect shell's gateway token to a non-loopback endpoint." >&2 + echo "The in-sandbox loopback gateway URL never carries credentials; unset OPENCLAW_GATEWAY_URL" >&2 + echo "to pair via the supported in-sandbox loopback resolution." >&2 + return 1 + ;; ws://127.0.0.1 | ws://127.0.0.1:* | ws://127.0.0.1/* | \ wss://127.0.0.1 | wss://127.0.0.1:* | wss://127.0.0.1/* | \ ws://localhost | ws://localhost:* | ws://localhost/* | \ diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index c6c11a30d5..4b378af380 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -446,6 +446,27 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); }); + it.each([ + "ws://127.0.0.1:1@evil.example", + "wss://127.0.0.1:1@evil.example", + "ws://localhost:1@evil.example", + "ws://[::1]:1@evil.example", + ])("fails closed on a loopback-prefixed userinfo gateway URL without invoking openclaw (%s)", (badUrl) => { + // The loopback host is userinfo, not the real host: the WHATWG URL + // parser reads `127.0.0.1:1` as user:password and connects to + // `evil.example`, so a raw ws://127.0.0.1:* prefix match would leak the + // gateway token to a non-loopback host. + const r = runGuard(["channels", "login", "--channel", "whatsapp"], { + gatewayUrl: badUrl, + gatewayToken: "guard-secret-token", + preloadPresent: true, + }); + expect(r.stderr).toContain("must not contain '@' (userinfo)"); + expect(r.stdout).toContain("GUARD_EXIT=1"); + expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); + expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); + }); + it("does not re-inject the stashed private gateway URL for WhatsApp (#6413)", () => { const r = runGuard(["channels", "login", "--channel", "whatsapp"], { privateGatewayUrl: "ws://10.200.0.2:18790", From 801a9498715e95d524d87d91332433df0bd8aa79 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 10 Jul 2026 22:15:13 -0700 Subject: [PATCH 08/13] test(whatsapp): keep gateway token fixture linear Signed-off-by: Carlos Villela --- test/whatsapp-qr-compact.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index 4b378af380..e7b8ad3e52 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -339,11 +339,11 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { } else { wrapperLines.push("unset OPENCLAW_GATEWAY_URL"); } - if (opts.gatewayToken !== undefined) { - wrapperLines.push(`export OPENCLAW_GATEWAY_TOKEN=${JSON.stringify(opts.gatewayToken)}`); - } else { - wrapperLines.push("unset OPENCLAW_GATEWAY_TOKEN"); - } + wrapperLines.push( + opts.gatewayToken !== undefined + ? `export OPENCLAW_GATEWAY_TOKEN=${JSON.stringify(opts.gatewayToken)}` + : "unset OPENCLAW_GATEWAY_TOKEN", + ); wrapperLines.push( opts.insecurePublicWs !== undefined ? `export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=${JSON.stringify(opts.insecurePublicWs)}` From 3d0ec5a06b5b38b2aa8472e32057c42d2ccde1bb Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 10 Jul 2026 22:28:02 -0700 Subject: [PATCH 09/13] fix(whatsapp): harden loopback token dispatch Signed-off-by: Carlos Villela --- scripts/nemoclaw-start.sh | 223 ++++++++++++++++--------------- test/whatsapp-qr-compact.test.ts | 60 ++++++++- 2 files changed, 175 insertions(+), 108 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 765080a386..0a259aba68 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3584,110 +3584,123 @@ openclaw() { # repro to confirm before deleting). _nemoclaw_whatsapp_gateway_url="${OPENCLAW_GATEWAY_URL:-}" _nemoclaw_whatsapp_insecure_ws="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" - if [ -n "$_nemoclaw_whatsapp_gateway_url" ]; then - # The OpenClaw gateway is a WebSocket endpoint (set to - # ws://127.0.0.1: at boot). Dispatch on the override: - # - # URL containing '@' (userinfo) -> reject - # ws(s):// on 127.0.0.1 | localhost | [::1] -> honor - # ws(s):// on any other host -> reject - # anything else (not a ws:// URL) -> reject - # - # Reject userinfo first: a glob like ws://127.0.0.1:* also - # matches ws://127.0.0.1:1@evil.example, but the WHATWG URL - # parser reads 127.0.0.1:1 as user:password and connects to - # evil.example — so a raw host-prefix match would leak the - # token to a non-loopback host. A real loopback gateway URL - # never carries userinfo, so any '@' is rejected outright. - # - # Non-loopback hosts: the connect shell exports the gateway - # token, so a caller-selected endpoint would receive it — - # reject rather than silently strip (stripping would flip the - # login into device-pairing auth against a foreign host). - # Malformed values fail fast as a gateway/env problem instead - # of an ambiguous close inside the login. - case "$_nemoclaw_whatsapp_gateway_url" in - *@*) - echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' must not contain '@' (userinfo)." >&2 - echo "A userinfo component (user:pass@host) makes the URL parser connect to the host after '@'," >&2 - echo "which would present the connect shell's gateway token to a non-loopback endpoint." >&2 - echo "The in-sandbox loopback gateway URL never carries credentials; unset OPENCLAW_GATEWAY_URL" >&2 - echo "to pair via the supported in-sandbox loopback resolution." >&2 - return 1 - ;; - ws://127.0.0.1 | ws://127.0.0.1:* | ws://127.0.0.1/* | \ - wss://127.0.0.1 | wss://127.0.0.1:* | wss://127.0.0.1/* | \ - ws://localhost | ws://localhost:* | ws://localhost/* | \ - wss://localhost | wss://localhost:* | wss://localhost/* | \ - "ws://[::1]" | "ws://[::1]:"* | "ws://[::1]/"* | \ - "wss://[::1]" | "wss://[::1]:"* | "wss://[::1]/"*) ;; - ws://* | wss://*) - echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' is not a loopback gateway URL." >&2 - echo "Explicit overrides are honored only for the in-sandbox loopback gateway (ws://127.0.0.1:," >&2 - echo "ws://localhost:, or ws://[::1]:) so the connect shell's gateway token is never" >&2 - echo "presented to a non-local endpoint. Unset OPENCLAW_GATEWAY_URL to pair via the supported" >&2 - echo "in-sandbox loopback resolution." >&2 - return 1 - ;; - *) - echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' is not a ws:// gateway URL." >&2 - echo "The OpenClaw gateway is a WebSocket endpoint (e.g. ws://127.0.0.1:); a malformed value" >&2 - echo "would fail the login in a way that looks like a QR/pairing problem (this is a gateway/env problem)." >&2 - echo "" >&2 - echo "Reconnect with 'openshell sandbox connect ' and retry. If it persists," >&2 - echo "exit the sandbox and rebuild with 'nemoclaw rebuild'." >&2 - return 1 - ;; - esac - echo "[whatsapp] Pairing via gateway ${_nemoclaw_whatsapp_gateway_url} (explicit override)." >&2 - else - echo "[whatsapp] Pairing via the in-sandbox gateway (loopback)." >&2 - fi - echo "[whatsapp] On your phone: WhatsApp > Linked devices > Link a device, then scan the QR below." >&2 - # Defense-in-depth: connect-session NODE_OPTIONS already wires - # manifest-declared connect preloads for every openclaw - # invocation; 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)" - # Run the login with errexit disabled so its exit status is - # always captured (and the post-login guidance 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 - ( - if [ -n "$_nemoclaw_whatsapp_gateway_url" ]; then - # Assign-then-export: the generated runtime env file embeds - # this guard body, and its emission contract forbids ambient - # single-line gateway-URL export statements anywhere in that - # file. These exports live only inside this login subshell. - OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" - export OPENCLAW_GATEWAY_URL OPENCLAW_ALLOW_INSECURE_PRIVATE_WS - fi - 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 - echo "[whatsapp] Pairing exited with code ${_whatsapp_login_exit} before it completed." >&2 - echo "[whatsapp] A gateway close (e.g. '1008 abnormal closure') is a gateway/session" >&2 - 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 - fi - [ "$_nemoclaw_whatsapp_login_errexit" = "1" ] && set -e - # Defer the return to the final dispatch below. If a caller has - # an imported function named `return`, it may alter the status - # but it cannot fall through into the token-preserving generic - # invocation. - _nemoclaw_guard_request_handled=1 - _nemoclaw_guard_request_status=$_whatsapp_login_exit + _nemoclaw_whatsapp_gateway_allowed=1 + # Keep every URL/token decision in shell grammar. Imported Bash + # functions can shadow `[` and `return`, but cannot shadow + # `case`; rejected URLs therefore never reach a child process. + case "$_nemoclaw_whatsapp_gateway_url" in + "") + echo "[whatsapp] Pairing via the in-sandbox gateway (loopback)." >&2 + ;; + *@*) + echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' must not contain '@' (userinfo)." >&2 + echo "A userinfo component (user:pass@host) makes the URL parser connect to the host after '@'," >&2 + echo "which would present the connect shell's gateway token to a non-loopback endpoint." >&2 + echo "The in-sandbox loopback gateway URL never carries credentials; unset OPENCLAW_GATEWAY_URL" >&2 + echo "to pair via the supported in-sandbox loopback resolution." >&2 + _nemoclaw_whatsapp_gateway_allowed=0 + ;; + ws://127.0.0.1 | ws://127.0.0.1:* | ws://127.0.0.1/* | \ + wss://127.0.0.1 | wss://127.0.0.1:* | wss://127.0.0.1/* | \ + ws://localhost | ws://localhost:* | ws://localhost/* | \ + wss://localhost | wss://localhost:* | wss://localhost/* | \ + "ws://[::1]" | "ws://[::1]:"* | "ws://[::1]/"* | \ + "wss://[::1]" | "wss://[::1]:"* | "wss://[::1]/"*) + echo "[whatsapp] Pairing via gateway ${_nemoclaw_whatsapp_gateway_url} (explicit override)." >&2 + ;; + ws://* | wss://*) + echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' is not a loopback gateway URL." >&2 + echo "Explicit overrides are honored only for the in-sandbox loopback gateway (ws://127.0.0.1:," >&2 + echo "ws://localhost:, or ws://[::1]:) so the connect shell's gateway token is never" >&2 + echo "presented to a non-local endpoint. Unset OPENCLAW_GATEWAY_URL to pair via the supported" >&2 + echo "in-sandbox loopback resolution." >&2 + _nemoclaw_whatsapp_gateway_allowed=0 + ;; + *) + echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' is not a ws:// gateway URL." >&2 + echo "The OpenClaw gateway is a WebSocket endpoint (e.g. ws://127.0.0.1:); a malformed value" >&2 + echo "would fail the login in a way that looks like a QR/pairing problem (this is a gateway/env problem)." >&2 + echo "" >&2 + echo "Reconnect with 'openshell sandbox connect ' and retry. If it persists," >&2 + echo "exit the sandbox and rebuild with 'nemoclaw rebuild'." >&2 + _nemoclaw_whatsapp_gateway_allowed=0 + ;; + esac + case "$_nemoclaw_whatsapp_gateway_allowed" in + 1) + echo "[whatsapp] On your phone: WhatsApp > Linked devices > Link a device, then scan the QR below." >&2 + # Defense-in-depth: connect-session NODE_OPTIONS already wires + # manifest-declared connect preloads for every openclaw + # invocation; 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)" + # Run the login with errexit disabled so its exit status is + # always captured (and the post-login guidance 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 + ( + case "$_nemoclaw_whatsapp_gateway_url" in + "") _nemoclaw_whatsapp_gateway_mode=unset ;; + *) _nemoclaw_whatsapp_gateway_mode=explicit ;; + esac + case "$_nemoclaw_connect_node_options" in + "") _nemoclaw_whatsapp_node_mode=plain ;; + *) _nemoclaw_whatsapp_node_mode=preload ;; + esac + # An absolute executable cannot be replaced by an imported + # `command` function. For the default path, explicitly drop + # any URL markers so OpenClaw resolves its loopback config. + case "$_nemoclaw_whatsapp_gateway_mode:$_nemoclaw_whatsapp_node_mode" in + unset:plain) + /usr/bin/env -u OPENCLAW_GATEWAY_URL -u OPENCLAW_ALLOW_INSECURE_PRIVATE_WS openclaw "$@" + ;; + unset:preload) + NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$_nemoclaw_connect_node_options" \ + /usr/bin/env -u OPENCLAW_GATEWAY_URL -u OPENCLAW_ALLOW_INSECURE_PRIVATE_WS openclaw "$@" + ;; + explicit:plain) + OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" \ + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" \ + /usr/bin/env openclaw "$@" + ;; + explicit:preload) + 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" \ + /usr/bin/env openclaw "$@" + ;; + esac + ) + _whatsapp_login_exit=$? + case "$_whatsapp_login_exit" in + 0) ;; + *) + echo "" >&2 + echo "[whatsapp] Pairing exited with code ${_whatsapp_login_exit} before it completed." >&2 + echo "[whatsapp] A gateway close (e.g. '1008 abnormal closure') is a gateway/session" >&2 + 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 + ;; + esac + case "$_nemoclaw_whatsapp_login_errexit" in + 1) set -e ;; + esac + _nemoclaw_guard_request_handled=1 + _nemoclaw_guard_request_status=$_whatsapp_login_exit + ;; + *) + # Do not return from the rejection site: an imported function + # named `return` may alter status, but final dispatch cannot + # fall through into the generic token-bearing invocation. + _nemoclaw_guard_request_handled=1 + _nemoclaw_guard_request_status=1 + ;; + esac ;; esac ;; @@ -3727,7 +3740,7 @@ openclaw() { esac case "$_nemoclaw_guard_request_handled" in 1) - return "$_nemoclaw_guard_request_status" + builtin return "$_nemoclaw_guard_request_status" ;; *) # #4538: re-assert the mutable config perm contract after any openclaw run diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index e7b8ad3e52..d0d75f7717 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -297,11 +297,12 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { insecurePrivateWs?: string; preloadPresent?: boolean; fakeExit?: number; + poisonShellFunctions?: readonly ("[" | "command" | "return")[]; }, ): { status: number; stdout: string; stderr: string; preloadPath: string } { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wa-guard-")); try { - // Fake `openclaw` binary on PATH so `command openclaw` resolves to it. + // Fake `openclaw` binary on PATH so the absolute env dispatch resolves to it. const binDir = path.join(tempDir, "bin"); fs.mkdirSync(binDir); const fakeOpenclaw = path.join(binDir, "openclaw"); @@ -363,7 +364,19 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { const wrapperPath = path.join(tempDir, "run.sh"); fs.writeFileSync(wrapperPath, wrapperLines.join("\n"), { mode: 0o700 }); - const r = spawnSync("bash", [wrapperPath], { encoding: "utf-8", timeout: 10000 }); + const poisonedFunctions = new Set(opts.poisonShellFunctions ?? []); + const poisonEnv = { + ...(poisonedFunctions.has("[") ? { "BASH_FUNC_[%%": "() { /usr/bin/false; }" } : {}), + ...(poisonedFunctions.has("command") + ? { "BASH_FUNC_command%%": "() { printf 'POISON_COMMAND_USED\\n'; }" } + : {}), + ...(poisonedFunctions.has("return") ? { "BASH_FUNC_return%%": "() { :; }" } : {}), + }; + const r = spawnSync("bash", [wrapperPath], { + encoding: "utf-8", + env: { ...process.env, ...poisonEnv }, + timeout: 10000, + }); return { status: r.status ?? -1, stdout: r.stdout ?? "", @@ -446,6 +459,46 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); }); + it("fails closed on a non-loopback URL when an imported function shadows the bracket builtin", () => { + const r = runGuard(["channels", "login", "--channel=whatsapp"], { + gatewayUrl: "wss://attacker.example.test:443", + gatewayToken: "guard-secret-token", + poisonShellFunctions: ["["], + preloadPresent: true, + }); + expect(r.stderr).toContain("is not a loopback gateway URL"); + expect(r.stdout).toContain("GUARD_EXIT=1"); + expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); + expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); + expect(r.stdout).not.toContain("POISON_COMMAND_USED"); + }); + + it("cannot continue into token-bearing dispatch when an imported function shadows return", () => { + const r = runGuard(["channels", "login", "--channel", "whatsapp"], { + gatewayUrl: "wss://attacker.example.test:443", + gatewayToken: "guard-secret-token", + poisonShellFunctions: ["return"], + preloadPresent: true, + }); + expect(r.stderr).toContain("is not a loopback gateway URL"); + expect(r.stdout).toContain("GUARD_EXIT=1"); + expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); + expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); + }); + + it("bypasses a shadowed command function for a validated loopback URL", () => { + const r = runGuard(["channels", "login", "--channel=whatsapp"], { + gatewayUrl: "ws://127.0.0.1:18789", + gatewayToken: "guard-secret-token", + poisonShellFunctions: ["command"], + preloadPresent: true, + }); + expect(r.stdout).toContain("FAKE_OPENCLAW_ARGS=channels login --channel=whatsapp"); + expect(r.stdout).toContain("FAKE_OPENCLAW_GATEWAY_URL=ws://127.0.0.1:18789"); + expect(r.stdout).toContain("FAKE_OPENCLAW_TOKEN=guard-secret-token"); + expect(r.stdout).not.toContain("POISON_COMMAND_USED"); + }); + it.each([ "ws://127.0.0.1:1@evil.example", "wss://127.0.0.1:1@evil.example", @@ -528,9 +581,10 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).toContain("GUARD_EXIT=0"); }); - it("surfaces gateway-close diagnostics separately from the QR on non-zero exit", () => { + it("surfaces gateway-close diagnostics and preserves exit status when return is shadowed", () => { const r = runGuard(["channels", "login", "--channel", "whatsapp"], { gatewayUrl: "ws://127.0.0.1:8080", + poisonShellFunctions: ["return"], preloadPresent: true, fakeExit: 7, }); From bfb6293553fb1a5fddbffe5b25f5c83b5363e83d Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 10 Jul 2026 22:34:34 -0700 Subject: [PATCH 10/13] test(whatsapp): assert rejected token stays redacted Signed-off-by: Carlos Villela --- test/whatsapp-qr-compact.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index d0d75f7717..ea9031ad9c 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -457,6 +457,7 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { // presented to the caller-selected endpoint. expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); + expect(`${r.stdout}\n${r.stderr}`).not.toContain("guard-secret-token"); }); it("fails closed on a non-loopback URL when an imported function shadows the bracket builtin", () => { @@ -471,6 +472,7 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); expect(r.stdout).not.toContain("POISON_COMMAND_USED"); + expect(`${r.stdout}\n${r.stderr}`).not.toContain("guard-secret-token"); }); it("cannot continue into token-bearing dispatch when an imported function shadows return", () => { @@ -484,6 +486,7 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).toContain("GUARD_EXIT=1"); expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); + expect(`${r.stdout}\n${r.stderr}`).not.toContain("guard-secret-token"); }); it("bypasses a shadowed command function for a validated loopback URL", () => { @@ -518,6 +521,7 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).toContain("GUARD_EXIT=1"); expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); + expect(`${r.stdout}\n${r.stderr}`).not.toContain("guard-secret-token"); }); it("does not re-inject the stashed private gateway URL for WhatsApp (#6413)", () => { From 78110fe295456b38bec391065cec09b59ccf55a4 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 11 Jul 2026 00:29:44 -0700 Subject: [PATCH 11/13] fix(whatsapp): isolate gateway tokens from caller URLs Signed-off-by: Carlos Villela --- scripts/nemoclaw-start.sh | 161 ++++++++++++---- test/nemoclaw-start-gateway-ws-host.test.ts | 197 +++++++++++++++++++- test/nemoclaw-start.test.ts | 20 +- test/whatsapp-qr-compact.test.ts | 93 ++++++++- 4 files changed, 416 insertions(+), 55 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 0a259aba68..801f222f22 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3353,6 +3353,7 @@ export AWS_EC2_METADATA_DISABLED="true" export JITI_FS_CACHE="false" PROXYEOF local _openclaw_env_name _openclaw_env_value _escaped_openclaw_env_value + local _escaped_gateway_port _escaped_gateway_url _escaped_gateway_token for _openclaw_env_name in OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH OPENCLAW_OAUTH_DIR OPENCLAW_WORKSPACE_DIR; do _openclaw_env_value="${!_openclaw_env_name:-}" [ -n "$_openclaw_env_value" ] || continue @@ -3369,20 +3370,19 @@ PROXYEOF # NemoClaw-owned commands that require it without forcing ordinary # OpenClaw CLI clients onto the explicit remote-gateway pairing path. printf "export NEMOCLAW_OPENCLAW_GATEWAY_URL='%s'\n" "$_escaped_gateway_url" + # Bake the trusted value into case syntax instead of consulting the + # caller-mutable NEMOCLAW_* alias. Imported shell functions can shadow + # `[` but cannot shadow `case`; a failed/shadowed unset only withholds + # the token below rather than pairing it with another destination. + printf "case \"\${OPENCLAW_GATEWAY_URL:-}\" in\n" + printf " '' | '%s')\n" "$_escaped_gateway_url" cat <<'GATEWAYURLENVEOF' -# Equality identifies NemoClaw's inherited private-interface value. A different -# nonempty raw value was supplied explicitly after this file was generated, so -# preserve that caller override and its matching insecure-WS marker. -if [ -z "${OPENCLAW_GATEWAY_URL:-}" ] || [ "${OPENCLAW_GATEWAY_URL}" = "${NEMOCLAW_OPENCLAW_GATEWAY_URL:-}" ]; then - unset OPENCLAW_GATEWAY_URL - unset OPENCLAW_ALLOW_INSECURE_PRIVATE_WS -fi + unset OPENCLAW_GATEWAY_URL + unset OPENCLAW_ALLOW_INSECURE_PRIVATE_WS + ;; +esac GATEWAYURLENVEOF fi - if [ -n "${OPENCLAW_GATEWAY_TOKEN:-}" ]; then - _escaped_gateway_token="$(printf '%s' "$OPENCLAW_GATEWAY_TOKEN" | sed "s/'/'\\\\''/g")" - printf "export OPENCLAW_GATEWAY_TOKEN='%s'\n" "$_escaped_gateway_token" - fi if [ -n "${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" ]; then # Retain the matching break-glass under the same private namespace. # WhatsApp reinjects it only for its gateway-backed login command. @@ -3593,7 +3593,7 @@ openclaw() { echo "[whatsapp] Pairing via the in-sandbox gateway (loopback)." >&2 ;; *@*) - echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' must not contain '@' (userinfo)." >&2 + echo "Error: WhatsApp pairing cannot start — gateway URL must not contain '@' (userinfo)." >&2 echo "A userinfo component (user:pass@host) makes the URL parser connect to the host after '@'," >&2 echo "which would present the connect shell's gateway token to a non-loopback endpoint." >&2 echo "The in-sandbox loopback gateway URL never carries credentials; unset OPENCLAW_GATEWAY_URL" >&2 @@ -3606,10 +3606,10 @@ openclaw() { wss://localhost | wss://localhost:* | wss://localhost/* | \ "ws://[::1]" | "ws://[::1]:"* | "ws://[::1]/"* | \ "wss://[::1]" | "wss://[::1]:"* | "wss://[::1]/"*) - echo "[whatsapp] Pairing via gateway ${_nemoclaw_whatsapp_gateway_url} (explicit override)." >&2 + echo "[whatsapp] Pairing via an explicit loopback gateway override." >&2 ;; ws://* | wss://*) - echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' is not a loopback gateway URL." >&2 + echo "Error: WhatsApp pairing cannot start — gateway URL is not a loopback gateway URL." >&2 echo "Explicit overrides are honored only for the in-sandbox loopback gateway (ws://127.0.0.1:," >&2 echo "ws://localhost:, or ws://[::1]:) so the connect shell's gateway token is never" >&2 echo "presented to a non-local endpoint. Unset OPENCLAW_GATEWAY_URL to pair via the supported" >&2 @@ -3617,7 +3617,7 @@ openclaw() { _nemoclaw_whatsapp_gateway_allowed=0 ;; *) - echo "Error: WhatsApp pairing cannot start — gateway URL='${_nemoclaw_whatsapp_gateway_url}' is not a ws:// gateway URL." >&2 + echo "Error: WhatsApp pairing cannot start — gateway URL is not a ws:// gateway URL." >&2 echo "The OpenClaw gateway is a WebSocket endpoint (e.g. ws://127.0.0.1:); a malformed value" >&2 echo "would fail the login in a way that looks like a QR/pairing problem (this is a gateway/env problem)." >&2 echo "" >&2 @@ -3643,35 +3643,59 @@ openclaw() { case $- in *e*) _nemoclaw_whatsapp_login_errexit=1 ;; esac set +e ( - case "$_nemoclaw_whatsapp_gateway_url" in - "") _nemoclaw_whatsapp_gateway_mode=unset ;; - *) _nemoclaw_whatsapp_gateway_mode=explicit ;; - esac case "$_nemoclaw_connect_node_options" in "") _nemoclaw_whatsapp_node_mode=plain ;; *) _nemoclaw_whatsapp_node_mode=preload ;; esac # An absolute executable cannot be replaced by an imported - # `command` function. For the default path, explicitly drop - # any URL markers so OpenClaw resolves its loopback config. - case "$_nemoclaw_whatsapp_gateway_mode:$_nemoclaw_whatsapp_node_mode" in - unset:plain) - /usr/bin/env -u OPENCLAW_GATEWAY_URL -u OPENCLAW_ALLOW_INSECURE_PRIVATE_WS openclaw "$@" + # `command` function. Revalidate the mutable URL again at + # the final exec boundary: imported functions used for + # guidance can mutate dynamically-scoped locals after the + # first allowlist check. Each accepted case executes env as + # its first command, leaving no shadowable validation/use + # gap. The default path explicitly drops URL markers so + # OpenClaw resolves its loopback config. + case "$_nemoclaw_whatsapp_gateway_url" in + "") + case "$_nemoclaw_whatsapp_node_mode" in + plain) + /usr/bin/env -u OPENCLAW_GATEWAY_URL -u OPENCLAW_ALLOW_INSECURE_PRIVATE_WS openclaw "$@" + ;; + preload) + NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$_nemoclaw_connect_node_options" \ + /usr/bin/env -u OPENCLAW_GATEWAY_URL -u OPENCLAW_ALLOW_INSECURE_PRIVATE_WS openclaw "$@" + ;; + esac ;; - unset:preload) - NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$_nemoclaw_connect_node_options" \ - /usr/bin/env -u OPENCLAW_GATEWAY_URL -u OPENCLAW_ALLOW_INSECURE_PRIVATE_WS openclaw "$@" + *@*) + /usr/bin/printf '%s\n' \ + 'Error: WhatsApp pairing stopped because the gateway URL changed after validation.' >&2 + /usr/bin/false ;; - explicit:plain) - OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" \ - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" \ - /usr/bin/env openclaw "$@" + ws://127.0.0.1 | ws://127.0.0.1:* | ws://127.0.0.1/* | \ + wss://127.0.0.1 | wss://127.0.0.1:* | wss://127.0.0.1/* | \ + ws://localhost | ws://localhost:* | ws://localhost/* | \ + wss://localhost | wss://localhost:* | wss://localhost/* | \ + "ws://[::1]" | "ws://[::1]:"* | "ws://[::1]/"* | \ + "wss://[::1]" | "wss://[::1]:"* | "wss://[::1]/"*) + case "$_nemoclaw_whatsapp_node_mode" in + plain) + OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" \ + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" \ + /usr/bin/env openclaw "$@" + ;; + preload) + 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" \ + /usr/bin/env openclaw "$@" + ;; + esac ;; - explicit:preload) - 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" \ - /usr/bin/env openclaw "$@" + *) + /usr/bin/printf '%s\n' \ + 'Error: WhatsApp pairing stopped because the gateway URL changed after validation.' >&2 + /usr/bin/false ;; esac ) @@ -3740,7 +3764,18 @@ openclaw() { esac case "$_nemoclaw_guard_request_handled" in 1) - builtin return "$_nemoclaw_guard_request_status" + # End the function with the recorded status without relying on Bash's + # `builtin return`: this generated env is also sourced by POSIX sh, and + # imported functions may shadow `return` or `exit`. The absolute child + # shell preserves the full 0-255 status after removing its startup hooks + # and Bash's encoded imported-exit function. + case "$_nemoclaw_guard_request_status" in + 0) ;; + *) + /usr/bin/env -u 'BASH_FUNC_exit%%' -u BASH_ENV -u ENV \ + /bin/sh -c 'exit "$1"' nemoclaw "$_nemoclaw_guard_request_status" + ;; + esac ;; *) # #4538: re-assert the mutable config perm contract after any openclaw run @@ -3751,11 +3786,26 @@ openclaw() { local _nemoclaw_oc_errexit=0 case $- in *e*) _nemoclaw_oc_errexit=1 ;; esac set +e - command openclaw "$@" + # The generated runtime env withholds the token from an explicit caller + # URL at source time. Repeat the boundary at dispatch so a URL assigned + # later cannot inherit the token, and use an absolute executable so an + # imported `command` function cannot intercept the decision. + case "${OPENCLAW_GATEWAY_URL:-}" in + "") /usr/bin/env openclaw "$@" ;; + *) /usr/bin/env -u OPENCLAW_GATEWAY_TOKEN openclaw "$@" ;; + esac local _nemoclaw_oc_status=$? _nemoclaw_restore_mutable_config_perms - [ "$_nemoclaw_oc_errexit" = "1" ] && set -e - return "$_nemoclaw_oc_status" + case "$_nemoclaw_oc_errexit" in + 1) set -e ;; + esac + case "$_nemoclaw_oc_status" in + 0) ;; + *) + /usr/bin/env -u 'BASH_FUNC_exit%%' -u BASH_ENV -u ENV \ + /bin/sh -c 'exit "$1"' nemoclaw "$_nemoclaw_oc_status" + ;; + esac ;; esac } @@ -3899,6 +3949,37 @@ GUARDENVEOF for _redir in "${_TOOL_REDIRECTS[@]}"; do echo "export ${_redir?}" done + if [ -n "${OPENCLAW_GATEWAY_TOKEN:-}" ]; then + _escaped_gateway_token="$(printf '%s' "$OPENCLAW_GATEWAY_TOKEN" | sed "s/'/'\\\\''/g")" + # Emit the token last, after every other generated export. Mark the name + # for export before the secret assignment so a shadowed export function + # never runs while the generated secret is present; a failed export makes + # the token unavailable rather than exposing it. Only publish the token + # when trusted URL normalization left the public URL empty or the caller + # supplied a validated loopback override. Other caller URLs receive an + # empty token; generic dispatch above removes the token for every explicit + # URL, including loopback, while WhatsApp revalidates its local override + # immediately at the specialized exec boundary. + printf 'export OPENCLAW_GATEWAY_TOKEN\n' + cat <<'GATEWAYTOKENENVEOF' +case "${OPENCLAW_GATEWAY_URL:-}" in + *@*) + OPENCLAW_GATEWAY_TOKEN= + ;; + '' | ws://127.0.0.1 | ws://127.0.0.1:* | ws://127.0.0.1/* | \ + wss://127.0.0.1 | wss://127.0.0.1:* | wss://127.0.0.1/* | \ + ws://localhost | ws://localhost:* | ws://localhost/* | \ + wss://localhost | wss://localhost:* | wss://localhost/* | \ + "ws://[::1]" | "ws://[::1]:"* | "ws://[::1]/"* | \ + "wss://[::1]" | "wss://[::1]:"* | "wss://[::1]/"*) +GATEWAYTOKENENVEOF + printf " OPENCLAW_GATEWAY_TOKEN='%s'\n" "$_escaped_gateway_token" + printf ' ;;\n' + printf ' *)\n' + printf ' OPENCLAW_GATEWAY_TOKEN=\n' + printf ' ;;\n' + printf 'esac\n' + fi } | emit_sandbox_sourced_file "$_PROXY_ENV_FILE" } diff --git a/test/nemoclaw-start-gateway-ws-host.test.ts b/test/nemoclaw-start-gateway-ws-host.test.ts index c6c20c0e95..c50d7946c4 100644 --- a/test/nemoclaw-start-gateway-ws-host.test.ts +++ b/test/nemoclaw-start-gateway-ws-host.test.ts @@ -317,7 +317,202 @@ describe("gateway websocket url host derivation", () => { expect(explicit).toContain("PRIVATE_URL=ws://10.200.0.2:18790"); expect(explicit).toContain("PRIVATE_INSECURE=1"); expect(explicit).toContain("PORT=18790"); - expect(explicit).toContain("TOKEN=test-gateway-token"); + expect(explicit).toContain("TOKEN="); + expect(explicit).not.toContain("test-gateway-token"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("withholds the gateway token from caller URLs during generic dispatch (#6413)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gwenv-generic-")); + try { + const runtimeEnv = writeRuntimeShellEnv(tmpDir); + const fakeBin = path.join(tmpDir, "bin"); + fs.mkdirSync(fakeBin); + fs.writeFileSync( + path.join(fakeBin, "openclaw"), + [ + "#!/bin/sh", + 'printf "ARGS=%s URL=%s TOKEN=%s\\n" "$*" "${OPENCLAW_GATEWAY_URL:-unset}" "${OPENCLAW_GATEWAY_TOKEN:-unset}"', + 'exit "${FAKE_OPENCLAW_EXIT:-0}"', + ].join("\n"), + { mode: 0o755 }, + ); + + const runGeneric = (sourceGatewayUrl: string, lateGatewayUrl?: string) => + spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + `. ${JSON.stringify(runtimeEnv)}`, + ...(lateGatewayUrl === undefined + ? [] + : [`builtin export OPENCLAW_GATEWAY_URL=${JSON.stringify(lateGatewayUrl)}`]), + "_nemoclaw_restore_mutable_config_perms() { :; }", + "openclaw devices list", + 'printf "GUARD_EXIT=%s\\n" "$?"', + ].join("; "), + ], + { + encoding: "utf-8", + timeout: 5000, + env: { + ...process.env, + "BASH_FUNC_[%%": "() { /usr/bin/false; }", + "BASH_FUNC_command%%": "() { printf 'POISON_COMMAND_USED\\n'; }", + "BASH_FUNC_exit%%": "() { printf 'POISON_EXIT_USED\\n'; }", + "BASH_FUNC_export%%": + "() { case \"${OPENCLAW_GATEWAY_TOKEN-unset}\" in test-gateway-token) printf 'POISON_EXPORT_SAW_GENERATED\\n' ;; *) printf 'POISON_EXPORT_CALLED\\n' ;; esac; }", + "BASH_FUNC_return%%": "() { :; }", + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + FAKE_OPENCLAW_EXIT: "0", + OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", + OPENCLAW_GATEWAY_URL: sourceGatewayUrl, + }, + }, + ); + + const trusted = runGeneric("ws://10.200.0.2:18790"); + expect(trusted.status, trusted.stderr).toBe(0); + expect(trusted.stdout).toContain("ARGS=devices list URL=unset TOKEN=test-gateway-token"); + expect(trusted.stdout).toContain("GUARD_EXIT=0"); + expect(trusted.stdout).not.toContain("POISON_COMMAND_USED"); + expect(trusted.stdout).not.toContain("POISON_EXIT_USED"); + expect(trusted.stdout).toContain("POISON_EXPORT_CALLED"); + expect(trusted.stdout).not.toContain("POISON_EXPORT_SAW_GENERATED"); + + const posix = spawnSync( + "/bin/sh", + [ + "-c", + [ + `. ${JSON.stringify(runtimeEnv)}`, + "_nemoclaw_restore_mutable_config_perms() { :; }", + "openclaw devices list", + 'printf "GUARD_EXIT=%s\\n" "$?"', + ].join("; "), + ], + { + encoding: "utf-8", + timeout: 5000, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + BASH_VERSION: "caller-controlled", + "BASH_FUNC_exit%%": "() { printf 'POISON_EXIT_USED\\n'; }", + FAKE_OPENCLAW_EXIT: "7", + OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", + OPENCLAW_GATEWAY_URL: "ws://10.200.0.2:18790", + }, + }, + ); + expect(posix.status, posix.stderr).toBe(0); + expect(posix.stdout).toContain("ARGS=devices list URL=unset TOKEN=test-gateway-token"); + expect(posix.stdout).toContain("GUARD_EXIT=7"); + expect(posix.stderr).not.toContain("builtin"); + expect(`${posix.stdout}\n${posix.stderr}`).not.toContain("POISON_EXIT_USED"); + + const explicitLoopback = runGeneric("ws://127.0.0.1:18790"); + expect(explicitLoopback.status, explicitLoopback.stderr).toBe(0); + expect(explicitLoopback.stdout).toContain( + "ARGS=devices list URL=ws://127.0.0.1:18790 TOKEN=unset", + ); + expect(explicitLoopback.stdout).toContain("GUARD_EXIT=0"); + expect(explicitLoopback.stdout).not.toContain("POISON_EXPORT_SAW_GENERATED"); + + const userinfoSource = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + `. ${JSON.stringify(runtimeEnv)}`, + 'printf "TOKEN=%s\\n" "${OPENCLAW_GATEWAY_TOKEN:-}"', + ].join("; "), + ], + { + encoding: "utf-8", + timeout: 5000, + env: { + ...process.env, + OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:1@evil.example.test", + }, + }, + ); + expect(userinfoSource.status, userinfoSource.stderr).toBe(0); + expect(userinfoSource.stdout).toContain("TOKEN=\n"); + expect(`${userinfoSource.stdout}\n${userinfoSource.stderr}`).not.toContain( + "test-gateway-token", + ); + expect(`${userinfoSource.stdout}\n${userinfoSource.stderr}`).not.toContain( + "ambient-gateway-token", + ); + + const runWhatsApp = (sourceGatewayUrl: string) => + spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + `. ${JSON.stringify(runtimeEnv)}`, + "openclaw channels login --channel whatsapp", + 'printf "GUARD_EXIT=%s\\n" "$?"', + ].join("; "), + ], + { + encoding: "utf-8", + timeout: 5000, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + FAKE_OPENCLAW_EXIT: "0", + OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", + OPENCLAW_GATEWAY_URL: sourceGatewayUrl, + }, + }, + ); + + const explicitWhatsApp = runWhatsApp("ws://127.0.0.1:18790"); + expect(explicitWhatsApp.status, explicitWhatsApp.stderr).toBe(0); + expect(explicitWhatsApp.stdout).toContain( + "ARGS=channels login --channel whatsapp URL=ws://127.0.0.1:18790 TOKEN=test-gateway-token", + ); + expect(explicitWhatsApp.stdout).toContain("GUARD_EXIT=0"); + expect(`${explicitWhatsApp.stdout}\n${explicitWhatsApp.stderr}`).not.toContain( + "ambient-gateway-token", + ); + + const rejectedWhatsApp = runWhatsApp("wss://attacker.example.test:443"); + expect(rejectedWhatsApp.status, rejectedWhatsApp.stderr).toBe(0); + expect(rejectedWhatsApp.stdout).toContain("GUARD_EXIT=1"); + expect(rejectedWhatsApp.stdout).not.toContain("ARGS=channels login"); + expect(`${rejectedWhatsApp.stdout}\n${rejectedWhatsApp.stderr}`).not.toContain( + "test-gateway-token", + ); + expect(`${rejectedWhatsApp.stdout}\n${rejectedWhatsApp.stderr}`).not.toContain( + "ambient-gateway-token", + ); + + const attacker = runGeneric("ws://10.200.0.2:18790", "wss://attacker.example.test:443"); + expect(attacker.status, attacker.stderr).toBe(0); + expect(attacker.stdout).toContain( + "ARGS=devices list URL=wss://attacker.example.test:443 TOKEN=unset", + ); + expect(attacker.stdout).toContain("GUARD_EXIT=0"); + expect(attacker.stdout).not.toContain("POISON_COMMAND_USED"); + expect(attacker.stdout).not.toContain("POISON_EXIT_USED"); + expect(attacker.stdout).toContain("POISON_EXPORT_CALLED"); + expect(attacker.stdout).not.toContain("POISON_EXPORT_SAW_GENERATED"); + expect(`${attacker.stdout}\n${attacker.stderr}`).not.toContain("test-gateway-token"); + expect(`${attacker.stdout}\n${attacker.stderr}`).not.toContain("ambient-gateway-token"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 5efee03d85..098e718d16 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -694,7 +694,8 @@ describe("nemoclaw-start gateway token export (#1114)", () => { expect(result.stderr).toContain("Dashboard auth token redacted from startup logs."); expect(result.stderr).not.toContain("#token="); expect(result.stderr).not.toContain("tok'en"); - expect(envFile).toContain("export OPENCLAW_GATEWAY_TOKEN='tok'\\''en'"); + expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN='tok'\\''en'"); + expect(envFile).toContain("export OPENCLAW_GATEWAY_TOKEN"); expect(envFile).toContain("nemoclaw-configure-guard begin"); expect(envFile).not.toContain(".bashrc"); expect(envFile).not.toContain(".profile"); @@ -712,7 +713,7 @@ describe("nemoclaw-start gateway token export (#1114)", () => { expect(envFile).toContain("export OPENCLAW_GATEWAY_PORT='18790'"); expect(envFile).toContain("export NEMOCLAW_OPENCLAW_GATEWAY_URL='ws://127.0.0.1:18790'"); expect(envFile).not.toContain("export OPENCLAW_GATEWAY_URL='ws://127.0.0.1:18790'"); - expect(envFile).toContain("export OPENCLAW_GATEWAY_TOKEN='token'"); + expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN='token'"); }); it("writes OpenClaw state env for connect-shell pairing approval (#3730)", () => { const { result, envFile } = runGatewayTokenHarness( @@ -725,7 +726,7 @@ describe("nemoclaw-start gateway token export (#1114)", () => { expect(envFile).toContain("export OPENCLAW_CONFIG_PATH='/sandbox/.openclaw/openclaw.json'"); expect(envFile).toContain("export OPENCLAW_OAUTH_DIR='/sandbox/.openclaw/credentials'"); expect(envFile.indexOf("export OPENCLAW_STATE_DIR=")).toBeLessThan( - envFile.indexOf("export OPENCLAW_GATEWAY_TOKEN="), + envFile.indexOf("OPENCLAW_GATEWAY_TOKEN='"), ); }); @@ -743,7 +744,7 @@ describe("nemoclaw-start gateway token export (#1114)", () => { expect(envFile).toContain("export OPENCLAW_GATEWAY_PORT='18790'"); expect(envFile).toContain("export NEMOCLAW_OPENCLAW_GATEWAY_URL='ws://127.0.0.1:18790'"); expect(envFile).not.toContain("export OPENCLAW_GATEWAY_URL='ws://127.0.0.1:18790'"); - expect(envFile).toContain(`export OPENCLAW_GATEWAY_TOKEN='${configAfter.gateway.auth.token}'`); + expect(envFile).toContain(`OPENCLAW_GATEWAY_TOKEN='${configAfter.gateway.auth.token}'`); expect(envFile).not.toContain("stale-token"); expect(hashAfter).not.toBe("initial-hash\n"); expect(hashAfter).toMatch(/ openclaw\.json\n$/); @@ -761,7 +762,7 @@ describe("nemoclaw-start gateway token export (#1114)", () => { expect(configAfter.gateway.auth.token).toEqual(expect.any(String)); expect(configAfter.gateway.auth.token).not.toBe(""); expect(configAfter.gateway.auth.token).not.toBe(oldToken); - expect(envFile).toContain(`export OPENCLAW_GATEWAY_TOKEN='${configAfter.gateway.auth.token}'`); + expect(envFile).toContain(`OPENCLAW_GATEWAY_TOKEN='${configAfter.gateway.auth.token}'`); expect(envFile).not.toContain(oldToken); expect(envFile).not.toContain("stale-token"); expect(hashAfter).not.toBe("initial-hash\n"); @@ -788,7 +789,7 @@ describe("nemoclaw-start gateway token export (#1114)", () => { expect(configAfter.gateway.auth.token).not.toBe(""); expect(configAfter.gateway.auth.token).not.toBe(oldToken); expect(configAfter.model).toBe("nvidia/nemotron-3-super-120b-a12b"); - expect(envFile).toContain(`export OPENCLAW_GATEWAY_TOKEN='${configAfter.gateway.auth.token}'`); + expect(envFile).toContain(`OPENCLAW_GATEWAY_TOKEN='${configAfter.gateway.auth.token}'`); expect(envFile).not.toContain(oldToken); expect(envFile).not.toContain("stale-token"); expect(hashAfter).not.toBe("initial-hash\n"); @@ -4809,16 +4810,15 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => expect(fs.existsSync(proxyEnvFile)).toBe(true); const proxyEnv = fs.readFileSync(proxyEnvFile, "utf-8"); - expect(proxyEnv).toMatch(/export OPENCLAW_GATEWAY_TOKEN='[A-Za-z0-9_-]{20,}'/); + expect(proxyEnv).toMatch(/OPENCLAW_GATEWAY_TOKEN='[A-Za-z0-9_-]{20,}'/); + expect(proxyEnv).toContain("export OPENCLAW_GATEWAY_TOKEN"); expect((fs.statSync(bashrcPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(profilePath).mode & 0o777).toString(8)).toBe("444"); const updatedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); expect(updatedConfig.gateway?.auth?.token).toMatch(/^[A-Za-z0-9_-]{20,}$/); - expect(proxyEnv).toContain( - `export OPENCLAW_GATEWAY_TOKEN='${updatedConfig.gateway.auth.token}'`, - ); + expect(proxyEnv).toContain(`OPENCLAW_GATEWAY_TOKEN='${updatedConfig.gateway.auth.token}'`); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index ea9031ad9c..c142a84837 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -297,7 +297,9 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { insecurePrivateWs?: string; preloadPresent?: boolean; fakeExit?: number; - poisonShellFunctions?: readonly ("[" | "command" | "return")[]; + runPrivateGatewayControl?: boolean; + shell?: "bash" | "/bin/sh"; + poisonShellFunctions?: readonly ("[" | "command" | "echo" | "exit" | "return")[]; }, ): { status: number; stdout: string; stderr: string; preloadPath: string } { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wa-guard-")); @@ -315,6 +317,19 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { 'echo "FAKE_OPENCLAW_GATEWAY_URL=${OPENCLAW_GATEWAY_URL:-unset}"', 'echo "FAKE_OPENCLAW_INSECURE_WS=${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-unset}"', 'echo "FAKE_OPENCLAW_TOKEN=${OPENCLAW_GATEWAY_TOKEN:-unset}"', + 'case "$*" in', + ' "channels login --channel whatsapp" | "channels login --channel=whatsapp")', + ' echo "POSTPAIR_RPC=channels.start"', + ' case "${OPENCLAW_GATEWAY_URL:-}" in', + " ws://10.200.0.2:*)", + ' echo "POSTPAIR_ERROR=missing scope: operator.admin" >&2', + ' echo "CHANNEL_STATE=stopped"', + " exit 13", + " ;;", + ' *) echo "CHANNEL_STATE=running" ;;', + " esac", + " ;;", + "esac", `exit ${opts.fakeExit ?? 0}`, ].join("\n"), { mode: 0o755 }, @@ -360,6 +375,13 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { guardBody, `openclaw ${args.map((a) => JSON.stringify(a)).join(" ")}`, 'echo "GUARD_EXIT=$?"', + ...(opts.runPrivateGatewayControl + ? [ + 'echo "PRIVATE_CONTROL_BEGIN"', + 'OPENCLAW_GATEWAY_URL="$NEMOCLAW_OPENCLAW_GATEWAY_URL" /usr/bin/env openclaw channels login --channel whatsapp', + 'echo "PRIVATE_CONTROL_EXIT=$?"', + ] + : []), ); const wrapperPath = path.join(tempDir, "run.sh"); fs.writeFileSync(wrapperPath, wrapperLines.join("\n"), { mode: 0o700 }); @@ -370,9 +392,18 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { ...(poisonedFunctions.has("command") ? { "BASH_FUNC_command%%": "() { printf 'POISON_COMMAND_USED\\n'; }" } : {}), + ...(poisonedFunctions.has("echo") + ? { + "BASH_FUNC_echo%%": + '() { _nemoclaw_whatsapp_gateway_url="ws://127.0.0.1:1@evil.example.test"; builtin echo "$@"; }', + } + : {}), + ...(poisonedFunctions.has("exit") + ? { "BASH_FUNC_exit%%": "() { printf 'POISON_EXIT_USED\\n'; }" } + : {}), ...(poisonedFunctions.has("return") ? { "BASH_FUNC_return%%": "() { :; }" } : {}), }; - const r = spawnSync("bash", [wrapperPath], { + const r = spawnSync(opts.shell ?? "bash", [wrapperPath], { encoding: "utf-8", env: { ...process.env, ...poisonEnv }, timeout: 10000, @@ -397,9 +428,11 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); }); - it("pairs over loopback config resolution when no gateway URL is set (#6413)", () => { + it("keeps the native post-pair channel running via loopback config resolution (#6413)", () => { const r = runGuard(["channels", "login", "--channel", "whatsapp"], { + privateGatewayUrl: "ws://10.200.0.2:18790", preloadPresent: true, + runPrivateGatewayControl: true, }); expect(r.stderr).toContain("Pairing via the in-sandbox gateway (loopback)"); expect(r.stdout).toContain("FAKE_OPENCLAW_ARGS=channels login --channel whatsapp"); @@ -408,7 +441,39 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { // survive the post-pair channels.start restart. expect(r.stdout).toContain("FAKE_OPENCLAW_GATEWAY_URL=unset"); expect(r.stdout).toContain("FAKE_OPENCLAW_INSECURE_WS=unset"); + expect(r.stdout).toContain("POSTPAIR_RPC=channels.start"); + expect(r.stdout).toContain("CHANNEL_STATE=running"); expect(r.stdout).toContain("GUARD_EXIT=0"); + // Control: the same fake upstream boundary models OpenClaw's known + // private-veth locality behavior. Re-injecting NemoClaw's stashed URL + // strips operator.admin from the native post-pair channels.start call and + // leaves the channel stopped. + expect(r.stdout).toMatch( + /PRIVATE_CONTROL_BEGIN[\s\S]*FAKE_OPENCLAW_GATEWAY_URL=ws:\/\/10\.200\.0\.2:18790[\s\S]*POSTPAIR_RPC=channels\.start[\s\S]*CHANNEL_STATE=stopped[\s\S]*PRIVATE_CONTROL_EXIT=13/, + ); + expect(r.stderr).toContain("POSTPAIR_ERROR=missing scope: operator.admin"); + }); + + it("preserves handled WhatsApp statuses when sourced by POSIX sh (#6413)", () => { + const failedLogin = runGuard(["channels", "login", "--channel", "whatsapp"], { + fakeExit: 7, + preloadPresent: true, + shell: "/bin/sh", + }); + expect(failedLogin.stdout).toContain("FAKE_OPENCLAW_ARGS=channels login --channel whatsapp"); + expect(failedLogin.stdout).toContain("GUARD_EXIT=7"); + expect(failedLogin.stderr).not.toContain("builtin"); + + const rejectedUrl = runGuard(["channels", "login", "--channel", "whatsapp"], { + gatewayUrl: "wss://attacker.example.test:443", + gatewayToken: "guard-secret-token", + preloadPresent: true, + shell: "/bin/sh", + }); + expect(rejectedUrl.stdout).toContain("GUARD_EXIT=1"); + expect(rejectedUrl.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); + expect(`${rejectedUrl.stdout}\n${rejectedUrl.stderr}`).not.toContain("guard-secret-token"); + expect(rejectedUrl.stderr).not.toContain("builtin"); }); it.each([ @@ -423,12 +488,14 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stderr).toContain("is not a ws:// gateway URL"); expect(r.stdout).toContain("GUARD_EXIT=1"); expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); + expect(`${r.stdout}\n${r.stderr}`).not.toContain(badUrl); }); it.each([ "ws://127.0.0.1:18789", "wss://localhost:443", "ws://[::1]:18789", + "ws://127.0.0.1:18789/path?token=loopback-secret#fragment", ])("accepts loopback ws:// and wss:// gateway URL overrides (%s)", (goodUrl) => { const r = runGuard(["channels", "login", "--channel", "whatsapp"], { gatewayUrl: goodUrl, @@ -437,6 +504,8 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).toContain("FAKE_OPENCLAW_ARGS=channels login --channel whatsapp"); expect(r.stdout).toContain(`FAKE_OPENCLAW_GATEWAY_URL=${goodUrl}`); expect(r.stdout).toContain("GUARD_EXIT=0"); + expect(r.stderr).not.toContain(goodUrl); + expect(r.stderr).not.toContain("loopback-secret"); }); it.each([ @@ -458,6 +527,7 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); expect(`${r.stdout}\n${r.stderr}`).not.toContain("guard-secret-token"); + expect(`${r.stdout}\n${r.stderr}`).not.toContain(badUrl); }); it("fails closed on a non-loopback URL when an imported function shadows the bracket builtin", () => { @@ -479,7 +549,7 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { const r = runGuard(["channels", "login", "--channel", "whatsapp"], { gatewayUrl: "wss://attacker.example.test:443", gatewayToken: "guard-secret-token", - poisonShellFunctions: ["return"], + poisonShellFunctions: ["exit", "return"], preloadPresent: true, }); expect(r.stderr).toContain("is not a loopback gateway URL"); @@ -489,6 +559,19 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(`${r.stdout}\n${r.stderr}`).not.toContain("guard-secret-token"); }); + it("revalidates the loopback destination after an imported echo mutates it (#6413)", () => { + const r = runGuard(["channels", "login", "--channel", "whatsapp"], { + gatewayToken: "guard-secret-token", + poisonShellFunctions: ["echo"], + preloadPresent: true, + }); + expect(r.stderr).toContain("gateway URL changed after validation"); + expect(r.stdout).toContain("GUARD_EXIT=1"); + expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); + expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); + expect(`${r.stdout}\n${r.stderr}`).not.toContain("guard-secret-token"); + }); + it("bypasses a shadowed command function for a validated loopback URL", () => { const r = runGuard(["channels", "login", "--channel=whatsapp"], { gatewayUrl: "ws://127.0.0.1:18789", @@ -522,6 +605,7 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stdout).not.toContain("FAKE_OPENCLAW_ARGS"); expect(r.stdout).not.toContain("FAKE_OPENCLAW_TOKEN"); expect(`${r.stdout}\n${r.stderr}`).not.toContain("guard-secret-token"); + expect(`${r.stdout}\n${r.stderr}`).not.toContain(badUrl); }); it("does not re-inject the stashed private gateway URL for WhatsApp (#6413)", () => { @@ -596,5 +680,6 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { expect(r.stderr).toContain("not a QR-size issue"); // Guard preserves the underlying exit code. expect(r.stdout).toContain("GUARD_EXIT=7"); + expect(r.stdout).not.toContain("POISON_EXIT_USED"); }); }); From a47c3882296b636de8cb6b60119950b6cae6c9e3 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 11 Jul 2026 00:39:42 -0700 Subject: [PATCH 12/13] test(runtime): exercise absolute OpenClaw dispatch Signed-off-by: Carlos Villela --- test/repro-4538-raw-doctor-perms.test.ts | 52 +++++++++++++----------- test/service-env.test.ts | 11 ++--- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/test/repro-4538-raw-doctor-perms.test.ts b/test/repro-4538-raw-doctor-perms.test.ts index efa643f513..471548871e 100644 --- a/test/repro-4538-raw-doctor-perms.test.ts +++ b/test/repro-4538-raw-doctor-perms.test.ts @@ -121,6 +121,23 @@ function seedTightenedConfigTree(): { tmpDir: string; configDir: string; configF return { tmpDir, configDir, configFile }; } +function writeDoctorFixFake(tmpDir: string): string { + const binDir = path.join(tmpDir, "bin"); + fs.mkdirSync(binDir); + fs.writeFileSync( + path.join(binDir, "openclaw"), + [ + "#!/bin/sh", + 'chmod 700 "$OPENCLAW_STATE_DIR"', + 'printf \'{"doctor":true}\\n\' > "$OPENCLAW_STATE_DIR/openclaw.json"', + 'chmod 600 "$OPENCLAW_STATE_DIR/openclaw.json"', + "exit 7", + ].join("\n"), + { mode: 0o755 }, + ); + return binDir; +} + describe("raw `openclaw doctor --fix` mutable-perm restore (#4538)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); @@ -199,6 +216,7 @@ describe("raw `openclaw doctor --fix` mutable-perm restore (#4538)", () => { fs.chmodSync(configDir, 0o2770); fs.chmodSync(configFile, 0o660); try { + const fakeBin = writeDoctorFixFake(tmpDir); const result = spawnSync( "bash", [ @@ -206,18 +224,6 @@ describe("raw `openclaw doctor --fix` mutable-perm restore (#4538)", () => { [ "set -uo pipefail", mutableSandboxOwnerStatShim(), - // Intercept `command openclaw ...` (the guard's terminal call) to - // simulate `doctor --fix`: tighten perms, then exit nonzero — the - // EACCES-on-.bashrc case the reporter hit. - "command() {", - ' if [ "${1:-}" = "openclaw" ]; then', - ' chmod 700 "$OPENCLAW_STATE_DIR";', - ' printf \'{"doctor":true}\\n\' > "$OPENCLAW_STATE_DIR/openclaw.json";', - ' chmod 600 "$OPENCLAW_STATE_DIR/openclaw.json";', - " return 7;", - " fi", - ' builtin command "$@";', - "}", extractGuardBlock(src), "openclaw doctor --fix", 'echo "GUARD_EXIT:$?"', @@ -226,7 +232,11 @@ describe("raw `openclaw doctor --fix` mutable-perm restore (#4538)", () => { { encoding: "utf-8", timeout: 5000, - env: { ...process.env, OPENCLAW_STATE_DIR: configDir }, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + OPENCLAW_STATE_DIR: configDir, + }, }, ); @@ -251,6 +261,7 @@ describe("raw `openclaw doctor --fix` mutable-perm restore (#4538)", () => { fs.chmodSync(configDir, 0o2770); fs.chmodSync(configFile, 0o660); try { + const fakeBin = writeDoctorFixFake(tmpDir); const result = spawnSync( "bash", [ @@ -258,15 +269,6 @@ describe("raw `openclaw doctor --fix` mutable-perm restore (#4538)", () => { [ "set -e", mutableSandboxOwnerStatShim(), - "command() {", - ' if [ "${1:-}" = "openclaw" ]; then', - ' chmod 700 "$OPENCLAW_STATE_DIR";', - ' printf \'{"doctor":true}\\n\' > "$OPENCLAW_STATE_DIR/openclaw.json";', - ' chmod 600 "$OPENCLAW_STATE_DIR/openclaw.json";', - " return 7;", - " fi", - ' builtin command "$@";', - "}", extractGuardBlock(src), "openclaw doctor --fix", 'echo "UNREACHABLE_UNDER_ERREXIT"', @@ -275,7 +277,11 @@ describe("raw `openclaw doctor --fix` mutable-perm restore (#4538)", () => { { encoding: "utf-8", timeout: 5000, - env: { ...process.env, OPENCLAW_STATE_DIR: configDir }, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + OPENCLAW_STATE_DIR: configDir, + }, }, ); diff --git a/test/service-env.test.ts b/test/service-env.test.ts index 93ac3cc99f..6afe9709be 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -633,9 +633,10 @@ describe("service environment", () => { expect(envFile).not.toContain("inference.local"); expect(envFile).toContain("10.200.0.1"); expect(envFile).toContain('export AWS_EC2_METADATA_DISABLED="true"'); - expect(envFile).toContain("export OPENCLAW_GATEWAY_TOKEN='test-token-123'"); + expect(envFile).toContain("export OPENCLAW_GATEWAY_TOKEN"); + expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN='test-token-123'"); expect(envFile).toContain("nemoclaw-configure-guard begin"); - expect(envFile).toContain('command openclaw "$@"'); + expect(envFile).toContain('/usr/bin/env openclaw "$@"'); // Tool cache redirects should be present (#804) expect(envFile).toContain("npm_config_cache"); expect(envFile).toContain("HISTFILE"); @@ -658,17 +659,17 @@ describe("service environment", () => { const perms = (lstatSync(join(fakeDataDir, "proxy-env.sh")).mode & 0o777).toString(8); expect(perms).toBe("444"); - const connectedValue = execFileSync( + const connectedValues = execFileSync( "bash", [ "--noprofile", "--norc", "-c", - `export AWS_EC2_METADATA_DISABLED=false; source ${JSON.stringify(join(fakeDataDir, "proxy-env.sh"))}; printf "%s" "$AWS_EC2_METADATA_DISABLED"`, + `export AWS_EC2_METADATA_DISABLED=false; source ${JSON.stringify(join(fakeDataDir, "proxy-env.sh"))}; printf "%s|%s" "$AWS_EC2_METADATA_DISABLED" "$OPENCLAW_GATEWAY_TOKEN"`, ], { encoding: "utf-8" }, ); - expect(connectedValue).toBe("true"); + expect(connectedValues).toBe("true|test-token-123"); } finally { try { unlinkSync(tmpFile); From eefbb8aa15faeef52a54762a8c7f3275cc36aa82 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 11 Jul 2026 00:50:57 -0700 Subject: [PATCH 13/13] docs(messaging): fix WhatsApp variant guidance Signed-off-by: Carlos Villela --- docs/manage-sandboxes/messaging-channels.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx index 5e8d3d8b3b..43336aae9f 100644 --- a/docs/manage-sandboxes/messaging-channels.mdx +++ b/docs/manage-sandboxes/messaging-channels.mdx @@ -153,8 +153,10 @@ 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. +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.