diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index b7dba00e18..d91a3cb6d3 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3260,27 +3260,43 @@ 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 for token-bearing helpers. The exported - # compatibility alias is intentionally caller-mutable, so it must never - # decide where a gateway token may be sent. Assign and verify the anchor - # in one fail-closed gate while keeping repeated sourcing idempotent. If - # the caller predeclared a conflicting readonly value, clear any ambient - # token and stop before token-bearing helpers are installed. - printf "if { [ \"\${_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL:-}\" != '%s' ] &&\\n" "$_escaped_gateway_url" - printf " ! builtin readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL='%s' 2>/dev/null; } ||\\n" "$_escaped_gateway_url" - printf " [ \"\${_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL:-}\" != '%s' ]; then\\n" "$_escaped_gateway_url" - printf " if ! builtin unset OPENCLAW_GATEWAY_TOKEN 2>/dev/null; then\\n" - printf " echo 'Error: NemoClaw rejected a conflicting gateway trust anchor, and the ambient gateway token could not be cleared.' >&2\\n" - printf " exit 1\\n" - printf " fi\\n" + # 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 "fi\\nbuiltin readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL\\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 @@ -3294,7 +3310,15 @@ 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" + # 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" fi if [ -n "${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" ]; then # Retain the matching break-glass under the same private namespace. @@ -3464,6 +3488,7 @@ _nemoclaw_whatsapp_postpair_start() { 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 # so OpenClaw resolves the local loopback gateway and device token. The # reviewed 2026.6.10 compatibility patch then performs bounded same-device @@ -3556,25 +3581,35 @@ openclaw() { ;; esac done - if [ "$_login_help" != "1" ] && [ "$_login_channel" != "whatsapp" ]; then - echo "Error: 'openclaw channels login' is only supported inside the sandbox for WhatsApp." >&2 - echo "Changes inside the sandbox do not persist across rebuilds." >&2 - echo "" >&2 - echo "To add or remove messaging channels, exit the sandbox and run:" >&2 - echo " nemoclaw channels add " >&2 - echo " nemoclaw channels remove " >&2 - echo "" >&2 - echo "WhatsApp pairs entirely inside the sandbox; complete pairing via:" >&2 - echo " openclaw channels login --channel whatsapp" >&2 - echo "WeChat captures its token via a host-side QR during the host-side" >&2 - echo "'channels add wechat' flow — no in-sandbox login step." >&2 - return 1 - fi + # Route the security-sensitive login without `[` predicates. An + # imported Bash function named `[` can otherwise make both the + # rejection and WhatsApp-specialized branches false, falling through + # to the generic token-preserving invocation. Fail closed by marking + # unsupported login forms handled before any later dispatch. + case "$_login_help:$_login_channel" in + 0:whatsapp | 1:*) ;; + *) + echo "Error: 'openclaw channels login' is only supported inside the sandbox for WhatsApp." >&2 + echo "Changes inside the sandbox do not persist across rebuilds." >&2 + echo "" >&2 + echo "To add or remove messaging channels, exit the sandbox and run:" >&2 + echo " nemoclaw channels add " >&2 + echo " nemoclaw channels remove " >&2 + echo "" >&2 + echo "WhatsApp pairs entirely inside the sandbox; complete pairing via:" >&2 + echo " openclaw channels login --channel whatsapp" >&2 + echo "WeChat captures its token via a host-side QR during the host-side" >&2 + echo "'channels add wechat' flow — no in-sandbox login step." >&2 + _nemoclaw_guard_request_handled=1 + _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. - if [ "$_login_help" != "1" ] && [ "$_login_channel" = "whatsapp" ]; then + 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 @@ -3619,26 +3654,37 @@ openclaw() { # 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 is caller-mutable, so only the readonly value baked into the - # generated root-owned runtime file may act as the trust anchor. + # 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. - _nemoclaw_whatsapp_trusted_url="${_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL:-}" +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 - if [ -n "$_nemoclaw_whatsapp_trusted_url" ] && - [ "$_nemoclaw_whatsapp_gateway_url" = "$_nemoclaw_whatsapp_trusted_url" ]; then - _nemoclaw_whatsapp_url_is_trusted=1 - fi + # 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 - if [ "$_nemoclaw_whatsapp_url_is_trusted" = "1" ] && [ -n "${OPENCLAW_GATEWAY_TOKEN:-}" ]; then - _nemoclaw_whatsapp_login_had_token=1 - fi + 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 @@ -3648,46 +3694,72 @@ openclaw() { set +e ( # A non-trusted (caller-selected) target must not receive the - # shared gateway token; strip it so the login there falls back to - # device auth, matching the #6291 WhatsApp-login boundary. - [ "$_nemoclaw_whatsapp_url_is_trusted" = "1" ] || builtin unset OPENCLAW_GATEWAY_TOKEN - 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 + # 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=$? - 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 - elif [ "$_nemoclaw_whatsapp_url_is_trusted" != "1" ]; then - # Login targeted a caller-supplied gateway URL rather than - # NemoClaw's injected private URL; the token was withheld above, - # and the reconcile must not send it there either. - echo "[whatsapp] Credentials saved. The channel was not auto-restarted because pairing used a" >&2 - echo "[whatsapp] custom gateway URL; exit the sandbox and run" >&2 - echo "[whatsapp] 'nemoclaw channels status --channel whatsapp' to confirm it reconnects." >&2 - elif [ "$_nemoclaw_whatsapp_login_had_token" = "1" ]; then - : # Login authenticated with the gateway token, so its own - # post-pair channels.start already restarted the channel; a - # second restart would only bounce the freshly started session. - else - _nemoclaw_whatsapp_postpair_start "$_nemoclaw_whatsapp_trusted_url" \ - "$_nemoclaw_whatsapp_insecure_ws" "$_login_account" - fi + 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 + ;; + *) + 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 - return $_whatsapp_login_exit - fi + # 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 ;; *) echo "Error: 'openclaw channels $2' cannot modify channels inside the sandbox." >&2 @@ -3723,19 +3795,26 @@ openclaw() { done ;; esac - # #4538: re-assert the mutable config perm contract after any openclaw run - # (notably `doctor --fix`), even on a nonzero exit, then preserve its status. - # Drop errexit around the call (mirroring the devices-approve branch above) so - # a nonzero openclaw exit cannot abort the guard before the restore runs — the - # nonzero-exit case is the exact #4538 scenario. - local _nemoclaw_oc_errexit=0 - case $- in *e*) _nemoclaw_oc_errexit=1 ;; esac - set +e - command openclaw "$@" - local _nemoclaw_oc_status=$? - _nemoclaw_restore_mutable_config_perms - [ "$_nemoclaw_oc_errexit" = "1" ] && set -e - return "$_nemoclaw_oc_status" + case "$_nemoclaw_guard_request_handled" in + 1) + return "$_nemoclaw_guard_request_status" + ;; + *) + # #4538: re-assert the mutable config perm contract after any openclaw run + # (notably `doctor --fix`), even on a nonzero exit, then preserve its status. + # Drop errexit around the call (mirroring the devices-approve branch above) so + # a nonzero openclaw exit cannot abort the guard before the restore runs — the + # nonzero-exit case is the exact #4538 scenario. + local _nemoclaw_oc_errexit=0 + case $- in *e*) _nemoclaw_oc_errexit=1 ;; esac + set +e + command openclaw "$@" + local _nemoclaw_oc_status=$? + _nemoclaw_restore_mutable_config_perms + [ "$_nemoclaw_oc_errexit" = "1" ] && set -e + return "$_nemoclaw_oc_status" + ;; + esac } # nemoclaw-configure-guard end # nemoclaw-policy-denial-hint begin diff --git a/test/nemoclaw-start-gateway-ws-host.test.ts b/test/nemoclaw-start-gateway-ws-host.test.ts index d8fcc33593..95825044c3 100644 --- a/test/nemoclaw-start-gateway-ws-host.test.ts +++ b/test/nemoclaw-start-gateway-ws-host.test.ts @@ -13,6 +13,7 @@ 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"); @@ -187,72 +188,367 @@ describe("gateway websocket url host derivation", () => { expect(envFile).not.toContain("export OPENCLAW_GATEWAY_URL="); expect(envFile).not.toContain("export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="); - const sourced = spawnSync( - "bash", + 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"), [ - "--noprofile", - "--norc", - "-c", + "#!/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, [ - `. ${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", + "-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, }, ); - expect(sourced.status, sourced.stderr).toBe(0); - expect(sourced.stdout).toContain("PUBLIC_URL=unset"); - expect(sourced.stdout).toContain("PRIVATE_URL=ws://10.200.0.2:18790"); - expect(sourced.stdout).toContain("TRUSTED_URL=ws://10.200.0.2:18790"); - expect(sourced.stdout).toContain("TRUSTED_READONLY=yes"); - expect(sourced.stdout).toContain("PUBLIC_INSECURE=unset"); - expect(sourced.stdout).toContain("PRIVATE_INSECURE=1"); - expect(sourced.stdout).toContain("PORT=18790"); - - const explicitOverride = spawnSync( + + 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( "bash", [ - "--noprofile", - "--norc", "-c", - `. ${JSON.stringify(envFilePath)}; printf "URL=%s INSECURE=%s\\n" "$OPENCLAW_GATEWAY_URL" "$OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"`, + [ + `. ${JSON.stringify(envFilePath)}`, + "function [ { return 1; }", + "export OPENCLAW_GATEWAY_URL=wss://caller-selected.example.test:443", + "openclaw channels login --channel whatsapp", + ].join("\n"), ], { encoding: "utf-8", timeout: 5000, env: { ...process.env, - OPENCLAW_GATEWAY_URL: "wss://gateway.example.test:443", - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "explicit-marker", + CALL_LOG: callLog, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", }, }, ); - expect(explicitOverride.status, explicitOverride.stderr).toBe(0); - expect(explicitOverride.stdout).toContain( - "URL=wss://gateway.example.test:443 INSECURE=explicit-marker", + 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("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-")); + 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"); @@ -261,8 +557,8 @@ describe("gateway websocket url host derivation", () => { fs.writeFileSync( path.join(fakeBin, "openclaw"), [ - "#!/usr/bin/env bash", - `printf 'ARGS=%s TOKEN=%s\\n' "$*" "\${OPENCLAW_GATEWAY_TOKEN:-unset}" >> ${JSON.stringify(callLog)}`, + "#!/bin/sh", + `printf 'URL=%s TOKEN=%s\\n' "\${OPENCLAW_GATEWAY_URL:-unset}" "\${OPENCLAW_GATEWAY_TOKEN:-unset}" > ${JSON.stringify(callLog)}`, ].join("\n"), { mode: 0o755 }, ); @@ -270,17 +566,12 @@ describe("gateway websocket url host derivation", () => { const probe = spawnSync( "bash", [ - "--noprofile", - "--norc", "-c", [ - "_NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL=ws://attacker.invalid:18790", - "builtin readonly _NEMOCLAW_TRUSTED_OPENCLAW_GATEWAY_URL", - `. ${JSON.stringify(envFilePath)} && echo SOURCE_STATUS=unexpected || echo SOURCE_STATUS=blocked`, - "if declare -F openclaw >/dev/null; then echo WHATSAPP_WRAPPER=installed; else echo WHATSAPP_WRAPPER=disabled; fi", - "if declare -F _nemoclaw_whatsapp_postpair_start >/dev/null; then echo TOKEN_HELPER=installed; else echo TOKEN_HELPER=disabled; fi", + ...setup, + `. ${JSON.stringify(envFilePath)}`, + "export OPENCLAW_GATEWAY_URL=wss://caller-selected.example.test:443", "openclaw channels login --channel whatsapp", - 'openclaw gateway call channels.start --params \'{"channel":"whatsapp"}\' --json', ].join("\n"), ], { @@ -290,22 +581,13 @@ describe("gateway websocket url host derivation", () => { ...process.env, PATH: `${fakeBin}:${process.env.PATH ?? ""}`, OPENCLAW_GATEWAY_TOKEN: "ambient-gateway-token", - OPENCLAW_GATEWAY_URL: "ws://attacker.invalid:18790", }, }, ); expect(probe.status, probe.stderr).toBe(0); - expect(probe.stdout).toContain("SOURCE_STATUS=blocked"); - expect(probe.stdout).toContain("WHATSAPP_WRAPPER=disabled"); - expect(probe.stdout).toContain("TOKEN_HELPER=disabled"); - expect(probe.stderr).toContain("gateway-token helpers were disabled"); - - const calls = fs.readFileSync(callLog, "utf-8").split("\n").filter(Boolean); - expect(calls).toHaveLength(2); - expect(calls[0]).toContain("ARGS=channels login --channel whatsapp TOKEN=unset"); - expect(calls[1]).toContain("ARGS=gateway call channels.start"); - expect(calls[1]).toContain("TOKEN=unset"); - expect(calls.every((line) => !line.includes("ambient-gateway-token"))).toBe(true); + 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 }); } diff --git a/test/repro-4538-raw-doctor-perms.test.ts b/test/repro-4538-raw-doctor-perms.test.ts index efa643f513..38719f7b48 100644 --- a/test/repro-4538-raw-doctor-perms.test.ts +++ b/test/repro-4538-raw-doctor-perms.test.ts @@ -30,6 +30,7 @@ * 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"; @@ -50,16 +51,28 @@ 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`. The block lives inside a single-quoted heredoc, so what the test - * sources is byte-identical to what a connect shell sources at runtime. + * 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. */ -function extractGuardBlock(src: string): string { +function extractGuardBlock(src: string, trustedGatewayUrl = "ws://127.0.0.1:18789"): 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"); } - return src.slice(begin, src.indexOf("\n", end) + 1); + 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)}`; } function modeBits(filePath: string): number { diff --git a/test/repro-6413-whatsapp-postpair-start.test.ts b/test/repro-6413-whatsapp-postpair-start.test.ts index dd5b6552ef..d11cf9ebe7 100644 --- a/test/repro-6413-whatsapp-postpair-start.test.ts +++ b/test/repro-6413-whatsapp-postpair-start.test.ts @@ -36,7 +36,7 @@ const GATEWAY_TOKEN = "unit-test-gateway-token"; const UPSTREAM_DENIAL_LINE = "Local login saved auth for whatsapp/default, but the running gateway did not restart it: missing scope: operator.admin"; -function extractGuardFunction(src: string): string { +function extractGuardFunction(src: string, trustedGatewayUrl: string): string { const beginMarker = "# nemoclaw-configure-guard begin"; const endMarker = "# nemoclaw-configure-guard end"; const begin = src.indexOf(beginMarker); @@ -45,7 +45,18 @@ function extractGuardFunction(src: string): string { begin !== -1 && end !== -1 && begin < end, "Expected nemoclaw-configure-guard markers in scripts/nemoclaw-start.sh", ); - return src.slice(begin, end); + 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 { @@ -74,7 +85,7 @@ interface ReconcileRunResult { describe("WhatsApp post-pair gateway channel start (#6413)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - const guard = extractGuardFunction(src); + const guard = extractGuardFunction(src, PRIVATE_GATEWAY_URL); function runLoginThroughGuard(opts: ReconcileRunOptions): ReconcileRunResult { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wa-postpair-")); diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index 3561294327..f01b10ce34 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -2,6 +2,7 @@ // 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"; @@ -274,18 +275,28 @@ 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): string { +function extractGuardFunction(src: string, trustedGatewayUrl: 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"); } - return src.slice(begin, end); + 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)}`; } describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - const guard = extractGuardFunction(src); function runGuard( args: string[], @@ -324,7 +335,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 = guard + const guardBody = extractGuardFunction(src, opts.privateGatewayUrl ?? "") .replaceAll("/tmp/nemoclaw-whatsapp-qr-compact.js", preloadPath) .replaceAll("/tmp/nemoclaw-messaging-connect-preloads.list", connectPreloadsPath);