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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions nemoclaw-blueprint/scripts/discord-loopback-proxy.js
Original file line number Diff line number Diff line change
@@ -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}`,
);
});
10 changes: 9 additions & 1 deletion scripts/generate-openclaw-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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"
Expand Down
67 changes: 65 additions & 2 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 &
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
17 changes: 10 additions & 7 deletions test/e2e/test-messaging-providers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
20 changes: 17 additions & 3 deletions test/generate-openclaw-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
3 changes: 3 additions & 0 deletions test/nemoclaw-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() { :; }',
Expand All @@ -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),
Expand Down Expand Up @@ -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);
}
Expand Down
Loading