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 <steipete@gmail.com>
This commit is contained in:
Dranbo Fieldston
2026-04-23 21:34:11 -06:00
committed by GitHub
parent 86fa8eeb68
commit 977a4b24af
10 changed files with 243 additions and 48 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
- 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.
- Providers/OpenRouter: send image-understanding prompts as user text before image parts, restoring non-empty vision responses for OpenRouter multimodal models. Fixes #70410.
- 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.

View File

@@ -75,6 +75,25 @@ describe("buildGuardedModelFetch", () => {
);
});
it("threads explicit transport timeouts into the shared guarded fetch seam", async () => {
const { buildGuardedModelFetch } = await import("./provider-transport-fetch.js");
const model = {
id: "gpt-5.4",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
} as unknown as Model<"openai-responses">;
const fetcher = buildGuardedModelFetch(model, 123_456);
await fetcher("https://api.openai.com/v1/responses", { method: "POST" });
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 123_456,
}),
);
});
it("does not force explicit debug proxy overrides onto plain HTTP model transports", async () => {
process.env.OPENCLAW_DEBUG_PROXY_ENABLED = "1";
process.env.OPENCLAW_DEBUG_PROXY_URL = "http://127.0.0.1:7799";

View File

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

View File

@@ -4,6 +4,10 @@ import {
GUARDED_FETCH_MODE,
retainSafeHeadersForCrossOriginRedirectHeaders,
} from "./fetch-guard.js";
import {
ensureGlobalUndiciStreamTimeouts,
resetGlobalUndiciStreamTimeoutsForTests,
} from "./undici-global-dispatcher.js";
import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "./undici-runtime.js";
const { agentCtor, envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({
@@ -171,6 +175,7 @@ describe("fetchWithSsrFGuard hardening", () => {
envHttpProxyAgentCtor.mockClear();
proxyAgentCtor.mockClear();
logWarnMock.mockClear();
resetGlobalUndiciStreamTimeoutsForTests();
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
});
@@ -1013,6 +1018,69 @@ describe("fetchWithSsrFGuard hardening", () => {
});
});
it("applies explicit timeoutMs to guarded direct dispatchers", async () => {
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
Agent: agentCtor,
EnvHttpProxyAgent: envHttpProxyAgentCtor,
ProxyAgent: proxyAgentCtor,
fetch: vi.fn(async () => okResponse()),
};
const fetchImpl = vi.fn(async () => okResponse());
const result = await fetchWithSsrFGuard({
url: "https://public.example/resource",
fetchImpl,
lookupFn: createPublicLookup(),
timeoutMs: 123_456,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(agentCtor).toHaveBeenCalledWith({
connect: expect.objectContaining({
lookup: expect.any(Function),
}),
allowH2: false,
bodyTimeout: 123_456,
headersTimeout: 123_456,
});
await result.release();
});
it("inherits the configured global stream timeout for guarded direct dispatchers", async () => {
const { getGlobalDispatcher, setGlobalDispatcher } = await import("undici");
const previousDispatcher = getGlobalDispatcher();
try {
ensureGlobalUndiciStreamTimeouts({ timeoutMs: 1_900_000 });
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
Agent: agentCtor,
EnvHttpProxyAgent: envHttpProxyAgentCtor,
ProxyAgent: proxyAgentCtor,
fetch: vi.fn(async () => okResponse()),
};
const fetchImpl = vi.fn(async () => okResponse());
const result = await fetchWithSsrFGuard({
url: "https://public.example/resource",
fetchImpl,
lookupFn: createPublicLookup(),
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(agentCtor).toHaveBeenCalledWith({
connect: expect.objectContaining({
lookup: expect.any(Function),
}),
allowH2: false,
bodyTimeout: 1_900_000,
headersTimeout: 1_900_000,
});
await result.release();
} finally {
setGlobalDispatcher(previousDispatcher);
resetGlobalUndiciStreamTimeoutsForTests();
}
});
it("allows explicit proxy on localhost when allowPrivateProxy is true even with restrictive hostnameAllowlist", async () => {
// Reproduces #61906: Telegram media downloads fail because the SSRF guard
// checks the proxy hostname (localhost) against a target-scoped allowlist

View File

@@ -19,12 +19,25 @@ import {
SsrFBlockedError,
type SsrFPolicy,
} from "./ssrf.js";
import { _globalUndiciStreamTimeoutMs } from "./undici-global-dispatcher.js";
import {
createHttp1Agent,
createHttp1EnvHttpProxyAgent,
createHttp1ProxyAgent,
} from "./undici-runtime.js";
function resolveDispatcherTimeoutMs(fromParams: number | undefined): number | undefined {
if (fromParams !== undefined) {
return fromParams;
}
// Fall back to module-level bridge set by ensureGlobalUndiciStreamTimeouts
// (avoids reading Undici's non-public `.options` field)
if (_globalUndiciStreamTimeoutMs !== undefined) {
return _globalUndiciStreamTimeoutMs;
}
return undefined;
}
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export const GUARDED_FETCH_MODE = {
@@ -125,6 +138,7 @@ function assertExplicitProxySupportsPinnedDns(
function createPolicyDispatcherWithoutPinnedDns(
dispatcherPolicy?: PinnedDispatcherPolicy,
timeoutMs?: number,
): Dispatcher | null {
if (!dispatcherPolicy) {
return null;
@@ -133,23 +147,28 @@ function createPolicyDispatcherWithoutPinnedDns(
if (dispatcherPolicy.mode === "direct") {
return createHttp1Agent(
dispatcherPolicy.connect ? { connect: { ...dispatcherPolicy.connect } } : undefined,
timeoutMs,
);
}
if (dispatcherPolicy.mode === "env-proxy") {
return createHttp1EnvHttpProxyAgent({
...(dispatcherPolicy.connect ? { connect: { ...dispatcherPolicy.connect } } : {}),
...(dispatcherPolicy.proxyTls ? { proxyTls: { ...dispatcherPolicy.proxyTls } } : {}),
});
return createHttp1EnvHttpProxyAgent(
{
...(dispatcherPolicy.connect ? { connect: { ...dispatcherPolicy.connect } } : {}),
...(dispatcherPolicy.proxyTls ? { proxyTls: { ...dispatcherPolicy.proxyTls } } : {}),
},
timeoutMs,
);
}
const proxyUrl = dispatcherPolicy.proxyUrl.trim();
return dispatcherPolicy.proxyTls
? createHttp1ProxyAgent({
uri: proxyUrl,
requestTls: { ...dispatcherPolicy.proxyTls },
})
: createHttp1ProxyAgent({ uri: proxyUrl });
if (dispatcherPolicy.proxyTls) {
return createHttp1ProxyAgent(
{ uri: proxyUrl, requestTls: { ...dispatcherPolicy.proxyTls } },
timeoutMs,
);
}
return createHttp1ProxyAgent({ uri: proxyUrl }, timeoutMs);
}
async function assertExplicitProxyAllowed(
@@ -337,25 +356,31 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
await assertExplicitProxyAllowed(params.dispatcherPolicy, params.lookupFn, params.policy);
const canUseTrustedEnvProxy =
mode === GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY && hasProxyEnvConfigured();
const timeoutMs = resolveDispatcherTimeoutMs(params.timeoutMs);
if (canUseTrustedEnvProxy) {
dispatcher = createHttp1EnvHttpProxyAgent();
dispatcher = createHttp1EnvHttpProxyAgent(undefined, timeoutMs);
} else if (usesTrustedExplicitProxyMode) {
// Explicit proxy targets are still checked against the caller's hostname
// policy, but the proxy does the DNS resolution for the final target.
assertHostnameAllowedWithPolicy(parsedUrl.hostname, params.policy);
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy);
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy, timeoutMs);
} else if (params.pinDns === false) {
await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
lookupFn: params.lookupFn,
policy: params.policy,
});
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy);
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy, timeoutMs);
} else {
const pinned = await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
lookupFn: params.lookupFn,
policy: params.policy,
});
dispatcher = createPinnedDispatcher(pinned, params.dispatcherPolicy, params.policy);
dispatcher = createPinnedDispatcher(
pinned,
params.dispatcherPolicy,
params.policy,
timeoutMs,
);
}
const init: DispatcherAwareRequestInit = {

View File

@@ -113,6 +113,26 @@ describe("createPinnedDispatcher", () => {
});
});
it("applies stream timeouts to pinned direct dispatchers", () => {
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
const pinned: PinnedHostname = {
hostname: "api.telegram.org",
addresses: ["149.154.167.220"],
lookup,
};
createPinnedDispatcher(pinned, undefined, undefined, 123_456);
expect(agentCtor).toHaveBeenCalledWith({
connect: {
lookup,
},
allowH2: false,
bodyTimeout: 123_456,
headersTimeout: 123_456,
});
});
it("replaces the pinned lookup when a dispatcher override hostname is provided", () => {
const originalLookup = vi.fn() as unknown as PinnedHostname["lookup"];
const lookup = createDispatcherWithPinnedOverride(originalLookup);
@@ -217,4 +237,37 @@ describe("createPinnedDispatcher", () => {
},
});
});
it("applies stream timeouts to explicit proxy dispatchers", () => {
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: "explicit-proxy",
proxyUrl: "http://127.0.0.1:7890",
proxyTls: {
autoSelectFamily: false,
},
},
undefined,
654_321,
);
expect(proxyAgentCtor).toHaveBeenCalledWith({
uri: "http://127.0.0.1:7890",
requestTls: {
autoSelectFamily: false,
lookup,
},
allowH2: false,
bodyTimeout: 654_321,
headersTimeout: 654_321,
});
});
});

View File

@@ -448,34 +448,39 @@ export function createPinnedDispatcher(
pinned: PinnedHostname,
policy?: PinnedDispatcherPolicy,
ssrfPolicy?: SsrFPolicy,
timeoutMs?: number,
): Dispatcher {
const lookup = resolvePinnedDispatcherLookup(pinned, policy?.pinnedHostname, ssrfPolicy);
if (!policy || policy.mode === "direct") {
return createHttp1Agent({
connect: withPinnedLookup(lookup, policy?.connect),
});
return createHttp1Agent({ connect: withPinnedLookup(lookup, policy?.connect) }, timeoutMs);
}
if (policy.mode === "env-proxy") {
return createHttp1EnvHttpProxyAgent({
connect: withPinnedLookup(lookup, policy.connect),
...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}),
});
return createHttp1EnvHttpProxyAgent(
{
connect: withPinnedLookup(lookup, policy.connect),
...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}),
},
timeoutMs,
);
}
const proxyUrl = policy.proxyUrl.trim();
const requestTls = withPinnedLookup(lookup, policy.proxyTls);
if (!requestTls) {
return createHttp1ProxyAgent({ uri: proxyUrl });
return createHttp1ProxyAgent({ uri: proxyUrl }, timeoutMs);
}
return createHttp1ProxyAgent({
uri: proxyUrl,
// `PinnedDispatcherPolicy.proxyTls` historically carried target-hop
// transport hints for explicit proxies. Translate that to undici's
// `requestTls` so HTTPS proxy tunnels keep the pinned DNS lookup.
requestTls,
});
return createHttp1ProxyAgent(
{
uri: proxyUrl,
// `PinnedDispatcherPolicy.proxyTls` historically carried target-hop
// transport hints for explicit proxies. Translate that to undici's
// `requestTls` so HTTPS proxy tunnels keep the pinned DNS lookup.
requestTls,
},
timeoutMs,
);
}
export async function closeDispatcher(dispatcher?: Dispatcher | null): Promise<void> {

View File

@@ -73,15 +73,17 @@ let DEFAULT_UNDICI_STREAM_TIMEOUT_MS: typeof import("./undici-global-dispatcher.
let ensureGlobalUndiciEnvProxyDispatcher: typeof import("./undici-global-dispatcher.js").ensureGlobalUndiciEnvProxyDispatcher;
let ensureGlobalUndiciStreamTimeouts: typeof import("./undici-global-dispatcher.js").ensureGlobalUndiciStreamTimeouts;
let resetGlobalUndiciStreamTimeoutsForTests: typeof import("./undici-global-dispatcher.js").resetGlobalUndiciStreamTimeoutsForTests;
let undiciGlobalDispatcherModule: typeof import("./undici-global-dispatcher.js");
describe("ensureGlobalUndiciStreamTimeouts", () => {
beforeAll(async () => {
undiciGlobalDispatcherModule = await import("./undici-global-dispatcher.js");
({
DEFAULT_UNDICI_STREAM_TIMEOUT_MS,
ensureGlobalUndiciEnvProxyDispatcher,
ensureGlobalUndiciStreamTimeouts,
resetGlobalUndiciStreamTimeoutsForTests,
} = await import("./undici-global-dispatcher.js"));
} = undiciGlobalDispatcherModule);
});
beforeEach(() => {
@@ -125,12 +127,13 @@ describe("ensureGlobalUndiciStreamTimeouts", () => {
});
});
it("does not override unsupported custom proxy dispatcher types", () => {
it("records timeout bridge but does not override unsupported custom proxy dispatcher types", () => {
setCurrentDispatcher(new ProxyAgent("http://proxy.test:8080"));
ensureGlobalUndiciStreamTimeouts();
ensureGlobalUndiciStreamTimeouts({ timeoutMs: 1_900_000 });
expect(setGlobalDispatcher).not.toHaveBeenCalled();
expect(undiciGlobalDispatcherModule._globalUndiciStreamTimeoutMs).toBe(1_900_000);
});
it("is idempotent for unchanged dispatcher kind and network policy", () => {

View File

@@ -5,6 +5,13 @@ import { hasEnvHttpProxyConfigured } from "./proxy-env.js";
export const DEFAULT_UNDICI_STREAM_TIMEOUT_MS = 30 * 60 * 1000;
/**
* Module-level bridge so `resolveDispatcherTimeoutMs` in fetch-guard.ts
* can read the global dispatcher timeout without relying on Undici's
* non-public `.options` field.
*/
export let _globalUndiciStreamTimeoutMs: number | undefined;
const AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300;
let lastAppliedTimeoutKey: string | null = null;
@@ -114,6 +121,7 @@ export function ensureGlobalUndiciStreamTimeouts(opts?: { timeoutMs?: number }):
return;
}
const timeoutMs = Math.max(DEFAULT_UNDICI_STREAM_TIMEOUT_MS, Math.floor(timeoutMsRaw));
_globalUndiciStreamTimeoutMs = timeoutMs;
const kind = resolveCurrentDispatcherKind();
if (kind === null) {
return;
@@ -152,4 +160,5 @@ export function ensureGlobalUndiciStreamTimeouts(opts?: { timeoutMs?: number }):
export function resetGlobalUndiciStreamTimeoutsForTests(): void {
lastAppliedTimeoutKey = null;
lastAppliedProxyBootstrap = false;
_globalUndiciStreamTimeoutMs = undefined;
}

View File

@@ -53,36 +53,47 @@ export function loadUndiciRuntimeDeps(): UndiciRuntimeDeps {
function withHttp1OnlyDispatcherOptions<T extends object | undefined>(
options?: T,
timeoutMs?: number,
): (T extends object ? T : Record<never, never>) & { allowH2: false } {
if (!options) {
return { ...HTTP1_ONLY_DISPATCHER_OPTIONS } as (T extends object ? T : Record<never, never>) & {
allowH2: false;
};
const base = {} as (T extends object ? T : Record<never, never>) & { allowH2: false };
if (options) {
Object.assign(base, options);
}
return {
...options,
...HTTP1_ONLY_DISPATCHER_OPTIONS,
} as (T extends object ? T : Record<never, never>) & { allowH2: false };
// Enforce HTTP/1.1-only — must come after options to prevent accidental override
Object.assign(base, HTTP1_ONLY_DISPATCHER_OPTIONS);
if (timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0) {
(base as Record<string, unknown>).bodyTimeout = timeoutMs;
(base as Record<string, unknown>).headersTimeout = timeoutMs;
}
return base;
}
export function createHttp1Agent(options?: UndiciAgentOptions): import("undici").Agent {
export function createHttp1Agent(
options?: UndiciAgentOptions,
timeoutMs?: number,
): import("undici").Agent {
const { Agent } = loadUndiciRuntimeDeps();
return new Agent(withHttp1OnlyDispatcherOptions(options));
return new Agent(withHttp1OnlyDispatcherOptions(options, timeoutMs));
}
export function createHttp1EnvHttpProxyAgent(
options?: UndiciEnvHttpProxyAgentOptions,
timeoutMs?: number,
): import("undici").EnvHttpProxyAgent {
const { EnvHttpProxyAgent } = loadUndiciRuntimeDeps();
return new EnvHttpProxyAgent(withHttp1OnlyDispatcherOptions(options));
return new EnvHttpProxyAgent(withHttp1OnlyDispatcherOptions(options, timeoutMs));
}
export function createHttp1ProxyAgent(
options: UndiciProxyAgentOptions,
timeoutMs?: number,
): import("undici").ProxyAgent {
const { ProxyAgent } = loadUndiciRuntimeDeps();
if (typeof options === "string" || options instanceof URL) {
return new ProxyAgent(withHttp1OnlyDispatcherOptions({ uri: options.toString() }));
}
return new ProxyAgent(withHttp1OnlyDispatcherOptions(options));
const normalized =
typeof options === "string" || options instanceof URL
? { uri: options.toString() }
: { ...options };
return new ProxyAgent(
withHttp1OnlyDispatcherOptions(normalized as object, timeoutMs) as UndiciProxyAgentOptions,
);
}