From df41a200a56f930daf0fcc4f555a3e9ee195c9e7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 08:21:40 +0000 Subject: [PATCH] fix: keep gaxios compat off the package root (#47914) (thanks @pdd-cli) --- CHANGELOG.md | 1 + src/entry.ts | 4 +- src/index.test.ts | 14 ++ src/index.ts | 8 +- src/infra/gaxios-fetch-compat.test.ts | 60 +++++++ src/infra/gaxios-fetch-compat.ts | 237 +++++++++++++++++++++++--- 6 files changed, 295 insertions(+), 29 deletions(-) create mode 100644 src/infra/gaxios-fetch-compat.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a80bae4ced0..4f9fb7a653d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai - Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc. - Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI. - Control UI/session routing: preserve established external delivery routes when webchat views or sends in externally originated sessions, so subagent completions still return to the original channel instead of the dashboard. (#47797) Thanks @brokemac79. +- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli. ## 2026.3.13 diff --git a/src/entry.ts b/src/entry.ts index 1136d4c954a..3496e48f0e9 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -7,7 +7,6 @@ import { isRootHelpInvocation, isRootVersionInvocation } from "./cli/argv.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; -import "./infra/gaxios-fetch-compat.js"; import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js"; import { isMainModule } from "./infra/is-main.js"; import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js"; @@ -42,6 +41,9 @@ if ( ) { // Imported as a dependency — skip all entry-point side effects. } else { + const { installGaxiosFetchCompat } = await import("./infra/gaxios-fetch-compat.js"); + + installGaxiosFetchCompat(); process.title = "openclaw"; ensureOpenClawExecMarkerOnProcess(); installProcessWarningFilter(); diff --git a/src/index.test.ts b/src/index.test.ts index d53d492c527..e1cd55a39e2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -34,6 +34,20 @@ describe("legacy root entry", () => { expect(runtimeMocks.runCli).not.toHaveBeenCalled(); }); + it("keeps library imports free of global window shims", async () => { + const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + Reflect.deleteProperty(globalThis as object, "window"); + + try { + await import("./index.js"); + expect("window" in globalThis).toBe(false); + } finally { + if (originalWindowDescriptor) { + Object.defineProperty(globalThis, "window", originalWindowDescriptor); + } + } + }); + it("delegates legacy direct-entry execution to run-main", async () => { const mod = await import("./index.js"); const argv = ["node", "dist/index.js", "status"]; diff --git a/src/index.ts b/src/index.ts index 83a5caacfa9..92cf6269cc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ #!/usr/bin/env node -import "./infra/gaxios-fetch-compat.js"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { formatUncaughtError } from "./infra/errors.js"; @@ -33,7 +32,12 @@ export const waitForever = library.waitForever; // Legacy direct file entrypoint only. Package root exports now live in library.ts. export async function runLegacyCliEntry(argv: string[] = process.argv): Promise { - const { runCli } = await import("./cli/run-main.js"); + const [{ installGaxiosFetchCompat }, { runCli }] = await Promise.all([ + import("./infra/gaxios-fetch-compat.js"), + import("./cli/run-main.js"), + ]); + + installGaxiosFetchCompat(); await runCli(argv); } diff --git a/src/infra/gaxios-fetch-compat.test.ts b/src/infra/gaxios-fetch-compat.test.ts new file mode 100644 index 00000000000..4d7559f3eee --- /dev/null +++ b/src/infra/gaxios-fetch-compat.test.ts @@ -0,0 +1,60 @@ +import { HttpsProxyAgent } from "https-proxy-agent"; +import { ProxyAgent } from "undici"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("gaxios fetch compat", () => { + afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("uses native fetch without defining window or importing node-fetch", async () => { + const fetchMock = vi.fn(async () => { + return new Response("ok", { + headers: { "content-type": "text/plain" }, + status: 200, + }); + }); + + vi.stubGlobal("fetch", fetchMock); + vi.doMock("node-fetch", () => { + throw new Error("node-fetch should not load"); + }); + + const { installGaxiosFetchCompat } = await import("./gaxios-fetch-compat.js"); + const { Gaxios } = await import("gaxios"); + + installGaxiosFetchCompat(); + + const res = await new Gaxios().request({ + responseType: "text", + url: "https://example.com", + }); + + expect(res.data).toBe("ok"); + expect(fetchMock).toHaveBeenCalledOnce(); + expect("window" in globalThis).toBe(false); + }); + + it("translates proxy agents into undici dispatchers for native fetch", async () => { + const fetchMock = vi.fn(async () => { + return new Response("ok", { + headers: { "content-type": "text/plain" }, + status: 200, + }); + }); + const { createGaxiosCompatFetch } = await import("./gaxios-fetch-compat.js"); + + const compatFetch = createGaxiosCompatFetch(fetchMock); + await compatFetch("https://example.com", { + agent: new HttpsProxyAgent("http://proxy.example:8080"), + } as RequestInit); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [, init] = fetchMock.mock.calls[0] ?? []; + + expect(init).not.toHaveProperty("agent"); + expect((init as { dispatcher?: unknown })?.dispatcher).toBeInstanceOf(ProxyAgent); + }); +}); diff --git a/src/infra/gaxios-fetch-compat.ts b/src/infra/gaxios-fetch-compat.ts index 2c0d0391a58..e4d3688d7e5 100644 --- a/src/infra/gaxios-fetch-compat.ts +++ b/src/infra/gaxios-fetch-compat.ts @@ -1,27 +1,212 @@ -/** - * Compatibility shim for gaxios@7.x on Node.js 22+. - * - * gaxios checks `typeof window !== 'undefined'` to decide between - * `window.fetch` (browser) and `import('node-fetch')` (Node.js). - * On Node.js 22+, `globalThis.fetch` is available natively, but gaxios - * does not check for it before falling back to node-fetch. - * - * node-fetch@3.x ESM loading is broken on Node.js 25 (ESM translator - * calls hasOwnProperty on null, throwing "Cannot convert undefined or - * null to object"). This causes ALL google-vertex auth requests to fail - * before any network call is made. - * - * Fix: define a minimal `window` shim with `fetch = globalThis.fetch` so - * gaxios uses the native fetch implementation instead of importing node-fetch. - * - * This module must be imported (as a side effect) before any google-auth-library - * or gaxios request is made. - */ -if ( - typeof (globalThis as Record)["window"] === "undefined" && - typeof globalThis.fetch === "function" -) { - // Tell gaxios it's in a "browser-like" environment with a working fetch. - // Only the `fetch` property is needed; gaxios only reads `window.fetch`. - (globalThis as Record)["window"] = { fetch: globalThis.fetch }; +import type { ConnectionOptions } from "node:tls"; +import { Gaxios } from "gaxios"; +import type { Dispatcher } from "undici"; +import { Agent as UndiciAgent, ProxyAgent } from "undici"; + +type ProxyRule = RegExp | URL | string; +type TlsCert = ConnectionOptions["cert"]; +type TlsKey = ConnectionOptions["key"]; + +type GaxiosFetchRequestInit = RequestInit & { + agent?: unknown; + cert?: TlsCert; + dispatcher?: Dispatcher; + fetchImplementation?: typeof fetch; + key?: TlsKey; + noProxy?: ProxyRule[]; + proxy?: string | URL; +}; + +type ProxyAgentLike = { + connectOpts?: { cert?: TlsCert; key?: TlsKey }; + proxy: URL; +}; + +type TlsAgentLike = { + options?: { cert?: TlsCert; key?: TlsKey }; +}; + +type GaxiosPrototype = { + _defaultAdapter: (this: Gaxios, config: GaxiosFetchRequestInit) => Promise; +}; + +let installState: "not-installed" | "installed" = "not-installed"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function hasDispatcher(value: unknown): value is Dispatcher { + return isRecord(value) && typeof value.dispatch === "function"; +} + +function hasProxyAgentShape(value: unknown): value is ProxyAgentLike { + return isRecord(value) && value.proxy instanceof URL; +} + +function hasTlsAgentShape(value: unknown): value is TlsAgentLike { + return isRecord(value) && isRecord(value.options); +} + +function resolveTlsOptions( + init: GaxiosFetchRequestInit, + url: URL, +): { cert?: TlsCert; key?: TlsKey } { + const explicit = { + cert: init.cert, + key: init.key, + }; + if (explicit.cert !== undefined || explicit.key !== undefined) { + return explicit; + } + + const agent = typeof init.agent === "function" ? init.agent(url) : init.agent; + if (hasProxyAgentShape(agent)) { + return { + cert: agent.connectOpts?.cert, + key: agent.connectOpts?.key, + }; + } + if (hasTlsAgentShape(agent)) { + return { + cert: agent.options?.cert, + key: agent.options?.key, + }; + } + return {}; +} + +function urlMayUseProxy(url: URL, noProxy: ProxyRule[] = []): boolean { + const rules = [...noProxy]; + const envRules = (process.env.NO_PROXY ?? process.env.no_proxy)?.split(",") ?? []; + for (const rule of envRules) { + const trimmed = rule.trim(); + if (trimmed.length > 0) { + rules.push(trimmed); + } + } + + for (const rule of rules) { + if (rule instanceof RegExp) { + if (rule.test(url.toString())) { + return false; + } + continue; + } + if (rule instanceof URL) { + if (rule.origin === url.origin) { + return false; + } + continue; + } + if (rule.startsWith("*.") || rule.startsWith(".")) { + const cleanedRule = rule.replace(/^\*\./, "."); + if (url.hostname.endsWith(cleanedRule)) { + return false; + } + continue; + } + if (rule === url.origin || rule === url.hostname || rule === url.href) { + return false; + } + } + + return true; +} + +function resolveProxyUri(init: GaxiosFetchRequestInit, url: URL): string | undefined { + if (init.proxy) { + const proxyUri = String(init.proxy); + return urlMayUseProxy(url, init.noProxy) ? proxyUri : undefined; + } + + const envProxy = + process.env.HTTPS_PROXY ?? + process.env.https_proxy ?? + process.env.HTTP_PROXY ?? + process.env.http_proxy; + if (!envProxy) { + return undefined; + } + + return urlMayUseProxy(url, init.noProxy) ? envProxy : undefined; +} + +function buildDispatcher(init: GaxiosFetchRequestInit, url: URL): Dispatcher | undefined { + if (init.dispatcher) { + return init.dispatcher; + } + + const agent = typeof init.agent === "function" ? init.agent(url) : init.agent; + if (hasDispatcher(agent)) { + return agent; + } + + const { cert, key } = resolveTlsOptions(init, url); + const proxyUri = + resolveProxyUri(init, url) ?? (hasProxyAgentShape(agent) ? String(agent.proxy) : undefined); + if (proxyUri) { + return new ProxyAgent({ + requestTls: cert !== undefined || key !== undefined ? { cert, key } : undefined, + uri: proxyUri, + }); + } + + if (cert !== undefined || key !== undefined) { + return new UndiciAgent({ + connect: { cert, key }, + }); + } + + return undefined; +} + +export function createGaxiosCompatFetch(baseFetch: typeof fetch = globalThis.fetch): typeof fetch { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const gaxiosInit = (init ?? {}) as GaxiosFetchRequestInit; + const requestUrl = + input instanceof Request + ? new URL(input.url) + : new URL(typeof input === "string" ? input : input.toString()); + const dispatcher = buildDispatcher(gaxiosInit, requestUrl); + + const nextInit: RequestInit = { ...gaxiosInit }; + delete (nextInit as GaxiosFetchRequestInit).agent; + delete (nextInit as GaxiosFetchRequestInit).cert; + delete (nextInit as GaxiosFetchRequestInit).fetchImplementation; + delete (nextInit as GaxiosFetchRequestInit).key; + delete (nextInit as GaxiosFetchRequestInit).noProxy; + delete (nextInit as GaxiosFetchRequestInit).proxy; + + if (dispatcher) { + (nextInit as RequestInit & { dispatcher: Dispatcher }).dispatcher = dispatcher; + } + + return baseFetch(input, nextInit); + }; +} + +export function installGaxiosFetchCompat(): void { + if (installState === "installed" || typeof globalThis.fetch !== "function") { + return; + } + + const prototype = Gaxios.prototype as unknown as GaxiosPrototype; + const originalDefaultAdapter = prototype._defaultAdapter; + const compatFetch = createGaxiosCompatFetch(); + + prototype._defaultAdapter = function patchedDefaultAdapter( + this: Gaxios, + config: GaxiosFetchRequestInit, + ): Promise { + if (config.fetchImplementation) { + return originalDefaultAdapter.call(this, config); + } + return originalDefaultAdapter.call(this, { + ...config, + fetchImplementation: compatFetch, + }); + }; + + installState = "installed"; }