fix(proxy): route through undici's own fetch and keep undici external

Corporate-proxy users could not connect to any remote MCP server from v2.
Two independent defects, both reported in #2067.

1. The web and TUI bundles inlined undici. It was declared in the root and
   `clients/cli` manifests but named in no client's tsup `external` list, and
   tsup auto-externalizes only what the *nearest* manifest declares — so
   esbuild inlined 1.05MB of it into `clients/{web,tui}/build/`. undici is
   CommonJS, so the emitted chunk's `require("assert")` hit esbuild's ESM
   `__require` shim and threw `Dynamic require of "assert" is not supported`.
   The rejected `import()` surfaced as "the `undici` package is not available",
   telling users to install a package that was already present — and could not
   have helped, because the specifier had been rewritten to a relative chunk at
   build time.

2. `withProxyDispatcher` injected a *userland* undici `EnvHttpProxyAgent` into
   Node's *built-in* fetch. That couples two different copies of undici at the
   dispatcher handler interface, which is not stable across majors: Node 22
   embeds undici 6, Node 24 embeds 7, Node 26 embeds 8. A userland undici 8
   agent handed a built-in undici 7 handler is rejected with
   `fetch failed: invalid onRequestStart method`, and the request never leaves
   the process. So fixing the bundling alone would only have converted the
   web/TUI failure into the CLI's.

Both halves of the pair now come from userland undici — its `fetch` and its
`EnvHttpProxyAgent` — which keeps the handler inside one copy and works
unchanged from the Node 22.19 engine floor through Node 26. Deleting the
wrapper in favor of Node's native `NODE_USE_ENV_PROXY` was not an option:
measured, it is unsupported on 22.19 (the floor) and only lands mid-22.x.

Because undici's `Response` is a different class from `globalThis.Response`,
the proxied fetch re-wraps each response as a genuine global `Response`,
streaming preserved. Without that, `res instanceof Response` is false for
callers that test it — including the SDK's OAuth error formatter, which would
degrade every message to `Raw body: [object Response]`.

The proxy now sits at the bottom of the fetch stack (`environment.fetch` for
the Node clients, and the `createTransportNode` default for the web backend,
its one caller that supplies no `fetchFn`) rather than wrapping the transport's
fetch. Proxying means *substituting* the fetch, not decorating one, so wrapping
a supplied `fetchFn` would discard it; installing it underneath lets
InspectorClient's own wrappers compose over it. A side effect worth having:
OAuth discovery and token requests go through `environment.fetch` too, and were
never proxied before.

Guarding it:

- `undici` is added to all three tsup `external` lists and removed from
  `clients/cli/package.json`, so it is root-declared only, per the placement
  rule in AGENTS.md. The two installs had already drifted (root 8.5.0 vs cli
  8.9.0).
- New `npm run verify:bundle-externals` fails if any package a client declares
  external was inlined into that client's bundle anyway. It reads the *built
  output*, not the config — those two disagreed for four releases — and derives
  what to check from each client's own `external` array, so a newly
  externalized package is covered without editing the guard. Verified against
  the real pre-fix artifact, which it flags.
- The proxy tests now drive a real forwarding proxy rather than a mock. That
  matters: defect 2 was a runtime interface mismatch, which no mock can catch
  because no mock ever dispatches.

AGENTS.md's must-not-bundle rule (added by #2082) is extended with why undici
slipped past it, and notes the `node -e` trap — probing the bundled chunk with
`node -e` falsely succeeds, because it exposes a global `require` that
satisfies esbuild's guard.

Closes #2067

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 21:03:31 -04:00
commit d90a955164

View file

@ -0,0 +1,122 @@
/**
* Table-driven tests for the pure parsers of `verify-bundle-externals.mjs`.
*
* One case per rule the guard encodes. The guard itself reads real build output,
* which no unit test can produce cheaply — so what is pinned here is the
* *decision* logic: which entries count as externals, and which emitted shapes
* count as "inlined anyway".
*/
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import {
BUNDLED_CLIENTS,
parseExternals,
findInlinedExternals,
} from "./verify-bundle-externals.mjs";
describe("parseExternals", () => {
test("collects bare string entries", () => {
assert.deepEqual(
parseExternals(`export default { external: ["undici", "pino"] }`),
["undici", "pino"],
);
});
test("ignores package names that appear only inside comments", () => {
// The real configs document each entry at length, and #2067's entry names
// `undici` in its own prose — a comment-blind scan would double-count it and,
// worse, invent entries for packages merely discussed.
const src = `export default { external: [
// "ghost" is explained here but is NOT an entry.
/* neither is "phantom" */
"undici",
] }`;
assert.deepEqual(parseExternals(src), ["undici"]);
});
test("handles single quotes and nested arrays", () => {
assert.deepEqual(
parseExternals(
`export default { external: ['a', 'b'], noExternal: ["c"] }`,
),
["a", "b"],
);
});
test("returns an empty list when there is no external array", () => {
assert.deepEqual(parseExternals(`export default { entry: ["x.ts"] }`), []);
});
});
describe("findInlinedExternals", () => {
test("flags an emitted chunk named after the package", () => {
// The exact shape #2067 shipped: `undici-HXPKCIY3.js` beside index.js.
const v = findInlinedExternals({
externals: ["undici"],
buildFiles: ["index.js", "undici-HXPKCIY3.js"],
entrySource: "",
});
assert.equal(v.length, 1);
assert.equal(v[0].pkg, "undici");
assert.match(v[0].reason, /undici-HXPKCIY3\.js/);
});
test("flags a rewritten relative specifier in the entry", () => {
// Belt to the chunk-name brace: if esbuild ever names the chunk differently,
// the rewritten import in the entry still gives the inlining away.
const v = findInlinedExternals({
externals: ["undici"],
buildFiles: ["index.js"],
entrySource: 'await import("./undici-ABC123.js")',
});
assert.equal(v.length, 1);
assert.match(v[0].reason, /rewritten relative chunk/);
});
test("matches a scoped package on its last segment", () => {
const v = findInlinedExternals({
externals: ["@napi-rs/keyring"],
buildFiles: ["index.js", "keyring-QQ11.js"],
entrySource: "",
});
assert.equal(v.length, 1);
assert.equal(v[0].pkg, "@napi-rs/keyring");
});
test("passes a genuinely external package", () => {
assert.deepEqual(
findInlinedExternals({
externals: ["undici", "pino"],
buildFiles: ["index.js"],
entrySource: 'await import("undici"); import "pino";',
}),
[],
);
});
test("does not mistake an unrelated chunk for the package", () => {
// `undici-types` is a @types transitive; a substring match would flag it.
assert.deepEqual(
findInlinedExternals({
externals: ["undici"],
buildFiles: ["index.js", "undici-types-AA22.js"],
entrySource: "",
}),
[],
);
});
});
describe("BUNDLED_CLIENTS", () => {
test("covers every tsup-bundled client", () => {
// `clients/launcher` is plain tsc and emits no bundle, so it is deliberately
// absent. If a fourth bundled client appears, it must be enrolled here.
assert.deepEqual(BUNDLED_CLIENTS.map((c) => c.name).sort(), [
"cli",
"tui",
"web",
]);
});
});