fix: avoid fetch runtime proxy imports

This commit is contained in:
Shakker
2026-05-06 06:03:28 +01:00
committed by Shakker
parent c9c66d7a1d
commit d52f581f76
3 changed files with 94 additions and 19 deletions

View File

@@ -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<string, string> | undefined;
requestBody?: BodyInit | Buffer | string | null;
response: Response;
transport?: "http" | "sse";
capture: GuardedFetchOptions["capture"];
auditContext?: string;
}): Promise<void> {
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<G
? await fetchWithRuntimeDispatcher(parsedUrl.toString(), init)
: await defaultFetch(parsedUrl.toString(), init);
if (params.capture !== false) {
captureHttpExchange({
url: parsedUrl.toString(),
method: currentInit?.method ?? "GET",
requestHeaders: currentInit?.headers as Headers | Record<string, string> | 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<string, string> | 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");

View File

@@ -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");
});
});

View File

@@ -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);
}