From 5b72c3d4947e6485d868a7ce5dfbed5caf561625 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Tue, 9 Jun 2026 04:50:17 +0000 Subject: [PATCH] fix(whatsapp): force compact pairing QR at the real qrcode renderer (#4522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-sandbox `openclaw channels login --channel whatsapp` pairing QR still rendered full size (~56 rows) and overflowed the terminal after PR #4607. Root cause: the upstream @openclaw/whatsapp plugin renders the QR via `renderQrTerminal()` → the `qrcode` package's `toString(text, { type: "terminal", small })`, and the bundled plugin (version-matched to OpenClaw, e.g. 2026.5.27) passes NO `small` flag, so it defaults to full size. The previous fix patched the unrelated `qrcode-terminal` package, which the WhatsApp plugin never loads — so the compact rendering was never applied at the actual pairing entrypoint. Fix: - Rewrite the NODE_OPTIONS preload to patch the `qrcode` package's `toString` (the real renderer) and force `small: true` for terminal renders, independent of what the plugin version passes. Detection requires an OWN `toString` + `create` so internal qrcode submodules are not mutated. The qrcode-terminal `generate` path is still patched as a fallback. Non-terminal renders (svg/png/utf8) are untouched. - Wire the preload into the connect-session NODE_OPTIONS (deferred `[ -f ]` guard) so ANY openclaw invocation in the session renders compact, not just the bypassable openclaw() shell-function path; the guard injection remains as defense-in-depth. Coverage (proves rendered QR size, not just preload presence): - New hermetic E2E `test/e2e/test-whatsapp-qr-compact-e2e.sh` installs the exact @openclaw/whatsapp + openclaw versions pinned in Dockerfile.base and drives the real `renderQrTerminal` symbol the channel-login onQr callback uses: 56 rows without the preload, 29 rows with it. Wired into regression-e2e.yaml. - Sandbox parity (test-messaging-providers.sh M-WA6d) renders the QR in-sandbox through the baked renderer with the connect-session NODE_OPTIONS active and asserts compact dimensions. - Unit test rewritten to patch/verify the `qrcode` package shape and the connect-session wiring. Signed-off-by: Yimo Jiang --- .github/workflows/regression-e2e.yaml | 46 +++- .../scripts/whatsapp-qr-compact.js | 131 +++++++--- scripts/nemoclaw-start.sh | 30 ++- .../migration/legacy-inventory.json | 11 + test/e2e/test-messaging-providers.sh | 68 ++++- test/e2e/test-whatsapp-qr-compact-e2e.sh | 189 ++++++++++++++ test/nemoclaw-start.test.ts | 6 +- test/whatsapp-qr-compact.test.ts | 244 ++++++++++++------ 8 files changed, 602 insertions(+), 123 deletions(-) create mode 100755 test/e2e/test-whatsapp-qr-compact-e2e.sh diff --git a/.github/workflows/regression-e2e.yaml b/.github/workflows/regression-e2e.yaml index 0d99143fe56..ec7108b59bc 100644 --- a/.github/workflows/regression-e2e.yaml +++ b/.github/workflows/regression-e2e.yaml @@ -21,7 +21,7 @@ on: jobs: description: >- Comma-separated regression job names to run (empty = all). - Valid: dashboard-remote-bind-e2e,gateway-health-honest-e2e,docker-unreachable-gateway-start-e2e,gateway-drift-preflight-e2e,openshell-version-pin-e2e,onboard-inference-smoke-e2e,strict-tool-call-probe-e2e,model-router-provider-routed-inference-e2e,openclaw-plugin-runtime-exdev-e2e + Valid: dashboard-remote-bind-e2e,gateway-health-honest-e2e,docker-unreachable-gateway-start-e2e,gateway-drift-preflight-e2e,openshell-version-pin-e2e,onboard-inference-smoke-e2e,strict-tool-call-probe-e2e,model-router-provider-routed-inference-e2e,openclaw-plugin-runtime-exdev-e2e,whatsapp-qr-compact-e2e required: false type: string default: "" @@ -53,6 +53,7 @@ jobs: strict_tool_call_probe: ${{ steps.select.outputs.strict_tool_call_probe }} model_router_provider_routed_inference: ${{ steps.select.outputs.model_router_provider_routed_inference }} openclaw_plugin_runtime_exdev: ${{ steps.select.outputs.openclaw_plugin_runtime_exdev }} + whatsapp_qr_compact: ${{ steps.select.outputs.whatsapp_qr_compact }} steps: - id: select env: @@ -122,6 +123,12 @@ jobs: echo "openclaw_plugin_runtime_exdev=false" >> "$GITHUB_OUTPUT" fi + if [ -z "$normalized" ] || includes_job "whatsapp-qr-compact-e2e"; then + echo "whatsapp_qr_compact=true" >> "$GITHUB_OUTPUT" + else + echo "whatsapp_qr_compact=false" >> "$GITHUB_OUTPUT" + fi + dashboard-remote-bind-e2e: needs: select_regression_jobs if: >- @@ -430,3 +437,40 @@ jobs: /tmp/nemoclaw-e2e-openclaw-plugin-exdev-agent.log /tmp/nemoclaw-e2e-openclaw-plugin-exdev-df.log if-no-files-found: ignore + + # ── WhatsApp compact-QR reporter-workflow E2E ────────────────── + # Coverage guard for #4522. Drives the real @openclaw/whatsapp + + # openclaw renderQrTerminal path (the symbol the in-sandbox + # `openclaw channels login --channel whatsapp` onQr callback invokes) + # at the version bundled in Dockerfile.base, and asserts the pairing QR + # renders compact with the NemoClaw preload and oversized without it. + # Hermetic: only needs node + npm (no Docker, GPU, or NVIDIA_API_KEY). + whatsapp-qr-compact-e2e: + needs: select_regression_jobs + if: >- + github.repository == 'NVIDIA/NemoClaw' && + needs.select_regression_jobs.outputs.whatsapp_qr_compact == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22" + + - name: Run WhatsApp compact-QR reporter-workflow E2E test + run: bash test/e2e/test-whatsapp-qr-compact-e2e.sh + + - name: Upload WhatsApp compact-QR E2E logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: whatsapp-qr-compact-e2e-logs + path: | + /tmp/nemoclaw-e2e-whatsapp-qr-install.log + if-no-files-found: ignore diff --git a/nemoclaw-blueprint/scripts/whatsapp-qr-compact.js b/nemoclaw-blueprint/scripts/whatsapp-qr-compact.js index db30683b546..7a1e5c3e065 100644 --- a/nemoclaw-blueprint/scripts/whatsapp-qr-compact.js +++ b/nemoclaw-blueprint/scripts/whatsapp-qr-compact.js @@ -4,29 +4,43 @@ // whatsapp-qr-compact.js — force compact, scan-friendly QR rendering during // in-sandbox WhatsApp pairing. // -// The upstream @openclaw/whatsapp plugin renders the Linked-Devices pairing QR -// through the `qrcode-terminal` package at full size. Full-size rendering uses -// two terminal cells per QR module, so a WhatsApp Web QR fills 50–80+ rows and -// hundreds of columns — on a DGX Spark terminal it overflows the screen and is -// impossible to capture in a single phone-camera frame (NemoClaw#4522). +// THE BUG (NemoClaw#4522): a WhatsApp Web Linked-Devices pairing payload is a +// long, dense string, so its QR needs ~53 modules per side. Rendered at full +// size — two terminal cells per module — it spans ~56 rows and ~110 columns +// and overflows a DGX Spark terminal, so it cannot be captured in a single +// phone-camera frame. Half-block ("small") rendering packs two QR rows into +// one terminal row and one cell per column, roughly quartering the area to +// ~29 rows / ~55 columns without changing the payload, so it still scans. // -// NemoClaw owns the user-facing pairing workflow, so this preload forces the -// same `{ small: true }` half-block rendering the host-side WeChat QR path -// already uses (src/ext/wechat/login.ts). Half-block mode packs two QR rows -// into one terminal row and one module per column, roughly quartering the -// rendered area without changing the QR payload, so it still scans. +// THE ACTUAL RENDER PATH (and why the previous fix missed it): the +// `openclaw channels login --channel whatsapp` flow renders the pairing QR +// through `renderQrTerminal()` in `openclaw/plugin-sdk/media-runtime`, which +// calls the **`qrcode`** package: `qrcode.toString(text, { type: "terminal", +// small })`. Crucially, the pinned @openclaw/whatsapp (version-matched to the +// bundled OpenClaw, e.g. 2026.5.22) calls `renderQrTerminal(qr)` with NO +// `small` option, so it defaults to `small: false` and renders full size. +// The previous NemoClaw fix patched the unrelated `qrcode-terminal` package, +// which the WhatsApp plugin never loads — so it never affected the QR. This +// preload patches the package that actually renders the QR. // -// The patch hooks Module._load rather than require('qrcode-terminal') directly -// because the package is resolved from the plugin's nested node_modules, which -// this preload (loaded from /tmp via NODE_OPTIONS) cannot resolve on its own. -// It only rewrites the `small` option; any caller that already opts into small -// rendering is unaffected, and the QR text/error-correction level is untouched. +// WHAT THIS DOES: it hooks Module._load (CJS require AND the CJS-interop path +// that `import("qrcode")` bottoms out at) and wraps the loaded module: +// * `qrcode` (has both `toString` and `create`): force `small: true` for +// terminal renders. Non-terminal renders (svg/png/utf8 data URIs) are +// left untouched, and a caller that already opts into `small` is a no-op. +// * `qrcode-terminal` (has `generate`): force `small: true` as well, so the +// fix also covers any agent/path that renders through that package. +// The QR text and error-correction level are never altered — only the +// terminal cell packing — so the rendered code is identical apart from size. +// +// The hook matches by module API shape (not just the request string) because +// `import("qrcode")` resolves the bare specifier to an absolute path before it +// reaches Module._load, so a `request === "qrcode"` check alone would miss it. // // Removal criterion: drop this preload (and its wiring in nemoclaw-start.sh) -// once the bundled @openclaw/whatsapp renders a scan-friendly QR by default or -// exposes a documented compact-rendering flag NemoClaw can set through -// openclaw.json. Verify by pairing on a DGX Spark terminal and confirming the -// QR fits without this preload. +// once every bundled @openclaw/whatsapp version renders a scan-friendly QR by +// default. Verify by pairing on a DGX Spark terminal and confirming the QR +// fits without this preload. // // Ref: https://github.com/NVIDIA/NemoClaw/issues/4522 @@ -43,13 +57,68 @@ var Module = require('module'); var origLoad = Module._load; - function patchQrcodeTerminal(mod) { - if (!mod || mod.__nemoclawCompactPatched) return mod; - if (typeof mod.generate !== 'function') return mod; + function markPatched(mod) { + try { + Object.defineProperty(mod, '__nemoclawCompactPatched', { value: true }); + } catch (_e) { + mod.__nemoclawCompactPatched = true; + } + } + + function hasOwn(mod, name) { + return mod && Object.prototype.hasOwnProperty.call(mod, name); + } + + // `qrcode` package main: renderQrTerminal() calls qrcode.toString(text, opts). + // Require an OWN toString (every object inherits Object.prototype.toString, so + // a plain `typeof mod.toString` check would also match qrcode's internal + // submodules — e.g. lib/core/qrcode.js, which exposes create() but only the + // inherited toString — and needlessly mutate them). The package main exposes + // its own toString + create; the submodules do not have an own toString. + function isQrcodePackage(mod) { + return hasOwn(mod, 'toString') && typeof mod.toString === 'function' && + typeof mod.create === 'function'; + } + + // `qrcode-terminal` package: exposes its own generate(text, opts, cb) and, + // unlike `qrcode`, has no create(). + function isQrcodeTerminalPackage(mod) { + return hasOwn(mod, 'generate') && typeof mod.generate === 'function' && + typeof mod.create !== 'function'; + } + + function patchQrcode(mod) { + if (mod.__nemoclawCompactPatched) return mod; + var origToString = mod.toString; + mod.toString = function (text, opts, cb) { + // Support toString(text, cb) and toString(text, opts, cb) / (text, opts). + if (typeof opts === 'function') { + cb = opts; + opts = undefined; + } + var merged = {}; + if (opts && typeof opts === 'object') { + for (var key in opts) { + if (Object.prototype.hasOwnProperty.call(opts, key)) merged[key] = opts[key]; + } + } + // Only the terminal renderer has the oversize problem. `type` defaults + // to "utf8" in the qrcode package, but the WhatsApp path always passes + // "terminal" explicitly; force small there and leave every other type + // (svg/png/utf8 data URIs used elsewhere) exactly as the caller asked. + if (merged.type === 'terminal') { + merged.small = true; + } + return origToString.call(this, text, merged, cb); + }; + markPatched(mod); + return mod; + } + function patchQrcodeTerminal(mod) { + if (mod.__nemoclawCompactPatched) return mod; var origGenerate = mod.generate; mod.generate = function (text, opts, cb) { - // Support both generate(text, cb) and generate(text, opts, cb). if (typeof opts === 'function') { cb = opts; opts = undefined; @@ -63,20 +132,20 @@ merged.small = true; return origGenerate.call(this, text, merged, cb); }; - - try { - Object.defineProperty(mod, '__nemoclawCompactPatched', { value: true }); - } catch (_e) { - mod.__nemoclawCompactPatched = true; - } + markPatched(mod); return mod; } Module._load = function (request, _parent, _isMain) { var loaded = origLoad.apply(this, arguments); - if (request === 'qrcode-terminal') { + // Cheap path filter: only inspect modules whose request mentions qrcode. + // `import("qrcode")` arrives here as the resolved absolute path + // (…/qrcode/lib/index.js), so match on the path segment too, not just the + // bare specifier. + if (typeof request === 'string' && request.indexOf('qrcode') !== -1) { try { - return patchQrcodeTerminal(loaded); + if (isQrcodePackage(loaded)) return patchQrcode(loaded); + if (isQrcodeTerminalPackage(loaded)) return patchQrcodeTerminal(loaded); } catch (_e) { return loaded; } diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index aaa8cb8dbb6..4aad00a43ba 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1460,13 +1460,16 @@ install_telegram_diagnostics() { } # ── WhatsApp compact-QR preload (scan-friendly in-sandbox pairing) ─── -# The upstream @openclaw/whatsapp QR renders at full size and overflows DGX -# Spark terminals (NemoClaw#4522). This preload forces qrcode-terminal into -# the same `{ small: true }` half-block mode the host-side WeChat path uses. -# Unlike the diagnostics/guard preloads it is NOT added to the global -# NODE_OPTIONS — the gateway never renders the pairing QR. The openclaw() -# guard injects it for the single `channels login --channel whatsapp` -# invocation, so we only need the file present in the sandbox. +# The upstream @openclaw/whatsapp QR renders at full size (~56 rows) and +# overflows DGX Spark terminals (NemoClaw#4522). The plugin renders through +# `renderQrTerminal()` → the `qrcode` package's toString(text,{type:"terminal"}) +# WITHOUT a `small` flag, so it defaults to full size. This preload patches the +# qrcode package to force `{ small: true }` half-block rendering for terminal +# output, roughly quartering the area without changing the payload. +# It is NOT added to the global boot NODE_OPTIONS (the gateway never renders the +# pairing QR); instead it is wired into the connect-session NODE_OPTIONS (so any +# openclaw invocation in the session gets it, not just the openclaw() shell +# function) and the openclaw() guard injects it as defense-in-depth. _WHATSAPP_QR_COMPACT_SCRIPT="/tmp/nemoclaw-whatsapp-qr-compact.js" _WHATSAPP_QR_COMPACT_SOURCE="/usr/local/lib/nemoclaw/preloads/whatsapp-qr-compact.js" @@ -2424,6 +2427,10 @@ PYAPPROVEAFTER esac echo "[whatsapp] Pairing via gateway ${OPENCLAW_GATEWAY_URL}." >&2 echo "[whatsapp] On your phone: WhatsApp > Linked devices > Link a device, then scan the QR below." >&2 + # Defense-in-depth: the connect-session NODE_OPTIONS already wires + # this preload in for every openclaw invocation; injecting it again + # here covers non-connect shells (e.g. `openshell sandbox exec`). + # The preload is idempotent, so a double --require is harmless. # Literal path: this guard body is emitted inside a single-quoted # heredoc, so shell variables are intentionally not expanded here. # Keep in sync with _WHATSAPP_QR_COMPACT_SCRIPT above. @@ -2511,6 +2518,15 @@ GUARDENVEOF # by install_slack_channel_guard() — conditional on the file existing at # source-time so connect sessions started before Slack is configured are safe. echo "[ -f \"$_SLACK_GUARD_SCRIPT\" ] && export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_SLACK_GUARD_SCRIPT\"" + # WhatsApp compact-QR preload for connect sessions (NemoClaw#4522). The + # in-sandbox `openclaw channels login --channel whatsapp` QR renders full + # size (~56 rows) and overflows the terminal. Wiring the preload into the + # connect-session NODE_OPTIONS forces compact rendering for ANY openclaw + # invocation in the session — not only the openclaw() shell-function path, + # which a direct binary call would bypass. The file is installed by + # install_whatsapp_qr_compact() only for WhatsApp sandboxes, so the + # source-time `[ -f ]` check leaves non-WhatsApp connect sessions untouched. + echo "[ -f \"$_WHATSAPP_QR_COMPACT_SCRIPT\" ] && export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_WHATSAPP_QR_COMPACT_SCRIPT\"" # Tool cache redirects — generated from _TOOL_REDIRECTS (single source of truth) echo '# Tool cache redirects — keep transient tool state under /tmp' for _redir in "${_TOOL_REDIRECTS[@]}"; do diff --git a/test/e2e-scenario/migration/legacy-inventory.json b/test/e2e-scenario/migration/legacy-inventory.json index 786522ba3b1..df3228ad6f8 100644 --- a/test/e2e-scenario/migration/legacy-inventory.json +++ b/test/e2e-scenario/migration/legacy-inventory.json @@ -68,6 +68,17 @@ "deletionReady": false, "notes": "Split into Telegram, Discord, Slack, fake-provider, and token-rotation Vitest scenarios before deleting." }, + { + "legacyScript": "test/e2e/test-whatsapp-qr-compact-e2e.sh", + "domain": "messaging", + "ownerIssue": "#4351", + "status": "not-migrated", + "targetVitestScenarios": [], + "bridgeProbes": [], + "retiredReason": "", + "deletionReady": false, + "notes": "Hermetic WhatsApp pairing-QR size guard (NemoClaw#4522); migrate alongside the messaging-provider scenarios once a Vitest harness can drive the openclaw renderQrTerminal renderer." + }, { "legacyScript": "test/e2e/test-token-rotation.sh", "domain": "messaging", diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 285dfcbfba3..da94723ffb4 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -943,12 +943,13 @@ else fi # M-WA6b: WhatsApp compact-QR pairing wiring (NemoClaw#4522). The entrypoint -# installs a NemoClaw-owned preload that forces qrcode-terminal into +# installs a NemoClaw-owned preload that forces the `qrcode` package (which +# OpenClaw's renderQrTerminal uses to render the pairing QR) into # `{ small: true }` half-block rendering so the in-sandbox pairing QR fits a -# phone-camera frame, and the openclaw() guard injects it for the single -# `channels login --channel whatsapp` invocation. Verify both the preload file -# (root-owned/read-only in root mode; read-only in non-root mode) and the guard -# wiring are present in the sandbox. +# phone-camera frame. The preload is wired into the connect-session NODE_OPTIONS +# and the openclaw() guard injects it for the `channels login --channel whatsapp` +# invocation. Verify the preload file (root-owned/read-only in root mode; +# read-only in non-root mode) and the guard wiring are present in the sandbox. whatsapp_qr_preload_stat=$(sandbox_exec "stat -c '%U:%a' /tmp/nemoclaw-whatsapp-qr-compact.js 2>/dev/null || echo missing") entrypoint_start_log_stat=$(sandbox_exec "stat -c '%U:%a' /tmp/nemoclaw-start.log 2>/dev/null || echo missing") if [ "$whatsapp_qr_preload_stat" = "root:444" ]; then @@ -978,6 +979,63 @@ else fail "M-WA6c: openclaw() guard missing compact-QR preload --require injection for WhatsApp login (#4522)" fi +# M-WA6d: Prove the rendered QR SIZE in the real sandbox, not just that the +# preload file/wiring exist (NemoClaw#4522). Render a representative WhatsApp +# pairing payload through the EXACT renderer the channel-login onQr callback +# uses — `renderQrTerminal` from the baked OpenClaw's plugin-sdk/media-runtime — +# once with the connect-session NODE_OPTIONS sourced (the preload active, as in +# the reporter workflow) and once with NODE_OPTIONS cleared. Assert the sourced +# render is compact and strictly smaller than the cleared baseline. +# +# The probe runs from the global node_modules parent so the bare +# `openclaw/...` specifier resolves against the globally-installed CLI. If the +# renderer cannot be resolved/executed at all (an infra/resolution issue, not a +# size regression) the sub-check SKIPs rather than failing the suite — an actual +# oversized render still yields a number above the ceiling and fails. The +# hard-gated, version-pinned size proof lives in test-whatsapp-qr-compact-e2e.sh. +WHATSAPP_QR_RENDER_PROBE=$( + cat <<'PROBE' +import { renderQrTerminal } from "openclaw/plugin-sdk/media-runtime"; +const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, ""); +const qr = "2@" + "ABcd12".repeat(8) + "," + "a8K3".repeat(11) + "=," + + "Xy90".repeat(11) + "=," + "Qr5T".repeat(9) + "="; +const out = strip(await renderQrTerminal(qr)); +process.stdout.write(String(out.split("\n").length)); +PROBE +) +whatsapp_qr_render_b64=$(printf '%s' "$WHATSAPP_QR_RENDER_PROBE" | base64 | tr -d '\n') +# Build a remote command that writes the probe to the global lib dir and runs +# it twice (preload sourced vs NODE_OPTIONS cleared), printing both row counts. +whatsapp_qr_render_remote=$( + cat < "\$PROBE_FILE" 2>/dev/null || { echo "RENDER_PROBE_UNAVAILABLE: write failed"; exit 0; } +cd "\$LIBDIR" || { echo "RENDER_PROBE_UNAVAILABLE: cd failed"; exit 0; } +# Compact render: source the connect-session env so the preload is on NODE_OPTIONS. +COMPACT="\$( [ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh 2>/dev/null; node "\$PROBE_FILE" 2>/dev/null )" || COMPACT="" +# Baseline render: explicitly clear NODE_OPTIONS so the preload is absent. +BASELINE="\$( NODE_OPTIONS="" node "\$PROBE_FILE" 2>/dev/null )" || BASELINE="" +rm -f "\$PROBE_FILE" 2>/dev/null || true +echo "RENDER_COMPACT=\${COMPACT:-NA} RENDER_BASELINE=\${BASELINE:-NA}" +REMOTE +) +whatsapp_qr_render_out=$(sandbox_exec "$whatsapp_qr_render_remote") +whatsapp_qr_compact_rows=$(printf '%s' "$whatsapp_qr_render_out" | sed -n 's/.*RENDER_COMPACT=\([0-9]*\).*/\1/p') +whatsapp_qr_baseline_rows=$(printf '%s' "$whatsapp_qr_render_out" | sed -n 's/.*RENDER_BASELINE=\([0-9]*\).*/\1/p') +if [ -n "$whatsapp_qr_compact_rows" ] && [ -n "$whatsapp_qr_baseline_rows" ]; then + if [ "$whatsapp_qr_compact_rows" -le 40 ] && [ "$whatsapp_qr_compact_rows" -lt "$whatsapp_qr_baseline_rows" ]; then + pass "M-WA6d: in-sandbox pairing QR renders compact (${whatsapp_qr_compact_rows} rows, baseline ${whatsapp_qr_baseline_rows}) (#4522)" + else + fail "M-WA6d: in-sandbox pairing QR not compact (compact=${whatsapp_qr_compact_rows} rows, baseline=${whatsapp_qr_baseline_rows}) (#4522)" + fi +else + skip "M-WA6d: in-sandbox QR render probe unavailable (${whatsapp_qr_render_out:0:160}) (#4522)" +fi + # M1: Verify Telegram provider exists in gateway if openshell provider get "${SANDBOX_NAME}-telegram-bridge" >/dev/null 2>&1; then pass "M1: Provider '${SANDBOX_NAME}-telegram-bridge' exists in gateway" diff --git a/test/e2e/test-whatsapp-qr-compact-e2e.sh b/test/e2e/test-whatsapp-qr-compact-e2e.sh new file mode 100755 index 00000000000..950898a3456 --- /dev/null +++ b/test/e2e/test-whatsapp-qr-compact-e2e.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Reporter-workflow coverage guard for NemoClaw#4522 — the in-sandbox WhatsApp +# pairing QR (`openclaw channels login --channel whatsapp`) must render compact +# enough to scan with a phone. +# +# WHY THIS SHAPE: a full live pairing cannot be automated — it needs a real +# WhatsApp account and a phone to scan the code. But the bug is purely in QR +# *rendering*, which happens in the plugin's `onQr` callback BEFORE any phone +# interaction. That callback renders through `renderQrTerminal()` in +# `openclaw/plugin-sdk/media-runtime`, which calls the `qrcode` package's +# `toString(text, { type: "terminal", small })`. This test installs the EXACT +# `@openclaw/whatsapp` + `openclaw` versions the sandbox bundles (pinned to the +# OPENCLAW_VERSION ARG in Dockerfile.base) and drives that real renderer with a +# representative WhatsApp pairing payload, measuring the rendered dimensions +# with and without the NemoClaw compact-QR preload. +# +# This proves rendered QR *size* (not merely that the preload file exists), +# through the same upstream symbol the reporter workflow invokes. It is fully +# hermetic: it needs only npm (to fetch the pinned plugin) and node — no Docker, +# no GPU, no NVIDIA_API_KEY, no sandbox. +# +# Ref: https://github.com/NVIDIA/NemoClaw/issues/4522 + +set -uo pipefail + +PASS=0 +FAIL=0 + +pass() { + ((PASS++)) + echo " OK: $1" +} +fail() { + ((FAIL++)) + echo " ERROR: $1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "${SCRIPT_DIR}/../.." && pwd)" +PRELOAD="${REPO}/nemoclaw-blueprint/scripts/whatsapp-qr-compact.js" + +# Scan-friendly ceiling: a half-block WhatsApp QR is ~29 rows. 40 leaves head +# room for QR-version drift while staying well under the ~56-row full-size form +# the reporter saw. The oversize floor (50) guards that we are still measuring +# the real, un-compacted render in the baseline. +COMPACT_MAX_ROWS="${WHATSAPP_QR_COMPACT_MAX_ROWS:-40}" +OVERSIZE_MIN_ROWS="${WHATSAPP_QR_OVERSIZE_MIN_ROWS:-50}" + +WORKDIR="$(mktemp -d /tmp/nemoclaw-wa-qr-e2e.XXXXXX)" +# shellcheck disable=SC2329 # invoked via the EXIT trap below +cleanup() { rm -rf "$WORKDIR" 2>/dev/null || true; } +trap cleanup EXIT + +section "Prerequisites" +if command -v node >/dev/null 2>&1; then + pass "node is available: $(node --version)" +else + fail "node is required" + exit 1 +fi +if command -v npm >/dev/null 2>&1; then + pass "npm is available: $(npm --version)" +else + fail "npm is required" + exit 1 +fi +if [ -f "$PRELOAD" ]; then + pass "compact-QR preload present: $PRELOAD" +else + fail "compact-QR preload missing: $PRELOAD" + exit 1 +fi + +section "Resolve bundled OpenClaw / WhatsApp plugin version" +# Single source of truth: the OPENCLAW_VERSION ARG default in Dockerfile.base. +# The sandbox installs @openclaw/whatsapp pinned to this same version +# (scripts/openclaw-build-messaging-plugins.py), so the rendered QR we measure +# matches what a real sandbox would show. +OC_VERSION="$(grep -m1 -E '^ARG OPENCLAW_VERSION=' "${REPO}/Dockerfile.base" | cut -d= -f2 | tr -d '[:space:]')" +if [ -n "$OC_VERSION" ]; then + pass "bundled OpenClaw version resolved: ${OC_VERSION}" +else + fail "could not parse OPENCLAW_VERSION from Dockerfile.base" + exit 1 +fi + +section "Install pinned @openclaw/whatsapp + openclaw" +(cd "$WORKDIR" && printf '{ "name": "wa-qr-e2e", "version": "1.0.0", "private": true }\n' >package.json) +# Keep the install log outside WORKDIR (which the EXIT trap removes) so CI can +# upload it as a failure artifact for debugging. +install_log="${E2E_WHATSAPP_QR_INSTALL_LOG:-/tmp/nemoclaw-e2e-whatsapp-qr-install.log}" +if (cd "$WORKDIR" && npm install --no-audit --no-fund \ + "openclaw@${OC_VERSION}" "@openclaw/whatsapp@${OC_VERSION}" >"$install_log" 2>&1); then + pass "installed openclaw@${OC_VERSION} and @openclaw/whatsapp@${OC_VERSION}" +else + fail "npm install failed; see ${install_log}" + tail -20 "$install_log" || true + exit 1 +fi + +section "Plugin renders the pairing QR via renderQrTerminal (real path)" +# Confirm the precondition the bug depends on: the channel-login QR path uses +# renderQrTerminal from the openclaw media-runtime SDK. If a future plugin +# version stops using it, this guard should be revisited. +if grep -rqs "renderQrTerminal" "${WORKDIR}/node_modules/@openclaw/whatsapp/dist/"; then + pass "plugin channel-login renders through renderQrTerminal" +else + fail "plugin no longer references renderQrTerminal — revisit this guard" + exit 1 +fi + +# Probe program: import the EXACT symbol the plugin's onQr callback calls +# (renderQrTerminal from openclaw/plugin-sdk/media-runtime), render a +# representative WhatsApp Web Linked-Devices pairing payload, and print the +# visible (ANSI-stripped) terminal dimensions as JSON. +cat >"${WORKDIR}/probe.mjs" <<'PROBE' +import { renderQrTerminal } from "openclaw/plugin-sdk/media-runtime"; +const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, ""); +// ref,noiseKey,signedIdentityKey,advSecret — the four comma-joined fields a +// baileys WhatsApp Web QR carries; long and dense like the real payload. +const qr = + "2@" + "ABcd12".repeat(8) + "," + "a8K3".repeat(11) + "=," + + "Xy90".repeat(11) + "=," + "Qr5T".repeat(9) + "="; +// Call EXACTLY as the plugin does at session login: renderQrTerminal(qr), +// with no { small } — so we exercise the real default, not a contrived opt-in. +const out = strip(await renderQrTerminal(qr)); +const lines = out.split("\n"); +process.stdout.write(JSON.stringify({ + rows: lines.length, + cols: Math.max(...lines.map((l) => [...l].length)), +})); +PROBE + +run_probe() { + # $1: "with" | "without" preload + if [ "$1" = "with" ]; then + (cd "$WORKDIR" && NODE_OPTIONS="--require ${PRELOAD}" node probe.mjs) + else + (cd "$WORKDIR" && node probe.mjs) + fi +} + +section "Baseline (no preload) reproduces the oversized QR" +baseline_json="$(run_probe without)" || { + fail "baseline probe failed" + exit 1 +} +baseline_rows="$(printf '%s' "$baseline_json" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).rows))')" +info "baseline rendered dimensions: ${baseline_json}" +if [ "$baseline_rows" -ge "$OVERSIZE_MIN_ROWS" ]; then + pass "baseline QR is oversized (${baseline_rows} rows >= ${OVERSIZE_MIN_ROWS}) — reproduces NemoClaw#4522" +else + fail "baseline QR was only ${baseline_rows} rows; expected >= ${OVERSIZE_MIN_ROWS} (precondition for the bug)" + exit 1 +fi + +section "With NemoClaw compact-QR preload, the QR is scan-friendly" +patched_json="$(run_probe with)" || { + fail "patched probe failed" + exit 1 +} +patched_rows="$(printf '%s' "$patched_json" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).rows))')" +info "compact rendered dimensions: ${patched_json}" +if [ "$patched_rows" -le "$COMPACT_MAX_ROWS" ]; then + pass "compact QR fits a scan frame (${patched_rows} rows <= ${COMPACT_MAX_ROWS})" +else + fail "compact QR was ${patched_rows} rows; expected <= ${COMPACT_MAX_ROWS}" +fi +if [ "$patched_rows" -lt "$baseline_rows" ]; then + pass "preload strictly shrinks the QR (${baseline_rows} -> ${patched_rows} rows)" +else + fail "preload did not shrink the QR (${baseline_rows} -> ${patched_rows} rows)" +fi + +section "Summary" +echo " PASS=${PASS} FAIL=${FAIL}" +if [ "$FAIL" -eq 0 ]; then + echo " WhatsApp compact-QR reporter-workflow E2E passed" + exit 0 +fi +exit 1 diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 08338fef350..139b50ccbbc 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -5228,9 +5228,8 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', "write_auth_profile() { :; }", "harden_auth_profiles() { :; }", - // write_runtime_shell_env reads a handful of script-globals; default - // them so `set -u` does not trip and the optional emit branches stay - // dormant in the test (their content is exercised elsewhere). + // Default the script-globals write_runtime_shell_env reads so `set -u` + // does not trip and the optional emit branches stay dormant in the test. '_SANDBOX_SAFETY_NET=""', '_PROXY_FIX_SCRIPT=""', '_WS_FIX_SCRIPT=""', @@ -5239,6 +5238,7 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => '_CIAO_GUARD_SCRIPT=""', '_TELEGRAM_DIAGNOSTICS_SCRIPT=""', '_SLACK_GUARD_SCRIPT=""', + '_WHATSAPP_QR_COMPACT_SCRIPT=""', '_TOOL_REDIRECTS=("NEMOCLAW_TEST_REDIRECT=/tmp/nemoclaw-test")', 'NODE_USE_ENV_PROXY=""', readToken, diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index 502b5df5b4b..f6adaa832a4 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -17,102 +17,194 @@ const PRELOAD_SOURCE = path.join( "whatsapp-qr-compact.js", ); -// A WhatsApp Web pairing ref is a long, dense payload. Use a deterministic one -// so the rendered dimensions are stable across runs. -const WHATSAPP_QR_PAYLOAD = - "2@" + - Buffer.from("ref-token-".repeat(8)).toString("base64") + - "," + - Buffer.from("noise-key-".repeat(4)).toString("base64") + - "," + - Buffer.from("identity-key").toString("base64") + - ",abcdEFGH1234"; - -// Render the QR via the cb form (so the rendered string is captured rather -// than written to stdout) and report its dimensions for several option shapes. -const PROBE_PROGRAM = ` -const qr = require("qrcode-terminal"); -const payload = ${JSON.stringify(WHATSAPP_QR_PAYLOAD)}; -function dims(call) { - let out = ""; - call((s) => { out = s; }); - const lines = out.split("\\n"); - return { lines: lines.length, cols: Math.max(...lines.map((l) => [...l].length)) }; +// The WhatsApp pairing QR is rendered by the `qrcode` package (bundled inside +// `openclaw`), NOT `qrcode-terminal`. The plugin's onQr callback calls +// renderQrTerminal() → qrcode.toString(text, { type: "terminal", small }) and +// the bundled @openclaw/whatsapp passes NO `small`, so it defaults to full +// size. These tests prove the preload patches that real package shape. End-to- +// end proof that this shrinks a *real* rendered QR lives in +// test/e2e/test-whatsapp-qr-compact-e2e.sh, which drives the actual upstream +// renderer at the version bundled in Dockerfile.base. Ref: NemoClaw#4522. + +// A fake `qrcode` package (toString + create — the shape the preload keys on) +// and a fake `qrcode-terminal` (generate). Each records the options it was +// called with so we can assert exactly what the preload forwarded, without +// depending on a real renderer or on network installs. +function writeFakeModules(root: string): void { + const qrcodeDir = path.join(root, "node_modules", "qrcode"); + fs.mkdirSync(qrcodeDir, { recursive: true }); + fs.writeFileSync( + path.join(qrcodeDir, "package.json"), + JSON.stringify({ name: "qrcode", version: "0.0.0-fake", main: "index.js" }), + ); + fs.writeFileSync( + path.join(qrcodeDir, "index.js"), + [ + "const calls = [];", + "module.exports = {", + " // qrcode's real signatures: toString(text, [opts], [cb]).", + " toString(text, opts, cb) {", + " if (typeof opts === 'function') { cb = opts; opts = undefined; }", + " calls.push(opts || {});", + " const out = JSON.stringify(opts || {});", + " if (typeof cb === 'function') return cb(null, out);", + " return Promise.resolve(out);", + " },", + " // Presence of create() is how the preload distinguishes qrcode from", + " // qrcode-terminal; it never calls through to it here.", + " create() { return { modules: { size: 0 } }; },", + " __calls: calls,", + "};", + ].join("\n"), + ); + + const termDir = path.join(root, "node_modules", "qrcode-terminal"); + fs.mkdirSync(termDir, { recursive: true }); + fs.writeFileSync( + path.join(termDir, "package.json"), + JSON.stringify({ name: "qrcode-terminal", version: "0.0.0-fake", main: "index.js" }), + ); + fs.writeFileSync( + path.join(termDir, "index.js"), + [ + "const calls = [];", + "module.exports = {", + " generate(text, opts, cb) {", + " if (typeof opts === 'function') { cb = opts; opts = undefined; }", + " calls.push(opts || {});", + " if (typeof cb === 'function') return cb('rendered');", + " },", + " setErrorLevel() {},", + " __calls: calls,", + "};", + ].join("\n"), + ); +} + +// Run a probe script under a temp project that has the fake modules installed, +// with the preload loaded via --require. Returns the parsed JSON the probe +// prints to stdout. +function runProbe(probe: string, opts: { withPreload?: boolean } = {}): any { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wa-qr-unit-")); + try { + writeFakeModules(tempDir); + const probePath = path.join(tempDir, "probe.mjs"); + fs.writeFileSync(probePath, probe); + const args = opts.withPreload + ? ["--require", PRELOAD_SOURCE, probePath] + : [probePath]; + const r = spawnSync(process.execPath, args, { + cwd: tempDir, + encoding: "utf-8", + timeout: 10000, + }); + if (r.status !== 0) { + throw new Error(`probe failed (status=${r.status}): ${r.stderr}`); + } + return JSON.parse(r.stdout.trim()); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } } -const result = { - // generate(text, cb) — no opts at all. - noOpts: dims((cb) => qr.generate(payload, cb)), - // generate(text, { small: false }, cb) — caller explicitly asks for big. - explicitBig: dims((cb) => qr.generate(payload, { small: false }, cb)), - // generate(text, { small: true }, cb) — caller already compact. - explicitSmall: dims((cb) => qr.generate(payload, { small: true }, cb)), -}; + +// Exercise the real require + dynamic-import entry points the OpenClaw renderer +// uses, capturing the options each toString/generate call actually received. +const QRCODE_PROBE = ` +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const result = {}; + +// 1) Dynamic import — exactly how openclaw's renderQrTerminal loads qrcode. +const dyn = (await import("qrcode")).default ?? (await import("qrcode")); +await dyn.toString("payload", { type: "terminal" }); // login default +await dyn.toString("payload", { type: "terminal", small: false }); // explicit big +await dyn.toString("payload", { type: "terminal", small: true }); // already small +await dyn.toString("payload", { type: "svg" }); // non-terminal +await dyn.toString("payload"); // no opts at all +result.qrcode = dyn.__calls; + +// 2) CommonJS require — same module object, same patch. +const cjs = require("qrcode"); +result.qrcodeRequireIsPatched = cjs.__calls === dyn.__calls; + +// 3) qrcode-terminal fallback path (for any agent that renders through it). +const term = require("qrcode-terminal"); +term.generate("payload", { small: false }, () => {}); +term.generate("payload", () => {}); +result.qrcodeTerminal = term.__calls; + process.stdout.write(JSON.stringify(result)); `; -function runProbe(withPreload: boolean): { - noOpts: { lines: number; cols: number }; - explicitBig: { lines: number; cols: number }; - explicitSmall: { lines: number; cols: number }; -} { - const args = withPreload ? ["--require", PRELOAD_SOURCE, "-e", PROBE_PROGRAM] : ["-e", PROBE_PROGRAM]; - const r = spawnSync(process.execPath, args, { - cwd: REPO_ROOT, - encoding: "utf-8", - timeout: 10000, +describe("WhatsApp compact-QR preload (qrcode package)", () => { + const baseline = runProbe(QRCODE_PROBE, { withPreload: false }); + const patched = runProbe(QRCODE_PROBE, { withPreload: true }); + + it("baseline leaves the qrcode terminal render at full size", () => { + // Sanity check the fixture: without the preload, a terminal render with no + // `small` (the reporter's path) is NOT forced small. + expect(baseline.qrcode[0]).toEqual({ type: "terminal" }); + expect(baseline.qrcode[0].small).toBeUndefined(); + }); + + it("forces small:true on a terminal render with no small option", () => { + expect(patched.qrcode[0]).toEqual({ type: "terminal", small: true }); }); - if (r.status !== 0) { - throw new Error(`probe failed (status=${r.status}): ${r.stderr}`); - } - return JSON.parse(r.stdout.trim()); -} -describe("WhatsApp compact-QR preload", () => { - const baseline = runProbe(false); - const patched = runProbe(true); + it("overrides an explicit small:false terminal render back to compact", () => { + expect(patched.qrcode[1]).toEqual({ type: "terminal", small: true }); + }); - it("baseline qrcode-terminal renders large QR without options", () => { - // Sanity check the fixture: the default (non-small) rendering is the - // oversized output the issue is about, and small mode is meaningfully - // shorter and narrower. - expect(baseline.noOpts.lines).toBeGreaterThan(baseline.explicitSmall.lines); - expect(baseline.noOpts.cols).toBeGreaterThan(baseline.explicitSmall.cols); + it("leaves an already-compact terminal render unchanged", () => { + expect(patched.qrcode[2]).toEqual({ type: "terminal", small: true }); }); - it("forces compact rendering when the caller passes no options", () => { - // With the preload, generate(text, cb) must match an explicit small render - // and be strictly smaller than the unpatched default. - expect(patched.noOpts).toEqual(baseline.explicitSmall); - expect(patched.noOpts.lines).toBeLessThan(baseline.noOpts.lines); + it("does NOT touch non-terminal renders (svg/png/utf8 data URIs)", () => { + // svg render — small must not be injected; other channels/flows rely on it. + expect(patched.qrcode[3]).toEqual({ type: "svg" }); + expect(patched.qrcode[3].small).toBeUndefined(); }); - it("overrides an explicit small:false back to compact rendering", () => { - expect(patched.explicitBig).toEqual(baseline.explicitSmall); + it("does NOT inject small when no type is given (defaults to non-terminal)", () => { + expect(patched.qrcode[4]).toEqual({}); }); - it("leaves an already-compact caller unchanged", () => { - expect(patched.explicitSmall).toEqual(baseline.explicitSmall); + it("patches the same module object for require() and dynamic import()", () => { + expect(patched.qrcodeRequireIsPatched).toBe(true); }); - it("keeps the rendered QR within a scan-friendly bound (<= 40 lines)", () => { - // The reporter saw 80+ lines. Compact rendering must stay well under a - // single phone-camera frame. 40 lines is a generous ceiling for a - // half-block WhatsApp QR. - expect(patched.noOpts.lines).toBeLessThanOrEqual(40); + it("also forces small:true on the qrcode-terminal generate() fallback", () => { + expect(patched.qrcodeTerminal[0]).toEqual({ small: true }); + expect(patched.qrcodeTerminal[1]).toEqual({ small: true }); }); - it("is idempotent when loaded twice", () => { - const r = spawnSync( - process.execPath, - ["--require", PRELOAD_SOURCE, "--require", PRELOAD_SOURCE, "-e", PROBE_PROGRAM], - { cwd: REPO_ROOT, encoding: "utf-8", timeout: 10000 }, - ); - expect(r.status).toBe(0); - const twice = JSON.parse(r.stdout.trim()); - expect(twice.noOpts).toEqual(baseline.explicitSmall); + it("is idempotent when the preload is required twice", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wa-qr-unit-twice-")); + try { + writeFakeModules(tempDir); + const probePath = path.join(tempDir, "probe.mjs"); + fs.writeFileSync(probePath, QRCODE_PROBE); + const r = spawnSync( + process.execPath, + ["--require", PRELOAD_SOURCE, "--require", PRELOAD_SOURCE, probePath], + { cwd: tempDir, encoding: "utf-8", timeout: 10000 }, + ); + expect(r.status).toBe(0); + const twice = JSON.parse(r.stdout.trim()); + expect(twice.qrcode[0]).toEqual({ type: "terminal", small: true }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } }); }); +// The connect-session NODE_OPTIONS wiring (and the openclaw() guard injection) +// is exercised behaviorally rather than by asserting on source text: the guard +// describe-block below executes the extracted openclaw() function and checks the +// --require injection, and the end-to-end renderer E2E +// (test/e2e/test-whatsapp-qr-compact-e2e.sh) plus the in-sandbox M-WA6d check in +// test-messaging-providers.sh prove the wired preload actually shrinks the QR. + // 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): string {