Skip to content

Commit 977a4b2

Browse files
fix: propagate timeoutMs to guarded dispatchers (local LLM 60s timeout) (#70831)
* fix: propagate timeoutMs to guarded dispatchers Thread timeoutMs through the dispatcher creation chain so that per-request (guarded) dispatchers honor the configured LLM timeout instead of falling back to undici's hardcoded 60s bodyTimeout/headersTimeout. Changes: - undici-runtime.ts: createHttp1Agent/ProxyAgent/EnvHttpProxyAgent now accept timeoutMs and apply bodyTimeout/headersTimeout to dispatcher options - ssrf.ts: createPinnedDispatcher accepts timeoutMs and passes it through - fetch-guard.ts: fetchWithSsrFGuard reads timeout from params or falls back to global dispatcher bodyTimeout via getGlobalDispatcher() - provider-transport-fetch.ts: buildGuardedModelFetch accepts optional timeoutMs and passes it to fetchWithSsrFGuard The global dispatcher timeout (set by ensureGlobalUndiciStreamTimeouts) is still applied to non-guarded requests. Guarded requests (used by LLM transports) now also receive the timeout via a fallback to the global dispatcher when not explicitly provided. Fixes #70829 * fix: resolve fallback timeout via module-level bridge variable Replace dead-code .options.bodyTimeout read in resolveDispatcherTimeoutMs with a module-level bridge (_globalUndiciStreamTimeoutMs) set by ensureGlobalUndiciStreamTimeouts. This avoids reliance on Undici's non-public .options field and ensures guarded dispatchers inherit the configured stream timeout instead of falling back to undici's 60s default. Fixes Greptile P1 and Codex comments on PR #70831 * chore: re-run CI smoke tests * test: cover guarded dispatcher timeout propagation * test: align timeout bridge expectation * docs: note guarded dispatcher timeout fix --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 86fa8ee commit 977a4b2

10 files changed

Lines changed: 244 additions & 49 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
3434
- Providers/OpenAI Codex: synthesize the `openai-codex/gpt-5.5` OAuth model row when Codex catalog discovery omits it, so cron and subagent runs do not fail with `Unknown model` while the account is authenticated.
3535
- Models/CLI: keep `openclaw models list` read-only while still showing eligible configured-provider rows, so listing models no longer rewrites per-agent `models.json`. (#70847) Thanks @shakkernerd.
3636
- Providers/Google: honor the private-network SSRF opt-in for Gemini image generation requests, so trusted proxy setups that resolve Google API hosts to private addresses can use `image_generate`. Fixes #67216.
37+
- Agents/transport: propagate configured attempt timeouts into guarded per-request dispatchers, so slow local LLM calls such as Ollama no longer fail at Undici's default 60-second body timeout. Fixes #70829. (#70831) Thanks @DranboFieldston.
3738
- Agents/transport: stop embedded runs from lowering the process-wide undici stream timeouts, so slow Gemini image generation and other long-running provider requests no longer inherit short run-attempt headers timeouts. Fixes #70423. Thanks @giangthb.
3839
- Providers/OpenRouter: send image-understanding prompts as user text before image parts, restoring non-empty vision responses for OpenRouter multimodal models. Fixes #70410.
3940
- Plugins/providers: mirror runtime auth choices in bundled provider manifests and detect `KIMI_API_KEY` for Moonshot/Kimi web search before plugin runtime loads. Thanks @vincentkoc.

src/agents/provider-transport-fetch.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,25 @@ describe("buildGuardedModelFetch", () => {
7575
);
7676
});
7777

78+
it("threads explicit transport timeouts into the shared guarded fetch seam", async () => {
79+
const { buildGuardedModelFetch } = await import("./provider-transport-fetch.js");
80+
const model = {
81+
id: "gpt-5.4",
82+
provider: "openai",
83+
api: "openai-responses",
84+
baseUrl: "https://api.openai.com/v1",
85+
} as unknown as Model<"openai-responses">;
86+
87+
const fetcher = buildGuardedModelFetch(model, 123_456);
88+
await fetcher("https://api.openai.com/v1/responses", { method: "POST" });
89+
90+
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
91+
expect.objectContaining({
92+
timeoutMs: 123_456,
93+
}),
94+
);
95+
});
96+
7897
it("does not force explicit debug proxy overrides onto plain HTTP model transports", async () => {
7998
process.env.OPENCLAW_DEBUG_PROXY_ENABLED = "1";
8099
process.env.OPENCLAW_DEBUG_PROXY_URL = "http://127.0.0.1:7799";

src/agents/provider-transport-fetch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ function resolveModelRequestPolicy(model: Model<Api>) {
150150
});
151151
}
152152

153-
export function buildGuardedModelFetch(model: Model<Api>): typeof fetch {
153+
export function buildGuardedModelFetch(model: Model<Api>, timeoutMs?: number): typeof fetch {
154154
const requestConfig = resolveModelRequestPolicy(model);
155155
const dispatcherPolicy = buildProviderRequestDispatcherPolicy(requestConfig);
156156
return async (input, init) => {
@@ -185,6 +185,7 @@ export function buildGuardedModelFetch(model: Model<Api>): typeof fetch {
185185
},
186186
},
187187
dispatcherPolicy,
188+
timeoutMs,
188189
// Provider transport intentionally keeps the secure default and never
189190
// replays unsafe request bodies across cross-origin redirects.
190191
allowCrossOriginUnsafeRedirectReplay: false,

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import {
44
GUARDED_FETCH_MODE,
55
retainSafeHeadersForCrossOriginRedirectHeaders,
66
} from "./fetch-guard.js";
7+
import {
8+
ensureGlobalUndiciStreamTimeouts,
9+
resetGlobalUndiciStreamTimeoutsForTests,
10+
} from "./undici-global-dispatcher.js";
711
import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "./undici-runtime.js";
812

913
const { agentCtor, envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({
@@ -171,6 +175,7 @@ describe("fetchWithSsrFGuard hardening", () => {
171175
envHttpProxyAgentCtor.mockClear();
172176
proxyAgentCtor.mockClear();
173177
logWarnMock.mockClear();
178+
resetGlobalUndiciStreamTimeoutsForTests();
174179
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
175180
});
176181

@@ -1013,6 +1018,69 @@ describe("fetchWithSsrFGuard hardening", () => {
10131018
});
10141019
});
10151020

1021+
it("applies explicit timeoutMs to guarded direct dispatchers", async () => {
1022+
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
1023+
Agent: agentCtor,
1024+
EnvHttpProxyAgent: envHttpProxyAgentCtor,
1025+
ProxyAgent: proxyAgentCtor,
1026+
fetch: vi.fn(async () => okResponse()),
1027+
};
1028+
const fetchImpl = vi.fn(async () => okResponse());
1029+
1030+
const result = await fetchWithSsrFGuard({
1031+
url: "https://public.example/resource",
1032+
fetchImpl,
1033+
lookupFn: createPublicLookup(),
1034+
timeoutMs: 123_456,
1035+
});
1036+
1037+
expect(fetchImpl).toHaveBeenCalledTimes(1);
1038+
expect(agentCtor).toHaveBeenCalledWith({
1039+
connect: expect.objectContaining({
1040+
lookup: expect.any(Function),
1041+
}),
1042+
allowH2: false,
1043+
bodyTimeout: 123_456,
1044+
headersTimeout: 123_456,
1045+
});
1046+
await result.release();
1047+
});
1048+
1049+
it("inherits the configured global stream timeout for guarded direct dispatchers", async () => {
1050+
const { getGlobalDispatcher, setGlobalDispatcher } = await import("undici");
1051+
const previousDispatcher = getGlobalDispatcher();
1052+
try {
1053+
ensureGlobalUndiciStreamTimeouts({ timeoutMs: 1_900_000 });
1054+
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
1055+
Agent: agentCtor,
1056+
EnvHttpProxyAgent: envHttpProxyAgentCtor,
1057+
ProxyAgent: proxyAgentCtor,
1058+
fetch: vi.fn(async () => okResponse()),
1059+
};
1060+
const fetchImpl = vi.fn(async () => okResponse());
1061+
1062+
const result = await fetchWithSsrFGuard({
1063+
url: "https://public.example/resource",
1064+
fetchImpl,
1065+
lookupFn: createPublicLookup(),
1066+
});
1067+
1068+
expect(fetchImpl).toHaveBeenCalledTimes(1);
1069+
expect(agentCtor).toHaveBeenCalledWith({
1070+
connect: expect.objectContaining({
1071+
lookup: expect.any(Function),
1072+
}),
1073+
allowH2: false,
1074+
bodyTimeout: 1_900_000,
1075+
headersTimeout: 1_900_000,
1076+
});
1077+
await result.release();
1078+
} finally {
1079+
setGlobalDispatcher(previousDispatcher);
1080+
resetGlobalUndiciStreamTimeoutsForTests();
1081+
}
1082+
});
1083+
10161084
it("allows explicit proxy on localhost when allowPrivateProxy is true even with restrictive hostnameAllowlist", async () => {
10171085
// Reproduces #61906: Telegram media downloads fail because the SSRF guard
10181086
// checks the proxy hostname (localhost) against a target-scoped allowlist

src/infra/net/fetch-guard.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,25 @@ import {
1919
SsrFBlockedError,
2020
type SsrFPolicy,
2121
} from "./ssrf.js";
22+
import { _globalUndiciStreamTimeoutMs } from "./undici-global-dispatcher.js";
2223
import {
2324
createHttp1Agent,
2425
createHttp1EnvHttpProxyAgent,
2526
createHttp1ProxyAgent,
2627
} from "./undici-runtime.js";
2728

29+
function resolveDispatcherTimeoutMs(fromParams: number | undefined): number | undefined {
30+
if (fromParams !== undefined) {
31+
return fromParams;
32+
}
33+
// Fall back to module-level bridge set by ensureGlobalUndiciStreamTimeouts
34+
// (avoids reading Undici's non-public `.options` field)
35+
if (_globalUndiciStreamTimeoutMs !== undefined) {
36+
return _globalUndiciStreamTimeoutMs;
37+
}
38+
return undefined;
39+
}
40+
2841
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
2942

3043
export const GUARDED_FETCH_MODE = {
@@ -125,6 +138,7 @@ function assertExplicitProxySupportsPinnedDns(
125138

126139
function createPolicyDispatcherWithoutPinnedDns(
127140
dispatcherPolicy?: PinnedDispatcherPolicy,
141+
timeoutMs?: number,
128142
): Dispatcher | null {
129143
if (!dispatcherPolicy) {
130144
return null;
@@ -133,23 +147,28 @@ function createPolicyDispatcherWithoutPinnedDns(
133147
if (dispatcherPolicy.mode === "direct") {
134148
return createHttp1Agent(
135149
dispatcherPolicy.connect ? { connect: { ...dispatcherPolicy.connect } } : undefined,
150+
timeoutMs,
136151
);
137152
}
138153

139154
if (dispatcherPolicy.mode === "env-proxy") {
140-
return createHttp1EnvHttpProxyAgent({
141-
...(dispatcherPolicy.connect ? { connect: { ...dispatcherPolicy.connect } } : {}),
142-
...(dispatcherPolicy.proxyTls ? { proxyTls: { ...dispatcherPolicy.proxyTls } } : {}),
143-
});
155+
return createHttp1EnvHttpProxyAgent(
156+
{
157+
...(dispatcherPolicy.connect ? { connect: { ...dispatcherPolicy.connect } } : {}),
158+
...(dispatcherPolicy.proxyTls ? { proxyTls: { ...dispatcherPolicy.proxyTls } } : {}),
159+
},
160+
timeoutMs,
161+
);
144162
}
145163

146164
const proxyUrl = dispatcherPolicy.proxyUrl.trim();
147-
return dispatcherPolicy.proxyTls
148-
? createHttp1ProxyAgent({
149-
uri: proxyUrl,
150-
requestTls: { ...dispatcherPolicy.proxyTls },
151-
})
152-
: createHttp1ProxyAgent({ uri: proxyUrl });
165+
if (dispatcherPolicy.proxyTls) {
166+
return createHttp1ProxyAgent(
167+
{ uri: proxyUrl, requestTls: { ...dispatcherPolicy.proxyTls } },
168+
timeoutMs,
169+
);
170+
}
171+
return createHttp1ProxyAgent({ uri: proxyUrl }, timeoutMs);
153172
}
154173

155174
async function assertExplicitProxyAllowed(
@@ -337,25 +356,31 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
337356
await assertExplicitProxyAllowed(params.dispatcherPolicy, params.lookupFn, params.policy);
338357
const canUseTrustedEnvProxy =
339358
mode === GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY && hasProxyEnvConfigured();
359+
const timeoutMs = resolveDispatcherTimeoutMs(params.timeoutMs);
340360
if (canUseTrustedEnvProxy) {
341-
dispatcher = createHttp1EnvHttpProxyAgent();
361+
dispatcher = createHttp1EnvHttpProxyAgent(undefined, timeoutMs);
342362
} else if (usesTrustedExplicitProxyMode) {
343363
// Explicit proxy targets are still checked against the caller's hostname
344364
// policy, but the proxy does the DNS resolution for the final target.
345365
assertHostnameAllowedWithPolicy(parsedUrl.hostname, params.policy);
346-
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy);
366+
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy, timeoutMs);
347367
} else if (params.pinDns === false) {
348368
await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
349369
lookupFn: params.lookupFn,
350370
policy: params.policy,
351371
});
352-
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy);
372+
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy, timeoutMs);
353373
} else {
354374
const pinned = await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
355375
lookupFn: params.lookupFn,
356376
policy: params.policy,
357377
});
358-
dispatcher = createPinnedDispatcher(pinned, params.dispatcherPolicy, params.policy);
378+
dispatcher = createPinnedDispatcher(
379+
pinned,
380+
params.dispatcherPolicy,
381+
params.policy,
382+
timeoutMs,
383+
);
359384
}
360385

361386
const init: DispatcherAwareRequestInit = {

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,26 @@ describe("createPinnedDispatcher", () => {
113113
});
114114
});
115115

116+
it("applies stream timeouts to pinned direct dispatchers", () => {
117+
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
118+
const pinned: PinnedHostname = {
119+
hostname: "api.telegram.org",
120+
addresses: ["149.154.167.220"],
121+
lookup,
122+
};
123+
124+
createPinnedDispatcher(pinned, undefined, undefined, 123_456);
125+
126+
expect(agentCtor).toHaveBeenCalledWith({
127+
connect: {
128+
lookup,
129+
},
130+
allowH2: false,
131+
bodyTimeout: 123_456,
132+
headersTimeout: 123_456,
133+
});
134+
});
135+
116136
it("replaces the pinned lookup when a dispatcher override hostname is provided", () => {
117137
const originalLookup = vi.fn() as unknown as PinnedHostname["lookup"];
118138
const lookup = createDispatcherWithPinnedOverride(originalLookup);
@@ -217,4 +237,37 @@ describe("createPinnedDispatcher", () => {
217237
},
218238
});
219239
});
240+
241+
it("applies stream timeouts to explicit proxy dispatchers", () => {
242+
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
243+
const pinned: PinnedHostname = {
244+
hostname: "api.telegram.org",
245+
addresses: ["149.154.167.220"],
246+
lookup,
247+
};
248+
249+
createPinnedDispatcher(
250+
pinned,
251+
{
252+
mode: "explicit-proxy",
253+
proxyUrl: "http://127.0.0.1:7890",
254+
proxyTls: {
255+
autoSelectFamily: false,
256+
},
257+
},
258+
undefined,
259+
654_321,
260+
);
261+
262+
expect(proxyAgentCtor).toHaveBeenCalledWith({
263+
uri: "http://127.0.0.1:7890",
264+
requestTls: {
265+
autoSelectFamily: false,
266+
lookup,
267+
},
268+
allowH2: false,
269+
bodyTimeout: 654_321,
270+
headersTimeout: 654_321,
271+
});
272+
});
220273
});

src/infra/net/ssrf.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -448,34 +448,39 @@ export function createPinnedDispatcher(
448448
pinned: PinnedHostname,
449449
policy?: PinnedDispatcherPolicy,
450450
ssrfPolicy?: SsrFPolicy,
451+
timeoutMs?: number,
451452
): Dispatcher {
452453
const lookup = resolvePinnedDispatcherLookup(pinned, policy?.pinnedHostname, ssrfPolicy);
453454

454455
if (!policy || policy.mode === "direct") {
455-
return createHttp1Agent({
456-
connect: withPinnedLookup(lookup, policy?.connect),
457-
});
456+
return createHttp1Agent({ connect: withPinnedLookup(lookup, policy?.connect) }, timeoutMs);
458457
}
459458

460459
if (policy.mode === "env-proxy") {
461-
return createHttp1EnvHttpProxyAgent({
462-
connect: withPinnedLookup(lookup, policy.connect),
463-
...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}),
464-
});
460+
return createHttp1EnvHttpProxyAgent(
461+
{
462+
connect: withPinnedLookup(lookup, policy.connect),
463+
...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}),
464+
},
465+
timeoutMs,
466+
);
465467
}
466468

467469
const proxyUrl = policy.proxyUrl.trim();
468470
const requestTls = withPinnedLookup(lookup, policy.proxyTls);
469471
if (!requestTls) {
470-
return createHttp1ProxyAgent({ uri: proxyUrl });
471-
}
472-
return createHttp1ProxyAgent({
473-
uri: proxyUrl,
474-
// `PinnedDispatcherPolicy.proxyTls` historically carried target-hop
475-
// transport hints for explicit proxies. Translate that to undici's
476-
// `requestTls` so HTTPS proxy tunnels keep the pinned DNS lookup.
477-
requestTls,
478-
});
472+
return createHttp1ProxyAgent({ uri: proxyUrl }, timeoutMs);
473+
}
474+
return createHttp1ProxyAgent(
475+
{
476+
uri: proxyUrl,
477+
// `PinnedDispatcherPolicy.proxyTls` historically carried target-hop
478+
// transport hints for explicit proxies. Translate that to undici's
479+
// `requestTls` so HTTPS proxy tunnels keep the pinned DNS lookup.
480+
requestTls,
481+
},
482+
timeoutMs,
483+
);
479484
}
480485

481486
export async function closeDispatcher(dispatcher?: Dispatcher | null): Promise<void> {

0 commit comments

Comments
 (0)