From c9a756802224fc118ba57043d5f758601350f5f7 Mon Sep 17 00:00:00 2001 From: Lucas Montiel Date: Wed, 22 Apr 2026 23:15:38 -0300 Subject: [PATCH 1/4] fix(sandbox): rewrite #2109 proxy fix as http.request wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2110's axios-only Module._load preload never fired at runtime: 1. nemoclaw-blueprint/scripts/ is excluded from the optimized sandbox build context (src/lib/sandbox-build-context.ts), so axios-proxy-fix.js was not baked into the sandbox image. 2. Adding scripts/ to the build context cache-busts the `COPY nemoclaw-blueprint/` Dockerfile layer and hangs npm ci in the k3s Docker-in-Docker build, so the delivery gap cannot be closed by expanding the context. 3. Even if the file had reached the image, intercepting require('axios') via Module._load cannot patch follow-redirects + proxy-from-env bundled as ESM in OpenClaw's dist/http-Bh-HtMAg.js — there are no require() calls to intercept. The Bot Connector reply path uses the bundled code. Replace with an http.request() wrapper — the lowest common denominator every HTTP library bottoms out at. Detect FORWARD-mode requests (hostname = proxy IP, path = full https:// URL) and rewrite them to https.request() against the real target, letting NODE_USE_ENV_PROXY handle the CONNECT tunnel correctly. Works for any HTTP client, including bundled ESM that makes no require() calls. Delivery: - nemoclaw-blueprint/scripts/http-proxy-fix.js — canonical source for review and tests. - scripts/nemoclaw-start.sh embeds the same JS inline via a heredoc, writes it to /tmp/nemoclaw-http-proxy-fix.js through emit_sandbox_sourced_file (root:root 444, symlink-safe), and loads it via NODE_OPTIONS=--require. No changes to sandbox-build-context. - test/http-proxy-fix-sync.test.ts enforces byte-for-byte equality between the heredoc and the canonical file, so future edits cannot silently diverge. - validate_tmp_permissions is invoked with the new path on both the root and non-root boot paths (the fix JS is a trust-boundary file — tampering would inject arbitrary code into every Node process via NODE_OPTIONS). Because the content ships inside nemoclaw-start.sh rather than as a separately-deployed file, the fix fires on the very first sandbox boot with no post-onboard deploy + restart dance required. Verified end-to-end on 2026-04-23: EC2 t3.large (ca-central-1), NemoClaw v0.0.22 + OpenShell v0.0.29, Node 22.22.1. Direct axios.get('https://clawhub.ai') returns 200 inside the sandbox; full Teams -> ALB -> OpenClaw -> LiteLLM/Bedrock -> Bot Connector -> Teams round-trip succeeds. No `FORWARD rejected` entries in OpenShell network logs. Comparison table and reproduction steps posted in the PR description. Scope: - Fixes the #2109 regression class (axios / follow-redirects / proxy-from-env FORWARD-mode rewrites on NODE_USE_ENV_PROXY=1). - Does NOT fix #1570 (Discord WebSocket via the ws library). That bug sits at a different layer — EnvHttpProxyAgent's FORWARD-vs- CONNECT decision for Upgrade: websocket requests — and needs the agent-swap treatment that #2296 applies. The http.request wrapper in this PR cannot safely handle that case (it would re-enter the same faulty agent logic). - Does NOT modify sandbox-build-context.ts. Removes the superseded nemoclaw-blueprint/scripts/axios-proxy-fix.js and updates the existing regression tests in service-env.test.ts to the new variable name (_PROXY_FIX_SCRIPT). Closes #2109. --- nemoclaw-blueprint/scripts/axios-proxy-fix.js | 64 -------- nemoclaw-blueprint/scripts/http-proxy-fix.js | 84 +++++++++++ scripts/nemoclaw-start.sh | 142 +++++++++++++++--- test/http-proxy-fix-sync.test.ts | 78 ++++++++++ test/service-env.test.ts | 42 +++--- 5 files changed, 307 insertions(+), 103 deletions(-) delete mode 100755 nemoclaw-blueprint/scripts/axios-proxy-fix.js create mode 100644 nemoclaw-blueprint/scripts/http-proxy-fix.js create mode 100644 test/http-proxy-fix-sync.test.ts diff --git a/nemoclaw-blueprint/scripts/axios-proxy-fix.js b/nemoclaw-blueprint/scripts/axios-proxy-fix.js deleted file mode 100755 index caecc3e21d4..00000000000 --- a/nemoclaw-blueprint/scripts/axios-proxy-fix.js +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env node -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// axios-proxy-fix.js — preload script to resolve the double-proxy conflict -// between axios and NODE_USE_ENV_PROXY=1 (Node.js 22+). -// -// Problem (NemoClaw#2109): -// When NODE_USE_ENV_PROXY=1 is set (baked into the OpenShell base image), -// Node.js 22 intercepts all https.request() calls and routes them through the -// L7 proxy via a CONNECT tunnel. axios ALSO reads HTTPS_PROXY and configures -// its own proxy — resulting in the request being processed twice: -// -// axios → proxy CONNECT to 10.200.0.1:3128 → "https://clawhub.ai:3128/" -// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -// port leaked into host → DENIED -// -// NODE_USE_ENV_PROXY alone handles the CONNECT tunnel correctly. axios's -// built-in proxy handling is redundant and conflicting. -// -// Fix: -// Intercept the first axios require() and set proxy: false on its defaults. -// Restore Module._load immediately after to avoid ongoing overhead. -// An idempotency guard prevents double-patching if the script is loaded twice. - -'use strict'; - -if (process.env.NODE_USE_ENV_PROXY !== '1') return; - -const Module = require('module'); -const _PATCHED = Symbol.for('nemoclaw.axiosProxyFix'); - -// Idempotency guard — safe if this script is required more than once -if (Module[_PATCHED]) return; -Module[_PATCHED] = 'installing'; - -const _originalLoad = Module._load; - -Module._load = function (request, _parent, _isMain) { - const result = _originalLoad.apply(this, arguments); - - if ( - (request === 'axios' || (typeof request === 'string' && request.endsWith('/axios/index.js'))) && - result && - typeof result === 'function' && - result.defaults !== undefined && - result.defaults.proxy === undefined - ) { - // Disable axios's own proxy handling so NODE_USE_ENV_PROXY handles HTTPS - // via CONNECT tunnel without double-processing the request. - result.defaults.proxy = false; - - // Restore Module._load now that axios has been patched — no ongoing overhead - Module._load = _originalLoad; - Module[_PATCHED] = 'done'; - } - - return result; -}; - -// Mark as installed (axios not yet loaded) -if (Module[_PATCHED] !== 'done') { - Module[_PATCHED] = 'installed'; -} diff --git a/nemoclaw-blueprint/scripts/http-proxy-fix.js b/nemoclaw-blueprint/scripts/http-proxy-fix.js new file mode 100644 index 00000000000..16d67ff0080 --- /dev/null +++ b/nemoclaw-blueprint/scripts/http-proxy-fix.js @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// http-proxy-fix.js — http.request() wrapper resolving the double-proxy +// conflict between NODE_USE_ENV_PROXY=1 (Node.js 22+) and HTTP libraries +// that independently read HTTPS_PROXY (axios, follow-redirects, +// proxy-from-env). See NemoClaw#2109. +// +// Problem: +// Node.js 22 with NODE_USE_ENV_PROXY=1 (baked into the OpenShell base +// image) intercepts https.request() calls and handles proxying via a +// CONNECT tunnel. HTTP libraries also read HTTPS_PROXY and configure +// HTTP FORWARD mode, so the request is processed twice and the L7 proxy +// rejects it with "FORWARD rejected: HTTPS requires CONNECT". +// +// Fix: +// Wrap http.request() — the lowest common denominator every HTTP client +// bottoms out at. Detect FORWARD-mode requests (hostname = proxy IP, +// path = full https:// URL) and rewrite them as https.request() against +// the real target host, letting NODE_USE_ENV_PROXY handle the CONNECT +// tunnel correctly. +// +// Earlier PR #2110 tried a Module._load hook intercepting require('axios'). +// That could not catch follow-redirects + proxy-from-env bundled as ESM in +// OpenClaw's dist/ — there are no require() calls to intercept. The +// http.request wrapper sits below all libraries and catches every path. +// +// This file is the canonical source for review and tests. At sandbox boot +// nemoclaw-start.sh writes an identical copy to /tmp/nemoclaw-http-proxy-fix.js +// and loads it via NODE_OPTIONS=--require. A sync test enforces byte-for-byte +// equality. The content cannot be baked into /opt/nemoclaw-blueprint/scripts/ +// because adding files to the optimized sandbox build context cache-busts the +// `COPY nemoclaw-blueprint/` Dockerfile layer and hangs npm ci in k3s +// Docker-in-Docker — see src/lib/sandbox-build-context.ts. + +(function () { + 'use strict'; + if (process.env.NODE_USE_ENV_PROXY !== '1') return; + + var http = require('http'); + var origRequest = http.request; + + var proxyUrl = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + ''; + var proxyHost = ''; + try { + proxyHost = new URL(proxyUrl).hostname; + } catch (e) { + /* no usable proxy configured */ + } + if (!proxyHost) return; + + http.request = function (options, callback) { + if (typeof options === 'string' || !options) { + return origRequest.apply(http, arguments); + } + if ( + options.hostname === proxyHost && + options.path && + options.path.startsWith('https://') + ) { + var target = new URL(options.path); + var https = require('https'); + return https.request( + { + method: options.method || 'GET', + hostname: target.hostname, + host: target.hostname, + port: target.port || 443, + path: target.pathname + target.search, + protocol: 'https:', + headers: options.headers, + timeout: options.timeout, + }, + callback, + ); + } + return origRequest.apply(http, arguments); + }; +})(); diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 4db3132b496..0ad1cd4d589 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -789,16 +789,118 @@ export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" -# axios + NODE_USE_ENV_PROXY double-proxy fix (NemoClaw#2109). +# HTTP library + NODE_USE_ENV_PROXY double-proxy fix (NemoClaw#2109). # Node.js 22 sets NODE_USE_ENV_PROXY=1 in the OpenShell base image, which -# intercepts all https.request() calls and handles proxy via CONNECT tunnel. -# axios also reads HTTPS_PROXY, causing a double-proxy conflict that produces -# malformed URLs (https://host:3128/) rejected by the L7 proxy. -# The preload script disables axios's own proxy handling so NODE_USE_ENV_PROXY -# takes over — the correct path for all other Node.js HTTP clients. -_AXIOS_FIX_SCRIPT="/opt/nemoclaw-blueprint/scripts/axios-proxy-fix.js" -if [ -f "$_AXIOS_FIX_SCRIPT" ] && [ "${NODE_USE_ENV_PROXY:-}" = "1" ]; then - export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_AXIOS_FIX_SCRIPT" +# intercepts https.request() calls and handles proxying via CONNECT tunnel. +# HTTP libraries (axios, follow-redirects, proxy-from-env) also read +# HTTPS_PROXY and configure HTTP FORWARD mode, double-processing the +# request — the L7 proxy rejects with "FORWARD rejected: HTTPS requires +# CONNECT". +# +# The preload wraps http.request() — the lowest common denominator every +# HTTP client bottoms out at — and rewrites FORWARD-mode requests back to +# https.request() so NODE_USE_ENV_PROXY can handle the CONNECT tunnel. +# +# Earlier PR #2110 intercepted require('axios') via a Module._load hook; +# that could not catch follow-redirects + proxy-from-env bundled as ESM +# in OpenClaw's dist/ (no require() calls to intercept). +# +# The JS is embedded inline rather than copied from +# nemoclaw-blueprint/scripts/http-proxy-fix.js because the blueprint +# scripts/ directory is intentionally excluded from the optimized sandbox +# build context — adding it cache-busts the `COPY nemoclaw-blueprint/` +# Dockerfile layer and hangs npm ci in k3s Docker-in-Docker. See +# src/lib/sandbox-build-context.ts. A sync test enforces that the +# embedded copy is byte-identical to the canonical file. +_PROXY_FIX_SCRIPT="/tmp/nemoclaw-http-proxy-fix.js" +if [ "${NODE_USE_ENV_PROXY:-}" = "1" ]; then + emit_sandbox_sourced_file "$_PROXY_FIX_SCRIPT" <<'HTTP_PROXY_FIX_EOF' +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// http-proxy-fix.js — http.request() wrapper resolving the double-proxy +// conflict between NODE_USE_ENV_PROXY=1 (Node.js 22+) and HTTP libraries +// that independently read HTTPS_PROXY (axios, follow-redirects, +// proxy-from-env). See NemoClaw#2109. +// +// Problem: +// Node.js 22 with NODE_USE_ENV_PROXY=1 (baked into the OpenShell base +// image) intercepts https.request() calls and handles proxying via a +// CONNECT tunnel. HTTP libraries also read HTTPS_PROXY and configure +// HTTP FORWARD mode, so the request is processed twice and the L7 proxy +// rejects it with "FORWARD rejected: HTTPS requires CONNECT". +// +// Fix: +// Wrap http.request() — the lowest common denominator every HTTP client +// bottoms out at. Detect FORWARD-mode requests (hostname = proxy IP, +// path = full https:// URL) and rewrite them as https.request() against +// the real target host, letting NODE_USE_ENV_PROXY handle the CONNECT +// tunnel correctly. +// +// Earlier PR #2110 tried a Module._load hook intercepting require('axios'). +// That could not catch follow-redirects + proxy-from-env bundled as ESM in +// OpenClaw's dist/ — there are no require() calls to intercept. The +// http.request wrapper sits below all libraries and catches every path. +// +// This file is the canonical source for review and tests. At sandbox boot +// nemoclaw-start.sh writes an identical copy to /tmp/nemoclaw-http-proxy-fix.js +// and loads it via NODE_OPTIONS=--require. A sync test enforces byte-for-byte +// equality. The content cannot be baked into /opt/nemoclaw-blueprint/scripts/ +// because adding files to the optimized sandbox build context cache-busts the +// `COPY nemoclaw-blueprint/` Dockerfile layer and hangs npm ci in k3s +// Docker-in-Docker — see src/lib/sandbox-build-context.ts. + +(function () { + 'use strict'; + if (process.env.NODE_USE_ENV_PROXY !== '1') return; + + var http = require('http'); + var origRequest = http.request; + + var proxyUrl = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + ''; + var proxyHost = ''; + try { + proxyHost = new URL(proxyUrl).hostname; + } catch (e) { + /* no usable proxy configured */ + } + if (!proxyHost) return; + + http.request = function (options, callback) { + if (typeof options === 'string' || !options) { + return origRequest.apply(http, arguments); + } + if ( + options.hostname === proxyHost && + options.path && + options.path.startsWith('https://') + ) { + var target = new URL(options.path); + var https = require('https'); + return https.request( + { + method: options.method || 'GET', + hostname: target.hostname, + host: target.hostname, + port: target.port || 443, + path: target.pathname + target.search, + protocol: 'https:', + headers: options.headers, + timeout: options.timeout, + }, + callback, + ); + } + return origRequest.apply(http, arguments); + }; +})(); +HTTP_PROXY_FIX_EOF + export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_PROXY_FIX_SCRIPT" fi # WebSocket CONNECT tunnel fix (NemoClaw#1570). @@ -843,11 +945,11 @@ export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" PROXYEOF - # axios double-proxy fix: also expose NODE_OPTIONS in connect sessions so that - # interactive shells and user commands started via `openshell sandbox connect` - # also benefit from the preload. (NemoClaw#2109) - if [ -f "$_AXIOS_FIX_SCRIPT" ] && [ "${NODE_USE_ENV_PROXY:-}" = "1" ]; then - echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_AXIOS_FIX_SCRIPT\"" + # HTTP library double-proxy fix: also expose NODE_OPTIONS in connect + # sessions so interactive shells and user commands started via + # `openshell sandbox connect` benefit from the preload. (NemoClaw#2109) + if [ "${NODE_USE_ENV_PROXY:-}" = "1" ]; then + echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_PROXY_FIX_SCRIPT\"" fi # WebSocket CONNECT tunnel fix for connect sessions. (NemoClaw#1570) if [ -f "$_WS_FIX_SCRIPT" ]; then @@ -973,8 +1075,10 @@ if [ "$(id -u)" -ne 0 ]; then chmod 600 /tmp/auto-pair.log # Defence-in-depth: verify /tmp file permissions before launching services. - # shellcheck disable=SC2119 - validate_tmp_permissions + # Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh + # (both are trust-boundary files; tampering would let the sandbox user + # inject code into any Node process via NODE_OPTIONS). + validate_tmp_permissions "$_PROXY_FIX_SCRIPT" # Start gateway in background, auto-pair, then wait nohup "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 & @@ -1043,8 +1147,10 @@ validate_openclaw_symlinks harden_openclaw_symlinks # Defence-in-depth: verify /tmp file permissions before launching services. -# shellcheck disable=SC2119 -validate_tmp_permissions +# Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh +# (both are trust-boundary files; tampering would let the sandbox user +# inject code into any Node process via NODE_OPTIONS). +validate_tmp_permissions "$_PROXY_FIX_SCRIPT" # Start the gateway as the 'gateway' user. # SECURITY: The sandbox user cannot kill this process because it runs diff --git a/test/http-proxy-fix-sync.test.ts b/test/http-proxy-fix-sync.test.ts new file mode 100644 index 00000000000..7ce7154f300 --- /dev/null +++ b/test/http-proxy-fix-sync.test.ts @@ -0,0 +1,78 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { describe, it, expect } from "vitest"; + +const ROOT = path.join(import.meta.dirname, ".."); +const CANONICAL_FIX = path.join(ROOT, "nemoclaw-blueprint", "scripts", "http-proxy-fix.js"); +const START_SCRIPT = path.join(ROOT, "scripts", "nemoclaw-start.sh"); + +describe("http-proxy-fix heredoc sync (#2109)", () => { + it("canonical http-proxy-fix.js exists and is non-empty", () => { + expect(fs.existsSync(CANONICAL_FIX)).toBe(true); + const content = fs.readFileSync(CANONICAL_FIX, "utf-8"); + expect(content.length).toBeGreaterThan(0); + expect(content).toContain("(function () {"); + expect(content).toContain("http.request = function"); + }); + + it("nemoclaw-start.sh embeds the fix via a HTTP_PROXY_FIX_EOF heredoc", () => { + const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); + expect(startScript).toMatch( + /emit_sandbox_sourced_file\s+"\$_PROXY_FIX_SCRIPT"\s+<<'HTTP_PROXY_FIX_EOF'/, + ); + expect(startScript).toMatch(/^HTTP_PROXY_FIX_EOF$/m); + }); + + // Critical: the heredoc content in nemoclaw-start.sh and the canonical file + // are two copies of the same code. If they drift, the shipped fix no longer + // matches what review was done against. This test is the only thing keeping + // the two in sync — a mismatch here is a bug. + it("embedded heredoc matches canonical file byte-for-byte", () => { + const canonical = fs.readFileSync(CANONICAL_FIX, "utf-8"); + const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); + const match = startScript.match( + /<<'HTTP_PROXY_FIX_EOF'\n([\s\S]*?)\nHTTP_PROXY_FIX_EOF/, + ); + expect(match).not.toBeNull(); + // The heredoc capture excludes the final newline preceding the delimiter. + // POSIX convention: the canonical file ends with a trailing newline. + const embedded = match[1] + "\n"; + if (embedded !== canonical) { + const embeddedLines = embedded.split("\n"); + const canonicalLines = canonical.split("\n"); + const firstDiff = embeddedLines.findIndex((l, i) => l !== canonicalLines[i]); + throw new Error( + `heredoc in scripts/nemoclaw-start.sh drifted from ${path.relative(ROOT, CANONICAL_FIX)} at line ${firstDiff + 1}:\n` + + ` canonical: ${JSON.stringify(canonicalLines[firstDiff])}\n` + + ` embedded: ${JSON.stringify(embeddedLines[firstDiff])}\n` + + "\nUpdate the heredoc in scripts/nemoclaw-start.sh (or the canonical file) so both match.", + ); + } + expect(embedded).toBe(canonical); + }); + + it("NODE_OPTIONS export references the same /tmp path the heredoc writes to", () => { + const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); + expect(startScript).toContain('_PROXY_FIX_SCRIPT="/tmp/nemoclaw-http-proxy-fix.js"'); + const primaryExport = startScript.match( + /export NODE_OPTIONS="\$\{NODE_OPTIONS:\+\$NODE_OPTIONS \}--require \$_PROXY_FIX_SCRIPT"/, + ); + expect(primaryExport).not.toBeNull(); + }); + + it("validate_tmp_permissions is invoked with the fix path in both root and non-root branches", () => { + const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); + const calls = startScript.match(/validate_tmp_permissions "\$_PROXY_FIX_SCRIPT"/g) || []; + expect(calls.length).toBeGreaterThanOrEqual(2); + }); + + it("legacy axios-proxy-fix variable is fully removed", () => { + const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); + expect(startScript).not.toContain("_AXIOS_FIX_SCRIPT"); + expect(startScript).not.toContain("axios-proxy-fix.js"); + }); +}); diff --git a/test/service-env.test.ts b/test/service-env.test.ts index 71667de12db..f372eb51aba 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -591,16 +591,16 @@ describe("service environment", () => { } }); - it("regression #2109: proxy-env.sh includes NODE_OPTIONS --require when NODE_USE_ENV_PROXY=1 and fix script exists", () => { - const fakeDataDir = join(tmpdir(), `nemoclaw-axios-fix-test-${process.pid}`); - const fakeFixScript = join(fakeDataDir, "axios-proxy-fix.js"); + it("regression #2109: proxy-env.sh includes NODE_OPTIONS --require when NODE_USE_ENV_PROXY=1", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-http-fix-test-${process.pid}`); execFileSync("mkdir", ["-p", fakeDataDir]); - const tmpFile = join(tmpdir(), `nemoclaw-axios-fix-env-${process.pid}.sh`); + const tmpFile = join(tmpdir(), `nemoclaw-http-fix-env-${process.pid}.sh`); + const fakeFixPath = "/tmp/nemoclaw-http-proxy-fix.js"; try { const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); const persistBlock = execFileSync( "sed", - ["-n", "/^_PROXY_ENV_FILE=/,/emit_sandbox_sourced_file.*\$_PROXY_ENV_FILE/p", scriptPath], + ["-n", "/^_PROXY_ENV_FILE=/,/emit_sandbox_sourced_file.*$_PROXY_ENV_FILE/p", scriptPath], { encoding: "utf-8" }, ); if (!persistBlock.trim()) { @@ -616,24 +616,26 @@ describe("service environment", () => { 'PROXY_PORT="3128"', '_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"', '_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"', - 'NODE_USE_ENV_PROXY=1', - '_TOOL_REDIRECTS=()', - `_AXIOS_FIX_SCRIPT="${fakeFixScript}"`, + "NODE_USE_ENV_PROXY=1", + "_TOOL_REDIRECTS=()", + `_PROXY_FIX_SCRIPT="${fakeFixPath}"`, `_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"`, "set +u # array expansion safe on macOS bash", persistBlock .trimEnd() .replaceAll("/tmp/nemoclaw-proxy-env.sh", `${fakeDataDir}/proxy-env.sh`), ].join("\n"); - // Create a fake fix script so the -f check passes - writeFileSync(fakeFixScript, "// 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(fakeFixScript); + // Preload target is the in-sandbox /tmp path; no dependency on an + // external /opt path (see axios-proxy-fix Bug 1 — scripts/ never + // made it into the optimized build context). The JS is embedded in + // nemoclaw-start.sh and written to /tmp at boot. + expect(envFile).toContain(fakeFixPath); } finally { try { execFileSync("rm", ["-rf", fakeDataDir, tmpFile]); @@ -644,15 +646,14 @@ describe("service environment", () => { }); it("regression #2109: proxy-env.sh does NOT include NODE_OPTIONS when NODE_USE_ENV_PROXY is unset", () => { - const fakeDataDir = join(tmpdir(), `nemoclaw-axios-noop-test-${process.pid}`); - const fakeFixScript = join(fakeDataDir, "axios-proxy-fix.js"); + const fakeDataDir = join(tmpdir(), `nemoclaw-http-noop-test-${process.pid}`); execFileSync("mkdir", ["-p", fakeDataDir]); - const tmpFile = join(tmpdir(), `nemoclaw-axios-noop-env-${process.pid}.sh`); + const tmpFile = join(tmpdir(), `nemoclaw-http-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.*\$_PROXY_ENV_FILE/p", scriptPath], + ["-n", "/^_PROXY_ENV_FILE=/,/emit_sandbox_sourced_file.*$_PROXY_ENV_FILE/p", scriptPath], { encoding: "utf-8" }, ); if (!persistBlock.trim()) { @@ -667,15 +668,14 @@ describe("service environment", () => { '_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"', '_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"', // NODE_USE_ENV_PROXY intentionally NOT set - '_TOOL_REDIRECTS=()', - `_AXIOS_FIX_SCRIPT="${fakeFixScript}"`, + "_TOOL_REDIRECTS=()", + `_PROXY_FIX_SCRIPT="/tmp/nemoclaw-http-proxy-fix.js"`, `_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"`, "set +u # array expansion safe on macOS bash", persistBlock .trimEnd() .replaceAll("/tmp/nemoclaw-proxy-env.sh", `${fakeDataDir}/proxy-env.sh`), ].join("\n"); - writeFileSync(fakeFixScript, "// fake", { mode: 0o644 }); writeFileSync(tmpFile, wrapper, { mode: 0o700 }); execFileSync("bash", [tmpFile], { encoding: "utf-8" }); @@ -683,7 +683,7 @@ describe("service environment", () => { // 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("http-proxy-fix"); expect(envFile).not.toContain("ws-proxy-fix"); } finally { try { @@ -719,7 +719,7 @@ describe("service environment", () => { `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"`, + `_PROXY_FIX_SCRIPT="/tmp/nemoclaw-http-proxy-fix.js"`, `_WS_FIX_SCRIPT="${fakeWsFixScript}"`, `_TOOL_REDIRECTS=()`, "set +u # array expansion safe on macOS bash", @@ -766,7 +766,7 @@ describe("service environment", () => { `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"`, + `_PROXY_FIX_SCRIPT="/tmp/nemoclaw-http-proxy-fix.js"`, `_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"`, `_TOOL_REDIRECTS=()`, "set +u # array expansion safe on macOS bash", From 003b99e01c8b94bd68b552beb32b940a3a032cb3 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 22 Apr 2026 22:32:10 -0700 Subject: [PATCH 2/4] fix(sandbox): guard new URL(options.path) against malformed URLs Wrap the `new URL(options.path)` call in a try/catch so that a malformed path value (which passes the `startsWith('https://')` check but still fails URL parsing) falls back to the original http.request instead of crashing the Node process. Co-Authored-By: Claude Opus 4.6 (1M context) --- nemoclaw-blueprint/scripts/http-proxy-fix.js | 7 ++++++- scripts/nemoclaw-start.sh | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/nemoclaw-blueprint/scripts/http-proxy-fix.js b/nemoclaw-blueprint/scripts/http-proxy-fix.js index 16d67ff0080..a3c88190f17 100644 --- a/nemoclaw-blueprint/scripts/http-proxy-fix.js +++ b/nemoclaw-blueprint/scripts/http-proxy-fix.js @@ -63,7 +63,12 @@ options.path && options.path.startsWith('https://') ) { - var target = new URL(options.path); + var target; + try { + target = new URL(options.path); + } catch (e) { + return origRequest.apply(http, arguments); + } var https = require('https'); return https.request( { diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 0ad1cd4d589..87e4b2b75e8 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -880,7 +880,12 @@ if [ "${NODE_USE_ENV_PROXY:-}" = "1" ]; then options.path && options.path.startsWith('https://') ) { - var target = new URL(options.path); + var target; + try { + target = new URL(options.path); + } catch (e) { + return origRequest.apply(http, arguments); + } var https = require('https'); return https.request( { From 16466a9edb31bd936643988c9290daa58a749367 Mon Sep 17 00:00:00 2001 From: Lucas Montiel Date: Thu, 23 Apr 2026 03:26:39 -0300 Subject: [PATCH 3/4] fix(proxy): preserve caller-supplied options in http.request rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review feedback on PR. The FORWARD-mode branch previously constructed a fresh options object with only {method, hostname, host, port, path, protocol, headers, timeout}, silently dropping caller-supplied fields that can matter for correctness: - signal — AbortController, used by modern axios/fetch for cancellation. Dropping it meant user-initiated aborts would not propagate to the rewritten https.request, leaving the request running after the caller thought it was cancelled. - TLS: ca, cert, key, passphrase, rejectUnauthorized — custom trust anchors or mTLS settings. Uncommon in the FORWARD path but not impossible. - auth — Basic-auth credentials for the target origin. - lookup, family, localAddress, maxHeaderSize, insecureHTTPParser — per-request network/parser tuning. Switch to Object.assign({}, options, { ...proxy-routing-fields }) which clones the caller's options and overwrites only the fields we explicitly need to change (method default, hostname/host/port/path/ protocol). Everything else is carried over verbatim. Mirror the edit in the inline heredoc in scripts/nemoclaw-start.sh so the canonical file and the embedded copy remain byte-identical; the http-proxy-fix-sync test enforces this. --- nemoclaw-blueprint/scripts/http-proxy-fix.js | 26 ++++++++++---------- scripts/nemoclaw-start.sh | 26 ++++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/nemoclaw-blueprint/scripts/http-proxy-fix.js b/nemoclaw-blueprint/scripts/http-proxy-fix.js index a3c88190f17..0aedd090e70 100644 --- a/nemoclaw-blueprint/scripts/http-proxy-fix.js +++ b/nemoclaw-blueprint/scripts/http-proxy-fix.js @@ -70,19 +70,19 @@ return origRequest.apply(http, arguments); } var https = require('https'); - return https.request( - { - method: options.method || 'GET', - hostname: target.hostname, - host: target.hostname, - port: target.port || 443, - path: target.pathname + target.search, - protocol: 'https:', - headers: options.headers, - timeout: options.timeout, - }, - callback, - ); + // Clone caller's options and overwrite only the proxy-specific + // routing fields. Preserves signal (AbortController), lookup, + // TLS fields (ca/cert/key/rejectUnauthorized), auth, timeout, + // and any other per-request setting the caller supplied. + var rewritten = Object.assign({}, options, { + method: options.method || 'GET', + hostname: target.hostname, + host: target.hostname, + port: target.port || 443, + path: target.pathname + target.search, + protocol: 'https:', + }); + return https.request(rewritten, callback); } return origRequest.apply(http, arguments); }; diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 87e4b2b75e8..55ac60f781f 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -887,19 +887,19 @@ if [ "${NODE_USE_ENV_PROXY:-}" = "1" ]; then return origRequest.apply(http, arguments); } var https = require('https'); - return https.request( - { - method: options.method || 'GET', - hostname: target.hostname, - host: target.hostname, - port: target.port || 443, - path: target.pathname + target.search, - protocol: 'https:', - headers: options.headers, - timeout: options.timeout, - }, - callback, - ); + // Clone caller's options and overwrite only the proxy-specific + // routing fields. Preserves signal (AbortController), lookup, + // TLS fields (ca/cert/key/rejectUnauthorized), auth, timeout, + // and any other per-request setting the caller supplied. + var rewritten = Object.assign({}, options, { + method: options.method || 'GET', + hostname: target.hostname, + host: target.hostname, + port: target.port || 443, + path: target.pathname + target.search, + protocol: 'https:', + }); + return https.request(rewritten, callback); } return origRequest.apply(http, arguments); }; From 97d901fe203011253989506fcb70ec620f265ba2 Mon Sep 17 00:00:00 2001 From: Lucas Montiel Date: Thu, 23 Apr 2026 03:34:40 -0300 Subject: [PATCH 4/4] chore(proxy): prefix unused catch bindings with underscore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review feedback (minor, PR #2323). Both `catch (e)` bindings in http-proxy-fix.js are unused — one in the proxy URL parse (falls through silently) and one in the FORWARD-path new URL guard (returns the original request). Rename to `catch (_e)` to satisfy the project's "unused variables must be prefixed with _" convention. Mirror the edit in the inline heredoc in scripts/nemoclaw-start.sh so the canonical file and the embedded copy remain byte-identical; the http-proxy-fix-sync test enforces this. --- nemoclaw-blueprint/scripts/http-proxy-fix.js | 4 ++-- scripts/nemoclaw-start.sh | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nemoclaw-blueprint/scripts/http-proxy-fix.js b/nemoclaw-blueprint/scripts/http-proxy-fix.js index 0aedd090e70..913404d90b2 100644 --- a/nemoclaw-blueprint/scripts/http-proxy-fix.js +++ b/nemoclaw-blueprint/scripts/http-proxy-fix.js @@ -49,7 +49,7 @@ var proxyHost = ''; try { proxyHost = new URL(proxyUrl).hostname; - } catch (e) { + } catch (_e) { /* no usable proxy configured */ } if (!proxyHost) return; @@ -66,7 +66,7 @@ var target; try { target = new URL(options.path); - } catch (e) { + } catch (_e) { return origRequest.apply(http, arguments); } var https = require('https'); diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 55ac60f781f..00b9bd53c5c 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -866,7 +866,7 @@ if [ "${NODE_USE_ENV_PROXY:-}" = "1" ]; then var proxyHost = ''; try { proxyHost = new URL(proxyUrl).hostname; - } catch (e) { + } catch (_e) { /* no usable proxy configured */ } if (!proxyHost) return; @@ -883,7 +883,7 @@ if [ "${NODE_USE_ENV_PROXY:-}" = "1" ]; then var target; try { target = new URL(options.path); - } catch (e) { + } catch (_e) { return origRequest.apply(http, arguments); } var https = require('https');