From 40ef42d6332592883c7082285ec911b2fe944605 Mon Sep 17 00:00:00 2001 From: Lucas Montiel Date: Thu, 30 Apr 2026 19:09:03 -0300 Subject: [PATCH] fix(sandbox): wrap fetch() to route HTTPS through EnvHttpProxyAgent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #2344 (the http.request rewrite). PR #2344 covers axios / follow-redirects / proxy-from-env. This change covers an additional code path the Microsoft Teams adapter uses: native fetch() with a custom undici dispatcher. When the OpenClaw Teams adapter handles a channel @mention it calls the Microsoft Graph API to fetch the full message body: fetch('https://graph.microsoft.com/v1.0/teams/.../messages/...', { dispatcher: customUndiciAgent }) The custom dispatcher routes the request through its own connection pool, bypassing Node's default EnvHttpProxyAgent — which is the only thing routing HTTPS through the OpenShell L7 proxy. Direct egress to graph.microsoft.com is blocked by the sandbox network namespace and surfaces as ECONNREFUSED. Channel @mentions silently fail; DMs work because their webhook payload carries the message body and no Graph call is needed. Wrap globalThis.fetch in the same preload that wraps http.request: when fetch() is called with a custom dispatcher and an HTTPS URL, strip the dispatcher and let EnvHttpProxyAgent handle the request. Non-HTTPS URLs and dispatcher-free calls pass through unchanged so direct intra-sandbox HTTP traffic and any non-proxy use of the dispatcher option keep working. Refinements over the originally proposed fix: - URL extraction handles all three fetch() input forms — string, URL object (.href), Request object (.url) — in that order. The naive `url?.url` check would miss URL objects (no .url property) and silently leave the dispatcher attached, defeating the fix on callers that pass URL instances. - One-shot console.warn so the strip is auditable in logs without spamming on every call (the Teams adapter polls the Graph API frequently under load — per-call logs would flood). The flag is closure-scoped so a process restart re-arms it. - Defensive `typeof origFetch === 'function'` guard so a Node runtime that ever ships without globalThis.fetch (or a future embedding context that strips it) silently no-ops instead of throwing. Mirrored in the inline heredoc in scripts/nemoclaw-start.sh; the http-proxy-fix-sync test enforces byte-for-byte equality between the canonical file and the heredoc, plus explicit fetch-wrapper presence assertions so a future "delete from both copies in lockstep" can't silently regress (byte-equality alone cannot catch that case). Verified end-to-end: bot now successfully replies to channel @mentions; DM regression check passes; non-Graph fetch() calls and fetch() calls without a custom dispatcher unaffected. Reproduction and rollout details posted in the PR description. --- nemoclaw-blueprint/scripts/http-proxy-fix.js | 60 ++++++++++++++++++++ scripts/nemoclaw-start.sh | 60 ++++++++++++++++++++ test/http-proxy-fix-sync.test.ts | 6 ++ 3 files changed, 126 insertions(+) diff --git a/nemoclaw-blueprint/scripts/http-proxy-fix.js b/nemoclaw-blueprint/scripts/http-proxy-fix.js index a96af18b4bb..d46da35b12c 100644 --- a/nemoclaw-blueprint/scripts/http-proxy-fix.js +++ b/nemoclaw-blueprint/scripts/http-proxy-fix.js @@ -180,4 +180,64 @@ } return origRequest.apply(http, arguments); }; + + // ── fetch() custom-dispatcher fix ──────────────────────────────── + // Problem: + // OpenClaw's Microsoft Teams adapter calls the Graph API via native + // fetch() with a custom undici dispatcher (`opts.dispatcher`). The + // custom dispatcher routes the request through its own connection + // pool — bypassing Node's default EnvHttpProxyAgent, which is the + // only thing routing HTTPS traffic through the OpenShell L7 proxy. + // Direct egress to e.g. graph.microsoft.com is blocked by the + // sandbox network namespace and surfaces as ECONNREFUSED. The bot + // then silently drops channel @mentions (DMs work because the + // webhook payload carries the message body and no Graph call is + // needed). + // + // Fix: + // When fetch() is called with a custom dispatcher and an HTTPS URL, + // strip the dispatcher and let the default EnvHttpProxyAgent handle + // the request through the proxy. Non-HTTPS URLs are left untouched + // — direct HTTP traffic inside the sandbox netns still works, and + // any non-proxy use of the dispatcher option remains honored. A + // one-shot console.warn makes the override discoverable without + // spamming on every call (the Teams adapter can call this many + // times per minute under load). + // + // fetch() input forms (handled in this order): + // - string — typeof === 'string' + // - URL object — has a string .href + // - Request object — has a string .url + // - anything else — passed through to origFetch unchanged + var origFetch = globalThis.fetch; + if (typeof origFetch === 'function') { + var dispatcherStripWarned = false; + globalThis.fetch = function (url, opts) { + if (opts && opts.dispatcher) { + var urlStr = ''; + if (typeof url === 'string') { + urlStr = url; + } else if (url && typeof url.href === 'string') { + urlStr = url.href; + } else if (url && typeof url.url === 'string') { + urlStr = url.url; + } + if (urlStr.startsWith('https://')) { + if (!dispatcherStripWarned) { + dispatcherStripWarned = true; + console.warn( + '[nemoclaw http-proxy-fix] stripping custom fetch() dispatcher for HTTPS URL ' + + urlStr + + ' so EnvHttpProxyAgent can route through the L7 proxy. ' + + 'Subsequent strips suppressed.', + ); + } + var newOpts = Object.assign({}, opts); + delete newOpts.dispatcher; + return origFetch.call(this, url, newOpts); + } + } + return origFetch.apply(this, arguments); + }; + } })(); diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 4e3a25f10dd..c1a08bf74c4 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1418,6 +1418,66 @@ if [ "${NODE_USE_ENV_PROXY:-}" = "1" ]; then } return origRequest.apply(http, arguments); }; + + // ── fetch() custom-dispatcher fix ──────────────────────────────── + // Problem: + // OpenClaw's Microsoft Teams adapter calls the Graph API via native + // fetch() with a custom undici dispatcher (`opts.dispatcher`). The + // custom dispatcher routes the request through its own connection + // pool — bypassing Node's default EnvHttpProxyAgent, which is the + // only thing routing HTTPS traffic through the OpenShell L7 proxy. + // Direct egress to e.g. graph.microsoft.com is blocked by the + // sandbox network namespace and surfaces as ECONNREFUSED. The bot + // then silently drops channel @mentions (DMs work because the + // webhook payload carries the message body and no Graph call is + // needed). + // + // Fix: + // When fetch() is called with a custom dispatcher and an HTTPS URL, + // strip the dispatcher and let the default EnvHttpProxyAgent handle + // the request through the proxy. Non-HTTPS URLs are left untouched + // — direct HTTP traffic inside the sandbox netns still works, and + // any non-proxy use of the dispatcher option remains honored. A + // one-shot console.warn makes the override discoverable without + // spamming on every call (the Teams adapter can call this many + // times per minute under load). + // + // fetch() input forms (handled in this order): + // - string — typeof === 'string' + // - URL object — has a string .href + // - Request object — has a string .url + // - anything else — passed through to origFetch unchanged + var origFetch = globalThis.fetch; + if (typeof origFetch === 'function') { + var dispatcherStripWarned = false; + globalThis.fetch = function (url, opts) { + if (opts && opts.dispatcher) { + var urlStr = ''; + if (typeof url === 'string') { + urlStr = url; + } else if (url && typeof url.href === 'string') { + urlStr = url.href; + } else if (url && typeof url.url === 'string') { + urlStr = url.url; + } + if (urlStr.startsWith('https://')) { + if (!dispatcherStripWarned) { + dispatcherStripWarned = true; + console.warn( + '[nemoclaw http-proxy-fix] stripping custom fetch() dispatcher for HTTPS URL ' + + urlStr + + ' so EnvHttpProxyAgent can route through the L7 proxy. ' + + 'Subsequent strips suppressed.', + ); + } + var newOpts = Object.assign({}, opts); + delete newOpts.dispatcher; + return origFetch.call(this, url, newOpts); + } + } + return origFetch.apply(this, arguments); + }; + } })(); HTTP_PROXY_FIX_EOF export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_PROXY_FIX_SCRIPT" diff --git a/test/http-proxy-fix-sync.test.ts b/test/http-proxy-fix-sync.test.ts index cf9a37bc534..e3d77dfe0f8 100644 --- a/test/http-proxy-fix-sync.test.ts +++ b/test/http-proxy-fix-sync.test.ts @@ -16,6 +16,12 @@ describe("http-proxy-fix heredoc sync (#2109)", () => { expect(content.length).toBeGreaterThan(0); expect(content).toContain("(function () {"); expect(content).toContain("http.request = function"); + // fetch() custom-dispatcher fix (the Teams Graph API path — see PR + // body). Asserted explicitly because the byte-equality test below + // only proves the two copies match — it cannot detect "both copies + // lost the wrapper". + expect(content).toContain("globalThis.fetch = function"); + expect(content).toContain("delete newOpts.dispatcher"); }); it("nemoclaw-start.sh embeds the fix via a HTTP_PROXY_FIX_EOF heredoc", () => {