Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
564b9a3
fix(inference): shim globalThis.fetch to proxy inference.local for cr…
Abhi190702 Jun 11, 2026
e3a72fd
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
Abhi190702 Jun 13, 2026
53af2c5
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
cv Jun 13, 2026
c986490
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
Abhi190702 Jun 13, 2026
09bbaa3
style(test): apply Biome format to http-proxy-fix-fetch.test.ts
Abhi190702 Jun 13, 2026
fef36be
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
Abhi190702 Jun 17, 2026
b9406bb
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
cv Jun 17, 2026
6a1eeff
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
cv Jun 23, 2026
db1a12d
test(proxy): avoid conditional growth in proxy tests
Abhi190702 Jun 23, 2026
e93b422
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
Abhi190702 Jun 23, 2026
670a08b
merge(pr): sync PR #4916 with main
cv Jun 24, 2026
6644918
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
cv Jun 24, 2026
702c282
fix(test): use guard pattern instead of expect for source-shape compl…
Abhi190702 Jun 25, 2026
9971dfd
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
Abhi190702 Jun 25, 2026
c62723b
fix(inference): harden inference.local fetch shim
Abhi190702 Jun 26, 2026
22092d1
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
Abhi190702 Jun 26, 2026
4582147
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
Abhi190702 Jul 8, 2026
cf0dc86
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
Abhi190702 Jul 11, 2026
32c5090
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
cv Jul 12, 2026
27b0bcf
Merge branch 'main' into fix/4730-fetch-proxy-inference-local
cv Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 261 additions & 6 deletions nemoclaw-blueprint/scripts/http-proxy-fix.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// 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.
// http-proxy-fix.js — transport wrapper resolving proxy mismatches 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 and NemoClaw#4730.
//
// Problem:
// Node.js 22 with NODE_USE_ENV_PROXY=1 (baked into the OpenShell base
Expand All @@ -14,12 +14,18 @@
// rejects it with "FORWARD rejected: HTTPS requires CONNECT".
//
// Fix:
// Wrap http.request() — the lowest common denominator every HTTP client
// Wrap http.request() — the lowest common denominator many HTTP clients
// 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.
//
// Also wrap fetch() only for https://inference.local/*, which OpenClaw cron
// provider preflight can reach through undici/fetch instead of http.request.
// The wrapper converts that fetch into the same FORWARD-mode shape handled
// above, preserving NemoClaw's managed inference.local route while avoiding
// a raw DNS lookup for the sandbox-only host.
//
// 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
Expand All @@ -44,8 +50,13 @@
process.env.http_proxy ||
'';
var proxyHost = '';
var proxyPort = '';
var proxyProtocol = '';
try {
proxyHost = new URL(proxyUrl).hostname;
var parsedProxy = new URL(proxyUrl);
proxyHost = parsedProxy.hostname;
proxyPort = parsedProxy.port || '80';
proxyProtocol = parsedProxy.protocol;
} catch (_e) {
/* no usable proxy configured */
}
Expand Down Expand Up @@ -111,6 +122,248 @@
return out;
}

function fetchInputUrl(input) {
if (typeof input === 'string') return input;
if (input && typeof input.url === 'string') return input.url;
if (input && typeof input.href === 'string') return input.href;
return '';
}

function inferenceLocalFetchUrl(input) {
var raw = fetchInputUrl(input);
if (!raw) return null;
try {
var target = new URL(raw);
if (target.protocol !== 'https:' || target.hostname !== 'inference.local') {
return null;
}
return target;
} catch (_e) {
return null;
}
}

// Maximum request body size for inference.local fetch bridging. Provider
// preflight payloads are small JSON (model listing, health checks); 1 MiB
// is generous while preventing accidental full-dataset buffering.
var MAX_INFERENCE_LOCAL_FETCH_BODY_BYTES = 1024 * 1024;

function contentLengthValue(headers) {
for (var key in headers) {
if (
Object.prototype.hasOwnProperty.call(headers, key) &&
String(key).toLowerCase() === 'content-length'
) {
return headers[key];
}
}
return undefined;
}

// Returns { body: Buffer|null } or throws with a descriptive message.
// GET/HEAD requests never have bodies. Non-GET/HEAD requests with a
// Content-Length exceeding the limit are rejected before materialization.
// Bodies that fail to materialize (streaming/duplex) or exceed the limit
// after materialization are also rejected.
async function boundedRequestBody(request, headers) {
var method = request.method || 'GET';
if (method === 'GET' || method === 'HEAD') return { body: null };

// Fast-reject oversized bodies from Content-Length before buffering.
var declaredLength = contentLengthValue(headers);
if (declaredLength !== undefined) {
var parsed = Number(declaredLength);
var isInvalid = !Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0;
if (!isInvalid && typeof declaredLength === 'string') {
var trimmed = declaredLength.trim();
if (!/^\d+$/.test(trimmed)) {
isInvalid = true;
}
}
if (isInvalid) {
throw new Error(
'inference.local fetch body rejected: invalid Content-Length'
);
}
if (parsed > MAX_INFERENCE_LOCAL_FETCH_BODY_BYTES) {
throw new Error(
'inference.local fetch body rejected: Content-Length ' +
parsed +
' exceeds limit of ' +
MAX_INFERENCE_LOCAL_FETCH_BODY_BYTES +
' bytes'
);
}
}

if (request.body === null) return { body: null };

// Materialize body with error handling for streaming/duplex bodies.
var arrayBuffer;
try {
arrayBuffer = await request.clone().arrayBuffer();
} catch (err) {
throw new Error(
'inference.local fetch body rejected: failed to buffer request body' +
(err && err.message ? ' (' + err.message + ')' : '')
);
}

var body = Buffer.from(arrayBuffer);
if (body.length > MAX_INFERENCE_LOCAL_FETCH_BODY_BYTES) {
throw new Error(
'inference.local fetch body rejected: materialized body ' +
body.length +
' bytes exceeds limit of ' +
MAX_INFERENCE_LOCAL_FETCH_BODY_BYTES +
' bytes'
);
}

// Set content-length if body exists and header is absent.
if (body.length > 0 && declaredLength === undefined) {
headers['content-length'] = String(body.length);
}
return { body: body.length > 0 ? body : null };
}

function requestHeaders(request) {
var out = {};
request.headers.forEach(function (value, key) {
out[key] = value;
});
return out;
}

function responseHeaders(headers) {
var out = [];
Object.keys(headers || {}).forEach(function (key) {
var value = headers[key];
if (Array.isArray(value)) {
value.forEach(function (entry) {
if (entry != null) out.push([key, String(entry)]);
});
} else if (value != null) {
out.push([key, String(value)]);
}
});
return out;
}

function responseBody(method, statusCode, res) {
if (method === 'HEAD' || statusCode === 204 || statusCode === 304) {
return null;
}
var stream = require('stream');
if (stream.Readable && typeof stream.Readable.toWeb === 'function') {
return stream.Readable.toWeb(res);
}
return res;
}

async function fetchViaForwardProxy(input, init, originalFetch, thisArg) {
if (proxyProtocol !== 'http:' || typeof Request === 'undefined') {
return originalFetch.call(thisArg, input, init);
}

var request;
try {
request = new Request(input, init);
} catch (_e) {
return originalFetch.call(thisArg, input, init);
}

var target = inferenceLocalFetchUrl(request);
if (!target) return originalFetch.call(thisArg, input, init);

var method = request.method || 'GET';
var headers = requestHeaders(request);
var bounded = await boundedRequestBody(request, headers);
var body = bounded.body;

return new Promise(function (resolve, reject) {
var req = http.request(
{
hostname: proxyHost,
port: proxyPort,
path: target.href,
method: method,
headers: headers,
signal: request.signal,
},
function (res) {
var status = res.statusCode || 200;
resolve(
new Response(responseBody(method, status, res), {
status: status,
statusText: res.statusMessage || '',
headers: responseHeaders(res.headers),
})
);
}
);
req.on('error', reject);
if (body && body.length > 0) req.write(body);
req.end();
});
}

/**
* NemoClaw#4730 — inference.local fetch shim.
*
* Invalid state:
* OpenClaw cron/provider preflight calls native fetch() for
* https://inference.local/v1, which triggers a raw DNS lookup and
* fails with getaddrinfo EAI_AGAIN because inference.local is a
* sandbox-only virtual hostname routed through the OpenShell proxy.
*
* Source boundary:
* The failing call path is OpenClaw cron/provider preflight; this file
* is the sandbox preload transport boundary (loaded via
* NODE_OPTIONS=--require at sandbox boot).
*
* Why localized preload fix:
* This preload is the controlled boundary available in this repo/version.
* The cron/provider preflight path may be generated, external, or
* version-coupled, so the localized preload keeps inference.local fetch
* inside the existing proxy rewrite boundary.
*
* Regression proof:
* test/http-proxy-fix-fetch.test.ts proves inference.local fetches use
* the preload/proxy path instead of native fetch, including body limits,
* header stripping, idempotence, and explicit port/path/query.
*
* Removal condition:
* Remove this shim when OpenClaw cron/provider preflight uses the
* sandbox proxy-aware provider route directly, or after upgrading to
* an OpenClaw version that no longer uses raw native fetch for
* inference.local.
*/

function isNemoClawFetchWrapper(fetchFn) {
return !!(fetchFn && fetchFn.__nemoclawInferenceLocalProxyFix === true);
}

function wrapFetchForInferenceLocal() {
if (typeof globalThis.fetch !== 'function') return;
// Check whether globalThis.fetch is already the NemoClaw wrapper.
// Do not trust the mutable boolean alone — a stale or colliding
// __nemoclawFetchPatched flag must not silently disable the patch.
if (isNemoClawFetchWrapper(globalThis.fetch)) return;

var _originalFetch = globalThis.fetch.bind(globalThis);
var wrappedFetch = async function (input, init) {
if (!inferenceLocalFetchUrl(input)) {
return _originalFetch(input, init);
}
return fetchViaForwardProxy(input, init, _originalFetch, globalThis);
};
wrappedFetch.__nemoclawInferenceLocalProxyFix = true;
globalThis.fetch = wrappedFetch;
// Keep backward-compatible flag for any external code checking it.
globalThis.__nemoclawFetchPatched = true;
}

http.request = function (options, callback) {
if (typeof options === 'string' || !options) {
return origRequest.apply(http, arguments);
Expand Down Expand Up @@ -177,4 +430,6 @@
}
return origRequest.apply(http, arguments);
};

wrapFetchForInferenceLocal();
})();
8 changes: 6 additions & 2 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3186,17 +3186,21 @@ if [ -n "${SSL_CERT_FILE:-}" ] && [ -f "${SSL_CERT_FILE}" ]; then
export GIT_SSL_CAINFO="$SSL_CERT_FILE"
fi

# HTTP library + NODE_USE_ENV_PROXY double-proxy fix (NemoClaw#2109).
# HTTP library + NODE_USE_ENV_PROXY proxy transport fixes
# (NemoClaw#2109, NemoClaw#4730).
# Node.js 22 sets NODE_USE_ENV_PROXY=1 in the OpenShell base image, which
# 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
# The preload wraps http.request() — the lowest common denominator many
# HTTP client bottoms out at — and rewrites FORWARD-mode requests back to
# https.request() so NODE_USE_ENV_PROXY can handle the CONNECT tunnel.
# It also routes fetch() calls to https://inference.local/* through that same
# path so OpenClaw cron provider preflight does not bypass the proxy and try
# a raw DNS lookup for the sandbox-only inference.local host.
#
# Earlier PR #2110 intercepted require('axios') via a Module._load hook;
# that could not catch follow-redirects + proxy-from-env bundled as ESM
Expand Down
3 changes: 0 additions & 3 deletions test/http-proxy-fix-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,6 @@ describe("http-proxy-fix end-to-end against a local OpenAI-compatible mock", ()
// an assertion threw before afterEach.
vi.stubEnv("NODE_USE_ENV_PROXY", "1");
vi.stubEnv("HTTPS_PROXY", `http://${PROXY_HOST}:3128`);
vi.stubEnv("https_proxy", "");
vi.stubEnv("HTTP_PROXY", "");
vi.stubEnv("http_proxy", "");
origHttpRequest = http.request;
loadWrapper();
});
Expand Down
Loading
Loading