Skip to content

Commit ecec68d

Browse files
committed
fix: apply undici family fallback to guarded fetch
1 parent 2b01bcf commit ecec68d

6 files changed

Lines changed: 199 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
4545
- Gateway/logging: expand leading `~` in `logging.file` before creating the file logger, preventing startup crash loops for home-relative log paths. Fixes #73587.
4646
- 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.
4747
- 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.
48+
- 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.
4849
- 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.
4950
- 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.
5051
- 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.

src/infra/net/fetch-guard.ssrf.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, describe, expect, it, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import {
33
fetchWithSsrFGuard,
44
GUARDED_FETCH_MODE,
@@ -24,8 +24,21 @@ const { agentCtor, envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({
2424
this.options = options;
2525
}),
2626
}));
27+
const { getDefaultAutoSelectFamily, isWSL2SyncMock } = vi.hoisted(() => ({
28+
getDefaultAutoSelectFamily: vi.fn(() => true as boolean | undefined),
29+
isWSL2SyncMock: vi.fn(() => false),
30+
}));
2731
const logWarnMock = vi.hoisted(() => vi.fn());
2832

33+
vi.mock("node:net", async (importOriginal) => ({
34+
...(await importOriginal<typeof import("node:net")>()),
35+
getDefaultAutoSelectFamily,
36+
}));
37+
38+
vi.mock("../wsl.js", () => ({
39+
isWSL2Sync: isWSL2SyncMock,
40+
}));
41+
2942
vi.mock("../../logger.js", async () => {
3043
const actual = await vi.importActual<typeof import("../../logger.js")>("../../logger.js");
3144
return {
@@ -163,17 +176,32 @@ describe("fetchWithSsrFGuard hardening", () => {
163176
if (params.expectEnvProxy) {
164177
expect(envHttpProxyAgentCtor).toHaveBeenCalledTimes(1);
165178
expect(envHttpProxyAgentCtor).toHaveBeenCalledWith({
179+
connect: {
180+
autoSelectFamily: true,
181+
autoSelectFamilyAttemptTimeout: 300,
182+
},
183+
proxyTls: {
184+
autoSelectFamily: true,
185+
autoSelectFamilyAttemptTimeout: 300,
186+
},
166187
allowH2: false,
167188
});
168189
}
169190
await result.release();
170191
}
171192

193+
beforeEach(() => {
194+
getDefaultAutoSelectFamily.mockReturnValue(true);
195+
isWSL2SyncMock.mockReturnValue(false);
196+
});
197+
172198
afterEach(() => {
173199
vi.unstubAllEnvs();
174200
agentCtor.mockClear();
175201
envHttpProxyAgentCtor.mockClear();
176202
proxyAgentCtor.mockClear();
203+
getDefaultAutoSelectFamily.mockClear();
204+
isWSL2SyncMock.mockClear();
177205
logWarnMock.mockClear();
178206
resetGlobalUndiciStreamTimeoutsForTests();
179207
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
@@ -511,6 +539,10 @@ describe("fetchWithSsrFGuard hardening", () => {
511539

512540
expect(proxyAgentCtor).toHaveBeenCalledWith({
513541
uri: "http://proxy.example:7890",
542+
proxyTls: {
543+
autoSelectFamily: true,
544+
autoSelectFamilyAttemptTimeout: 300,
545+
},
514546
allowH2: false,
515547
requestTls: {
516548
servername: "public.example",

src/infra/net/ssrf.dispatcher.test.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ const { agentCtor, envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({
1616
}),
1717
}));
1818

19+
const { getDefaultAutoSelectFamily, isWSL2SyncMock } = vi.hoisted(() => ({
20+
getDefaultAutoSelectFamily: vi.fn(() => true as boolean | undefined),
21+
isWSL2SyncMock: vi.fn(() => false),
22+
}));
23+
24+
vi.mock("node:net", async (importOriginal) => ({
25+
...(await importOriginal<typeof import("node:net")>()),
26+
getDefaultAutoSelectFamily,
27+
}));
28+
29+
vi.mock("../wsl.js", () => ({
30+
isWSL2Sync: isWSL2SyncMock,
31+
}));
32+
1933
import type { PinnedHostname } from "./ssrf.js";
2034

2135
let createPinnedDispatcher: typeof import("./ssrf.js").createPinnedDispatcher;
@@ -28,6 +42,8 @@ beforeEach(() => {
2842
agentCtor.mockClear();
2943
envHttpProxyAgentCtor.mockClear();
3044
proxyAgentCtor.mockClear();
45+
getDefaultAutoSelectFamily.mockReturnValue(true);
46+
isWSL2SyncMock.mockReturnValue(false);
3147
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
3248
Agent: agentCtor,
3349
EnvHttpProxyAgent: envHttpProxyAgentCtor,
@@ -62,7 +78,7 @@ function createDispatcherWithPinnedOverride(lookup: PinnedHostname["lookup"]) {
6278
}
6379

6480
describe("createPinnedDispatcher", () => {
65-
it("uses pinned lookup without overriding global family policy", () => {
81+
it("uses pinned lookup and inherits the shared undici family policy", () => {
6682
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
6783
const pinned: PinnedHostname = {
6884
hostname: "api.telegram.org",
@@ -76,13 +92,36 @@ describe("createPinnedDispatcher", () => {
7692
expect(agentCtor).toHaveBeenCalledWith({
7793
connect: {
7894
lookup,
95+
autoSelectFamily: true,
96+
autoSelectFamilyAttemptTimeout: 300,
7997
},
8098
allowH2: false,
8199
});
82100
const firstCallArg = agentCtor.mock.calls[0]?.[0] as
83101
| { connect?: Record<string, unknown> }
84102
| undefined;
85-
expect(firstCallArg?.connect?.autoSelectFamily).toBeUndefined();
103+
expect(firstCallArg?.connect?.autoSelectFamily).toBe(true);
104+
});
105+
106+
it("reuses the global WSL2 autoSelectFamily policy for pinned dispatchers", () => {
107+
isWSL2SyncMock.mockReturnValue(true);
108+
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
109+
const pinned: PinnedHostname = {
110+
hostname: "api.telegram.org",
111+
addresses: ["149.154.167.220"],
112+
lookup,
113+
};
114+
115+
createPinnedDispatcher(pinned);
116+
117+
expect(agentCtor).toHaveBeenCalledWith({
118+
connect: {
119+
lookup,
120+
autoSelectFamily: false,
121+
autoSelectFamilyAttemptTimeout: 300,
122+
},
123+
allowH2: false,
124+
});
86125
});
87126

88127
it("preserves caller transport hints while overriding lookup", () => {
@@ -113,6 +152,32 @@ describe("createPinnedDispatcher", () => {
113152
});
114153
});
115154

155+
it("preserves explicit family-selection opt-outs", () => {
156+
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
157+
const pinned: PinnedHostname = {
158+
hostname: "api.telegram.org",
159+
addresses: ["149.154.167.220"],
160+
lookup,
161+
};
162+
163+
createPinnedDispatcher(pinned, {
164+
mode: "direct",
165+
connect: {
166+
autoSelectFamily: false,
167+
autoSelectFamilyAttemptTimeout: 50,
168+
},
169+
});
170+
171+
expect(agentCtor).toHaveBeenCalledWith({
172+
connect: {
173+
autoSelectFamily: false,
174+
autoSelectFamilyAttemptTimeout: 50,
175+
lookup,
176+
},
177+
allowH2: false,
178+
});
179+
});
180+
116181
it("applies stream timeouts to pinned direct dispatchers", () => {
117182
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
118183
const pinned: PinnedHostname = {
@@ -126,6 +191,8 @@ describe("createPinnedDispatcher", () => {
126191
expect(agentCtor).toHaveBeenCalledWith({
127192
connect: {
128193
lookup,
194+
autoSelectFamily: true,
195+
autoSelectFamilyAttemptTimeout: 300,
129196
timeout: 123_456,
130197
},
131198
allowH2: false,
@@ -204,11 +271,13 @@ describe("createPinnedDispatcher", () => {
204271
expect(envHttpProxyAgentCtor).toHaveBeenCalledWith({
205272
connect: {
206273
autoSelectFamily: true,
274+
autoSelectFamilyAttemptTimeout: 300,
207275
lookup,
208276
},
209277
allowH2: false,
210278
proxyTls: {
211279
autoSelectFamily: true,
280+
autoSelectFamilyAttemptTimeout: 300,
212281
},
213282
});
214283
});
@@ -231,6 +300,10 @@ describe("createPinnedDispatcher", () => {
231300

232301
expect(proxyAgentCtor).toHaveBeenCalledWith({
233302
uri: "http://127.0.0.1:7890",
303+
proxyTls: {
304+
autoSelectFamily: true,
305+
autoSelectFamilyAttemptTimeout: 300,
306+
},
234307
allowH2: false,
235308
requestTls: {
236309
autoSelectFamily: false,
@@ -266,7 +339,9 @@ describe("createPinnedDispatcher", () => {
266339
autoSelectFamily: false,
267340
lookup,
268341
},
269-
connect: {
342+
proxyTls: {
343+
autoSelectFamily: true,
344+
autoSelectFamilyAttemptTimeout: 300,
270345
timeout: 654_321,
271346
},
272347
allowH2: false,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import * as net from "node:net";
2+
import { isWSL2Sync } from "../wsl.js";
3+
4+
const AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300;
5+
6+
export function resolveUndiciAutoSelectFamily(): boolean | undefined {
7+
if (typeof net.getDefaultAutoSelectFamily !== "function") {
8+
return undefined;
9+
}
10+
try {
11+
const systemDefault = net.getDefaultAutoSelectFamily();
12+
// WSL2 has unstable IPv6 connectivity; disable autoSelectFamily to force
13+
// IPv4 connections and avoid fetch failures when reaching Windows-host services.
14+
if (systemDefault && isWSL2Sync()) {
15+
return false;
16+
}
17+
return systemDefault;
18+
} catch {
19+
return undefined;
20+
}
21+
}
22+
23+
export function createUndiciAutoSelectFamilyConnectOptions(
24+
autoSelectFamily: boolean | undefined,
25+
): { autoSelectFamily: boolean; autoSelectFamilyAttemptTimeout: number } | undefined {
26+
if (autoSelectFamily === undefined) {
27+
return undefined;
28+
}
29+
return {
30+
autoSelectFamily,
31+
autoSelectFamilyAttemptTimeout: AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS,
32+
};
33+
}
34+
35+
export function resolveUndiciAutoSelectFamilyConnectOptions():
36+
| { autoSelectFamily: boolean; autoSelectFamilyAttemptTimeout: number }
37+
| undefined {
38+
return createUndiciAutoSelectFamilyConnectOptions(resolveUndiciAutoSelectFamily());
39+
}

src/infra/net/undici-global-dispatcher.ts

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import * as net from "node:net";
21
import { Agent, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from "undici";
3-
import { isWSL2Sync } from "../wsl.js";
42
import { hasEnvHttpProxyAgentConfigured, resolveEnvHttpProxyAgentOptions } from "./proxy-env.js";
3+
import {
4+
createUndiciAutoSelectFamilyConnectOptions,
5+
resolveUndiciAutoSelectFamily,
6+
} from "./undici-family-policy.js";
57

68
export const DEFAULT_UNDICI_STREAM_TIMEOUT_MS = 30 * 60 * 1000;
79

@@ -12,8 +14,6 @@ export const DEFAULT_UNDICI_STREAM_TIMEOUT_MS = 30 * 60 * 1000;
1214
*/
1315
export let _globalUndiciStreamTimeoutMs: number | undefined;
1416

15-
const AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300;
16-
1717
let lastAppliedTimeoutKey: string | null = null;
1818
let lastAppliedProxyBootstrap = false;
1919

@@ -36,36 +36,6 @@ function resolveDispatcherKind(dispatcher: unknown): DispatcherKind {
3636
return "unsupported";
3737
}
3838

39-
function resolveAutoSelectFamily(): boolean | undefined {
40-
if (typeof net.getDefaultAutoSelectFamily !== "function") {
41-
return undefined;
42-
}
43-
try {
44-
const systemDefault = net.getDefaultAutoSelectFamily();
45-
// WSL2 has unstable IPv6 connectivity; disable autoSelectFamily to
46-
// force IPv4 connections and avoid "fetch failed" errors when reaching
47-
// Windows-host services (e.g. Ollama) from inside WSL2.
48-
if (systemDefault && isWSL2Sync()) {
49-
return false;
50-
}
51-
return systemDefault;
52-
} catch {
53-
return undefined;
54-
}
55-
}
56-
57-
function resolveConnectOptions(
58-
autoSelectFamily: boolean | undefined,
59-
): { autoSelectFamily: boolean; autoSelectFamilyAttemptTimeout: number } | undefined {
60-
if (autoSelectFamily === undefined) {
61-
return undefined;
62-
}
63-
return {
64-
autoSelectFamily,
65-
autoSelectFamilyAttemptTimeout: AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS,
66-
};
67-
}
68-
6939
function resolveDispatcherKey(params: {
7040
kind: DispatcherKind;
7141
timeoutMs: number;
@@ -127,13 +97,13 @@ export function ensureGlobalUndiciStreamTimeouts(opts?: { timeoutMs?: number }):
12797
return;
12898
}
12999

130-
const autoSelectFamily = resolveAutoSelectFamily();
100+
const autoSelectFamily = resolveUndiciAutoSelectFamily();
131101
const nextKey = resolveDispatcherKey({ kind, timeoutMs, autoSelectFamily });
132102
if (lastAppliedTimeoutKey === nextKey) {
133103
return;
134104
}
135105

136-
const connect = resolveConnectOptions(autoSelectFamily);
106+
const connect = createUndiciAutoSelectFamilyConnectOptions(autoSelectFamily);
137107
try {
138108
if (kind === "env-proxy") {
139109
const proxyOptions = {

0 commit comments

Comments
 (0)