diff --git a/nemoclaw-blueprint/scripts/ws-proxy-fix.js b/nemoclaw-blueprint/scripts/ws-proxy-fix.js new file mode 100644 index 00000000000..80865ab1aa3 --- /dev/null +++ b/nemoclaw-blueprint/scripts/ws-proxy-fix.js @@ -0,0 +1,177 @@ +"use strict"; +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// ws-proxy-fix.ts — preload script to fix Discord WebSocket connections +// through the OpenShell L7 proxy when HTTPS_PROXY is set. +// +// Problem (NemoClaw#1570): +// The `ws` library (used by OpenClaw's Discord extension via @buape/carbon) +// establishes WebSocket connections by calling https.request() for wss:// URLs. +// Inside the sandbox, HTTPS_PROXY is set and Node.js 22 (with +// NODE_USE_ENV_PROXY=1) routes these through EnvHttpProxyAgent — which sends a +// forward proxy request (GET https://...) instead of a CONNECT tunnel. The +// OpenShell L7 proxy correctly rejects forward proxy HTTPS with HTTP 400. +// Without NODE_USE_ENV_PROXY, ws goes direct, which the sandbox network +// namespace blocks. Either way, the WebSocket handshake fails and the bot +// loops on close code 1006. +// +// Fix: +// Patch https.request() to detect WebSocket upgrade requests to Discord +// gateway hosts (gateway.discord.gg) and inject an agent that issues a proper +// CONNECT request to the proxy, then upgrades the tunnel socket to TLS. +// All other HTTPS requests — including non-Discord WebSockets — pass through +// completely untouched. +// +// Uses only Node.js built-in modules — no external dependencies. +// +// Belt-and-suspenders: works regardless of any upstream OpenClaw changes. +// If the caller already provides a custom (non-default) agent, we step aside +// — no double-tunnelling. +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const node_http_1 = __importDefault(require("node:http")); +const node_net_1 = __importDefault(require("node:net")); +const node_tls_1 = __importDefault(require("node:tls")); +const node_https_1 = __importDefault(require("node:https")); +const node_url_1 = require("node:url"); +const _PATCHED = Symbol.for("nemoclaw.wsProxyFix"); +/** + * Self-executing initialiser. Using an IIFE rather than top-level `return` + * keeps the source valid TypeScript while preserving early-exit semantics. + */ +(function wsProxyFixInit() { + const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy; + if (!proxyUrl) + return; + if (globalThis[_PATCHED]) + return; + let proxy; + try { + proxy = new node_url_1.URL(proxyUrl); + } + catch { + return; + } + const proxyHost = proxy.hostname; + const proxyPort = parseInt(proxy.port, 10) || 3128; + // ---------- CONNECT tunnel agent ---------------------------------------- + /** + * Create an https.Agent whose createConnection() establishes a CONNECT + * tunnel through the HTTP proxy, then upgrades to TLS — the correct + * behaviour that EnvHttpProxyAgent fails to perform for HTTPS. + */ + function createTunnelAgent(targetHost, targetPort) { + const agent = new node_https_1.default.Agent({ keepAlive: false, maxSockets: 1 }); + // Override createConnection to route through the proxy's CONNECT tunnel. + // The typing is intentionally loosened because the actual Node.js runtime + // signature is broader than what @types/node declares. + agent.createConnection = function (options, callback) { + const connectReq = node_http_1.default.request({ + host: proxyHost, + port: proxyPort, + method: "CONNECT", + path: `${targetHost}:${targetPort}`, + headers: { Host: `${targetHost}:${targetPort}` }, + }); + connectReq.on("connect", (_res, socket, head) => { + if (_res.statusCode !== 200) { + socket.destroy(); + callback(new Error(`ws-proxy-fix: CONNECT ${targetHost}:${targetPort} via proxy failed (${_res.statusCode})`)); + return; + } + // Preserve any bytes already buffered from the tunnel before TLS. + if (head && head.length > 0) { + socket.unshift(head); + } + const tlsSocket = node_tls_1.default.connect({ + socket, + servername: options.servername || targetHost, + }); + callback(null, tlsSocket); + }); + connectReq.on("error", (err) => { + connectReq.destroy(); + callback(err); + }); + connectReq.end(); + // createConnection expects a synchronous return; the real socket arrives + // via the callback. Return a placeholder that Node.js will discard. + return new node_net_1.default.Socket(); + }; + return agent; + } + // ---------- Target check ------------------------------------------------- + /** + * Return true only for WebSocket upgrade requests targeting Discord + * gateway hosts (gateway.discord.gg and regional variants). + */ + function isDiscordWsUpgrade(host, headers) { + if (!host || !headers || typeof headers !== "object") + return false; + const h = host.toLowerCase(); + if (h !== "gateway.discord.gg" && !h.endsWith(".discord.gg")) + return false; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === "upgrade" && + String(headers[key]).toLowerCase() === "websocket") { + return true; + } + } + return false; + } + // ---------- Patch https.request() --------------------------------------- + // Capture the original — typed as a loose callable so we can invoke it + // with the normalised (options, cb) form without fighting overload resolution. + const origRequest = node_https_1.default.request; + function wsProxyFixedRequest(input, options, callback) { + // --- Normalise arguments (Node.js accepts multiple call signatures) --- + let opts; + let cb; + if (typeof input === "string" || input instanceof node_url_1.URL) { + if (typeof options === "function") { + cb = options; + opts = {}; + } + else { + opts = options || {}; + cb = callback; + } + const url = typeof input === "string" ? new node_url_1.URL(input) : input; + opts = { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + ...opts, + }; + } + else { + opts = input || {}; + cb = typeof options === "function" ? options : callback; + } + // opts.host may include a port (e.g. "gateway.discord.gg:443") — strip it + // so the CONNECT path doesn't become "host:443:443". + let host = opts.hostname || undefined; + if (!host && opts.host) { + host = opts.host.replace(/:\d+$/, ""); + } + if (isDiscordWsUpgrade(host, opts.headers)) { + // Discord WebSocket upgrade — inject CONNECT tunnel agent unless the + // caller already provides a custom (non-default) agent. + if (!opts.agent || opts.agent === node_https_1.default.globalAgent) { + const port = parseInt(String(opts.port), 10) || 443; + opts = { ...opts, agent: createTunnelAgent(host, port) }; + } + return origRequest.call(node_https_1.default, opts, cb); + } + // Non-WebSocket — pass through original arguments unchanged. + // eslint-disable-next-line prefer-rest-params + return origRequest.apply(node_https_1.default, arguments); + } + // Replace https.request with our patched version. + node_https_1.default.request = wsProxyFixedRequest; + globalThis[_PATCHED] = true; +})(); diff --git a/nemoclaw-blueprint/scripts/ws-proxy-fix.ts b/nemoclaw-blueprint/scripts/ws-proxy-fix.ts new file mode 100644 index 00000000000..a82f3cdc952 --- /dev/null +++ b/nemoclaw-blueprint/scripts/ws-proxy-fix.ts @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// ws-proxy-fix.ts — preload script to fix Discord WebSocket connections +// through the OpenShell L7 proxy when HTTPS_PROXY is set. +// +// Problem (NemoClaw#1570): +// The `ws` library (used by OpenClaw's Discord extension via @buape/carbon) +// establishes WebSocket connections by calling https.request() for wss:// URLs. +// Inside the sandbox, HTTPS_PROXY is set and Node.js 22 (with +// NODE_USE_ENV_PROXY=1) routes these through EnvHttpProxyAgent — which sends a +// forward proxy request (GET https://...) instead of a CONNECT tunnel. The +// OpenShell L7 proxy correctly rejects forward proxy HTTPS with HTTP 400. +// Without NODE_USE_ENV_PROXY, ws goes direct, which the sandbox network +// namespace blocks. Either way, the WebSocket handshake fails and the bot +// loops on close code 1006. +// +// Fix: +// Patch https.request() to detect WebSocket upgrade requests to Discord +// gateway hosts (gateway.discord.gg) and inject an agent that issues a proper +// CONNECT request to the proxy, then upgrades the tunnel socket to TLS. +// All other HTTPS requests — including non-Discord WebSockets — pass through +// completely untouched. +// +// Uses only Node.js built-in modules — no external dependencies. +// +// Belt-and-suspenders: works regardless of any upstream OpenClaw changes. +// If the caller already provides a custom (non-default) agent, we step aside +// — no double-tunnelling. + +import http from "node:http"; +import net from "node:net"; +import tls from "node:tls"; +import https from "node:https"; +import { URL } from "node:url"; + +const _PATCHED = Symbol.for("nemoclaw.wsProxyFix"); + +type RequestCallback = (res: http.IncomingMessage) => void; + +/** + * Merged options after normalising the multiple call signatures of + * https.request(). Only fields we inspect are listed. + */ +interface ReqOpts extends https.RequestOptions { + headers?: http.OutgoingHttpHeaders; +} + +/** + * Self-executing initialiser. Using an IIFE rather than top-level `return` + * keeps the source valid TypeScript while preserving early-exit semantics. + */ +(function wsProxyFixInit(): void { + const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy; + if (!proxyUrl) return; + + if ((globalThis as Record)[_PATCHED]) return; + + let proxy: URL; + try { + proxy = new URL(proxyUrl); + } catch { + return; + } + + const proxyHost: string = proxy.hostname; + const proxyPort: number = parseInt(proxy.port, 10) || 3128; + + // ---------- CONNECT tunnel agent ---------------------------------------- + + /** + * Create an https.Agent whose createConnection() establishes a CONNECT + * tunnel through the HTTP proxy, then upgrades to TLS — the correct + * behaviour that EnvHttpProxyAgent fails to perform for HTTPS. + */ + function createTunnelAgent( + targetHost: string, + targetPort: number, + ): https.Agent { + const agent = new https.Agent({ keepAlive: false, maxSockets: 1 }); + + // Override createConnection to route through the proxy's CONNECT tunnel. + // The typing is intentionally loosened because the actual Node.js runtime + // signature is broader than what @types/node declares. + (agent as unknown as Record).createConnection = function ( + options: Record, + callback: (err: Error | null, socket?: tls.TLSSocket) => void, + ): net.Socket { + const connectReq = http.request({ + host: proxyHost, + port: proxyPort, + method: "CONNECT", + path: `${targetHost}:${targetPort}`, + headers: { Host: `${targetHost}:${targetPort}` }, + }); + + connectReq.on( + "connect", + (_res: http.IncomingMessage, socket: net.Socket, head: Buffer) => { + if (_res.statusCode !== 200) { + socket.destroy(); + callback( + new Error( + `ws-proxy-fix: CONNECT ${targetHost}:${targetPort} via proxy failed (${_res.statusCode})`, + ), + ); + return; + } + // Preserve any bytes already buffered from the tunnel before TLS. + if (head && head.length > 0) { + socket.unshift(head); + } + const tlsSocket = tls.connect({ + socket, + servername: (options.servername as string) || targetHost, + }); + callback(null, tlsSocket); + }, + ); + + connectReq.on("error", (err: Error) => { + connectReq.destroy(); + callback(err); + }); + connectReq.end(); + + // createConnection expects a synchronous return; the real socket arrives + // via the callback. Return a placeholder that Node.js will discard. + return new net.Socket(); + }; + + return agent; + } + + // ---------- Target check ------------------------------------------------- + + /** + * Return true only for WebSocket upgrade requests targeting Discord + * gateway hosts (gateway.discord.gg and regional variants). + */ + function isDiscordWsUpgrade( + host: string | undefined, + headers: http.OutgoingHttpHeaders | undefined, + ): boolean { + if (!host || !headers || typeof headers !== "object") return false; + const h = host.toLowerCase(); + if (h !== "gateway.discord.gg" && !h.endsWith(".discord.gg")) return false; + for (const key of Object.keys(headers)) { + if ( + key.toLowerCase() === "upgrade" && + String(headers[key]).toLowerCase() === "websocket" + ) { + return true; + } + } + return false; + } + + // ---------- Patch https.request() --------------------------------------- + + // Capture the original — typed as a loose callable so we can invoke it + // with the normalised (options, cb) form without fighting overload resolution. + const origRequest = https.request as ( + options: ReqOpts, + callback?: RequestCallback, + ) => http.ClientRequest; + + function wsProxyFixedRequest( + input: string | URL | ReqOpts, + options?: RequestCallback | ReqOpts, + callback?: RequestCallback, + ): http.ClientRequest { + // --- Normalise arguments (Node.js accepts multiple call signatures) --- + let opts: ReqOpts; + let cb: RequestCallback | undefined; + + if (typeof input === "string" || input instanceof URL) { + if (typeof options === "function") { + cb = options; + opts = {}; + } else { + opts = (options as ReqOpts) || {}; + cb = callback; + } + const url = typeof input === "string" ? new URL(input) : input; + opts = { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + ...opts, + }; + } else { + opts = input || {}; + cb = typeof options === "function" ? options : callback; + } + + // opts.host may include a port (e.g. "gateway.discord.gg:443") — strip it + // so the CONNECT path doesn't become "host:443:443". + let host = opts.hostname || undefined; + if (!host && opts.host) { + host = opts.host.replace(/:\d+$/, ""); + } + if (isDiscordWsUpgrade(host, opts.headers)) { + // Discord WebSocket upgrade — inject CONNECT tunnel agent unless the + // caller already provides a custom (non-default) agent. + if (!opts.agent || opts.agent === https.globalAgent) { + const port = parseInt(String(opts.port), 10) || 443; + opts = { ...opts, agent: createTunnelAgent(host!, port) }; + } + return origRequest.call(https, opts, cb); + } + + // Non-WebSocket — pass through original arguments unchanged. + // eslint-disable-next-line prefer-rest-params + return (origRequest as unknown as Function).apply(https, arguments); + } + + // Replace https.request with our patched version. + (https as unknown as Record).request = wsProxyFixedRequest; + + (globalThis as Record)[_PATCHED] = true; +})(); + +export {}; diff --git a/nemoclaw-blueprint/tsconfig.json b/nemoclaw-blueprint/tsconfig.json new file mode 100644 index 00000000000..307ca2f9ad2 --- /dev/null +++ b/nemoclaw-blueprint/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": false, + "types": ["node"] + }, + "include": ["scripts/**/*.ts"] +} diff --git a/package.json b/package.json index 9878a89b353..21272914f1a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "format": "prettier --write 'bin/**/*.js' 'scripts/**/*.ts' 'test/**/*.{js,ts}'", "format:check": "prettier --check 'bin/**/*.js' 'scripts/**/*.ts' 'test/**/*.{js,ts}'", "typecheck": "tsc -p jsconfig.json", - "build:cli": "tsc -p tsconfig.src.json", + "build:cli": "tsc -p tsconfig.src.json && tsc -p nemoclaw-blueprint/tsconfig.json", "typecheck:cli": "tsc -p tsconfig.cli.json", "validate:configs": "tsx scripts/validate-configs.ts", "migrate:js-to-ts": "tsx scripts/migrate-js-to-ts.ts", diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index b46f3a529fa..09457c338eb 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -973,6 +973,19 @@ if [ -f "$_AXIOS_FIX_SCRIPT" ] && [ "${NODE_USE_ENV_PROXY:-}" = "1" ]; then export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_AXIOS_FIX_SCRIPT" fi +# WebSocket CONNECT tunnel fix (NemoClaw#1570). +# The `ws` library calls https.request() for wss:// WebSocket upgrades. +# EnvHttpProxyAgent (NODE_USE_ENV_PROXY=1) sends a forward proxy request +# instead of CONNECT — rejected by the L7 proxy with 400. Without +# NODE_USE_ENV_PROXY, ws goes direct — blocked by sandbox netns. +# The preload patches https.request() to inject a CONNECT tunnel agent for +# WebSocket upgrade requests. Activates whenever HTTPS_PROXY is set (the +# script itself guards on the env var). +_WS_FIX_SCRIPT="/opt/nemoclaw-blueprint/scripts/ws-proxy-fix.js" +if [ -f "$_WS_FIX_SCRIPT" ]; then + export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_WS_FIX_SCRIPT" +fi + # OpenShell re-injects narrow NO_PROXY/no_proxy=127.0.0.1,localhost,::1 every # time a user connects via `openshell sandbox connect`. The connect path spawns # `/bin/bash -i` (interactive, non-login), which sources ~/.bashrc — NOT @@ -1008,6 +1021,10 @@ PROXYEOF if [ -f "$_AXIOS_FIX_SCRIPT" ] && [ "${NODE_USE_ENV_PROXY:-}" = "1" ]; then echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_AXIOS_FIX_SCRIPT\"" fi + # WebSocket CONNECT tunnel fix for connect sessions. (NemoClaw#1570) + if [ -f "$_WS_FIX_SCRIPT" ]; then + echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_WS_FIX_SCRIPT\"" + fi # Tool cache redirects — generated from _TOOL_REDIRECTS (single source of truth) echo '# Tool cache redirects — /sandbox is Landlock read-only (#804)' for _redir in "${_TOOL_REDIRECTS[@]}"; do diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index a7046cdf5ef..4d5d13614bd 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -678,6 +678,204 @@ else fi fi +# M13c: Full Discord gateway handshake via ws-proxy-fix CONNECT tunnel (#1570). +# The `ws` library opens WebSocket connections via https.request() with an +# Upgrade: websocket header. The preload patches https.request() to issue a +# CONNECT tunnel for Discord gateway hosts. +# +# This test exercises the real Discord gateway protocol end-to-end: +# 1. https.request with Upgrade: websocket → CONNECT tunnel via proxy +# 2. Receive Discord Hello (opcode 10) with heartbeat_interval +# 3. Send a Heartbeat (opcode 1) back to the gateway +# 4. Receive Heartbeat ACK (opcode 11) +# 5. Send close frame and disconnect cleanly +# +# If the CONNECT tunnel is broken the connection never upgrades (400 from L7 +# proxy) and none of the protocol steps succeed. +dc_ws_tunnel=$(sandbox_exec 'node -e " +const https = require(\"https\"); +const crypto = require(\"crypto\"); + +// --- Minimal WebSocket framing (no ws dependency) --- +function unmaskFrame(buf) { + if (buf.length < 2) return null; + const fin = (buf[0] & 0x80) !== 0; + const opcode = buf[0] & 0x0f; + const masked = (buf[1] & 0x80) !== 0; + let payloadLen = buf[1] & 0x7f; + let offset = 2; + if (payloadLen === 126) { + if (buf.length < 4) return null; + payloadLen = buf.readUInt16BE(2); + offset = 4; + } else if (payloadLen === 127) { + if (buf.length < 10) return null; + payloadLen = Number(buf.readBigUInt64BE(2)); + offset = 10; + } + if (masked) offset += 4; + if (buf.length < offset + payloadLen) return null; + const data = buf.slice(offset, offset + payloadLen); + return { fin, opcode, data, totalLen: offset + payloadLen }; +} + +function makeFrame(opcode, payload) { + const buf = Buffer.from(payload); + const mask = crypto.randomBytes(4); + const masked = Buffer.alloc(buf.length); + for (let i = 0; i < buf.length; i++) masked[i] = buf[i] ^ mask[i % 4]; + let header; + if (buf.length < 126) { + header = Buffer.alloc(6); + header[0] = 0x80 | opcode; + header[1] = 0x80 | buf.length; + mask.copy(header, 2); + } else { + header = Buffer.alloc(8); + header[0] = 0x80 | opcode; + header[1] = 0x80 | 126; + header.writeUInt16BE(buf.length, 2); + mask.copy(header, 4); + } + return Buffer.concat([header, masked]); +} + +function makeCloseFrame(code) { + const payload = Buffer.alloc(2); + payload.writeUInt16BE(code, 0); + return makeFrame(8, payload); +} + +// --- Handshake --- +const results = []; +const done = () => { + console.log(results.join(\"\\n\")); + process.exit(0); +}; +const timer = setTimeout(() => { results.push(\"TIMEOUT\"); done(); }, 20000); + +const key = crypto.randomBytes(16).toString(\"base64\"); +const req = https.request({ + hostname: \"gateway.discord.gg\", + port: 443, + path: \"/?v=10&encoding=json\", + method: \"GET\", + headers: { + \"Connection\": \"Upgrade\", + \"Upgrade\": \"websocket\", + \"Sec-WebSocket-Key\": key, + \"Sec-WebSocket-Version\": \"13\", + }, +}); + +req.on(\"upgrade\", (_res, socket, head) => { + results.push(\"UPGRADED\"); + let pending = head && head.length ? Buffer.from(head) : Buffer.alloc(0); + + socket.on(\"data\", (chunk) => { + pending = Buffer.concat([pending, chunk]); + while (true) { + const frame = unmaskFrame(pending); + if (!frame) break; + pending = pending.slice(frame.totalLen); + + if (frame.opcode === 1) { + let msg; + try { msg = JSON.parse(frame.data.toString()); } catch { continue; } + + if (msg.op === 10) { + const hbInterval = msg.d && msg.d.heartbeat_interval; + results.push(\"HELLO op=10 heartbeat_interval=\" + hbInterval); + + // Send Heartbeat (opcode 1, d: null) + const hb = JSON.stringify({ op: 1, d: null }); + socket.write(makeFrame(1, hb)); + results.push(\"SENT_HEARTBEAT op=1\"); + } else if (msg.op === 11) { + results.push(\"HEARTBEAT_ACK op=11\"); + // Full round-trip complete — close cleanly + socket.write(makeCloseFrame(1000)); + setTimeout(() => { socket.destroy(); clearTimeout(timer); done(); }, 500); + } + } else if (frame.opcode === 8) { + results.push(\"CLOSE_FRAME code=\" + (frame.data.length >= 2 ? frame.data.readUInt16BE(0) : \"none\")); + socket.destroy(); + clearTimeout(timer); + done(); + } + } + }); + + socket.on(\"error\", (e) => { results.push(\"SOCKET_ERROR \" + e.message); }); + socket.on(\"close\", () => { clearTimeout(timer); done(); }); +}); + +req.on(\"response\", (res) => { + results.push(\"HTTP_\" + res.statusCode); + res.resume(); + res.on(\"end\", () => { clearTimeout(timer); done(); }); +}); +req.on(\"error\", (e) => { + results.push(\"ERROR \" + e.message); + clearTimeout(timer); + done(); +}); +req.end(); +"' 2>/dev/null || true) + +info "Discord ws-proxy-fix probe: ${dc_ws_tunnel:0:500}" + +# Check each step of the handshake independently +if echo "$dc_ws_tunnel" | grep -q "UPGRADED"; then + pass "M13c: WebSocket upgrade succeeded via CONNECT tunnel (#1570)" +elif echo "$dc_ws_tunnel" | grep -q "HTTP_400"; then + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13c: Discord gateway got 400 — CONNECT tunnel not working" + else + skip "M13c: Discord gateway got 400 — ws-proxy-fix may not be active" + fi +elif echo "$dc_ws_tunnel" | grep -qiE "EAI_AGAIN|getaddrinfo"; then + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13c: Discord gateway DNS failure (${dc_ws_tunnel:0:200})" + else + skip "M13c: Discord gateway DNS failure (${dc_ws_tunnel:0:200})" + fi +elif echo "$dc_ws_tunnel" | grep -q "TIMEOUT"; then + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13c: Discord gateway CONNECT tunnel timed out" + else + skip "M13c: Discord gateway CONNECT tunnel timed out" + fi +elif echo "$dc_ws_tunnel" | grep -q "ERROR"; then + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13c: Discord gateway CONNECT tunnel failed (${dc_ws_tunnel:0:200})" + else + skip "M13c: Discord gateway CONNECT tunnel failed (${dc_ws_tunnel:0:200})" + fi +else + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13c: Discord gateway returned unclassified result (${dc_ws_tunnel:0:200})" + else + skip "M13c: Discord gateway returned unclassified result (${dc_ws_tunnel:0:200})" + fi +fi + +if echo "$dc_ws_tunnel" | grep -q "HELLO op=10"; then + pass "M13d: Received Discord Hello (opcode 10) with heartbeat interval" +elif echo "$dc_ws_tunnel" | grep -q "UPGRADED"; then + fail "M13d: Upgraded but never received Discord Hello" +else + skip "M13d: WebSocket upgrade did not complete" +fi + +if echo "$dc_ws_tunnel" | grep -q "HEARTBEAT_ACK op=11"; then + pass "M13e: Sent Heartbeat, received ACK (opcode 11) — full round-trip verified" +elif echo "$dc_ws_tunnel" | grep -q "SENT_HEARTBEAT"; then + fail "M13e: Sent Heartbeat but never received ACK" +else + skip "M13e: Heartbeat exchange did not occur" +fi + # M14 (negative): curl should be blocked by binary restriction curl_reach=$(sandbox_exec "curl -s --max-time 10 https://api.telegram.org/ 2>&1" 2>/dev/null || true) if echo "$curl_reach" | grep -qiE "(blocked|denied|forbidden|refused|not found|no such)"; then diff --git a/test/service-env.test.ts b/test/service-env.test.ts index 5b229bb8c0a..5cce25e1034 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -632,6 +632,7 @@ describe("service environment", () => { `_NO_PROXY_VAL="localhost,127.0.0.1,::1,\${PROXY_HOST}"`, `NODE_USE_ENV_PROXY=1`, `_AXIOS_FIX_SCRIPT="${fakeFixScript}"`, + `_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"`, `_TOOL_REDIRECTS=()`, "set +u # array expansion safe on macOS bash", persistBlock @@ -682,6 +683,7 @@ describe("service environment", () => { `_NO_PROXY_VAL="localhost,127.0.0.1,::1,\${PROXY_HOST}"`, // NODE_USE_ENV_PROXY intentionally NOT set `_AXIOS_FIX_SCRIPT="${fakeFixScript}"`, + `_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"`, `_TOOL_REDIRECTS=()`, "set +u # array expansion safe on macOS bash", persistBlock @@ -694,8 +696,10 @@ describe("service environment", () => { const envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); // NODE_OPTIONS preload should NOT be injected when NODE_USE_ENV_PROXY is not 1 + // and ws fix script does not exist expect(envFile).not.toContain("--require"); expect(envFile).not.toContain("axios-proxy-fix"); + expect(envFile).not.toContain("ws-proxy-fix"); } finally { try { execFileSync("rm", ["-rf", fakeDataDir, tmpFile]); @@ -704,5 +708,219 @@ describe("service environment", () => { } } }); + + it("NemoClaw#1570: proxy-env.sh includes ws-proxy-fix NODE_OPTIONS when fix script exists", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-ws-fix-test-${process.pid}`); + const fakeWsFixScript = join(fakeDataDir, "ws-proxy-fix.js"); + execFileSync("mkdir", ["-p", fakeDataDir]); + const tmpFile = join(tmpdir(), `nemoclaw-ws-fix-env-${process.pid}.sh`); + try { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const persistBlock = execFileSync( + "sed", + ["-n", "/^_PROXY_ENV_FILE=/,/emit_sandbox_sourced_file/p", scriptPath], + { encoding: "utf-8" }, + ); + if (!persistBlock.trim()) { + throw new Error( + "sed anchors (_PROXY_ENV_FILE…emit_sandbox_sourced_file) not found in nemoclaw-start.sh — test cannot run", + ); + } + const emitHelper = extractEmitHelper(); + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + emitHelper, + `PROXY_HOST="10.200.0.1"`, + `PROXY_PORT="3128"`, + `_PROXY_URL="http://\${PROXY_HOST}:\${PROXY_PORT}"`, + `_NO_PROXY_VAL="localhost,127.0.0.1,::1,\${PROXY_HOST}"`, + `_AXIOS_FIX_SCRIPT="/nonexistent/axios-proxy-fix.js"`, + `_WS_FIX_SCRIPT="${fakeWsFixScript}"`, + `_TOOL_REDIRECTS=()`, + "set +u # array expansion safe on macOS bash", + persistBlock + .trimEnd() + .replaceAll("/tmp/nemoclaw-proxy-env.sh", `${fakeDataDir}/proxy-env.sh`), + ].join("\n"); + writeFileSync(fakeWsFixScript, "// fake", { mode: 0o644 }); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + + const envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); + expect(envFile).toContain("NODE_OPTIONS"); + expect(envFile).toContain("--require"); + expect(envFile).toContain(fakeWsFixScript); + } finally { + try { + execFileSync("rm", ["-rf", fakeDataDir, tmpFile]); + } catch { + /* ignore */ + } + } + }); + + it("NemoClaw#1570: proxy-env.sh omits ws-proxy-fix when script does not exist", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-ws-noop-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeDataDir]); + const tmpFile = join(tmpdir(), `nemoclaw-ws-noop-env-${process.pid}.sh`); + try { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const persistBlock = execFileSync( + "sed", + ["-n", "/^_PROXY_ENV_FILE=/,/emit_sandbox_sourced_file/p", scriptPath], + { encoding: "utf-8" }, + ); + if (!persistBlock.trim()) { + throw new Error("sed anchors not found in nemoclaw-start.sh — test cannot run"); + } + const emitHelper = extractEmitHelper(); + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + emitHelper, + `PROXY_HOST="10.200.0.1"`, + `PROXY_PORT="3128"`, + `_PROXY_URL="http://\${PROXY_HOST}:\${PROXY_PORT}"`, + `_NO_PROXY_VAL="localhost,127.0.0.1,::1,\${PROXY_HOST}"`, + `_AXIOS_FIX_SCRIPT="/nonexistent/axios-proxy-fix.js"`, + `_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"`, + `_TOOL_REDIRECTS=()`, + "set +u # array expansion safe on macOS bash", + persistBlock + .trimEnd() + .replaceAll("/tmp/nemoclaw-proxy-env.sh", `${fakeDataDir}/proxy-env.sh`), + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + + const envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); + expect(envFile).not.toContain("ws-proxy-fix"); + } finally { + try { + execFileSync("rm", ["-rf", fakeDataDir, tmpFile]); + } catch { + /* ignore */ + } + } + }); + }); + + describe("ws-proxy-fix preload (issue #1570)", () => { + const wsFixPath = join(import.meta.dirname, "../nemoclaw-blueprint/scripts/ws-proxy-fix.js"); + + it("patches https.request when HTTPS_PROXY is set", () => { + const result = execFileSync( + "node", + ["--require", wsFixPath, "-e", "console.log(require('https').request.name)"], + { + encoding: "utf-8", + env: { ...process.env, HTTPS_PROXY: "http://10.200.0.1:3128" }, + }, + ).trim(); + expect(result).toBe("wsProxyFixedRequest"); + }); + + it("is a no-op when HTTPS_PROXY is unset", () => { + const env = { ...process.env }; + delete env.HTTPS_PROXY; + delete env.https_proxy; + const result = execFileSync( + "node", + ["--require", wsFixPath, "-e", "console.log(require('https').request.name)"], + { encoding: "utf-8", env }, + ).trim(); + expect(result).not.toBe("wsProxyFixedRequest"); + }); + + it("is idempotent — loading twice does not double-patch", () => { + const result = execFileSync( + "node", + [ + "--require", + wsFixPath, + "-e", + `require("${wsFixPath}"); console.log(require('https').request.name)`, + ], + { + encoding: "utf-8", + env: { ...process.env, HTTPS_PROXY: "http://10.200.0.1:3128" }, + }, + ).trim(); + expect(result).toBe("wsProxyFixedRequest"); + }); + + it("strips port from opts.host to avoid double-port CONNECT path", () => { + // When callers pass host:"gateway.discord.gg:443" instead of hostname, + // the CONNECT target must be "gateway.discord.gg:443" not + // "gateway.discord.gg:443:443". + const result = execFileSync( + "node", + [ + "--require", + wsFixPath, + "-e", + ` +const https = require("https"); +const http = require("http"); +// Intercept http.request to capture the CONNECT path, then abort immediately +http.request = function(opts) { + if (opts.method === "CONNECT") { + console.log(opts.path); + process.exit(0); + } + return http.__proto__.request.apply(this, arguments); +}; +const req = https.request({ + host: "gateway.discord.gg:443", + path: "/?v=10&encoding=json", + headers: { Connection: "Upgrade", Upgrade: "websocket", "Sec-WebSocket-Key": "dGVzdA==", "Sec-WebSocket-Version": "13" }, +}); +req.on("error", () => {}); +req.end(); + `, + ], + { + encoding: "utf-8", + env: { ...process.env, HTTPS_PROXY: "http://10.200.0.1:3128" }, + }, + ).trim(); + expect(result).toBe("gateway.discord.gg:443"); + expect(result).not.toContain("443:443"); + }); + + it("ignores non-Discord WebSocket upgrades", () => { + const result = execFileSync( + "node", + [ + "--require", + wsFixPath, + "-e", + ` +const https = require("https"); +const http = require("http"); +let sawConnect = false; +http.request = function(opts) { + if (opts.method === "CONNECT") sawConnect = true; + return http.__proto__.request.apply(this, arguments); +}; +const req = https.request({ + hostname: "echo.websocket.org", + path: "/", + headers: { Connection: "Upgrade", Upgrade: "websocket", "Sec-WebSocket-Key": "dGVzdA==", "Sec-WebSocket-Version": "13" }, +}); +req.on("error", () => {}); +req.destroy(); +console.log(sawConnect ? "CONNECT" : "NO_CONNECT"); + `, + ], + { + encoding: "utf-8", + env: { ...process.env, HTTPS_PROXY: "http://10.200.0.1:3128" }, + }, + ).trim(); + // Non-Discord host should NOT trigger the CONNECT tunnel + expect(result).toBe("NO_CONNECT"); + }); }); }); diff --git a/tsconfig.cli.json b/tsconfig.cli.json index 6fe53d6f789..dae891bde31 100644 --- a/tsconfig.cli.json +++ b/tsconfig.cli.json @@ -15,6 +15,6 @@ "moduleDetection": "force", "types": ["node"] }, - "include": ["bin/**/*.ts", "scripts/**/*.ts", "src/**/*.ts", "test/**/*.ts"], + "include": ["bin/**/*.ts", "scripts/**/*.ts", "src/**/*.ts", "test/**/*.ts", "nemoclaw-blueprint/scripts/**/*.ts"], "exclude": ["node_modules", "nemoclaw"] }