Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 4 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -389,9 +389,10 @@ USER sandbox
# bot loops on close-code 1006. Baking `accounts.default.proxy` into
# openclaw.json feeds DiscordAccountConfig.proxy, which the gateway plugin
# threads through to the `ws` `agent` option, routing the upgrade through
# the OpenShell proxy. Mirror of the Telegram treatment immediately below.
# Remove once OpenClaw lands an env-var-honouring fix for the Discord
# gateway equivalent to openclaw/openclaw#62878 (Slack Socket Mode).
# the OpenShell proxy. Prefer OpenShell's managed loopback listener when it is
# available; NemoClaw's startup helper remains a compatibility fallback until
# the OpenShell minimum version includes that listener. Mirror of the Telegram
# treatment immediately below.
# Generate openclaw.json from environment variables. Config generation logic
# lives in scripts/generate-openclaw-config.py — see that file for the full
# list of env vars and derivation rules.
Expand Down
25 changes: 22 additions & 3 deletions scripts/generate-openclaw-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
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)
OPENSHELL_LOOPBACK_PROXY_URL OpenShell-managed sandbox loopback proxy URL
NEMOCLAW_WEB_SEARCH_ENABLED Set to "1" to enable web search tools
"""

Expand Down Expand Up @@ -94,6 +95,20 @@ def _normalize_url_for_parse(raw_url: str) -> str:
return raw_url


def _valid_loopback_http_proxy_url(raw_url: str) -> str:
value = (raw_url or "").strip()
if not value:
return ""
try:
parsed = urlparse(_normalize_url_for_parse(value))
_ = parsed.port
except ValueError:
return ""
if parsed.scheme != "http" or not parsed.hostname or not is_loopback(parsed.hostname):
return ""
return value
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


def _validate_dashboard_port(raw: str, env_name: str) -> int:
stripped = raw.strip()
if not re.match(r"^\d+$", stripped):
Expand Down Expand Up @@ -361,10 +376,14 @@ def build_config(env: dict | None = None) -> dict:
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.
# gateway traffic. Prefer OpenShell's managed loopback listener when
# present; keep NemoClaw's temporary loopback bridge as a compatibility
# fallback for older OpenShell releases.
openshell_loopback_proxy_url = _valid_loopback_http_proxy_url(
env.get("OPENSHELL_LOOPBACK_PROXY_URL") or ""
)
discord_proxy_port = env.get("NEMOCLAW_DISCORD_PROXY_PORT") or "3128"
discord_proxy_url = f"http://127.0.0.1:{discord_proxy_port}"
discord_proxy_url = openshell_loopback_proxy_url or 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
11 changes: 11 additions & 0 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1464,8 +1464,19 @@
_DISCORD_LOOPBACK_PROXY_SCRIPT="/tmp/nemoclaw-discord-loopback-proxy.js"
_DISCORD_LOOPBACK_PROXY_SOURCE="/usr/local/lib/nemoclaw/preloads/discord-loopback-proxy.js"

is_openshell_loopback_proxy_url() {
case "${1:-}" in
http://127.*:* | http://localhost:* | http://[::1]:*) return 0 ;;
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
*) return 1 ;;
esac
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

start_discord_loopback_proxy() {
[ -n "${DISCORD_BOT_TOKEN:-}" ] || return 0
if is_openshell_loopback_proxy_url "${OPENSHELL_LOOPBACK_PROXY_URL:-}"; then
echo "[channels] Discord loopback proxy provided by OpenShell (${OPENSHELL_LOOPBACK_PROXY_URL}); skipping NemoClaw helper" >&2
return 0
fi
command -v node >/dev/null 2>&1 || {
echo "[channels] Discord loopback proxy skipped: node is not available" >&2
return 0
Expand Down
10 changes: 5 additions & 5 deletions test/e2e/test-messaging-providers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -919,10 +919,10 @@ print(account.get('token', ''))
# 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 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.
# 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)
Expand All @@ -931,7 +931,7 @@ account = accounts.get('default') or accounts.get('main') or {}
print(account.get('proxy', ''))
" 2>/dev/null || true)

expected_dc_proxy="http://127.0.0.1:${NEMOCLAW_DISCORD_PROXY_PORT:-3128}"
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
Expand Down
26 changes: 26 additions & 0 deletions test/generate-openclaw-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,32 @@ describe("generate-openclaw-config.py: config generation", () => {
});
});

it("prefers the OpenShell-managed loopback proxy for Discord when present", () => {
const channels = Buffer.from(JSON.stringify(["discord"])).toString("base64");
const config = runConfigScript({
NEMOCLAW_MESSAGING_CHANNELS_B64: channels,
OPENSHELL_LOOPBACK_PROXY_URL: "http://127.0.0.1:45211",
NEMOCLAW_DISCORD_PROXY_PORT: "43129",
});

expect(config.channels.discord.accounts.default).toMatchObject({
token: "openshell:resolve:env:DISCORD_BOT_TOKEN",
enabled: true,
proxy: "http://127.0.0.1:45211",
});
});

it("ignores a non-loopback OpenShell proxy URL for Discord and keeps the fallback", () => {
const channels = Buffer.from(JSON.stringify(["discord"])).toString("base64");
const config = runConfigScript({
NEMOCLAW_MESSAGING_CHANNELS_B64: channels,
OPENSHELL_LOOPBACK_PROXY_URL: "http://10.200.0.1:3128",
NEMOCLAW_DISCORD_PROXY_PORT: "43129",
});

expect(config.channels.discord.accounts.default.proxy).toBe("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({
Expand Down
40 changes: 40 additions & 0 deletions test/nemoclaw-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2040,6 +2040,46 @@ describe("Slack secrets-on-disk tripwire (#2085)", () => {
});
});

describe("Discord loopback proxy compatibility", () => {
const src = fs.readFileSync(START_SCRIPT, "utf-8");

it("skips the NemoClaw helper when OpenShell provides a loopback proxy", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-discord-loopback-"));
const scriptPath = path.join(tmpDir, "run.sh");
const fn = [
extractShellFunctionFromSource(src, "is_openshell_loopback_proxy_url"),
extractShellFunctionFromSource(src, "start_discord_loopback_proxy"),
].join("\n");

try {
fs.writeFileSync(
scriptPath,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"DISCORD_BOT_TOKEN=x",
"OPENSHELL_LOOPBACK_PROXY_URL=http://127.0.0.1:3128",
"DISCORD_LOOPBACK_PROXY_PORT=3128",
"_DISCORD_LOOPBACK_PROXY_SOURCE=/missing",
"_DISCORD_LOOPBACK_PROXY_SCRIPT=/missing",
'emit_sandbox_sourced_file() { echo "BAD_EMIT"; exit 7; }',
'node() { echo "BAD_NODE"; exit 8; }',
fn,
"start_discord_loopback_proxy",
].join("\n"),
{ mode: 0o700 },
);

const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 });
expect(result.status).toBe(0);
expect(result.stderr).toContain("Discord loopback proxy provided by OpenShell");
expect(result.stdout).not.toContain("BAD_");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});

describe("Telegram diagnostics (#2766)", () => {
const src = fs.readFileSync(START_SCRIPT, "utf-8");
const telegramDiagnosticsScript = startScriptHeredoc(src, "TELEGRAM_DIAGNOSTICS_EOF");
Expand Down
Loading