Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion .github/workflows/regression-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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: >-
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- 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
131 changes: 100 additions & 31 deletions nemoclaw-blueprint/scripts/whatsapp-qr-compact.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
30 changes: 23 additions & 7 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions test/e2e-scenario/migration/legacy-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading