From d52f581f76c217fe4d165df38a855982de085fea Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 6 May 2026 06:03:28 +0100 Subject: [PATCH] fix: avoid fetch runtime proxy imports --- src/infra/net/fetch-guard.ts | 64 ++++++++++++++++++++-------- src/plugin-sdk/fetch-runtime.test.ts | 38 +++++++++++++++++ src/proxy-capture/env.ts | 11 ++++- 3 files changed, 94 insertions(+), 19 deletions(-) create mode 100644 src/plugin-sdk/fetch-runtime.test.ts diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index 564edf5e9a3..1be073cbc42 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -1,6 +1,5 @@ import type { Dispatcher } from "undici"; import { logWarn } from "../../logger.js"; -import { captureHttpExchange } from "../../proxy-capture/runtime.js"; import { buildTimeoutAbortSignal } from "../../utils/fetch-timeout.js"; import { hasProxyEnvConfigured, shouldUseEnvHttpProxyForUrl } from "./proxy-env.js"; import { retainSafeHeadersForCrossOriginRedirect as retainSafeRedirectHeaders } from "./redirect-headers.js"; @@ -95,6 +94,11 @@ type GuardedFetchPresetOptions = Omit< >; const DEFAULT_MAX_REDIRECTS = 3; +const OPENCLAW_DEBUG_PROXY_ENABLED = "OPENCLAW_DEBUG_PROXY_ENABLED"; + +function isTruthyEnvValue(value: string | undefined): boolean { + return value === "1" || value === "true" || value === "yes" || value === "on"; +} export function withStrictGuardedFetchMode(params: GuardedFetchPresetOptions): GuardedFetchOptions { return { ...params, mode: GUARDED_FETCH_MODE.STRICT }; @@ -232,6 +236,36 @@ export function retainSafeHeadersForCrossOriginRedirectHeaders( return retainSafeRedirectHeaders(headers); } +async function captureGuardedFetchExchange(params: { + url: string; + method: string; + requestHeaders?: Headers | Record | undefined; + requestBody?: BodyInit | Buffer | string | null; + response: Response; + transport?: "http" | "sse"; + capture: GuardedFetchOptions["capture"]; + auditContext?: string; +}): Promise { + if (params.capture === false || !isTruthyEnvValue(process.env[OPENCLAW_DEBUG_PROXY_ENABLED])) { + return; + } + const { captureHttpExchange } = await import("../../proxy-capture/runtime.js"); + captureHttpExchange({ + url: params.url, + method: params.method, + requestHeaders: params.requestHeaders, + requestBody: params.requestBody, + response: params.response, + transport: params.transport, + flowId: params.capture?.flowId, + meta: { + captureOrigin: "guarded-fetch", + ...(params.auditContext ? { auditContext: params.auditContext } : {}), + ...params.capture?.meta, + }, + }); +} + function retainSafeHeadersForCrossOriginRedirect(init?: RequestInit): RequestInit | undefined { if (!init?.headers) { return init; @@ -433,23 +467,17 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise | undefined, - requestBody: - (currentInit as (RequestInit & { body?: BodyInit | null }) | undefined)?.body ?? null, - response, - transport: "http", - flowId: params.capture?.flowId, - meta: { - captureOrigin: "guarded-fetch", - ...(params.auditContext ? { auditContext: params.auditContext } : {}), - ...params.capture?.meta, - }, - }); - } + await captureGuardedFetchExchange({ + url: parsedUrl.toString(), + method: currentInit?.method ?? "GET", + requestHeaders: currentInit?.headers as Headers | Record | undefined, + requestBody: + (currentInit as (RequestInit & { body?: BodyInit | null }) | undefined)?.body ?? null, + response, + transport: "http", + capture: params.capture, + auditContext: params.auditContext, + }); if (isRedirectStatus(response.status)) { const location = response.headers.get("location"); diff --git a/src/plugin-sdk/fetch-runtime.test.ts b/src/plugin-sdk/fetch-runtime.test.ts new file mode 100644 index 00000000000..13d89648e5b --- /dev/null +++ b/src/plugin-sdk/fetch-runtime.test.ts @@ -0,0 +1,38 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; + +describe("plugin SDK fetch runtime", () => { + it("does not initialize the undici global dispatcher on import", () => { + const moduleUrl = pathToFileURL(path.resolve("src/plugin-sdk/fetch-runtime.ts")).href; + const source = ` + const dispatcherKey = Symbol.for("undici.globalDispatcher.1"); + await import(${JSON.stringify(moduleUrl)}); + if (globalThis[dispatcherKey] !== undefined) { + throw new Error("undici global dispatcher was initialized"); + } + console.log("ok"); + `; + const env = { ...process.env }; + for (const key of [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "OPENCLAW_DEBUG_PROXY_ENABLED", + ]) { + delete env[key]; + } + + const output = execFileSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", source], + { cwd: process.cwd(), encoding: "utf8", env }, + ); + + expect(output.trim()).toBe("ok"); + }); +}); diff --git a/src/proxy-capture/env.ts b/src/proxy-capture/env.ts index a05b62661f5..520b92eb014 100644 --- a/src/proxy-capture/env.ts +++ b/src/proxy-capture/env.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import type { Agent } from "node:http"; +import { createRequire } from "node:module"; import process from "node:process"; -import { HttpsProxyAgent } from "https-proxy-agent"; import { resolveDebugProxyBlobDir, resolveDebugProxyCertDir, @@ -28,6 +28,14 @@ export type DebugProxySettings = { }; let cachedImplicitSessionId: string | undefined; +let cachedHttpsProxyAgent: typeof import("https-proxy-agent").HttpsProxyAgent | undefined; + +function loadHttpsProxyAgent(): typeof import("https-proxy-agent").HttpsProxyAgent { + cachedHttpsProxyAgent ??= ( + createRequire(import.meta.url)("https-proxy-agent") as typeof import("https-proxy-agent") + ).HttpsProxyAgent; + return cachedHttpsProxyAgent; +} function isTruthy(value: string | undefined): boolean { return value === "1" || value === "true" || value === "yes" || value === "on"; @@ -80,6 +88,7 @@ export function createDebugProxyWebSocketAgent(settings: DebugProxySettings): Ag if (!settings.enabled || !settings.proxyUrl) { return undefined; } + const HttpsProxyAgent = loadHttpsProxyAgent(); return new HttpsProxyAgent(settings.proxyUrl); }