-
Notifications
You must be signed in to change notification settings - Fork 3.1k
Expand file tree
/
Copy pathtest-messaging-providers.sh
More file actions
executable file
·2088 lines (1903 loc) · 92.4 KB
/
Copy pathtest-messaging-providers.sh
File metadata and controls
executable file
·2088 lines (1903 loc) · 92.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# shellcheck disable=SC2016,SC2034
# SC2016: Single-quoted strings are intentional — Node.js code passed via SSH.
# SC2034: Some variables are used indirectly or reserved for later phases.
# Messaging Credential Provider E2E Tests
#
# Validates that messaging credentials (Telegram, Discord, Slack, WeChat)
# flow correctly through the OpenShell provider/placeholder/L7-proxy pipeline,
# and holds WhatsApp's QR-only channel to the same config/policy/no-secret
# standard even though it has no host-side token provider. Tests every
# layer of the chain introduced in PR #1081:
#
# 1. Provider creation — openshell stores the real token
# 2. Sandbox attachment — --provider flags wire providers to the sandbox
# 3. Credential isolation — real tokens never appear in sandbox env,
# process list, or filesystem
# 4. Config patching — openclaw.json channels use placeholder values
# 5. Network reachability — Node.js can reach messaging APIs through proxy
# 6. Native Discord gateway path — WebSocket L7 path is tested hermetically
# 7. L7 proxy rewriting — placeholder is rewritten to real token at egress
# 8. WhatsApp QR-only parity — channel add/rebuild applies policy, bakes
# openclaw.json, creates no providers, and leaks no token placeholders
#
# Uses fake tokens by default (no external accounts needed). With fake tokens,
# the API returns 401 — proving the full chain worked (request reached the
# real API with the token rewritten). Optional real tokens enable a bonus
# round-trip phase.
#
# Prerequisites:
# - Docker running
# - NemoClaw installed (install.sh or brev-setup.sh already ran)
# - NVIDIA_API_KEY set
# - openshell on PATH
#
# Environment variables:
# NVIDIA_API_KEY — required
# NEMOCLAW_NON_INTERACTIVE=1 — required
# NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 — required
# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-msg-provider)
# TELEGRAM_BOT_TOKEN — defaults to fake token
# DISCORD_BOT_TOKEN — defaults to fake token
# TELEGRAM_ALLOWED_IDS — comma-separated Telegram user IDs for DM allowlisting
# TELEGRAM_BOT_TOKEN_REAL — optional: enables Phase 6 real round-trip
# DISCORD_BOT_TOKEN_REAL — optional: enables Phase 6 real round-trip
# SLACK_BOT_TOKEN — defaults to fake token (xoxb-fake-...)
# SLACK_APP_TOKEN — defaults to fake token (xapp-fake-...)
# SLACK_ALLOWED_USERS — comma-separated Slack user IDs for DM and channel @mention allowlisting
# SLACK_BOT_TOKEN_REVOKED — optional: revoked xoxb- token to test auth pre-validation (#2340)
# SLACK_APP_TOKEN_REVOKED — optional: paired xapp- token for the revoked bot token
# WECHAT_BOT_TOKEN — defaults to fake token; presence skips host-side QR login
# WECHAT_ACCOUNT_ID — defaults to fake iLink account ID (seed-wechat-accounts.py key)
# WECHAT_BASE_URL — defaults to fake iLink baseUrl (per-account API host)
# WECHAT_USER_ID — defaults to fake operator wechat user ID (seeds DM allowlist)
# WECHAT_ALLOWED_IDS — optional: comma-separated DM allowlist for wechat
# WhatsApp — QR-only; the test enables it via `channels add whatsapp`
# WHATSAPP_TOKEN / WHATSAPP_BOT_TOKEN / WHATSAPP_SESSION_SECRET
# — overwritten with fake decoys to prove NemoClaw ignores host-side
# WhatsApp credential-shaped env vars
# TELEGRAM_CHAT_ID_E2E — optional: enables sendMessage test
# NEMOCLAW_OPENSHELL_BIN — optional OpenShell binary under test
# NEMOCLAW_FRESH=1 — auto-set to discard interrupted onboard sessions
#
# Usage:
# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \
# NVIDIA_API_KEY=nvapi-... bash test/e2e/test-messaging-providers.sh
#
# See: https://github.com/NVIDIA/NemoClaw/pull/1081
set -uo pipefail
PASS=0
FAIL=0
SKIP=0
TOTAL=0
pass() {
((PASS++))
((TOTAL++))
printf '\033[32m PASS: %s\033[0m\n' "$1"
}
fail() {
((FAIL++))
((TOTAL++))
printf '\033[31m FAIL: %s\033[0m\n' "$1"
}
skip() {
((SKIP++))
((TOTAL++))
printf '\033[33m SKIP: %s\033[0m\n' "$1"
}
section() {
echo ""
printf '\033[1;36m=== %s ===\033[0m\n' "$1"
}
info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; }
is_unresolved_placeholder_rejection() {
printf '%s\n' "$1" | grep -qiE 'credential_injection_failed|unresolved credential placeholder'
}
# Determine repo root
if [ -d /workspace ] && [ -f /workspace/install.sh ]; then
REPO="/workspace"
elif [ -f "$(cd "$(dirname "$0")/../.." && pwd)/install.sh" ]; then
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
else
echo "ERROR: Cannot find repo root."
exit 1
fi
SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-msg-provider}"
OPENSHELL_BIN="${NEMOCLAW_OPENSHELL_BIN:-openshell}"
REGISTRY="$HOME/.nemoclaw/sandboxes.json"
openshell() {
if [ "$OPENSHELL_BIN" = "openshell" ]; then
command openshell "$@"
else
"$OPENSHELL_BIN" "$@"
fi
}
registry_field() {
local field="$1"
if [ ! -f "$REGISTRY" ]; then
echo "null"
return
fi
if command -v jq >/dev/null 2>&1; then
jq -c --arg name "$SANDBOX_NAME" --arg field "$field" \
'.sandboxes[$name][$field]' "$REGISTRY" 2>/dev/null || echo "null"
else
node -e "
const r = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'));
const v = (r.sandboxes || {})[process.argv[2]]?.[process.argv[3]];
process.stdout.write(JSON.stringify(v ?? null));
" "$REGISTRY" "$SANDBOX_NAME" "$field" 2>/dev/null || echo "null"
fi
}
registry_array_contains() {
local field="$1"
local item="$2"
local value
value="$(registry_field "$field")"
printf '%s' "$value" | grep -Fq "\"${item}\""
}
# shellcheck source=test/e2e/lib/sandbox-teardown.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib/sandbox-teardown.sh"
register_sandbox_for_teardown "$SANDBOX_NAME"
# Default to fake tokens if not provided
TELEGRAM_TOKEN="${TELEGRAM_BOT_TOKEN:-test-fake-telegram-token-e2e}"
DISCORD_TOKEN="${DISCORD_BOT_TOKEN:-test-fake-discord-token-e2e}"
SLACK_TOKEN="${SLACK_BOT_TOKEN:-xoxb-fake-slack-token-e2e}"
SLACK_APP="${SLACK_APP_TOKEN:-xapp-fake-slack-app-token-e2e}"
TELEGRAM_IDS="${TELEGRAM_ALLOWED_IDS:-123456789,987654321}"
SLACK_IDS="${SLACK_ALLOWED_USERS-U0AR85ATALW,U09E2ESLACK}"
# WeChat: pre-seeding WECHAT_BOT_TOKEN + the per-account metadata env vars lets
# the non-interactive onboard path (src/lib/onboard.ts:8433) treat wechat as
# "already configured" and skip the host-qr handler entirely. Fake values are
# enough — Phase 1-3 verify placeholders/isolation; no live iLink contact is
# made because no token exchange happens at build time.
WECHAT_TOKEN="${WECHAT_BOT_TOKEN:-test-fake-wechat-token-e2e}"
WECHAT_ACCOUNT="${WECHAT_ACCOUNT_ID:-e2e-fake-account-12345}"
WECHAT_BASE="${WECHAT_BASE_URL:-https://ilinkai-fake-e2e.wechat.com}"
WECHAT_USER="${WECHAT_USER_ID:-wxid_e2efakeoperator}"
WECHAT_IDS="${WECHAT_ALLOWED_IDS:-${WECHAT_USER}}"
# WhatsApp is QR-only, but seed host-side decoys to prove they are ignored.
WHATSAPP_TOKEN_DECOY="test-fake-whatsapp-token-e2e"
WHATSAPP_BOT_TOKEN_DECOY="test-fake-whatsapp-bot-token-e2e"
WHATSAPP_SESSION_SECRET_DECOY="test-fake-whatsapp-session-secret-e2e"
export TELEGRAM_BOT_TOKEN="$TELEGRAM_TOKEN"
export DISCORD_BOT_TOKEN="$DISCORD_TOKEN"
export SLACK_BOT_TOKEN="$SLACK_TOKEN"
export SLACK_APP_TOKEN="$SLACK_APP"
export TELEGRAM_ALLOWED_IDS="$TELEGRAM_IDS"
export SLACK_ALLOWED_USERS="$SLACK_IDS"
export WECHAT_BOT_TOKEN="$WECHAT_TOKEN"
export WECHAT_ACCOUNT_ID="$WECHAT_ACCOUNT"
export WECHAT_BASE_URL="$WECHAT_BASE"
export WECHAT_USER_ID="$WECHAT_USER"
export WECHAT_ALLOWED_IDS="$WECHAT_IDS"
export WHATSAPP_TOKEN="$WHATSAPP_TOKEN_DECOY"
export WHATSAPP_BOT_TOKEN="$WHATSAPP_BOT_TOKEN_DECOY"
export WHATSAPP_SESSION_SECRET="$WHATSAPP_SESSION_SECRET_DECOY"
# Run a command inside the sandbox via stdin (avoids exposing sensitive args in process list)
sandbox_exec_stdin() {
local cmd="$1"
local ssh_config
ssh_config="$(mktemp)"
openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null
local result
result=$(timeout 60 ssh -F "$ssh_config" \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=10 \
-o LogLevel=ERROR \
"openshell-${SANDBOX_NAME}" \
"$cmd" \
2>/dev/null) || true
rm -f "$ssh_config"
echo "$result"
}
# Run a command inside the sandbox and capture output
sandbox_exec() {
local cmd="$1"
local ssh_config
ssh_config="$(mktemp)"
openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null
local result
result=$(timeout 60 ssh -F "$ssh_config" \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=10 \
-o LogLevel=ERROR \
"openshell-${SANDBOX_NAME}" \
"$cmd" \
2>&1) || true
rm -f "$ssh_config"
echo "$result"
}
# shellcheck source=test/e2e/lib/discord-gateway-proof.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib/discord-gateway-proof.sh"
# shellcheck source=test/e2e/lib/discord-rest-policy-proof.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib/discord-rest-policy-proof.sh"
# shellcheck source=test/e2e/lib/slack-api-proof.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib/slack-api-proof.sh"
# ══════════════════════════════════════════════════════════════════
# Phase 0: Prerequisites
# ══════════════════════════════════════════════════════════════════
section "Phase 0: Prerequisites"
if [ -z "${NVIDIA_API_KEY:-}" ]; then
fail "NVIDIA_API_KEY not set"
exit 1
fi
pass "NVIDIA_API_KEY is set"
if ! docker info >/dev/null 2>&1; then
fail "Docker is not running"
exit 1
fi
pass "Docker is running"
info "Telegram token: ${TELEGRAM_TOKEN:0:10}... (${#TELEGRAM_TOKEN} chars)"
info "Discord token: ${DISCORD_TOKEN:0:10}... (${#DISCORD_TOKEN} chars)"
info "Slack bot token: configured (${#SLACK_TOKEN} chars)"
info "Slack app token: configured (${#SLACK_APP} chars)"
slack_allowed_user_count=0
if [ -n "$SLACK_IDS" ]; then
IFS=',' read -ra _slack_allowed_ids <<<"$SLACK_IDS"
for _sid in "${_slack_allowed_ids[@]}"; do
_sid="${_sid//[[:space:]]/}"
[ -n "$_sid" ] && ((slack_allowed_user_count++))
done
fi
info "Slack allowed users configured: ${slack_allowed_user_count} ID(s)"
info "WeChat token: configured (${#WECHAT_TOKEN} chars), account=${WECHAT_ACCOUNT}"
info "Sandbox name: $SANDBOX_NAME"
# ══════════════════════════════════════════════════════════════════
# Phase 1: Install NemoClaw (non-interactive mode)
# ══════════════════════════════════════════════════════════════════
section "Phase 1: Install NemoClaw with messaging tokens"
cd "$REPO" || exit 1
# Pre-cleanup: destroy any leftover sandbox from previous runs
info "Pre-cleanup..."
if command -v nemoclaw >/dev/null 2>&1; then
nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true
fi
if openshell --version >/dev/null 2>&1; then
openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true
openshell gateway destroy -g nemoclaw 2>/dev/null || true
fi
pass "Pre-cleanup complete"
if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ]; then
if ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then
export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1
info "Host cannot reach api.telegram.org; skipping onboarding Telegram reachability probe for fake-token E2E"
fi
fi
# Pre-merge Slack policy into the base sandbox policy.
#
# The base policy (openclaw-sandbox.yaml) includes Telegram and Discord
# network rules but NOT Slack — Slack access normally comes from the
# slack.yaml preset, applied in onboard Step 8. However, the sandbox
# container starts in Step 6, so the gateway boots without Slack access.
# The Slack SDK's connection attempt hangs or gets a CONNECT 403 before
# the preset is applied, preventing the gateway from serving on 18789.
#
# By appending the Slack rules to the base policy BEFORE install.sh, the
# sandbox is created with Slack access from the start. The Slack SDK gets
# a fast "invalid_auth" response, the channel guard catches it, and the
# gateway continues serving.
# Ref: #2340
BASE_POLICY="$REPO/nemoclaw-blueprint/policies/openclaw-sandbox.yaml"
SLACK_PRESET="$REPO/nemoclaw-blueprint/policies/presets/slack.yaml"
if [ -f "$BASE_POLICY" ] && [ -f "$SLACK_PRESET" ] && ! grep -q "api.slack.com" "$BASE_POLICY"; then
BASE_POLICY_BAK="$(mktemp)"
cp "$BASE_POLICY" "$BASE_POLICY_BAK"
_previous_exit_trap=$(trap -p EXIT | sed "s/^trap -- '//;s/' EXIT$//")
trap ''"${_previous_exit_trap:+$_previous_exit_trap;}"' cp "$BASE_POLICY_BAK" "$BASE_POLICY" 2>/dev/null || true; rm -f "$BASE_POLICY_BAK"' EXIT
info "Pre-merging Slack network policy into base sandbox policy..."
cat >>"$BASE_POLICY" <<'SLACK_POLICY_EOF'
# ── Slack — pre-merged for messaging E2E (#2340) ──────────────
# Normally applied as a preset in onboard Step 8, but the sandbox
# container starts before presets are applied. Inline here so the
# gateway has Slack access from first boot.
slack:
name: slack
endpoints:
- host: slack.com
port: 443
protocol: rest
enforcement: enforce
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: POST, path: "/**" }
- host: api.slack.com
port: 443
protocol: rest
enforcement: enforce
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: POST, path: "/**" }
- host: hooks.slack.com
port: 443
protocol: rest
enforcement: enforce
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: POST, path: "/**" }
- host: wss-primary.slack.com
port: 443
protocol: websocket
enforcement: enforce
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: WEBSOCKET_TEXT, path: "/**" }
- host: wss-backup.slack.com
port: 443
protocol: websocket
enforcement: enforce
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: WEBSOCKET_TEXT, path: "/**" }
binaries:
- { path: /usr/local/bin/node }
- { path: /usr/bin/node }
SLACK_POLICY_EOF
if ! grep -q "api.slack.com" "$BASE_POLICY"; then
fail "Failed to append Slack policy to base sandbox policy"
exit 1
fi
pass "Slack network policy pre-merged into base policy"
else
if grep -q "api.slack.com" "$BASE_POLICY" 2>/dev/null; then
info "Slack policy already present in base policy — skipping pre-merge"
else
fail "Cannot pre-merge Slack policy: missing base policy or preset file"
exit 1
fi
fi
# Run install.sh --non-interactive which installs Node.js, openshell,
# NemoClaw, and runs onboard. Messaging tokens are already exported so
# the onboard step creates providers and attaches them to the sandbox.
info "Running install.sh --non-interactive..."
info "This installs Node.js, openshell, NemoClaw, and runs onboard with messaging providers."
info "Expected duration: 5-10 minutes on first run."
export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME"
export NEMOCLAW_RECREATE_SANDBOX=1
export NEMOCLAW_FRESH=1
INSTALL_LOG="/tmp/nemoclaw-e2e-install.log"
bash install.sh --non-interactive >"$INSTALL_LOG" 2>&1 &
install_pid=$!
tail -f "$INSTALL_LOG" --pid=$install_pid 2>/dev/null &
tail_pid=$!
wait $install_pid
install_exit=$?
kill $tail_pid 2>/dev/null || true
wait $tail_pid 2>/dev/null || true
# Source shell profile to pick up nvm/PATH changes from install.sh
if [ -f "$HOME/.bashrc" ]; then
# shellcheck source=/dev/null
source "$HOME/.bashrc" 2>/dev/null || true
fi
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
# shellcheck source=/dev/null
. "$NVM_DIR/nvm.sh"
fi
if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
export PATH="$HOME/.local/bin:$PATH"
fi
if [ $install_exit -eq 0 ]; then
pass "M0: install.sh completed (exit 0)"
else
fail "M0: install.sh failed (exit $install_exit)"
info "Last 30 lines of install log:"
tail -30 "$INSTALL_LOG" 2>/dev/null || true
exit 1
fi
# Verify tools are on PATH
if ! openshell --version >/dev/null 2>&1; then
fail "openshell not found on PATH after install"
exit 1
fi
pass "openshell installed ($(openshell --version 2>&1 || echo unknown))"
if ! command -v nemoclaw >/dev/null 2>&1; then
fail "nemoclaw not found on PATH after install"
exit 1
fi
pass "nemoclaw installed at $(command -v nemoclaw)"
# Verify sandbox is ready
sandbox_list=$(openshell sandbox list 2>&1 || true)
if echo "$sandbox_list" | grep -q "$SANDBOX_NAME.*Ready"; then
pass "M0b: Sandbox '$SANDBOX_NAME' is Ready"
else
fail "M0b: Sandbox '$SANDBOX_NAME' not Ready (list: ${sandbox_list:0:200})"
exit 1
fi
# ══════════════════════════════════════════════════════════════════
# Phase 1b: Enable WhatsApp QR-only channel
# ══════════════════════════════════════════════════════════════════
section "Phase 1b: Enable WhatsApp QR-only channel"
WHATSAPP_ADD_LOG="/tmp/nemoclaw-e2e-whatsapp-add.log"
if nemoclaw "$SANDBOX_NAME" channels add whatsapp >"$WHATSAPP_ADD_LOG" 2>&1; then
whatsapp_add_exit=0
else
whatsapp_add_exit=$?
fi
cat "$WHATSAPP_ADD_LOG"
if [ "$whatsapp_add_exit" -eq 0 ] && grep -q "Enabled whatsapp channel" "$WHATSAPP_ADD_LOG"; then
pass "M-WA0: channels add whatsapp registered QR-only channel"
else
fail "M-WA0: channels add whatsapp failed or did not register channel"
tail -30 "$WHATSAPP_ADD_LOG" 2>/dev/null || true
exit 1
fi
if openshell provider get "${SANDBOX_NAME}-whatsapp-bridge" >/dev/null 2>&1; then
fail "M-WA1: Unexpected WhatsApp bridge provider exists in gateway"
else
pass "M-WA1: WhatsApp QR-only channel creates no bridge provider"
fi
if registry_array_contains messagingChannels "whatsapp"; then
pass "M-WA2: registry.messagingChannels contains whatsapp after channel add"
else
fail "M-WA2: registry.messagingChannels missing whatsapp after channel add ($(registry_field messagingChannels))"
fi
whatsapp_policy_pre=$(openshell policy get --full "$SANDBOX_NAME" 2>/dev/null || true)
if echo "$whatsapp_policy_pre" | grep -q "web.whatsapp.com" \
&& echo "$whatsapp_policy_pre" | grep -q "whatsapp.net" \
&& echo "$whatsapp_policy_pre" | grep -q "raw.githubusercontent.com"; then
pass "M-WA3: WhatsApp policy preset applied before rebuild"
else
fail "M-WA3: WhatsApp policy preset missing expected endpoints before rebuild"
fi
WHATSAPP_REBUILD_LOG="/tmp/nemoclaw-e2e-whatsapp-rebuild.log"
info "Rebuilding sandbox so WhatsApp is baked into openclaw.json..."
if nemoclaw "$SANDBOX_NAME" rebuild --yes >"$WHATSAPP_REBUILD_LOG" 2>&1; then
pass "M-WA4: Rebuild completed after WhatsApp channel add"
else
fail "M-WA4: Rebuild failed after WhatsApp channel add"
tail -50 "$WHATSAPP_REBUILD_LOG" 2>/dev/null || true
exit 1
fi
whatsapp_policy_post=$(openshell policy get --full "$SANDBOX_NAME" 2>/dev/null || true)
if echo "$whatsapp_policy_post" | grep -q "web.whatsapp.com" \
&& echo "$whatsapp_policy_post" | grep -q "whatsapp.net" \
&& echo "$whatsapp_policy_post" | grep -q "raw.githubusercontent.com" \
&& { echo "$whatsapp_policy_post" | grep -q "/usr/local/bin/node" || echo "$whatsapp_policy_post" | grep -q "/usr/bin/node"; }; then
pass "M-WA5: WhatsApp policy preset survived rebuild with Node binary scope"
else
fail "M-WA5: WhatsApp policy preset missing expected endpoints/binaries after rebuild"
fi
sandbox_list=$(openshell sandbox list 2>&1 || true)
if echo "$sandbox_list" | grep -q "$SANDBOX_NAME.*Ready"; then
pass "M-WA6: Sandbox '$SANDBOX_NAME' is Ready after WhatsApp rebuild"
else
fail "M-WA6: Sandbox '$SANDBOX_NAME' not Ready after WhatsApp rebuild (list: ${sandbox_list:0:200})"
exit 1
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"
else
fail "M1: Provider '${SANDBOX_NAME}-telegram-bridge' not found in gateway"
fi
# M2: Verify Discord provider exists in gateway
if openshell provider get "${SANDBOX_NAME}-discord-bridge" >/dev/null 2>&1; then
pass "M2: Provider '${SANDBOX_NAME}-discord-bridge' exists in gateway"
else
fail "M2: Provider '${SANDBOX_NAME}-discord-bridge' not found in gateway"
fi
# M-W1: Verify WeChat provider exists in gateway. Non-interactive onboard
# saw WECHAT_BOT_TOKEN in env (skipping host-qr login) and registered the
# bridge provider just like the other channels.
if openshell provider get "${SANDBOX_NAME}-wechat-bridge" >/dev/null 2>&1; then
pass "M-W1: Provider '${SANDBOX_NAME}-wechat-bridge' exists in gateway"
else
fail "M-W1: Provider '${SANDBOX_NAME}-wechat-bridge' not found in gateway (non-interactive QR-skip path may be broken)"
fi
# ══════════════════════════════════════════════════════════════════
# Phase 2: Credential Isolation — env vars inside sandbox
# ══════════════════════════════════════════════════════════════════
section "Phase 2: Credential Isolation"
# M3: TELEGRAM_BOT_TOKEN inside sandbox must NOT contain the host-side token
sandbox_telegram=$(sandbox_exec "printenv TELEGRAM_BOT_TOKEN" 2>/dev/null || true)
if [ -z "$sandbox_telegram" ]; then
info "TELEGRAM_BOT_TOKEN not set inside sandbox (provider-only mode)"
TELEGRAM_PLACEHOLDER=""
elif echo "$sandbox_telegram" | grep -qF "$TELEGRAM_TOKEN"; then
fail "M3: Real Telegram token leaked into sandbox env"
else
pass "M3: Sandbox TELEGRAM_BOT_TOKEN is a placeholder (not the real token)"
TELEGRAM_PLACEHOLDER="$sandbox_telegram"
info "Telegram placeholder: ${TELEGRAM_PLACEHOLDER:0:30}..."
fi
# M4: DISCORD_BOT_TOKEN inside sandbox must NOT contain the host-side token
sandbox_discord=$(sandbox_exec "printenv DISCORD_BOT_TOKEN" 2>/dev/null || true)
if [ -z "$sandbox_discord" ]; then
info "DISCORD_BOT_TOKEN not set inside sandbox (provider-only mode)"
DISCORD_PLACEHOLDER=""
elif echo "$sandbox_discord" | grep -qF "$DISCORD_TOKEN"; then
fail "M4: Real Discord token leaked into sandbox env"
else
pass "M4: Sandbox DISCORD_BOT_TOKEN is a placeholder (not the real token)"
DISCORD_PLACEHOLDER="$sandbox_discord"
info "Discord placeholder: ${DISCORD_PLACEHOLDER:0:30}..."
fi
# M5: At least one placeholder should be present for subsequent phases
if [ -n "$TELEGRAM_PLACEHOLDER" ] || [ -n "$DISCORD_PLACEHOLDER" ]; then
pass "M5: At least one messaging placeholder detected in sandbox"
else
skip "M5: No messaging placeholders found — OpenShell may not inject them as env vars"
info "Subsequent phases that depend on placeholders will adapt"
fi
# M3/M4 verify the specific TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN
# env vars hold placeholders. The checks below verify the real
# host-side tokens do not appear on ANY observable surface inside
# the sandbox: full environment, process list, or filesystem.
sandbox_env_all=$(sandbox_exec "env 2>/dev/null" 2>/dev/null || true)
sandbox_ps=$(openshell sandbox exec -n "$SANDBOX_NAME" -- \
sh -c 'cat /proc/[0-9]*/cmdline 2>/dev/null | tr "\0" "\n"' 2>/dev/null || true)
if [ -n "$sandbox_ps" ]; then
info "Process cmdlines captured ($(echo "$sandbox_ps" | wc -l | tr -d ' ') lines)"
else
info "Process cmdline capture returned empty — M5b/M5f will skip"
fi
# M5a: Full environment dump must not contain the real Telegram token
if [ -z "$sandbox_env_all" ]; then
skip "M5a: Environment variable list is empty"
elif echo "$sandbox_env_all" | grep -qF "$TELEGRAM_TOKEN"; then
fail "M5a: Real Telegram token found in full sandbox environment dump"
else
pass "M5a: Real Telegram token absent from full sandbox environment"
fi
# M5b: Process list must not contain the real Telegram token
if [ -z "$sandbox_ps" ]; then
skip "M5b: Process list is empty"
elif echo "$sandbox_ps" | grep -qF "$TELEGRAM_TOKEN"; then
fail "M5b: Real Telegram token found in sandbox process list"
else
pass "M5b: Real Telegram token absent from sandbox process list"
fi
# M5c: Recursive filesystem search for the real Telegram token.
# Covers /sandbox (workspace), /home, /etc, /tmp, /var.
sandbox_fs_tg=$(printf '%s' "$TELEGRAM_TOKEN" | sandbox_exec_stdin "grep -rFlm1 -f - /sandbox /home /etc /tmp /var 2>/dev/null || true")
if [ -n "$sandbox_fs_tg" ]; then
fail "M5c: Real Telegram token found on sandbox filesystem: ${sandbox_fs_tg}"
else
pass "M5c: Real Telegram token absent from sandbox filesystem"
fi
# M5d: Placeholder string must be present in the sandbox environment
if [ -n "$TELEGRAM_PLACEHOLDER" ]; then
if echo "$sandbox_env_all" | grep -qF "$TELEGRAM_PLACEHOLDER"; then
pass "M5d: Telegram placeholder confirmed present in sandbox environment"
else
fail "M5d: Telegram placeholder not found in sandbox environment"
fi
else
skip "M5d: No Telegram placeholder to verify (provider-only mode)"
fi
# M5e: Full environment dump must not contain the real Discord token
if [ -z "$sandbox_env_all" ]; then
skip "M5e: Environment variable list is empty"
elif echo "$sandbox_env_all" | grep -qF "$DISCORD_TOKEN"; then
fail "M5e: Real Discord token found in full sandbox environment dump"
else
pass "M5e: Real Discord token absent from full sandbox environment"
fi
# M5f: Process list must not contain the real Discord token
if [ -z "$sandbox_ps" ]; then
skip "M5f: Process list is empty"
elif echo "$sandbox_ps" | grep -qF "$DISCORD_TOKEN"; then
fail "M5f: Real Discord token found in sandbox process list"
else
pass "M5f: Real Discord token absent from sandbox process list"
fi
# M5g: Recursive filesystem search for the real Discord token
sandbox_fs_dc=$(printf '%s' "$DISCORD_TOKEN" | sandbox_exec_stdin "grep -rFlm1 -f - /sandbox /home /etc /tmp /var 2>/dev/null || true")
if [ -n "$sandbox_fs_dc" ]; then
fail "M5g: Real Discord token found on sandbox filesystem: ${sandbox_fs_dc}"
else
pass "M5g: Real Discord token absent from sandbox filesystem"
fi
# M5h: Discord placeholder must be present in the sandbox environment
if [ -n "$DISCORD_PLACEHOLDER" ]; then
if echo "$sandbox_env_all" | grep -qF "$DISCORD_PLACEHOLDER"; then
pass "M5h: Discord placeholder confirmed present in sandbox environment"
else
fail "M5h: Discord placeholder not found in sandbox environment"
fi
else
skip "M5h: No Discord placeholder to verify (provider-only mode)"
fi
# ── Slack credential isolation (#2085) ────────────────────────────
# Mirrors M5a/M5e/M5g for Slack now that provider-shaped aliases are resolved
# directly by OpenShell. The host-side fake token must never appear on any
# observable surface inside the sandbox.
# M-S5a: Full environment dump must not contain the real Slack bot token.
if [ -z "$sandbox_env_all" ]; then
skip "M-S5a: Environment variable list is empty"
elif echo "$sandbox_env_all" | grep -qF "$SLACK_TOKEN"; then
fail "M-S5a: Real Slack bot token found in full sandbox environment dump"
else
pass "M-S5a: Real Slack bot token absent from full sandbox environment"
fi
# M-S5b: Process list must not contain the real Slack bot token.
if [ -z "$sandbox_ps" ]; then
skip "M-S5b: Process list is empty"
elif echo "$sandbox_ps" | grep -qF "$SLACK_TOKEN"; then
fail "M-S5b: Real Slack bot token found in sandbox process list"
else
pass "M-S5b: Real Slack bot token absent from sandbox process list"
fi
# M-S5c: Recursive filesystem search for the real Slack bot token.
sandbox_fs_sl=$(printf '%s' "$SLACK_TOKEN" | sandbox_exec_stdin "grep -rFlm1 -f - /sandbox /home /etc /tmp /var 2>/dev/null || true")
if [ -n "$sandbox_fs_sl" ]; then
fail "M-S5c: Real Slack bot token found on sandbox filesystem: ${sandbox_fs_sl}"
else
pass "M-S5c: Real Slack bot token absent from sandbox filesystem"
fi
# M-S5d: Same checks for the xapp- Socket Mode token.
if [ -n "$SLACK_APP" ]; then
if [ -z "$sandbox_env_all" ]; then
skip "M-S5d: Environment variable list is empty"
elif echo "$sandbox_env_all" | grep -qF "$SLACK_APP"; then
fail "M-S5d: Real Slack app token found in full sandbox environment dump"
else
pass "M-S5d: Real Slack app token absent from sandbox environment"
fi
if [ -z "$sandbox_ps" ]; then
skip "M-S5d2: Process list is empty"
elif echo "$sandbox_ps" | grep -qF "$SLACK_APP"; then
fail "M-S5d2: Real Slack app token found in sandbox process list"
else
pass "M-S5d2: Real Slack app token absent from sandbox process list"
fi
sandbox_fs_sapp=$(printf '%s' "$SLACK_APP" | sandbox_exec_stdin "grep -rFlm1 -f - /sandbox /home /etc /tmp /var 2>/dev/null || true")
if [ -n "$sandbox_fs_sapp" ]; then
fail "M-S5e: Real Slack app token found on sandbox filesystem: ${sandbox_fs_sapp}"
else
pass "M-S5e: Real Slack app token absent from sandbox filesystem"
fi
fi
# M-S5f: openclaw.json must contain the Bolt-shape placeholder, not the
# real token. OpenShell resolves the provider-shaped alias directly on egress.
config_slack=$(sandbox_exec "cat /sandbox/.openclaw/openclaw.json 2>/dev/null | grep -E '\"(bot|app)Token\"'" 2>/dev/null || true)
if [ -n "$config_slack" ] && {
echo "$config_slack" | grep -qF "$SLACK_TOKEN" \
|| echo "$config_slack" | grep -qF "$SLACK_APP"
}; then
fail "M-S5f: Real Slack bot/app token spliced into openclaw.json — apply_slack_token_override regression?"
elif [ -n "$config_slack" ] \
&& echo "$config_slack" | grep -q 'xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN' \
&& echo "$config_slack" | grep -q 'xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN'; then
pass "M-S5f: openclaw.json holds both Bolt-shape Slack placeholders (no real token on disk)"
else
skip "M-S5f: Could not extract Slack token fields from openclaw.json"
fi
# M-S5g: No Slack transport bridge should be installed. NODE_OPTIONS may still
# include non-transport resilience guards, but not the removed token rewriter.
sandbox_node_opts=$(openshell sandbox exec --name "$SANDBOX_NAME" -- bash -lc 'echo "$NODE_OPTIONS"' 2>/dev/null || echo "")
if echo "$sandbox_node_opts" | grep -q "nemoclaw-slack-token-rewriter.js"; then
fail "M-S5g: removed Slack token rewriter preload still present in NODE_OPTIONS"
else
pass "M-S5g: Slack token rewriter preload absent from NODE_OPTIONS"
fi
# ── WeChat credential isolation ───────────────────────────────────
# Mirrors M5a/M5b/M5c for WeChat. The host-side WECHAT_BOT_TOKEN must
# never appear on any observable surface inside the sandbox — the
# upstream @tencent-weixin/openclaw-weixin plugin reads it via the
# placeholder in <stateDir>/openclaw-weixin/accounts/<id>.json and the
# L7 proxy rewrites at egress.
# M-W3: WECHAT_BOT_TOKEN inside the sandbox must NOT contain the host token.
sandbox_wechat=$(sandbox_exec "printenv WECHAT_BOT_TOKEN" 2>/dev/null || true)
if [ -z "$sandbox_wechat" ]; then
info "WECHAT_BOT_TOKEN not set inside sandbox (provider-only mode)"
WECHAT_PLACEHOLDER=""
elif echo "$sandbox_wechat" | grep -qF "$WECHAT_TOKEN"; then
fail "M-W3: Real WeChat token leaked into sandbox env"
else
pass "M-W3: Sandbox WECHAT_BOT_TOKEN is a placeholder (not the real token)"
WECHAT_PLACEHOLDER="$sandbox_wechat"
info "WeChat placeholder: ${WECHAT_PLACEHOLDER:0:30}..."
fi
# M-W3a: Full environment dump must not contain the real WeChat token.
if [ -z "$sandbox_env_all" ]; then
skip "M-W3a: Environment variable list is empty"
elif echo "$sandbox_env_all" | grep -qF "$WECHAT_TOKEN"; then
fail "M-W3a: Real WeChat token found in full sandbox environment dump"
else
pass "M-W3a: Real WeChat token absent from full sandbox environment"
fi
# M-W3b: Process list must not contain the real WeChat token.
if [ -z "$sandbox_ps" ]; then
skip "M-W3b: Process list is empty"
elif echo "$sandbox_ps" | grep -qF "$WECHAT_TOKEN"; then
fail "M-W3b: Real WeChat token found in sandbox process list"
else
pass "M-W3b: Real WeChat token absent from sandbox process list"
fi
# M-W3c: Recursive filesystem search for the real WeChat token. The seed
# script writes the placeholder, not the token — a hit here would mean
# something upstream is splicing the real value into account state files.
sandbox_fs_wc=$(printf '%s' "$WECHAT_TOKEN" | sandbox_exec_stdin "grep -rFlm1 -f - /sandbox /home /etc /tmp /var 2>/dev/null || true")
if [ -n "$sandbox_fs_wc" ]; then
fail "M-W3c: Real WeChat token found on sandbox filesystem: ${sandbox_fs_wc}"
else
pass "M-W3c: Real WeChat token absent from sandbox filesystem"
fi
# M-W3d: WeChat placeholder must be present in the sandbox environment.
if [ -n "$WECHAT_PLACEHOLDER" ]; then
if echo "$sandbox_env_all" | grep -qF "$WECHAT_PLACEHOLDER"; then
pass "M-W3d: WeChat placeholder confirmed present in sandbox environment"
else
fail "M-W3d: WeChat placeholder not found in sandbox environment"
fi
else
skip "M-W3d: No WeChat placeholder to verify (provider-only mode)"
fi
# ── WhatsApp QR-only isolation ────────────────────────────────────
# WhatsApp is deliberately tokenless from NemoClaw's perspective. The operator
# pairs inside the sandbox, and mutable QR session state is allowed in durable
# agent state. There must be no host-side WhatsApp credential provider,
# placeholder, or token env for OpenShell to rewrite.
if [ -z "$sandbox_env_all" ]; then
skip "M-WA7a: Environment variable list is empty"
elif echo "$sandbox_env_all" | grep -qE '(^|[[:space:]])WHATSAPP_.*(TOKEN|SECRET|AUTH|SESSION)='; then
fail "M-WA7a: WhatsApp credential-like env var found in sandbox environment"
else
pass "M-WA7a: No WhatsApp credential-like env var present in sandbox environment"
fi
if [ -z "$sandbox_ps" ]; then
skip "M-WA7b: Process list is empty"
elif echo "$sandbox_ps" | grep -qE 'WHATSAPP_.*(TOKEN|SECRET|AUTH|SESSION)|openshell:resolve:env:WHATSAPP'; then
fail "M-WA7b: WhatsApp credential placeholder found in sandbox process list"
else
pass "M-WA7b: No WhatsApp credential placeholder present in sandbox process list"
fi
sandbox_fs_wa=$(sandbox_exec "
{
grep -rIlm1 -E '(^|[^A-Z0-9_])WHATSAPP_[A-Z0-9_]*(TOKEN|SECRET|AUTH|SESSION)[A-Z0-9_]*=' /sandbox /home /etc /tmp /var 2>/dev/null || true
grep -rIlm1 -F 'openshell:resolve:env:WHATSAPP' /sandbox /home /etc /tmp /var 2>/dev/null || true
grep -rIlm1 -F '$WHATSAPP_TOKEN_DECOY' /sandbox /home /etc /tmp /var 2>/dev/null || true
grep -rIlm1 -F '$WHATSAPP_BOT_TOKEN_DECOY' /sandbox /home /etc /tmp /var 2>/dev/null || true
grep -rIlm1 -F '$WHATSAPP_SESSION_SECRET_DECOY' /sandbox /home /etc /tmp /var 2>/dev/null || true
} | sort -u
")
if [ -n "$sandbox_fs_wa" ]; then
fail "M-WA7c: WhatsApp host credential material found on sandbox filesystem: ${sandbox_fs_wa}"
else
pass "M-WA7c: No WhatsApp host credential material found on sandbox filesystem"
fi
# ══════════════════════════════════════════════════════════════════
# Phase 3: Config Patching — openclaw.json channels
# ══════════════════════════════════════════════════════════════════
section "Phase 3: Config Patching Verification"
# Read openclaw.json and extract channel config
channel_json=$(sandbox_exec "python3 -c \"
import json, sys
try:
cfg = json.load(open('/sandbox/.openclaw/openclaw.json'))
channels = cfg.get('channels', {})
print(json.dumps(channels))
except Exception as e:
print(json.dumps({'error': str(e)}))
\"" 2>/dev/null || true)
if [ -z "$channel_json" ] || echo "$channel_json" | grep -q '"error"'; then
fail "M6: Could not read openclaw.json channels (${channel_json:0:200})"
else
info "Channel config: ${channel_json:0:300}"
# M6: Telegram channel exists with a bot token
# Note: non-root sandboxes cannot patch openclaw.json (chmod 444, root-owned).
# Channels still work via L7 proxy token rewriting without config patching.
# SKIP (not FAIL) when channels are absent — this is the expected non-root path.
tg_token=$(echo "$channel_json" | python3 -c "
import json, sys
d = json.load(sys.stdin)
accounts = d.get('telegram', {}).get('accounts', {})
account = accounts.get('default') or accounts.get('main') or {}
print(account.get('botToken', ''))
" 2>/dev/null || true)
if [ -n "$tg_token" ]; then
pass "M6: Telegram channel botToken present in openclaw.json"
else
skip "M6: Telegram channel not in openclaw.json (expected in non-root sandbox)"
fi
# M7: Telegram token is NOT the real/fake host token
if [ -n "$tg_token" ] && [ "$tg_token" != "$TELEGRAM_TOKEN" ]; then
pass "M7: Telegram botToken is not the host-side token (placeholder confirmed)"
elif [ -n "$tg_token" ]; then
fail "M7: Telegram botToken matches host-side token — credential leaked into config!"
else
skip "M7: No Telegram botToken to check"
fi
# M8: Discord channel exists with a token
dc_token=$(echo "$channel_json" | python3 -c "
import json, sys
d = json.load(sys.stdin)
accounts = d.get('discord', {}).get('accounts', {})
account = accounts.get('default') or accounts.get('main') or {}
print(account.get('token', ''))
" 2>/dev/null || true)
if [ -n "$dc_token" ]; then
pass "M8: Discord channel token present in openclaw.json"
else
skip "M8: Discord channel not in openclaw.json (expected in non-root sandbox)"
fi
# M9: Discord token is NOT the real/fake host token
if [ -n "$dc_token" ] && [ "$dc_token" != "$DISCORD_TOKEN" ]; then
pass "M9: Discord token is not the host-side token (placeholder confirmed)"
elif [ -n "$dc_token" ]; then
fail "M9: Discord token matches host-side token — credential leaked into config!"
else
skip "M9: No Discord token to check"
fi
# M9b: Discord Gateway WebSocket routing uses the loopback proxy.
# #3894 regressed because OpenClaw's Discord gateway client ignores proxy
# env vars and only uses the per-account proxy setting. OpenClaw rejects
# non-loopback proxy URLs for Discord, so OpenShell exposes a managed
# sandbox-local listener (with NemoClaw's helper as a compatibility fallback).
# The fake Gateway proof in M13b-M13g exercises that full relay path; this
# config assertion ensures the real OpenClaw Discord account is wired to it.
dc_proxy=$(echo "$channel_json" | python3 -c "
import json, sys
d = json.load(sys.stdin)
accounts = d.get('discord', {}).get('accounts', {})
account = accounts.get('default') or accounts.get('main') or {}
print(account.get('proxy', ''))
" 2>/dev/null || true)
expected_dc_proxy="${OPENSHELL_LOOPBACK_PROXY_URL:-http://127.0.0.1:${NEMOCLAW_DISCORD_PROXY_PORT:-3128}}"
if [ -n "$dc_token" ] && [ "$dc_proxy" = "$expected_dc_proxy" ]; then
pass "M9b: Discord account loopback proxy is baked into openclaw.json for Gateway WebSocket routing"
elif [ -n "$dc_token" ]; then
fail "M9b: Discord account loopback proxy missing or wrong; Gateway WebSocket may bypass OpenShell proxy (proxy='${dc_proxy}', expected='${expected_dc_proxy}')"
else
skip "M9b: No Discord channel config to check"
fi
# M10: Telegram enabled
tg_enabled=$(echo "$channel_json" | python3 -c "
import json, sys
d = json.load(sys.stdin)
accounts = d.get('telegram', {}).get('accounts', {})
account = accounts.get('default') or accounts.get('main') or {}
print(account.get('enabled', False))
" 2>/dev/null || true)
if [ "$tg_enabled" = "True" ]; then
pass "M10: Telegram channel is enabled"
else
skip "M10: Telegram channel not enabled (expected in non-root sandbox)"
fi
# M11: Discord enabled
dc_enabled=$(echo "$channel_json" | python3 -c "
import json, sys
d = json.load(sys.stdin)
accounts = d.get('discord', {}).get('accounts', {})
account = accounts.get('default') or accounts.get('main') or {}
print(account.get('enabled', False))
" 2>/dev/null || true)
if [ "$dc_enabled" = "True" ]; then
pass "M11: Discord channel is enabled"
else
skip "M11: Discord channel not enabled (expected in non-root sandbox)"
fi
# M11b: Telegram dmPolicy is allowlist (not pairing)
tg_dm_policy=$(echo "$channel_json" | python3 -c "
import json, sys
d = json.load(sys.stdin)
accounts = d.get('telegram', {}).get('accounts', {})
account = accounts.get('default') or accounts.get('main') or {}
print(account.get('dmPolicy', ''))
" 2>/dev/null || true)
if [ "$tg_dm_policy" = "allowlist" ]; then
pass "M11b: Telegram dmPolicy is 'allowlist'"
elif [ -n "$tg_dm_policy" ]; then
fail "M11b: Telegram dmPolicy is '$tg_dm_policy' (expected 'allowlist')"
else
skip "M11b: Telegram dmPolicy not set (channel may not be configured)"
fi
# M11c: Telegram allowFrom contains the expected user IDs
tg_allow_from=$(echo "$channel_json" | python3 -c "
import json, sys
d = json.load(sys.stdin)
accounts = d.get('telegram', {}).get('accounts', {})
account = accounts.get('default') or accounts.get('main') or {}
ids = account.get('allowFrom', [])
print(','.join(str(i) for i in ids))
" 2>/dev/null || true)
if [ -n "$tg_allow_from" ]; then