diff --git a/nemoclaw-blueprint/scripts/discord-loopback-proxy.js b/nemoclaw-blueprint/scripts/discord-loopback-proxy.js new file mode 100644 index 0000000000..7442417bbc --- /dev/null +++ b/nemoclaw-blueprint/scripts/discord-loopback-proxy.js @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const net = require("node:net"); + +const listenPort = Number(process.argv[2] || "3128"); +const upstreamHost = process.argv[3] || "10.200.0.1"; +const upstreamPort = Number(process.argv[4] || "3128"); + +function validPort(value) { + return Number.isInteger(value) && value > 0 && value <= 65535; +} + +if (!validPort(listenPort) || !validPort(upstreamPort)) { + console.error("[discord-loopback-proxy] invalid port"); + process.exit(1); +} + +const server = net.createServer((client) => { + const upstream = net.connect({ host: upstreamHost, port: upstreamPort }); + client.on("error", () => {}); + upstream.on("error", () => client.destroy()); + client.pipe(upstream); + upstream.pipe(client); +}); + +server.on("error", (error) => { + console.error(`[discord-loopback-proxy] ${error.message}`); + process.exit(1); +}); + +server.listen(listenPort, "127.0.0.1", () => { + console.error( + `[discord-loopback-proxy] listening on 127.0.0.1:${listenPort} -> ${upstreamHost}:${upstreamPort}`, + ); +}); diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index 98328d3f13..ad23074a52 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -35,6 +35,7 @@ NEMOCLAW_DISABLE_DEVICE_AUTH Set to "1" to force-disable device auth NEMOCLAW_PROXY_HOST Egress proxy host (default: 10.200.0.1) NEMOCLAW_PROXY_PORT Egress proxy port (default: 3128) + NEMOCLAW_DISCORD_PROXY_PORT Loopback proxy port for Discord (default: 3128) NEMOCLAW_WEB_SEARCH_ENABLED Set to "1" to enable web search tools """ @@ -359,6 +360,11 @@ def build_config(env: dict | None = None) -> dict: proxy_host = env.get("NEMOCLAW_PROXY_HOST") or "10.200.0.1" proxy_port = env.get("NEMOCLAW_PROXY_PORT") or "3128" proxy_url = f"http://{proxy_host}:{proxy_port}" + # OpenClaw's Discord channel accepts only loopback proxy URLs for REST and + # gateway traffic. NemoClaw starts a loopback bridge in nemoclaw-start.sh + # that forwards to the real OpenShell proxy. + discord_proxy_port = env.get("NEMOCLAW_DISCORD_PROXY_PORT") or "3128" + discord_proxy_url = f"http://127.0.0.1:{discord_proxy_port}" model = env["NEMOCLAW_MODEL"] raw_chat_ui_url = env.get("CHAT_UI_URL") or "" chat_ui_url = raw_chat_ui_url or f"http://127.0.0.1:{DEFAULT_DASHBOARD_PORT}" @@ -514,7 +520,9 @@ def _placeholder(channel: str, env_key: str) -> str: } if ch == "slack": account["appToken"] = _placeholder(ch, "SLACK_APP_TOKEN") - if ch in {"discord", "telegram"}: + if ch == "discord": + account["proxy"] = discord_proxy_url + elif ch == "telegram": account["proxy"] = proxy_url if ch == "telegram": account["groupPolicy"] = "open" diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index d180383a06..676249a95a 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1449,6 +1449,65 @@ export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" +DISCORD_LOOPBACK_PROXY_PORT="${NEMOCLAW_DISCORD_PROXY_PORT:-3128}" +case "$DISCORD_LOOPBACK_PROXY_PORT" in + *[!0-9]* | '') + echo "[channels] Invalid NEMOCLAW_DISCORD_PROXY_PORT='${NEMOCLAW_DISCORD_PROXY_PORT:-}' - using 3128" >&2 + DISCORD_LOOPBACK_PROXY_PORT=3128 + ;; +esac +if [ "$DISCORD_LOOPBACK_PROXY_PORT" -lt 1 ] || [ "$DISCORD_LOOPBACK_PROXY_PORT" -gt 65535 ]; then + echo "[channels] Invalid NEMOCLAW_DISCORD_PROXY_PORT='${DISCORD_LOOPBACK_PROXY_PORT}' - using 3128" >&2 + DISCORD_LOOPBACK_PROXY_PORT=3128 +fi + +_DISCORD_LOOPBACK_PROXY_SCRIPT="/tmp/nemoclaw-discord-loopback-proxy.js" +_DISCORD_LOOPBACK_PROXY_SOURCE="/usr/local/lib/nemoclaw/preloads/discord-loopback-proxy.js" + +start_discord_loopback_proxy() { + [ -n "${DISCORD_BOT_TOKEN:-}" ] || return 0 + command -v node >/dev/null 2>&1 || { + echo "[channels] Discord loopback proxy skipped: node is not available" >&2 + return 0 + } + if [ ! -f "$_DISCORD_LOOPBACK_PROXY_SOURCE" ]; then + echo "[channels] Discord loopback proxy skipped: helper is not installed" >&2 + return 0 + fi + + local log="/tmp/nemoclaw-discord-loopback-proxy.log" + local port="$DISCORD_LOOPBACK_PROXY_PORT" + + if node -e ' +const net = require("net"); +const port = Number(process.argv[1]); +const socket = net.createConnection({ host: "127.0.0.1", port, timeout: 500 }); +socket.on("connect", () => process.exit(0)); +socket.on("error", () => process.exit(1)); +socket.on("timeout", () => process.exit(1)); +' "$port" >/dev/null 2>&1; then + echo "[channels] Discord loopback proxy already listening on 127.0.0.1:${port}" >&2 + return 0 + fi + + emit_sandbox_sourced_file "$_DISCORD_LOOPBACK_PROXY_SCRIPT" <"$_DISCORD_LOOPBACK_PROXY_SOURCE" + : >"$log" + chmod 600 "$log" 2>/dev/null || true + + if [ "$(id -u)" -eq 0 ]; then + chown root:root "$log" 2>/dev/null || true + nohup "${STEP_DOWN_PREFIX_SANDBOX[@]}" env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \ + -u http_proxy -u https_proxy -u all_proxy NODE_USE_ENV_PROXY=0 \ + node "$_DISCORD_LOOPBACK_PROXY_SCRIPT" "$port" "$PROXY_HOST" "$PROXY_PORT" >>"$log" 2>&1 & + else + nohup env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \ + -u http_proxy -u https_proxy -u all_proxy NODE_USE_ENV_PROXY=0 \ + node "$_DISCORD_LOOPBACK_PROXY_SCRIPT" "$port" "$PROXY_HOST" "$PROXY_PORT" >>"$log" 2>&1 & + fi + DISCORD_LOOPBACK_PROXY_PID=$! + echo "[channels] Discord loopback proxy launched (pid ${DISCORD_LOOPBACK_PROXY_PID}, 127.0.0.1:${port} -> ${PROXY_HOST}:${PROXY_PORT})" >&2 +} + # Git TLS CA bundle fix (NemoClaw#2270). # OpenShell's L7 proxy does MITM TLS termination and re-signs with its own CA. # OpenShell injects SSL_CERT_FILE and CURL_CA_BUNDLE pointing at the CA bundle, @@ -2234,6 +2293,7 @@ if [ "$(id -u)" -ne 0 ]; then fi configure_messaging_channels + start_discord_loopback_proxy install_telegram_diagnostics install_slack_channel_guard verify_no_slack_secrets_on_disk @@ -2278,7 +2338,7 @@ if [ "$(id -u)" -ne 0 ]; then # Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh # (both are trust-boundary files; tampering would let the sandbox user # inject code into any Node process via NODE_OPTIONS). - validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_WS_FIX_SCRIPT" "$_SECCOMP_GUARD_SCRIPT" "$_CIAO_GUARD_SCRIPT" "$_TELEGRAM_DIAGNOSTICS_SCRIPT" "$_SLACK_GUARD_SCRIPT" + validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_WS_FIX_SCRIPT" "$_SECCOMP_GUARD_SCRIPT" "$_CIAO_GUARD_SCRIPT" "$_TELEGRAM_DIAGNOSTICS_SCRIPT" "$_SLACK_GUARD_SCRIPT" "$_DISCORD_LOOPBACK_PROXY_SCRIPT" # Start gateway in background, auto-pair, then wait nohup "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 & @@ -2295,6 +2355,7 @@ if [ "$(id -u)" -ne 0 ]; then # registration and the final append is a small race window (same as before # the shared-library refactor). Acceptable for entrypoint-level cleanup. SANDBOX_CHILD_PIDS=("$GATEWAY_PID") + [ -n "${DISCORD_LOOPBACK_PROXY_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DISCORD_LOOPBACK_PROXY_PID") [ -n "${AUTO_PAIR_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$AUTO_PAIR_PID") [ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID") [ -n "${GATEWAY_LOG_PERSIST_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_PERSIST_PID") @@ -2372,6 +2433,7 @@ lock_rc_files "$_SANDBOX_HOME" # Must run AFTER integrity check (to detect build-time tampering) and # BEFORE chattr +i (which locks the config permanently). configure_messaging_channels +start_discord_loopback_proxy install_telegram_diagnostics install_slack_channel_guard verify_no_slack_secrets_on_disk @@ -2488,7 +2550,7 @@ seed_default_workspace_templates_as_sandbox # Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh # (both are trust-boundary files; tampering would let the sandbox user # inject code into any Node process via NODE_OPTIONS). -validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_WS_FIX_SCRIPT" "$_SECCOMP_GUARD_SCRIPT" "$_CIAO_GUARD_SCRIPT" "$_TELEGRAM_DIAGNOSTICS_SCRIPT" "$_SLACK_GUARD_SCRIPT" +validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_WS_FIX_SCRIPT" "$_SECCOMP_GUARD_SCRIPT" "$_CIAO_GUARD_SCRIPT" "$_TELEGRAM_DIAGNOSTICS_SCRIPT" "$_SLACK_GUARD_SCRIPT" "$_DISCORD_LOOPBACK_PROXY_SCRIPT" # Start the gateway as the 'gateway' user. # SECURITY: The sandbox user cannot kill this process because it runs @@ -2521,6 +2583,7 @@ start_auto_pair # registration and the final append is a small race window (same as before # the shared-library refactor). Acceptable for entrypoint-level cleanup. SANDBOX_CHILD_PIDS=("$GATEWAY_PID") +[ -n "${DISCORD_LOOPBACK_PROXY_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DISCORD_LOOPBACK_PROXY_PID") [ -n "${AUTO_PAIR_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$AUTO_PAIR_PID") [ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID") [ -n "${GATEWAY_LOG_PERSIST_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_PERSIST_PID") diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index c2c871c748..ba79ae7505 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -916,11 +916,13 @@ print(account.get('token', '')) skip "M9: No Discord token to check" fi - # M9b: Discord Gateway WebSocket routing uses the OpenShell proxy. + # 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. The fake Gateway - # proof in M13b-M13g exercises that OpenShell WebSocket relay; this config - # assertion ensures the real OpenClaw Discord account is wired to the relay. + # env vars and only uses the per-account proxy setting. OpenClaw rejects + # non-loopback proxy URLs for Discord, so NemoClaw starts a local helper that + # forwards 127.0.0.1:${NEMOCLAW_DISCORD_PROXY_PORT:-3128} to OpenShell. The + # fake Gateway proof in M13b-M13g exercises that full relay path; this config + # assertion ensures the real OpenClaw Discord account is wired to the helper. dc_proxy=$(echo "$channel_json" | python3 -c " import json, sys d = json.load(sys.stdin) @@ -929,10 +931,11 @@ account = accounts.get('default') or accounts.get('main') or {} print(account.get('proxy', '')) " 2>/dev/null || true) - if [ -n "$dc_token" ] && [ "$dc_proxy" = "http://10.200.0.1:3128" ]; then - pass "M9b: Discord account proxy is baked into openclaw.json for Gateway WebSocket routing" + expected_dc_proxy="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 proxy missing or wrong; Gateway WebSocket may bypass OpenShell proxy (proxy='${dc_proxy}')" + 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 diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index b6f15c20e9..8760c04f2e 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -358,24 +358,38 @@ describe("generate-openclaw-config.py: config generation", () => { "openshell:resolve:env:DISCORD_BOT_TOKEN", ); expect(config.channels.telegram.accounts.default.proxy).toBe("http://10.200.0.1:3128"); - expect(config.channels.discord.accounts.default.proxy).toBe("http://10.200.0.1:3128"); + expect(config.channels.discord.accounts.default.proxy).toBe("http://127.0.0.1:3128"); }); - it("#3894: routes Discord gateway traffic through the configured OpenShell proxy", () => { + it("#3894: routes Discord gateway traffic through the sandbox loopback proxy", () => { const channels = Buffer.from(JSON.stringify(["discord"])).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels, NEMOCLAW_PROXY_HOST: "10.201.0.9", NEMOCLAW_PROXY_PORT: "43128", + NEMOCLAW_DISCORD_PROXY_PORT: "43129", }); expect(config.channels.discord.accounts.default).toMatchObject({ token: "openshell:resolve:env:DISCORD_BOT_TOKEN", enabled: true, - proxy: "http://10.201.0.9:43128", + proxy: "http://127.0.0.1:43129", }); }); + it("keeps Telegram on the OpenShell proxy when Discord uses loopback", () => { + const channels = Buffer.from(JSON.stringify(["telegram", "discord"])).toString("base64"); + const config = runConfigScript({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channels, + NEMOCLAW_PROXY_HOST: "10.201.0.9", + NEMOCLAW_PROXY_PORT: "43128", + NEMOCLAW_DISCORD_PROXY_PORT: "43129", + }); + + expect(config.channels.telegram.accounts.default.proxy).toBe("http://10.201.0.9:43128"); + expect(config.channels.discord.accounts.default.proxy).toBe("http://127.0.0.1:43129"); + }); + it("emits Bolt-shape placeholders for Slack so the SDK's prefix regex passes", () => { const channels = Buffer.from(JSON.stringify(["slack"])).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 207fba2746..efd4ad367e 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -2120,6 +2120,7 @@ describe("Telegram diagnostics (#2766)", () => { 'ensure_runtime_shell_env_shim() { :; }', 'lock_rc_files() { :; }', 'configure_messaging_channels() { echo "ORDER:configure"; }', + 'start_discord_loopback_proxy() { echo "ORDER:discord-loopback"; }', 'install_slack_channel_guard() { :; }', 'verify_no_slack_secrets_on_disk() { :; }', 'seed_default_workspace_templates() { :; }', @@ -2145,6 +2146,7 @@ describe("Telegram diagnostics (#2766)", () => { `_SECCOMP_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "seccomp-guard.js"))}`, `_CIAO_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "ciao-guard.js"))}`, `_SLACK_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "slack-guard.js"))}`, + `_DISCORD_LOOPBACK_PROXY_SCRIPT=${JSON.stringify(path.join(tmpDir, "discord-loopback-proxy.js"))}`, "NEMOCLAW_CMD=()", telegramDiagnosticsSection(preloadPath, configPath), preGatewaySetupBlock(kind, gatewayLog, autoPairLog), @@ -2267,6 +2269,7 @@ process.stderr.write('FailoverError: token=123456:LATER\\n'); expect(setup.preloadExists).toBe(true); expect(setup.preloadMode).toBe("444"); expect(setup.result.stdout).toContain("ORDER:configure"); + expect(setup.result.stdout).toContain("ORDER:discord-loopback"); expect(setup.result.stdout).toContain("VALIDATE:"); expect(setup.result.stdout).toContain(setup.preloadPath); }