From 4e4c7070423b396835f372ef455b9077bd16741a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 22 Apr 2026 12:34:17 -0700 Subject: [PATCH 01/11] fix(sandbox): add WebSocket CONNECT tunnel preload for Discord gateway (#1570) Node.js EnvHttpProxyAgent sends forward proxy requests instead of CONNECT for HTTPS WebSocket upgrades, causing the OpenShell L7 proxy to reject Discord gateway connections with 400. Add a --require preload script that patches https.request() to detect Upgrade: websocket headers and inject a proper CONNECT tunnel agent. Non-WebSocket HTTPS requests pass through unchanged. Belt-and-suspenders: works regardless of upstream OpenClaw changes. If the caller provides a custom agent, the preload steps aside. --- .gitignore | 1 + nemoclaw-blueprint/scripts/ws-proxy-fix.ts | 210 +++++++++++++++++++++ nemoclaw-blueprint/tsconfig.json | 15 ++ package.json | 3 +- scripts/nemoclaw-start.sh | 17 ++ test/service-env.test.ts | 145 ++++++++++++++ tsconfig.cli.json | 2 +- 7 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 nemoclaw-blueprint/scripts/ws-proxy-fix.ts create mode 100644 nemoclaw-blueprint/tsconfig.json diff --git a/.gitignore b/.gitignore index 10836b71279..84b825b5138 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage/ dist/ docs/_build/ node_modules/ +nemoclaw-blueprint/scripts/ws-proxy-fix.js # OS metadata .DS_Store diff --git a/nemoclaw-blueprint/scripts/ws-proxy-fix.ts b/nemoclaw-blueprint/scripts/ws-proxy-fix.ts new file mode 100644 index 00000000000..50d2681bce3 --- /dev/null +++ b/nemoclaw-blueprint/scripts/ws-proxy-fix.ts @@ -0,0 +1,210 @@ +// 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 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 (Upgrade: websocket +// header) and inject an agent that issues a proper CONNECT request to the proxy, +// then upgrades the tunnel socket to TLS. Non-WebSocket HTTPS requests pass +// through unchanged. +// +// 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) => { + if (_res.statusCode !== 200) { + socket.destroy(); + callback( + new Error( + `ws-proxy-fix: CONNECT ${targetHost}:${targetPort} via proxy failed (${_res.statusCode})`, + ), + ); + return; + } + const tlsSocket = tls.connect({ + socket, + servername: (options.servername as string) || targetHost, + }); + callback(null, tlsSocket); + }, + ); + + connectReq.on("error", (err: Error) => 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; + } + + // ---------- Header check ------------------------------------------------ + + /** + * Check whether a headers object contains `Upgrade: websocket` + * (case-insensitive keys and values). + */ + function isWsUpgrade( + headers: http.OutgoingHttpHeaders | undefined, + ): boolean { + if (!headers || typeof headers !== "object") 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; + } + + if (isWsUpgrade(opts.headers)) { + // WebSocket upgrade detected — inject CONNECT tunnel agent unless the + // caller already provides a custom (non-default) agent. A custom agent + // means the upstream (OpenClaw) has its own proxy handling; step aside. + if (!opts.agent || opts.agent === https.globalAgent) { + const host = opts.hostname || opts.host || "localhost"; + 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..5bda60e4fd1 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "format:check": "prettier --check 'bin/**/*.js' 'scripts/**/*.ts' 'test/**/*.{js,ts}'", "typecheck": "tsc -p jsconfig.json", "build:cli": "tsc -p tsconfig.src.json", + "build:blueprint": "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", @@ -22,7 +23,7 @@ "ts-migration:guard": "tsx scripts/check-legacy-migrated-paths.ts", "type-safety:hotspots": "tsx scripts/type-safety-hotspots.ts", "bump:version": "tsx scripts/bump-version.ts", - "prepare": "if command -v tsc >/dev/null 2>&1 || [ -x node_modules/.bin/tsc ]; then npm run build:cli; fi && (npm install --omit=dev --ignore-scripts 2>/dev/null || true) && if [ -d .git ]; then if [ -z \"${NEMOCLAW_INSTALLING:-}\" ]; then npm link 2>/dev/null || true; fi; if command -v prek >/dev/null 2>&1; then prek install; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi", + "prepare": "if command -v tsc >/dev/null 2>&1 || [ -x node_modules/.bin/tsc ]; then npm run build:cli && npm run build:blueprint; fi && (npm install --omit=dev --ignore-scripts 2>/dev/null || true) && if [ -d .git ]; then if [ -z \"${NEMOCLAW_INSTALLING:-}\" ]; then npm link 2>/dev/null || true; fi; if command -v prek >/dev/null 2>&1; then prek install; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi", "prepublishOnly": "git describe --tags --match 'v*' | sed 's/^v//' > .version && test -s .version && cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc" }, "dependencies": { 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/service-env.test.ts b/test/service-env.test.ts index 5b229bb8c0a..bbcbacba11d 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,146 @@ 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"); + }); }); }); 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"] } From 1a836bd86652845772292447c95042c46a667b3e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 22 Apr 2026 12:49:59 -0700 Subject: [PATCH 02/11] ci(checks): add build:blueprint step to basic-checks action The ws-proxy-fix preload tests need the compiled .js file, but CI uses `npm install --ignore-scripts` which skips `prepare` and never runs `tsc -p nemoclaw-blueprint/tsconfig.json`. --- .github/actions/basic-checks/action.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/actions/basic-checks/action.yaml b/.github/actions/basic-checks/action.yaml index df509fd7ef2..14fd7f361e0 100644 --- a/.github/actions/basic-checks/action.yaml +++ b/.github/actions/basic-checks/action.yaml @@ -38,6 +38,10 @@ runs: shell: bash run: npm run build:cli + - name: Build blueprint TypeScript modules + shell: bash + run: npm run build:blueprint + - name: Validate config schemas shell: bash run: npm run validate:configs From bae5df2e64eb263ccc9fa53a7a9fcdfbdf898e80 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 22 Apr 2026 12:51:24 -0700 Subject: [PATCH 03/11] Revert "ci(checks): add build:blueprint step to basic-checks action" This reverts commit 1a836bd86652845772292447c95042c46a667b3e. --- .github/actions/basic-checks/action.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/actions/basic-checks/action.yaml b/.github/actions/basic-checks/action.yaml index 14fd7f361e0..df509fd7ef2 100644 --- a/.github/actions/basic-checks/action.yaml +++ b/.github/actions/basic-checks/action.yaml @@ -38,10 +38,6 @@ runs: shell: bash run: npm run build:cli - - name: Build blueprint TypeScript modules - shell: bash - run: npm run build:blueprint - - name: Validate config schemas shell: bash run: npm run validate:configs From 9417fe18be5a84f4a905be1cf49e7d5075d29513 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 22 Apr 2026 12:55:52 -0700 Subject: [PATCH 04/11] fix(ci): fold blueprint tsc into build:cli so CI compiles ws-proxy-fix The ws-proxy-fix preload tests need the compiled .js output but CI runs `npm run build:cli`, not the separate `build:blueprint`. Chain the blueprint tsc into build:cli so the existing CI step covers it. --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 5bda60e4fd1..21272914f1a 100644 --- a/package.json +++ b/package.json @@ -13,8 +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:blueprint": "tsc -p nemoclaw-blueprint/tsconfig.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", @@ -23,7 +22,7 @@ "ts-migration:guard": "tsx scripts/check-legacy-migrated-paths.ts", "type-safety:hotspots": "tsx scripts/type-safety-hotspots.ts", "bump:version": "tsx scripts/bump-version.ts", - "prepare": "if command -v tsc >/dev/null 2>&1 || [ -x node_modules/.bin/tsc ]; then npm run build:cli && npm run build:blueprint; fi && (npm install --omit=dev --ignore-scripts 2>/dev/null || true) && if [ -d .git ]; then if [ -z \"${NEMOCLAW_INSTALLING:-}\" ]; then npm link 2>/dev/null || true; fi; if command -v prek >/dev/null 2>&1; then prek install; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi", + "prepare": "if command -v tsc >/dev/null 2>&1 || [ -x node_modules/.bin/tsc ]; then npm run build:cli; fi && (npm install --omit=dev --ignore-scripts 2>/dev/null || true) && if [ -d .git ]; then if [ -z \"${NEMOCLAW_INSTALLING:-}\" ]; then npm link 2>/dev/null || true; fi; if command -v prek >/dev/null 2>&1; then prek install; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi", "prepublishOnly": "git describe --tags --match 'v*' | sed 's/^v//' > .version && test -s .version && cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc" }, "dependencies": { From 45e4525118cec1724f509ad4cffbe55531eba6ba Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 22 Apr 2026 13:01:01 -0700 Subject: [PATCH 05/11] fix(sandbox): scope ws-proxy-fix to Discord gateway hosts only The preload was monkey-patching https.request() globally for all WebSocket upgrades. Narrow it to *.discord.gg so non-Discord traffic is never touched. --- nemoclaw-blueprint/scripts/ws-proxy-fix.ts | 37 ++++++++++++---------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/nemoclaw-blueprint/scripts/ws-proxy-fix.ts b/nemoclaw-blueprint/scripts/ws-proxy-fix.ts index 50d2681bce3..cba1ac0683d 100644 --- a/nemoclaw-blueprint/scripts/ws-proxy-fix.ts +++ b/nemoclaw-blueprint/scripts/ws-proxy-fix.ts @@ -1,8 +1,8 @@ // 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 WebSocket connections through the -// OpenShell L7 proxy when HTTPS_PROXY is set. +// 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) @@ -16,10 +16,11 @@ // loops on close code 1006. // // Fix: -// Patch https.request() to detect WebSocket upgrade requests (Upgrade: websocket -// header) and inject an agent that issues a proper CONNECT request to the proxy, -// then upgrades the tunnel socket to TLS. Non-WebSocket HTTPS requests pass -// through unchanged. +// 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. // @@ -124,16 +125,19 @@ interface ReqOpts extends https.RequestOptions { return agent; } - // ---------- Header check ------------------------------------------------ + // ---------- Target check ------------------------------------------------- /** - * Check whether a headers object contains `Upgrade: websocket` - * (case-insensitive keys and values). + * Return true only for WebSocket upgrade requests targeting Discord + * gateway hosts (gateway.discord.gg and regional variants). */ - function isWsUpgrade( + function isDiscordWsUpgrade( + host: string | undefined, headers: http.OutgoingHttpHeaders | undefined, ): boolean { - if (!headers || typeof headers !== "object") return false; + 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" && @@ -184,14 +188,13 @@ interface ReqOpts extends https.RequestOptions { cb = typeof options === "function" ? options : callback; } - if (isWsUpgrade(opts.headers)) { - // WebSocket upgrade detected — inject CONNECT tunnel agent unless the - // caller already provides a custom (non-default) agent. A custom agent - // means the upstream (OpenClaw) has its own proxy handling; step aside. + const host = opts.hostname || opts.host || undefined; + 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 host = opts.hostname || opts.host || "localhost"; const port = parseInt(String(opts.port), 10) || 443; - opts = { ...opts, agent: createTunnelAgent(host, port) }; + opts = { ...opts, agent: createTunnelAgent(host!, port) }; } return origRequest.call(https, opts, cb); } From 10cdc288c7c7818b5191c66d01813e64f398d1d7 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 22 Apr 2026 13:05:11 -0700 Subject: [PATCH 06/11] =?UTF-8?q?test(e2e):=20add=20M13c=20=E2=80=94=20Dis?= =?UTF-8?q?cord=20gateway=20CONNECT=20tunnel=20probe=20(#1570)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercises the exact code path the ws library uses: https.request with Upgrade: websocket headers to gateway.discord.gg. Verifies the ws-proxy-fix preload issues a CONNECT tunnel that results in an HTTP 101 upgrade rather than a 400 from the L7 proxy. --- test/e2e/test-messaging-providers.sh | 84 ++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index a7046cdf5ef..3a9c40d6b10 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -678,6 +678,90 @@ else fi fi +# M13c: Verify the ws-proxy-fix CONNECT tunnel preload (#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 that exact +# code path — an https.request to gateway.discord.gg with upgrade headers — +# and checks that the connection succeeds (HTTP 101 upgrade) rather than +# getting a 400 from the L7 proxy or a network block. +dc_ws_tunnel=$(sandbox_exec 'node -e " +const https = require(\"https\"); +const crypto = require(\"crypto\"); +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) => { + let buf = \"\"; + socket.on(\"data\", (chunk) => { + buf += chunk.toString(); + if (buf.length > 10) { + console.log(\"UPGRADE \" + buf.slice(0, 200).replace(/[\\x00-\\x1f]+/g, \" \")); + socket.destroy(); + } + }); + setTimeout(() => { + if (!socket.destroyed) { + console.log(\"UPGRADE (no data)\"); + socket.destroy(); + } + }, 5000); +}); +req.on(\"response\", (res) => { + console.log(\"HTTP_\" + res.statusCode); + res.resume(); +}); +req.on(\"error\", (e) => console.log(\"ERROR \" + e.message)); +req.setTimeout(15000, () => { req.destroy(); console.log(\"TIMEOUT\"); }); +req.end(); +"' 2>/dev/null || true) + +info "Discord ws-proxy-fix probe: ${dc_ws_tunnel:0:300}" + +if echo "$dc_ws_tunnel" | grep -q "UPGRADE"; then + pass "M13c: Discord gateway CONNECT tunnel succeeded (ws-proxy-fix #1570)" +elif echo "$dc_ws_tunnel" | grep -q "HTTP_400"; then + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13c: Discord gateway got 400 — ws-proxy-fix CONNECT tunnel not working" + else + skip "M13c: Discord gateway got 400 — ws-proxy-fix may not be active (${dc_ws_tunnel:0:200})" + fi +elif echo "$dc_ws_tunnel" | grep -qiE "EAI_AGAIN|getaddrinfo"; then + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13c: Discord gateway DNS resolution failure (${dc_ws_tunnel:0:200})" + else + skip "M13c: Discord gateway DNS resolution 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 CONNECT tunnel returned unclassified result (${dc_ws_tunnel:0:200})" + else + skip "M13c: Discord gateway CONNECT tunnel returned unclassified result (${dc_ws_tunnel:0:200})" + fi +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 From e063db898c39b9b7f36bdc97de25af85b313c549 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 22 Apr 2026 13:07:43 -0700 Subject: [PATCH 07/11] =?UTF-8?q?test(e2e):=20M13c-e=20=E2=80=94=20full=20?= =?UTF-8?q?Discord=20gateway=20protocol=20round-trip=20(#1570)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the minimal upgrade probe with a complete gateway handshake: connect via CONNECT tunnel, receive Hello (op 10), send Heartbeat (op 1), receive Heartbeat ACK (op 11), close cleanly. Uses raw WebSocket framing over https.request — the exact code path ws uses — with no external dependencies. Three separate assertions (M13c/d/e) verify each stage independently. --- test/e2e/test-messaging-providers.sh | 170 ++++++++++++++++++++++----- 1 file changed, 140 insertions(+), 30 deletions(-) diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 3a9c40d6b10..0f41df5d895 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -678,16 +678,82 @@ else fi fi -# M13c: Verify the ws-proxy-fix CONNECT tunnel preload (#1570). +# 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 that exact -# code path — an https.request to gateway.discord.gg with upgrade headers — -# and checks that the connection succeeds (HTTP 101 upgrade) rather than -# getting a 400 from the L7 proxy or a network block. +# 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\", @@ -701,46 +767,78 @@ const req = https.request({ \"Sec-WebSocket-Version\": \"13\", }, }); -req.on(\"upgrade\", (res, socket) => { - let buf = \"\"; + +req.on(\"upgrade\", (_res, socket) => { + results.push(\"UPGRADED\"); + let pending = Buffer.alloc(0); + socket.on(\"data\", (chunk) => { - buf += chunk.toString(); - if (buf.length > 10) { - console.log(\"UPGRADE \" + buf.slice(0, 200).replace(/[\\x00-\\x1f]+/g, \" \")); - socket.destroy(); + 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(); + } } }); - setTimeout(() => { - if (!socket.destroyed) { - console.log(\"UPGRADE (no data)\"); - socket.destroy(); - } - }, 5000); + + socket.on(\"error\", (e) => { results.push(\"SOCKET_ERROR \" + e.message); }); + socket.on(\"close\", () => { clearTimeout(timer); done(); }); }); + req.on(\"response\", (res) => { - console.log(\"HTTP_\" + res.statusCode); + 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.on(\"error\", (e) => console.log(\"ERROR \" + e.message)); -req.setTimeout(15000, () => { req.destroy(); console.log(\"TIMEOUT\"); }); req.end(); "' 2>/dev/null || true) -info "Discord ws-proxy-fix probe: ${dc_ws_tunnel:0:300}" +info "Discord ws-proxy-fix probe: ${dc_ws_tunnel:0:500}" -if echo "$dc_ws_tunnel" | grep -q "UPGRADE"; then - pass "M13c: Discord gateway CONNECT tunnel succeeded (ws-proxy-fix #1570)" +# 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 — ws-proxy-fix CONNECT tunnel not working" + fail "M13c: Discord gateway got 400 — CONNECT tunnel not working" else - skip "M13c: Discord gateway got 400 — ws-proxy-fix may not be active (${dc_ws_tunnel:0:200})" + 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 resolution failure (${dc_ws_tunnel:0:200})" + fail "M13c: Discord gateway DNS failure (${dc_ws_tunnel:0:200})" else - skip "M13c: Discord gateway DNS resolution failure (${dc_ws_tunnel:0:200})" + 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 @@ -756,12 +854,24 @@ elif echo "$dc_ws_tunnel" | grep -q "ERROR"; then fi else if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then - fail "M13c: Discord gateway CONNECT tunnel returned unclassified result (${dc_ws_tunnel:0:200})" + fail "M13c: Discord gateway returned unclassified result (${dc_ws_tunnel:0:200})" else - skip "M13c: Discord gateway CONNECT tunnel returned unclassified result (${dc_ws_tunnel:0:200})" + 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" +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" +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 From 2c14c332d7d3fde9d1d3e60499e96352be5b3c85 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 22 Apr 2026 13:15:21 -0700 Subject: [PATCH 08/11] fix(sandbox): address CodeRabbit review feedback - Destroy socket on CONNECT error to prevent resource leaks - Strip port from opts.host to avoid double-port in CONNECT path (e.g. gateway.discord.gg:443:443) - Handle upgrade event head buffer in e2e test to capture initial bytes that may contain the Hello frame --- nemoclaw-blueprint/scripts/ws-proxy-fix.ts | 12 ++++++++++-- test/e2e/test-messaging-providers.sh | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/nemoclaw-blueprint/scripts/ws-proxy-fix.ts b/nemoclaw-blueprint/scripts/ws-proxy-fix.ts index cba1ac0683d..a34cbee7d76 100644 --- a/nemoclaw-blueprint/scripts/ws-proxy-fix.ts +++ b/nemoclaw-blueprint/scripts/ws-proxy-fix.ts @@ -114,7 +114,10 @@ interface ReqOpts extends https.RequestOptions { }, ); - connectReq.on("error", (err: Error) => callback(err)); + connectReq.on("error", (err: Error) => { + connectReq.destroy(); + callback(err); + }); connectReq.end(); // createConnection expects a synchronous return; the real socket arrives @@ -188,7 +191,12 @@ interface ReqOpts extends https.RequestOptions { cb = typeof options === "function" ? options : callback; } - const host = opts.hostname || opts.host || undefined; + // 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. diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 0f41df5d895..367b03590fb 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -768,9 +768,9 @@ const req = https.request({ }, }); -req.on(\"upgrade\", (_res, socket) => { +req.on(\"upgrade\", (_res, socket, head) => { results.push(\"UPGRADED\"); - let pending = Buffer.alloc(0); + let pending = head && head.length ? Buffer.from(head) : Buffer.alloc(0); socket.on(\"data\", (chunk) => { pending = Buffer.concat([pending, chunk]); From 7690a6255bca205c4f3d267aca59886f0f1e5d90 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 22 Apr 2026 13:50:52 -0700 Subject: [PATCH 09/11] fix(sandbox): track compiled ws-proxy-fix.js and add regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dockerfile does a raw COPY of nemoclaw-blueprint/ — the compiled .js was gitignored so it never shipped to the sandbox image. Remove the .gitignore entry and track the compiled output. Add regression tests verifying: - opts.host with port (gateway.discord.gg:443) produces correct CONNECT path without double-port - non-Discord WebSocket upgrades are not intercepted --- .gitignore | 1 - nemoclaw-blueprint/scripts/ws-proxy-fix.js | 173 +++++++++++++++++++++ test/service-env.test.ts | 73 +++++++++ 3 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 nemoclaw-blueprint/scripts/ws-proxy-fix.js diff --git a/.gitignore b/.gitignore index 84b825b5138..10836b71279 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ coverage/ dist/ docs/_build/ node_modules/ -nemoclaw-blueprint/scripts/ws-proxy-fix.js # OS metadata .DS_Store diff --git a/nemoclaw-blueprint/scripts/ws-proxy-fix.js b/nemoclaw-blueprint/scripts/ws-proxy-fix.js new file mode 100644 index 00000000000..20ede357274 --- /dev/null +++ b/nemoclaw-blueprint/scripts/ws-proxy-fix.js @@ -0,0 +1,173 @@ +"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) => { + if (_res.statusCode !== 200) { + socket.destroy(); + callback(new Error(`ws-proxy-fix: CONNECT ${targetHost}:${targetPort} via proxy failed (${_res.statusCode})`)); + return; + } + 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/test/service-env.test.ts b/test/service-env.test.ts index bbcbacba11d..5cce25e1034 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -849,5 +849,78 @@ describe("service environment", () => { ).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"); + }); }); }); From 771450157dfe3d93848f17c4ecbde2efd3da36e7 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 22 Apr 2026 14:08:17 -0700 Subject: [PATCH 10/11] fix(sandbox): preserve CONNECT head buffer before TLS upgrade The CONNECT event's third argument (head) may contain initial bytes from the tunnel. Unshift them back into the socket before calling tls.connect() so the TLS handshake doesn't drop the first record. --- nemoclaw-blueprint/scripts/ws-proxy-fix.js | 6 +++++- nemoclaw-blueprint/scripts/ws-proxy-fix.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/nemoclaw-blueprint/scripts/ws-proxy-fix.js b/nemoclaw-blueprint/scripts/ws-proxy-fix.js index 20ede357274..80865ab1aa3 100644 --- a/nemoclaw-blueprint/scripts/ws-proxy-fix.js +++ b/nemoclaw-blueprint/scripts/ws-proxy-fix.js @@ -76,12 +76,16 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix"); path: `${targetHost}:${targetPort}`, headers: { Host: `${targetHost}:${targetPort}` }, }); - connectReq.on("connect", (_res, socket) => { + 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, diff --git a/nemoclaw-blueprint/scripts/ws-proxy-fix.ts b/nemoclaw-blueprint/scripts/ws-proxy-fix.ts index a34cbee7d76..a82f3cdc952 100644 --- a/nemoclaw-blueprint/scripts/ws-proxy-fix.ts +++ b/nemoclaw-blueprint/scripts/ws-proxy-fix.ts @@ -96,7 +96,7 @@ interface ReqOpts extends https.RequestOptions { connectReq.on( "connect", - (_res: http.IncomingMessage, socket: net.Socket) => { + (_res: http.IncomingMessage, socket: net.Socket, head: Buffer) => { if (_res.statusCode !== 200) { socket.destroy(); callback( @@ -106,6 +106,10 @@ interface ReqOpts extends https.RequestOptions { ); 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, From cc0d6ef237fb2601ba3890d0ecebe5b4a8cc98cb Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 22 Apr 2026 14:11:50 -0700 Subject: [PATCH 11/11] test(e2e): emit explicit skip for M13d/M13e when upgrade fails When the WebSocket upgrade doesn't complete, M13d and M13e were silently absent from the test summary. Add else branches that emit skip markers so all three tests always appear in results. --- test/e2e/test-messaging-providers.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 367b03590fb..4d5d13614bd 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -864,12 +864,16 @@ 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