Skip to content

fix(sandbox): wrap fetch() to route HTTPS through EnvHttpProxyAgent - #3

Merged
lcsmontiel merged 1 commit into
mainfrom
fix/fetch-dispatcher-proxy
May 19, 2026
Merged

lcsmontiel merged 1 commit into
mainfrom
fix/fetch-dispatcher-proxy

Conversation

@lcsmontiel

Copy link
Copy Markdown
Owner

Summary

Reproduction

  1. Deploy NemoClaw with the Teams adapter (proxy-enabled sandbox).
  2. Invite the bot to a team channel (not a DM).
  3. @mention the bot.
  4. Gateway log:
    msteams debounce flush failed: fetch failed
    [FETCH] https://graph.microsoft.com/v1.0/teams/.../messages/... agent=custom
    [FETCH FAIL] ... msg=fetch failed cause=none code=ECONNREFUSED
    

DMs continue to work because the webhook payload carries the message body — no Graph API call is needed.

The fix

Add a globalThis.fetch wrapper alongside the existing http.request wrapper in nemoclaw-blueprint/scripts/http-proxy-fix.js. 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.

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;   // URL
      else if (url && typeof url.url === 'string')  urlStr = url.url;    // Request
      if (urlStr.startsWith('https://')) {
        if (!dispatcherStripWarned) {
          dispatcherStripWarned = true;
          console.warn('[nemoclaw http-proxy-fix] stripping custom fetch() dispatcher ...');
        }
        var newOpts = Object.assign({}, opts);
        delete newOpts.dispatcher;
        return origFetch.call(this, url, newOpts);
      }
    }
    return origFetch.apply(this, arguments);
  };
}

Non-HTTPS URLs and dispatcher-free fetch() calls pass through unchanged — direct intra-sandbox HTTP traffic and any non-proxy use of the dispatcher option keep working.

Design notes

  • URL extraction handles all three fetch() input forms — string, URL object (.href), Request object (.url) — in that order. A naive url?.url would miss URL instances (which have .href, not .url) and silently leave the dispatcher attached, defeating the fix.
  • One-shot console.warn so the strip is auditable in logs without spamming on every call. The Teams adapter polls Graph frequently under load — per-call logs would flood. The warned 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 at preload time.
  • Delivery: after refactor(runtime): extract entrypoint preload modules NVIDIA/NemoClaw#3109 the preload is shipped as a standalone module under /usr/local/lib/nemoclaw/preloads/. This PR only touches the canonical http-proxy-fix.js — no heredoc to keep in sync. The existing http-proxy-fix-sync.test.ts end-to-end test (which extracts the entrypoint block, runs it, and reads the generated /tmp/... file) is extended with explicit assertions that both the http.request wrapper and the globalThis.fetch wrapper are present in the generated preload, so a future accidental deletion of either trips CI.

Why not patch undici / replace the dispatcher with a proxy-aware one?

Relationship to NVIDIA#2344 / NVIDIA#2296 / NVIDIA#2109

Path Bug class Fixed by
http.request() (axios, follow-redirects, proxy-from-env) Library configures FORWARD-mode proxy request NVIDIA#2344
https.request() Upgrade: websocket (Discord) EnvHttpProxyAgent picks FORWARD instead of CONNECT for WS NVIDIA#2296 (ws-proxy-fix.js)
fetch() + custom dispatcher (Teams Graph) Custom dispatcher bypasses EnvHttpProxyAgent entirely This PR

Same root cause family (HTTP code path that doesn't respect NODE_USE_ENV_PROXY), three different surface code paths, three orthogonal fixes.

Test plan

  • npx vitest run --project cli test/http-proxy-fix-sync.test.ts — 1/1 pass (extracts entrypoint block, runs it, reads the generated preload; now asserts both http.request and globalThis.fetch wrappers + delete newOpts.dispatcher)
  • npx vitest run --project cli test/service-env.test.ts — passes (no regression in the persist block tests)
  • npm run typecheck:cli — clean
  • shellcheck scripts/nemoclaw-start.sh — clean (unchanged in this PR)
  • End-to-end on real proxy-enabled sandbox: Teams channel @mentions now succeed; DM regression check passes; non-Graph fetch() calls and fetch() calls without a custom dispatcher confirmed unaffected.

Closes the channel-message gap left by NVIDIA#2344.

Follow-up to NVIDIA#2344 (the http.request wrapper for NVIDIA#2109). PR NVIDIA#2344
covers axios / follow-redirects / proxy-from-env, all of which flow
through Node's http.request and EnvHttpProxyAgent. This change covers
an additional code path the Microsoft Teams adapter uses: native
fetch() with a custom undici dispatcher.

When OpenClaw's Teams adapter handles a channel @mention it calls the
Microsoft Graph API via:

    fetch('https://graph.microsoft.com/v1.0/teams/.../messages/...',
          { dispatcher: customUndiciAgent })

The custom dispatcher routes the request through its own connection
pool, bypassing the 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 the 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
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. A
    naive url?.url check would miss URL instances (which have .href,
    not .url) 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 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 silently no-ops instead
    of throwing at preload time.

After NVIDIA#3109 the preload is delivered as a standalone module copied
from /usr/local/lib/nemoclaw/preloads/ at boot, so this PR only
touches the canonical http-proxy-fix.js — no heredoc to keep in sync.
http-proxy-fix-sync.test.ts already runs the entrypoint block end-to-
end and reads the generated file; extended with explicit assertions
for http.request and globalThis.fetch wrapper presence so a future
deletion of either wrapper trips the test.

Verified end-to-end on a real proxy-enabled sandbox: bot replies to
Teams channel @mentions; DM regression check passes; non-Graph
fetch() and fetch() without a custom dispatcher unaffected.
@lcsmontiel
lcsmontiel marked this pull request as ready for review May 19, 2026 03:19
@lcsmontiel
lcsmontiel merged commit 07265aa into main May 19, 2026
19 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant