From ecec68d06d19108a147db3bf563a49f876aed308 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 01:46:43 +0100 Subject: [PATCH] fix: apply undici family fallback to guarded fetch --- CHANGELOG.md | 1 + src/infra/net/fetch-guard.ssrf.test.ts | 34 +++++++++- src/infra/net/ssrf.dispatcher.test.ts | 81 ++++++++++++++++++++++- src/infra/net/undici-family-policy.ts | 39 +++++++++++ src/infra/net/undici-global-dispatcher.ts | 42 ++---------- src/infra/net/undici-runtime.ts | 46 +++++++++++-- 6 files changed, 199 insertions(+), 44 deletions(-) create mode 100644 src/infra/net/undici-family-policy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 32098aeaa7f..ca25ed400ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai - Gateway/logging: expand leading `~` in `logging.file` before creating the file logger, preventing startup crash loops for home-relative log paths. Fixes #73587. - Channels/CLI: keep `openclaw channels list --json` usable when provider usage fetching fails, and report per-provider usage errors without aborting the channel list. Refs #67595. - Agents/messaging: deliver distinct final commentary after same-target `message` tool sends while still deduping text/media already sent by the tool, so short closing remarks are no longer silently dropped. Fixes #76915. Thanks @hclsys. +- OpenAI Codex: let SSRF-guarded provider requests inherit OpenClaw's undici IPv4/IPv6 fallback policy, so ChatGPT-backed Codex runs recover on IPv4-working hosts when DNS still returns unreachable IPv6 addresses. Fixes #76857. Thanks @jplavoiemtl and @SymbolStar. - Gateway/systemd: preserve operator-added secrets in the Gateway env file across re-stage while clearing OpenClaw-managed keys (such as `OPENCLAW_GATEWAY_TOKEN`) so a fresh staging value is never shadowed by a stale env-file copy; operator secrets are also retained when the state-dir `.env` is empty. Fixes #76860. Thanks @hclsys. - Plugin updates: do not short-circuit trusted official npm updates as unchanged when the default/latest spec still resolves to an already-installed prerelease that the installer should replace with a stable fallback. Thanks @vincentkoc. - Plugin tools: keep auth-unavailable optional tools hidden even when another default tool from the same plugin is available and `tools.alsoAllow` names the optional tool. Thanks @vincentkoc. diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 3a6848f31c2..ada989e34ce 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fetchWithSsrFGuard, GUARDED_FETCH_MODE, @@ -24,8 +24,21 @@ const { agentCtor, envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({ this.options = options; }), })); +const { getDefaultAutoSelectFamily, isWSL2SyncMock } = vi.hoisted(() => ({ + getDefaultAutoSelectFamily: vi.fn(() => true as boolean | undefined), + isWSL2SyncMock: vi.fn(() => false), +})); const logWarnMock = vi.hoisted(() => vi.fn()); +vi.mock("node:net", async (importOriginal) => ({ + ...(await importOriginal()), + getDefaultAutoSelectFamily, +})); + +vi.mock("../wsl.js", () => ({ + isWSL2Sync: isWSL2SyncMock, +})); + vi.mock("../../logger.js", async () => { const actual = await vi.importActual("../../logger.js"); return { @@ -163,17 +176,32 @@ describe("fetchWithSsrFGuard hardening", () => { if (params.expectEnvProxy) { expect(envHttpProxyAgentCtor).toHaveBeenCalledTimes(1); expect(envHttpProxyAgentCtor).toHaveBeenCalledWith({ + connect: { + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }, + proxyTls: { + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }, allowH2: false, }); } await result.release(); } + beforeEach(() => { + getDefaultAutoSelectFamily.mockReturnValue(true); + isWSL2SyncMock.mockReturnValue(false); + }); + afterEach(() => { vi.unstubAllEnvs(); agentCtor.mockClear(); envHttpProxyAgentCtor.mockClear(); proxyAgentCtor.mockClear(); + getDefaultAutoSelectFamily.mockClear(); + isWSL2SyncMock.mockClear(); logWarnMock.mockClear(); resetGlobalUndiciStreamTimeoutsForTests(); Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY); @@ -511,6 +539,10 @@ describe("fetchWithSsrFGuard hardening", () => { expect(proxyAgentCtor).toHaveBeenCalledWith({ uri: "http://proxy.example:7890", + proxyTls: { + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }, allowH2: false, requestTls: { servername: "public.example", diff --git a/src/infra/net/ssrf.dispatcher.test.ts b/src/infra/net/ssrf.dispatcher.test.ts index 586fed39105..b483d727bc2 100644 --- a/src/infra/net/ssrf.dispatcher.test.ts +++ b/src/infra/net/ssrf.dispatcher.test.ts @@ -16,6 +16,20 @@ const { agentCtor, envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({ }), })); +const { getDefaultAutoSelectFamily, isWSL2SyncMock } = vi.hoisted(() => ({ + getDefaultAutoSelectFamily: vi.fn(() => true as boolean | undefined), + isWSL2SyncMock: vi.fn(() => false), +})); + +vi.mock("node:net", async (importOriginal) => ({ + ...(await importOriginal()), + getDefaultAutoSelectFamily, +})); + +vi.mock("../wsl.js", () => ({ + isWSL2Sync: isWSL2SyncMock, +})); + import type { PinnedHostname } from "./ssrf.js"; let createPinnedDispatcher: typeof import("./ssrf.js").createPinnedDispatcher; @@ -28,6 +42,8 @@ beforeEach(() => { agentCtor.mockClear(); envHttpProxyAgentCtor.mockClear(); proxyAgentCtor.mockClear(); + getDefaultAutoSelectFamily.mockReturnValue(true); + isWSL2SyncMock.mockReturnValue(false); (globalThis as Record)[TEST_UNDICI_RUNTIME_DEPS_KEY] = { Agent: agentCtor, EnvHttpProxyAgent: envHttpProxyAgentCtor, @@ -62,7 +78,7 @@ function createDispatcherWithPinnedOverride(lookup: PinnedHostname["lookup"]) { } describe("createPinnedDispatcher", () => { - it("uses pinned lookup without overriding global family policy", () => { + it("uses pinned lookup and inherits the shared undici family policy", () => { const lookup = vi.fn() as unknown as PinnedHostname["lookup"]; const pinned: PinnedHostname = { hostname: "api.telegram.org", @@ -76,13 +92,36 @@ describe("createPinnedDispatcher", () => { expect(agentCtor).toHaveBeenCalledWith({ connect: { lookup, + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, }, allowH2: false, }); const firstCallArg = agentCtor.mock.calls[0]?.[0] as | { connect?: Record } | undefined; - expect(firstCallArg?.connect?.autoSelectFamily).toBeUndefined(); + expect(firstCallArg?.connect?.autoSelectFamily).toBe(true); + }); + + it("reuses the global WSL2 autoSelectFamily policy for pinned dispatchers", () => { + isWSL2SyncMock.mockReturnValue(true); + const lookup = vi.fn() as unknown as PinnedHostname["lookup"]; + const pinned: PinnedHostname = { + hostname: "api.telegram.org", + addresses: ["149.154.167.220"], + lookup, + }; + + createPinnedDispatcher(pinned); + + expect(agentCtor).toHaveBeenCalledWith({ + connect: { + lookup, + autoSelectFamily: false, + autoSelectFamilyAttemptTimeout: 300, + }, + allowH2: false, + }); }); it("preserves caller transport hints while overriding lookup", () => { @@ -113,6 +152,32 @@ describe("createPinnedDispatcher", () => { }); }); + it("preserves explicit family-selection opt-outs", () => { + const lookup = vi.fn() as unknown as PinnedHostname["lookup"]; + const pinned: PinnedHostname = { + hostname: "api.telegram.org", + addresses: ["149.154.167.220"], + lookup, + }; + + createPinnedDispatcher(pinned, { + mode: "direct", + connect: { + autoSelectFamily: false, + autoSelectFamilyAttemptTimeout: 50, + }, + }); + + expect(agentCtor).toHaveBeenCalledWith({ + connect: { + autoSelectFamily: false, + autoSelectFamilyAttemptTimeout: 50, + lookup, + }, + allowH2: false, + }); + }); + it("applies stream timeouts to pinned direct dispatchers", () => { const lookup = vi.fn() as unknown as PinnedHostname["lookup"]; const pinned: PinnedHostname = { @@ -126,6 +191,8 @@ describe("createPinnedDispatcher", () => { expect(agentCtor).toHaveBeenCalledWith({ connect: { lookup, + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, timeout: 123_456, }, allowH2: false, @@ -204,11 +271,13 @@ describe("createPinnedDispatcher", () => { expect(envHttpProxyAgentCtor).toHaveBeenCalledWith({ connect: { autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, lookup, }, allowH2: false, proxyTls: { autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, }, }); }); @@ -231,6 +300,10 @@ describe("createPinnedDispatcher", () => { expect(proxyAgentCtor).toHaveBeenCalledWith({ uri: "http://127.0.0.1:7890", + proxyTls: { + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }, allowH2: false, requestTls: { autoSelectFamily: false, @@ -266,7 +339,9 @@ describe("createPinnedDispatcher", () => { autoSelectFamily: false, lookup, }, - connect: { + proxyTls: { + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, timeout: 654_321, }, allowH2: false, diff --git a/src/infra/net/undici-family-policy.ts b/src/infra/net/undici-family-policy.ts new file mode 100644 index 00000000000..bd0cd8388a6 --- /dev/null +++ b/src/infra/net/undici-family-policy.ts @@ -0,0 +1,39 @@ +import * as net from "node:net"; +import { isWSL2Sync } from "../wsl.js"; + +const AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300; + +export function resolveUndiciAutoSelectFamily(): boolean | undefined { + if (typeof net.getDefaultAutoSelectFamily !== "function") { + return undefined; + } + try { + const systemDefault = net.getDefaultAutoSelectFamily(); + // WSL2 has unstable IPv6 connectivity; disable autoSelectFamily to force + // IPv4 connections and avoid fetch failures when reaching Windows-host services. + if (systemDefault && isWSL2Sync()) { + return false; + } + return systemDefault; + } catch { + return undefined; + } +} + +export function createUndiciAutoSelectFamilyConnectOptions( + autoSelectFamily: boolean | undefined, +): { autoSelectFamily: boolean; autoSelectFamilyAttemptTimeout: number } | undefined { + if (autoSelectFamily === undefined) { + return undefined; + } + return { + autoSelectFamily, + autoSelectFamilyAttemptTimeout: AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS, + }; +} + +export function resolveUndiciAutoSelectFamilyConnectOptions(): + | { autoSelectFamily: boolean; autoSelectFamilyAttemptTimeout: number } + | undefined { + return createUndiciAutoSelectFamilyConnectOptions(resolveUndiciAutoSelectFamily()); +} diff --git a/src/infra/net/undici-global-dispatcher.ts b/src/infra/net/undici-global-dispatcher.ts index 05476b43b67..9ee4fbb52f2 100644 --- a/src/infra/net/undici-global-dispatcher.ts +++ b/src/infra/net/undici-global-dispatcher.ts @@ -1,7 +1,9 @@ -import * as net from "node:net"; import { Agent, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from "undici"; -import { isWSL2Sync } from "../wsl.js"; import { hasEnvHttpProxyAgentConfigured, resolveEnvHttpProxyAgentOptions } from "./proxy-env.js"; +import { + createUndiciAutoSelectFamilyConnectOptions, + resolveUndiciAutoSelectFamily, +} from "./undici-family-policy.js"; export const DEFAULT_UNDICI_STREAM_TIMEOUT_MS = 30 * 60 * 1000; @@ -12,8 +14,6 @@ export const DEFAULT_UNDICI_STREAM_TIMEOUT_MS = 30 * 60 * 1000; */ export let _globalUndiciStreamTimeoutMs: number | undefined; -const AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300; - let lastAppliedTimeoutKey: string | null = null; let lastAppliedProxyBootstrap = false; @@ -36,36 +36,6 @@ function resolveDispatcherKind(dispatcher: unknown): DispatcherKind { return "unsupported"; } -function resolveAutoSelectFamily(): boolean | undefined { - if (typeof net.getDefaultAutoSelectFamily !== "function") { - return undefined; - } - try { - const systemDefault = net.getDefaultAutoSelectFamily(); - // WSL2 has unstable IPv6 connectivity; disable autoSelectFamily to - // force IPv4 connections and avoid "fetch failed" errors when reaching - // Windows-host services (e.g. Ollama) from inside WSL2. - if (systemDefault && isWSL2Sync()) { - return false; - } - return systemDefault; - } catch { - return undefined; - } -} - -function resolveConnectOptions( - autoSelectFamily: boolean | undefined, -): { autoSelectFamily: boolean; autoSelectFamilyAttemptTimeout: number } | undefined { - if (autoSelectFamily === undefined) { - return undefined; - } - return { - autoSelectFamily, - autoSelectFamilyAttemptTimeout: AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS, - }; -} - function resolveDispatcherKey(params: { kind: DispatcherKind; timeoutMs: number; @@ -127,13 +97,13 @@ export function ensureGlobalUndiciStreamTimeouts(opts?: { timeoutMs?: number }): return; } - const autoSelectFamily = resolveAutoSelectFamily(); + const autoSelectFamily = resolveUndiciAutoSelectFamily(); const nextKey = resolveDispatcherKey({ kind, timeoutMs, autoSelectFamily }); if (lastAppliedTimeoutKey === nextKey) { return; } - const connect = resolveConnectOptions(autoSelectFamily); + const connect = createUndiciAutoSelectFamilyConnectOptions(autoSelectFamily); try { if (kind === "env-proxy") { const proxyOptions = { diff --git a/src/infra/net/undici-runtime.ts b/src/infra/net/undici-runtime.ts index d08d05a09e7..98a64e62c84 100644 --- a/src/infra/net/undici-runtime.ts +++ b/src/infra/net/undici-runtime.ts @@ -1,4 +1,5 @@ import { createRequire } from "node:module"; +import { resolveUndiciAutoSelectFamilyConnectOptions } from "./undici-family-policy.js"; export const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__"; @@ -27,6 +28,17 @@ function isObjectRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } +function applyMissingConnectOptions( + connect: Record, + defaults: Record, +): void { + for (const [key, value] of Object.entries(defaults)) { + if (!(key in connect)) { + connect[key] = value; + } + } +} + function isUndiciRuntimeDeps(value: unknown): value is UndiciRuntimeDeps { return ( typeof value === "object" && @@ -58,6 +70,7 @@ export function loadUndiciRuntimeDeps(): UndiciRuntimeDeps { function withHttp1OnlyDispatcherOptions( options?: T, timeoutMs?: number, + applyTo?: { connect?: boolean; proxyTls?: boolean }, ): (T extends object ? T : Record) & { allowH2: false } { const base = {} as (T extends object ? T : Record) & { allowH2: false }; if (options) { @@ -65,17 +78,35 @@ function withHttp1OnlyDispatcherOptions( } // Enforce HTTP/1.1-only — must come after options to prevent accidental override Object.assign(base, HTTP1_ONLY_DISPATCHER_OPTIONS); + const baseRecord = base as Record; + const targets = applyTo ?? { connect: true }; + const autoSelectConnect = resolveUndiciAutoSelectFamilyConnectOptions(); + if (autoSelectConnect && targets.connect && typeof baseRecord.connect !== "function") { + const connect = isObjectRecord(baseRecord.connect) ? baseRecord.connect : {}; + applyMissingConnectOptions(connect, autoSelectConnect); + baseRecord.connect = connect; + } + if (autoSelectConnect && targets.proxyTls) { + const proxyTls = isObjectRecord(baseRecord.proxyTls) ? baseRecord.proxyTls : {}; + applyMissingConnectOptions(proxyTls, autoSelectConnect); + baseRecord.proxyTls = proxyTls; + } if (timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0) { const normalizedTimeoutMs = Math.floor(timeoutMs); - const baseRecord = base as Record; baseRecord.bodyTimeout = normalizedTimeoutMs; baseRecord.headersTimeout = normalizedTimeoutMs; - if (typeof baseRecord.connect !== "function") { + if (targets.connect && typeof baseRecord.connect !== "function") { baseRecord.connect = { ...(isObjectRecord(baseRecord.connect) ? baseRecord.connect : {}), timeout: normalizedTimeoutMs, }; } + if (targets.proxyTls) { + baseRecord.proxyTls = { + ...(isObjectRecord(baseRecord.proxyTls) ? baseRecord.proxyTls : {}), + timeout: normalizedTimeoutMs, + }; + } } return base; } @@ -93,7 +124,12 @@ export function createHttp1EnvHttpProxyAgent( timeoutMs?: number, ): import("undici").EnvHttpProxyAgent { const { EnvHttpProxyAgent } = loadUndiciRuntimeDeps(); - return new EnvHttpProxyAgent(withHttp1OnlyDispatcherOptions(options, timeoutMs)); + return new EnvHttpProxyAgent( + withHttp1OnlyDispatcherOptions(options, timeoutMs, { + connect: true, + proxyTls: true, + }), + ); } export function createHttp1ProxyAgent( @@ -106,6 +142,8 @@ export function createHttp1ProxyAgent( ? { uri: options.toString() } : { ...options }; return new ProxyAgent( - withHttp1OnlyDispatcherOptions(normalized as object, timeoutMs) as UndiciProxyAgentOptions, + withHttp1OnlyDispatcherOptions(normalized as object, timeoutMs, { + proxyTls: true, + }) as UndiciProxyAgentOptions, ); }