From d513dc71469f13746836164c97c51cab95e2a137 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 02:22:03 +0100 Subject: [PATCH] fix: bootstrap gateway env proxy dispatcher Co-authored-by: mjamiv <74088820+mjamiv@users.noreply.github.com> --- CHANGELOG.md | 3 + .../server-network-runtime.e2e.test.ts | 109 ++++++++++++++++++ src/gateway/server-network-runtime.test.ts | 21 ++++ src/gateway/server-network-runtime.ts | 5 + src/gateway/server.impl.ts | 3 + 5 files changed, 141 insertions(+) create mode 100644 src/gateway/server-network-runtime.e2e.test.ts create mode 100644 src/gateway/server-network-runtime.test.ts create mode 100644 src/gateway/server-network-runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9753f8e49fd..16bb53fd88d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,9 @@ Docs: https://docs.openclaw.ai - Gateway/Fly.io: seed Control UI allowed origins from the actual runtime bind and port so CLI-driven non-loopback starts do not crash before config exists. Fixes #71823. +- Gateway/proxy: bootstrap env proxy dispatching from direct Gateway startup + so provider and plugin network requests honor `HTTPS_PROXY`/`HTTP_PROXY` + before the first embedded agent attempt runs. (#71833) Thanks @mjamiv. - Models/LM Studio: preserve `@iq*` quant suffixes in model refs and provider matching so `/model lmstudio/...@iq3_xxs` keeps the exact LM Studio variant. Fixes #71474. (#71486) Thanks @Bartok9, @XinwuC, and @Sanjays2402. diff --git a/src/gateway/server-network-runtime.e2e.test.ts b/src/gateway/server-network-runtime.e2e.test.ts new file mode 100644 index 00000000000..03bd74ea8a6 --- /dev/null +++ b/src/gateway/server-network-runtime.e2e.test.ts @@ -0,0 +1,109 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Agent, getGlobalDispatcher, setGlobalDispatcher } from "undici"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { clearAllBootstrapSnapshots } from "../agents/bootstrap-cache.js"; +import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; +import { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; +import { resetAgentRunContextForTest } from "../infra/agent-events.js"; +import { PROXY_ENV_KEYS } from "../infra/net/proxy-env.js"; +import { clearGatewaySubagentRuntime } from "../plugins/runtime/index.js"; +import { captureEnv } from "../test-utils/env.js"; +import { startGatewayServer } from "./server.js"; +import { getFreeGatewayPort } from "./test-helpers.e2e.js"; + +const NETWORK_GATEWAY_ENV_KEYS = [ + "HOME", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_GATEWAY_TOKEN", + "OPENCLAW_SKIP_CHANNELS", + "OPENCLAW_SKIP_GMAIL_WATCHER", + "OPENCLAW_SKIP_CRON", + "OPENCLAW_SKIP_CANVAS_HOST", + "OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", + "OPENCLAW_SKIP_PROVIDERS", + "OPENCLAW_BUNDLED_PLUGINS_DIR", + "OPENCLAW_TEST_MINIMAL_GATEWAY", + ...PROXY_ENV_KEYS, + "NO_PROXY", + "no_proxy", +] as const; + +function isEnvHttpProxyDispatcher(dispatcher: unknown): boolean { + return ( + (dispatcher as { constructor?: { name?: string } } | undefined)?.constructor?.name === + "EnvHttpProxyAgent" + ); +} + +describe("gateway network runtime", () => { + beforeEach(() => { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + clearSessionStoreCacheForTest(); + resetAgentRunContextForTest(); + clearAllBootstrapSnapshots(); + clearGatewaySubagentRuntime(); + }); + + afterEach(() => { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + clearSessionStoreCacheForTest(); + resetAgentRunContextForTest(); + clearAllBootstrapSnapshots(); + clearGatewaySubagentRuntime(); + }); + + it("bootstraps env proxy dispatching when the gateway starts directly", async () => { + const envSnapshot = captureEnv([...NETWORK_GATEWAY_ENV_KEYS]); + const originalDispatcher = getGlobalDispatcher(); + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-proxy-home-")); + let server: Awaited> | undefined; + + try { + setGlobalDispatcher(new Agent()); + for (const key of NETWORK_GATEWAY_ENV_KEYS) { + delete process.env[key]; + } + process.env.HTTPS_PROXY = "http://127.0.0.1:9"; + + process.env.HOME = tempHome; + process.env.OPENCLAW_STATE_DIR = path.join(tempHome, ".openclaw"); + process.env.OPENCLAW_SKIP_CHANNELS = "1"; + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; + process.env.OPENCLAW_SKIP_CRON = "1"; + process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; + process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1"; + process.env.OPENCLAW_SKIP_PROVIDERS = "1"; + process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "1"; + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(tempHome, "empty-bundled-plugins"); + await fs.mkdir(process.env.OPENCLAW_BUNDLED_PLUGINS_DIR, { recursive: true }); + + const token = `proxy-token-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}`; + process.env.OPENCLAW_GATEWAY_TOKEN = token; + const configPath = path.join(tempHome, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ gateway: { auth: { mode: "token", token } } }, null, 2)}\n`, + ); + process.env.OPENCLAW_CONFIG_PATH = configPath; + + server = await startGatewayServer(await getFreeGatewayPort(), { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + }); + + expect(isEnvHttpProxyDispatcher(getGlobalDispatcher())).toBe(true); + } finally { + await server?.close({ reason: "gateway proxy bootstrap test complete" }); + setGlobalDispatcher(originalDispatcher); + await fs.rm(tempHome, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); +}); diff --git a/src/gateway/server-network-runtime.test.ts b/src/gateway/server-network-runtime.test.ts new file mode 100644 index 00000000000..ff468d6f34b --- /dev/null +++ b/src/gateway/server-network-runtime.test.ts @@ -0,0 +1,21 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ensureGlobalUndiciEnvProxyDispatcherMock = vi.fn(); + +vi.mock("../infra/net/undici-global-dispatcher.js", () => ({ + ensureGlobalUndiciEnvProxyDispatcher: ensureGlobalUndiciEnvProxyDispatcherMock, +})); + +const { bootstrapGatewayNetworkRuntime } = await import("./server-network-runtime.js"); + +describe("bootstrapGatewayNetworkRuntime", () => { + beforeEach(() => { + ensureGlobalUndiciEnvProxyDispatcherMock.mockClear(); + }); + + it("installs the env proxy dispatcher for gateway-owned network work", () => { + bootstrapGatewayNetworkRuntime(); + + expect(ensureGlobalUndiciEnvProxyDispatcherMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/gateway/server-network-runtime.ts b/src/gateway/server-network-runtime.ts new file mode 100644 index 00000000000..b71a2f457a7 --- /dev/null +++ b/src/gateway/server-network-runtime.ts @@ -0,0 +1,5 @@ +import { ensureGlobalUndiciEnvProxyDispatcher } from "../infra/net/undici-global-dispatcher.js"; + +export function bootstrapGatewayNetworkRuntime(): void { + ensureGlobalUndiciEnvProxyDispatcher(); +} diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index a59b9b8e1a7..ca9c22c3f3f 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -55,6 +55,7 @@ import { createGatewayServerLiveState, type GatewayServerLiveState } from "./ser import { GATEWAY_EVENTS } from "./server-methods-list.js"; import { coreGatewayHandlers } from "./server-methods.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js"; +import { bootstrapGatewayNetworkRuntime } from "./server-network-runtime.js"; import { createGatewayNodeSessionRuntime } from "./server-node-session-runtime.js"; import { setFallbackGatewayContextResolver } from "./server-plugins.js"; import { startManagedGatewayConfigReloader } from "./server-reload-handlers.js"; @@ -244,6 +245,8 @@ export async function startGatewayServer( port = 18789, opts: GatewayServerOptions = {}, ): Promise { + bootstrapGatewayNetworkRuntime(); + const minimalTestGateway = isVitestRuntimeEnv() && process.env.OPENCLAW_TEST_MINIMAL_GATEWAY === "1";