Skip to content

Commit 0e4cba1

Browse files
committed
fix(whatsapp): reconcile post-pair gateway restart without operator.admin (#6413)
OpenClaw's `channels login --channel whatsapp` saves credentials locally and then asks the running gateway to restart the channel via the `channels.start` RPC, which is gated behind `operator.admin`. Since #6291 stopped ordinary in-sandbox argv from inheriting the gateway token, the login runs on device auth, the device approval policy deliberately never grants `operator.admin`, and the post-pair restart is denied — credentials are saved but the running channel keeps its old session ("missing scope: operator.admin"). After a successful token-less WhatsApp login, re-issue that same bounded `channels.start` as a NemoClaw-owned one-shot authenticated with the gateway token (read from the mutable config), scoped to a single fixed-argv child and sent only to NemoClaw's trusted private gateway URL. This mirrors the existing sessions.reset/delete admin RPC path; it does not auto-approve operator.admin or reintroduce ambient token auth, preserving the #6291 boundary. A failed or ambient-token login is left untouched, and reconcile failures downgrade to host-side recovery guidance without changing the login's exit code. The WhatsApp login subprocess now also strips an ambient gateway token when the login targets a caller-supplied (non-trusted) URL, closing a token exfiltration path, and runs under saved/restored errexit so its exit status is always captured. Adds test/repro-6413-whatsapp-postpair-start.test.ts covering the reconcile, trusted-URL gating, token non-exfiltration (login and reconcile), account-id handling, and the errexit path. Signed-off-by: harjoth <harjoth.khara@gmail.com>
1 parent 372f31b commit 0e4cba1

2 files changed

Lines changed: 506 additions & 9 deletions

File tree

scripts/nemoclaw-start.sh

Lines changed: 165 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3342,6 +3342,103 @@ _nemoclaw_messaging_connect_node_options() {
33423342
done < "/tmp/nemoclaw-messaging-connect-preloads.list"
33433343
printf '%s' "$_nemoclaw_options"
33443344
}
3345+
# Read gateway.auth.token from the mutable OpenClaw config. Mirrors the
3346+
# entrypoint's _read_gateway_token, which is not emitted into this sourced
3347+
# file; keep the JSON5 fallback in sync with it.
3348+
_nemoclaw_whatsapp_gateway_token() {
3349+
node - "${OPENCLAW_STATE_DIR:-/sandbox/.openclaw}/openclaw.json" <<'NODEWATOKEN'
3350+
const fs = require("fs");
3351+
3352+
const configPath = process.argv[2];
3353+
3354+
function loadJson5() {
3355+
try {
3356+
const JSON5 = require("/opt/nemoclaw/node_modules/json5");
3357+
if (JSON5 && typeof JSON5.parse === "function") {
3358+
return JSON5;
3359+
}
3360+
} catch {
3361+
// Fall through to the caller's empty-token behavior.
3362+
}
3363+
return undefined;
3364+
}
3365+
3366+
try {
3367+
const text = fs.readFileSync(configPath, "utf8");
3368+
let cfg;
3369+
try {
3370+
cfg = JSON.parse(text);
3371+
} catch (jsonError) {
3372+
const JSON5 = loadJson5();
3373+
if (!JSON5) {
3374+
throw jsonError;
3375+
}
3376+
cfg = JSON5.parse(text);
3377+
}
3378+
console.log(cfg?.gateway?.auth?.token || "");
3379+
} catch {
3380+
console.log("");
3381+
}
3382+
NODEWATOKEN
3383+
}
3384+
# NemoClaw#6413: OpenClaw's `channels login` saves WhatsApp credentials
3385+
# locally and then asks the running gateway to restart the channel through the
3386+
# `channels.start` RPC, which the gateway gates behind `operator.admin`. When
3387+
# the login runs without the ambient gateway token, the client falls back to
3388+
# device auth, the device approval policy deliberately never grants
3389+
# `operator.admin`, and the post-pair restart is denied: credentials are saved
3390+
# but the running channel keeps its old session. Re-issue that same bounded
3391+
# RPC as a NemoClaw-owned one-shot with gateway-token auth after a successful
3392+
# login. The token stays scoped to this single fixed-argv child; per the
3393+
# runtime-env contract, removing the token from ordinary caller argv "is not a
3394+
# secrecy boundary against a command that deliberately reads the file", and
3395+
# this helper is exactly such an owned reader. Auto-approving `operator.admin`
3396+
# for ordinary devices would weaken the reviewed #6291 boundary and is
3397+
# deliberately not done here. Never fails the login: credentials are already
3398+
# saved, so a reconcile failure downgrades to host-side recovery guidance.
3399+
# Removal condition: drop this helper when the pinned OpenClaw lets a local
3400+
# login (re)start its own channel account without `operator.admin`, or ships
3401+
# its own bounded post-login reconcile.
3402+
_nemoclaw_whatsapp_postpair_start() {
3403+
local _nemoclaw_wa_gateway_url="$1" _nemoclaw_wa_insecure_ws="$2" _nemoclaw_wa_account="$3"
3404+
local _nemoclaw_wa_token _nemoclaw_wa_params _nemoclaw_wa_call_output
3405+
# The account id is caller input embedded in JSON params; allowlist it
3406+
# instead of quoting it (mirrors the approval policy's request-id pattern).
3407+
# Evaluate the range under the C locale so [A-Za-z0-9._:-] stays ASCII and is
3408+
# not widened by the caller's LC_COLLATE/LC_CTYPE. Scoped to this function
3409+
# subshell-style: the assignment cannot leak into the interactive shell
3410+
# because the helper only runs inside command substitution / the reconcile.
3411+
local LC_ALL=C
3412+
case "$_nemoclaw_wa_account" in
3413+
"") _nemoclaw_wa_params='{"channel":"whatsapp"}' ;;
3414+
*[!A-Za-z0-9._:-]*)
3415+
echo "[whatsapp] Credentials saved, but the account id contains unsupported characters, so the" >&2
3416+
echo "[whatsapp] running gateway was not asked to restart the channel. Exit the sandbox and run" >&2
3417+
echo "[whatsapp] 'nemoclaw <sandbox> channels status --channel whatsapp' to confirm it reconnects." >&2
3418+
return 0
3419+
;;
3420+
*) _nemoclaw_wa_params='{"channel":"whatsapp","accountId":"'"$_nemoclaw_wa_account"'"}' ;;
3421+
esac
3422+
_nemoclaw_wa_token="$(_nemoclaw_whatsapp_gateway_token)"
3423+
if [ -z "$_nemoclaw_wa_token" ]; then
3424+
echo "[whatsapp] Credentials saved, but the gateway token is unavailable in this shell, so the" >&2
3425+
echo "[whatsapp] running gateway was not asked to restart the channel. Exit the sandbox and run" >&2
3426+
echo "[whatsapp] 'nemoclaw <sandbox> channels status --channel whatsapp' to confirm it reconnects." >&2
3427+
return 0
3428+
fi
3429+
if _nemoclaw_wa_call_output="$(OPENCLAW_GATEWAY_URL="$_nemoclaw_wa_gateway_url" \
3430+
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_wa_insecure_ws" \
3431+
OPENCLAW_GATEWAY_TOKEN="$_nemoclaw_wa_token" \
3432+
command openclaw gateway call channels.start --params "$_nemoclaw_wa_params" --json 2>&1)"; then
3433+
echo "[whatsapp] Restarted the WhatsApp channel on the running gateway with the new credentials." >&2
3434+
else
3435+
echo "[whatsapp] Credentials saved, but restarting the channel on the running gateway failed:" >&2
3436+
printf '%s\n' "$_nemoclaw_wa_call_output" | tail -n 3 | sed 's/^/[whatsapp] /' >&2
3437+
echo "[whatsapp] Exit the sandbox and run 'nemoclaw <sandbox> channels status --channel whatsapp'" >&2
3438+
echo "[whatsapp] to check the channel; re-run the login from a fresh connect shell if it stays down." >&2
3439+
fi
3440+
return 0
3441+
}
33453442
openclaw() {
33463443
# NemoClaw#4462: approval calls temporarily drop the gateway URL/port/token
33473444
# so OpenClaw resolves the local loopback gateway and device token. The
@@ -3392,8 +3489,10 @@ openclaw() {
33923489
list | status | "" | -h | --help) ;;
33933490
login)
33943491
_login_channel=""
3492+
_login_account=""
33953493
_login_help=0
33963494
_prev_arg_was_channel_flag=0
3495+
_prev_arg_was_account_flag=0
33973496
_seen_login_subcommand=0
33983497
for _arg in "$@"; do
33993498
if [ "$_seen_login_subcommand" = "0" ]; then
@@ -3405,13 +3504,24 @@ openclaw() {
34053504
_prev_arg_was_channel_flag=0
34063505
continue
34073506
fi
3507+
if [ "$_prev_arg_was_account_flag" = "1" ]; then
3508+
_login_account="$_arg"
3509+
_prev_arg_was_account_flag=0
3510+
continue
3511+
fi
34083512
case "$_arg" in
34093513
--channel)
34103514
_prev_arg_was_channel_flag=1
34113515
;;
34123516
--channel=*)
34133517
_login_channel="${_arg#--channel=}"
34143518
;;
3519+
--account)
3520+
_prev_arg_was_account_flag=1
3521+
;;
3522+
--account=*)
3523+
_login_account="${_arg#--account=}"
3524+
;;
34153525
-h | --help)
34163526
_login_help=1
34173527
;;
@@ -3482,16 +3592,47 @@ openclaw() {
34823592
# injecting them again here covers non-connect shells. Runtime
34833593
# preload modules are idempotent, so a double --require is harmless.
34843594
_nemoclaw_connect_node_options="$(_nemoclaw_messaging_connect_node_options)"
3485-
if [ -n "$_nemoclaw_connect_node_options" ]; then
3486-
OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" \
3487-
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" \
3488-
NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$_nemoclaw_connect_node_options" \
3489-
command openclaw "$@"
3490-
else
3491-
OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url" \
3492-
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws" \
3493-
command openclaw "$@"
3595+
# The shared gateway token (ambient in connect shells; see the
3596+
# runtime env at OPENCLAW_GATEWAY_TOKEN) must never reach a
3597+
# caller-selected gateway. NemoClaw injects its trusted private URL
3598+
# as NEMOCLAW_OPENCLAW_GATEWAY_URL; the login tolerates a
3599+
# caller-supplied OPENCLAW_GATEWAY_URL override, so classify whether
3600+
# this login is aimed at the trusted URL. Only the trusted URL may
3601+
# carry the token — for the login itself and for the reconcile.
3602+
_nemoclaw_whatsapp_trusted_url="${NEMOCLAW_OPENCLAW_GATEWAY_URL:-}"
3603+
_nemoclaw_whatsapp_url_is_trusted=0
3604+
if [ -n "$_nemoclaw_whatsapp_trusted_url" ] &&
3605+
[ "$_nemoclaw_whatsapp_gateway_url" = "$_nemoclaw_whatsapp_trusted_url" ]; then
3606+
_nemoclaw_whatsapp_url_is_trusted=1
3607+
fi
3608+
# Whether the login could authenticate with the gateway token (only
3609+
# when it is both present and pointed at the trusted URL). If so,
3610+
# OpenClaw's own post-pair `channels.start` succeeds and no NemoClaw
3611+
# reconcile is needed; otherwise that restart is denied for lack of
3612+
# operator.admin and NemoClaw reconciles it below (NemoClaw#6413).
3613+
_nemoclaw_whatsapp_login_had_token=0
3614+
if [ "$_nemoclaw_whatsapp_url_is_trusted" = "1" ] && [ -n "${OPENCLAW_GATEWAY_TOKEN:-}" ]; then
3615+
_nemoclaw_whatsapp_login_had_token=1
34943616
fi
3617+
# Run the login with errexit disabled so its exit status is always
3618+
# captured (and the post-login guidance/reconcile always runs) even
3619+
# when the caller shell has `set -e`; mirrors the devices-approve
3620+
# and configure-guard branches. Restored before returning.
3621+
_nemoclaw_whatsapp_login_errexit=0
3622+
case $- in *e*) _nemoclaw_whatsapp_login_errexit=1 ;; esac
3623+
set +e
3624+
(
3625+
# A non-trusted (caller-selected) target must not receive the
3626+
# shared gateway token; strip it so the login there falls back to
3627+
# device auth, matching the #6291 WhatsApp-login boundary.
3628+
[ "$_nemoclaw_whatsapp_url_is_trusted" = "1" ] || builtin unset OPENCLAW_GATEWAY_TOKEN
3629+
export OPENCLAW_GATEWAY_URL="$_nemoclaw_whatsapp_gateway_url"
3630+
export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="$_nemoclaw_whatsapp_insecure_ws"
3631+
if [ -n "$_nemoclaw_connect_node_options" ]; then
3632+
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$_nemoclaw_connect_node_options"
3633+
fi
3634+
command openclaw "$@"
3635+
)
34953636
_whatsapp_login_exit=$?
34963637
if [ "$_whatsapp_login_exit" -ne 0 ]; then
34973638
echo "" >&2
@@ -3500,7 +3641,22 @@ openclaw() {
35003641
echo "issue, not a QR-size issue — the QR above rendered independently of the gateway." >&2
35013642
echo "[whatsapp] Re-run 'openclaw channels login --channel whatsapp' to retry. If it keeps" >&2
35023643
echo "closing, exit the sandbox and run 'nemoclaw <sandbox> channels status --channel whatsapp'." >&2
3644+
elif [ "$_nemoclaw_whatsapp_url_is_trusted" != "1" ]; then
3645+
# Login targeted a caller-supplied gateway URL rather than
3646+
# NemoClaw's injected private URL; the token was withheld above,
3647+
# and the reconcile must not send it there either.
3648+
echo "[whatsapp] Credentials saved. The channel was not auto-restarted because pairing used a" >&2
3649+
echo "[whatsapp] custom gateway URL; exit the sandbox and run" >&2
3650+
echo "[whatsapp] 'nemoclaw <sandbox> channels status --channel whatsapp' to confirm it reconnects." >&2
3651+
elif [ "$_nemoclaw_whatsapp_login_had_token" = "1" ]; then
3652+
: # Login authenticated with the gateway token, so its own
3653+
# post-pair channels.start already restarted the channel; a
3654+
# second restart would only bounce the freshly started session.
3655+
else
3656+
_nemoclaw_whatsapp_postpair_start "$_nemoclaw_whatsapp_trusted_url" \
3657+
"$_nemoclaw_whatsapp_insecure_ws" "$_login_account"
35033658
fi
3659+
[ "$_nemoclaw_whatsapp_login_errexit" = "1" ] && set -e
35043660
return $_whatsapp_login_exit
35053661
fi
35063662
;;

0 commit comments

Comments
 (0)