From 0078070680a3c88d9d24d4656c3e41411c0d5932 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:16:03 +0000 Subject: [PATCH] fix(telegram): refresh global undici dispatcher for autoSelectFamily (#25682) Land PR #25682 from @lairtonlelis after maintainer rework: track dispatcher updates when network decision changes to avoid stale global fetch behavior. Co-authored-by: Ailton --- CHANGELOG.md | 1 + src/telegram/fetch.test.ts | 54 ++++++++++++++++++++++++++++++++++++++ src/telegram/fetch.ts | 30 +++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72be42da620..bc3793181fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting. - Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. +- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. - Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. - Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. diff --git a/src/telegram/fetch.test.ts b/src/telegram/fetch.test.ts index 9f1c676119b..b36f5dab7a8 100644 --- a/src/telegram/fetch.test.ts +++ b/src/telegram/fetch.test.ts @@ -4,6 +4,12 @@ import { resetTelegramFetchStateForTests, resolveTelegramFetch } from "./fetch.j const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn()); const setDefaultResultOrder = vi.hoisted(() => vi.fn()); +const setGlobalDispatcher = vi.hoisted(() => vi.fn()); +const AgentCtor = vi.hoisted(() => + vi.fn(function MockAgent(this: { options: unknown }, options: unknown) { + this.options = options; + }), +); vi.mock("node:net", async () => { const actual = await vi.importActual("node:net"); @@ -21,12 +27,19 @@ vi.mock("node:dns", async () => { }; }); +vi.mock("undici", () => ({ + Agent: AgentCtor, + setGlobalDispatcher, +})); + const originalFetch = globalThis.fetch; afterEach(() => { resetTelegramFetchStateForTests(); setDefaultAutoSelectFamily.mockReset(); setDefaultResultOrder.mockReset(); + setGlobalDispatcher.mockReset(); + AgentCtor.mockClear(); vi.unstubAllEnvs(); vi.clearAllMocks(); if (originalFetch) { @@ -133,4 +146,45 @@ describe("resolveTelegramFetch", () => { expect(setDefaultResultOrder).toHaveBeenCalledTimes(2); }); + + it("replaces global undici dispatcher with autoSelectFamily-enabled agent", async () => { + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); + expect(AgentCtor).toHaveBeenCalledWith({ + connect: { + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }, + }); + }); + + it("sets global dispatcher only once across repeated equal decisions", async () => { + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); + }); + + it("updates global dispatcher when autoSelectFamily decision changes", async () => { + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + resolveTelegramFetch(undefined, { network: { autoSelectFamily: false } }); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(2); + expect(AgentCtor).toHaveBeenNthCalledWith(1, { + connect: { + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }, + }); + expect(AgentCtor).toHaveBeenNthCalledWith(2, { + connect: { + autoSelectFamily: false, + autoSelectFamilyAttemptTimeout: 300, + }, + }); + }); }); diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index 48fdf72eff7..3dec18cc0dd 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -1,5 +1,6 @@ import * as dns from "node:dns"; import * as net from "node:net"; +import { Agent, setGlobalDispatcher } from "undici"; import type { TelegramNetworkConfig } from "../config/types.telegram.js"; import { resolveFetch } from "../infra/fetch.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -10,6 +11,7 @@ import { let appliedAutoSelectFamily: boolean | null = null; let appliedDnsResultOrder: string | null = null; +let appliedGlobalDispatcherAutoSelectFamily: boolean | null = null; const log = createSubsystemLogger("telegram/network"); // Node 22 workaround: enable autoSelectFamily to allow IPv4 fallback on broken IPv6 networks. @@ -31,6 +33,33 @@ function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void } } + // Node 22's built-in globalThis.fetch uses undici's internal Agent whose + // connect options are frozen at construction time. Calling + // net.setDefaultAutoSelectFamily() after that agent is created has no + // effect on it. Replace the global dispatcher with one that carries the + // current autoSelectFamily setting so subsequent globalThis.fetch calls + // inherit the same decision. + // See: https://github.com/openclaw/openclaw/issues/25676 + if ( + autoSelectDecision.value !== null && + autoSelectDecision.value !== appliedGlobalDispatcherAutoSelectFamily + ) { + try { + setGlobalDispatcher( + new Agent({ + connect: { + autoSelectFamily: autoSelectDecision.value, + autoSelectFamilyAttemptTimeout: 300, + }, + }), + ); + appliedGlobalDispatcherAutoSelectFamily = autoSelectDecision.value; + log.info(`global undici dispatcher autoSelectFamily=${autoSelectDecision.value}`); + } catch { + // ignore if setGlobalDispatcher is unavailable + } + } + // Apply DNS result order workaround for IPv4/IPv6 issues. // Some APIs (including Telegram) may fail with IPv6 on certain networks. // See: https://github.com/openclaw/openclaw/issues/5311 @@ -68,4 +97,5 @@ export function resolveTelegramFetch( export function resetTelegramFetchStateForTests(): void { appliedAutoSelectFamily = null; appliedDnsResultOrder = null; + appliedGlobalDispatcherAutoSelectFamily = null; }