mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
fix: apply undici family fallback to guarded fetch
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<typeof import("node:net")>()),
|
||||
getDefaultAutoSelectFamily,
|
||||
}));
|
||||
|
||||
vi.mock("../wsl.js", () => ({
|
||||
isWSL2Sync: isWSL2SyncMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../logger.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../logger.js")>("../../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",
|
||||
|
||||
@@ -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<typeof import("node:net")>()),
|
||||
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<string, unknown>)[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<string, unknown> }
|
||||
| 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,
|
||||
|
||||
39
src/infra/net/undici-family-policy.ts
Normal file
39
src/infra/net/undici-family-policy.ts
Normal file
@@ -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());
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function applyMissingConnectOptions(
|
||||
connect: Record<string, unknown>,
|
||||
defaults: Record<string, unknown>,
|
||||
): 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<T extends object | undefined>(
|
||||
options?: T,
|
||||
timeoutMs?: number,
|
||||
applyTo?: { connect?: boolean; proxyTls?: boolean },
|
||||
): (T extends object ? T : Record<never, never>) & { allowH2: false } {
|
||||
const base = {} as (T extends object ? T : Record<never, never>) & { allowH2: false };
|
||||
if (options) {
|
||||
@@ -65,17 +78,35 @@ function withHttp1OnlyDispatcherOptions<T extends object | undefined>(
|
||||
}
|
||||
// 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user