From dfce4052dc289716044388f1cca972ef82e9ec81 Mon Sep 17 00:00:00 2001 From: "winter.loo" Date: Sun, 15 Mar 2026 23:42:05 +0800 Subject: [PATCH 1/2] fix(gateway/discord): respect env proxy vars and prevent ECONNRESET on proxy tunnels Gateway startup now bootstraps the global undici EnvHttpProxyAgent so Node's fetch() honors https_proxy/HTTP_PROXY. The Discord gateway plugin also falls back to env proxy vars for its WebSocket connection when no explicit channels.discord.proxy is configured. All proxy ProxyAgent instances disable keepAlive to avoid ECONNRESET from local proxies (Clash, Surge, etc.) that close CONNECT tunnels after one request. Co-Authored-By: Claude Sonnet 4.6 --- extensions/discord/src/monitor/gateway-plugin.ts | 5 +++-- extensions/discord/src/monitor/provider.proxy.test.ts | 7 ++++++- .../discord/src/monitor/provider.rest-proxy.test.ts | 3 ++- extensions/discord/src/monitor/rest-fetch.ts | 2 +- src/cli/gateway-cli/run.ts | 9 +++++++++ src/infra/net/undici-global-dispatcher.test.ts | 1 + src/infra/net/undici-global-dispatcher.ts | 8 ++++++-- 7 files changed, 28 insertions(+), 7 deletions(-) diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 5acab8d5339..b5fd85bc3e7 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -6,6 +6,7 @@ import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { ProxyAgent, fetch as undiciFetch } from "undici"; import WebSocket from "ws"; +import { resolveEnvHttpProxyUrl } from "../../../../src/infra/net/proxy-env.js"; const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot"; const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/"; @@ -270,7 +271,7 @@ export function createDiscordGatewayPlugin(params: { runtime: RuntimeEnv; }): GatewayPlugin { const intents = resolveDiscordGatewayIntents(params.discordConfig?.intents); - const proxy = params.discordConfig?.proxy?.trim(); + const proxy = params.discordConfig?.proxy?.trim() || resolveEnvHttpProxyUrl("https"); const options = { reconnect: { maxAttempts: 50 }, intents, @@ -287,7 +288,7 @@ export function createDiscordGatewayPlugin(params: { try { const wsAgent = new HttpsProxyAgent(proxy); - const fetchAgent = new ProxyAgent(proxy); + const fetchAgent = new ProxyAgent({ uri: proxy, connect: { keepAlive: false } }); params.runtime.log?.("discord: gateway proxy enabled"); diff --git a/extensions/discord/src/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts index f8e9f52c198..62d06f8199c 100644 --- a/extensions/discord/src/monitor/provider.proxy.test.ts +++ b/extensions/discord/src/monitor/provider.proxy.test.ts @@ -89,7 +89,8 @@ vi.mock("https-proxy-agent", () => ({ vi.mock("undici", () => ({ ProxyAgent: class { proxyUrl: string; - constructor(proxyUrl: string) { + constructor(opts: string | { uri: string }) { + const proxyUrl = typeof opts === "string" ? opts : opts.uri; this.proxyUrl = proxyUrl; undiciProxyAgentSpy(proxyUrl); restProxyAgentSpy(proxyUrl); @@ -98,6 +99,10 @@ vi.mock("undici", () => ({ fetch: undiciFetchMock, })); +vi.mock("../../../../src/infra/net/proxy-env.js", () => ({ + resolveEnvHttpProxyUrl: () => undefined, +})); + vi.mock("ws", () => ({ default: class MockWebSocket { constructor(url: string, options?: { agent?: unknown }) { diff --git a/extensions/discord/src/monitor/provider.rest-proxy.test.ts b/extensions/discord/src/monitor/provider.rest-proxy.test.ts index 47ed5bb6335..5f8eeb2e90f 100644 --- a/extensions/discord/src/monitor/provider.rest-proxy.test.ts +++ b/extensions/discord/src/monitor/provider.rest-proxy.test.ts @@ -9,7 +9,8 @@ const { undiciFetchMock, proxyAgentSpy } = vi.hoisted(() => ({ vi.mock("undici", () => { class ProxyAgent { proxyUrl: string; - constructor(proxyUrl: string) { + constructor(opts: string | { uri: string }) { + const proxyUrl = typeof opts === "string" ? opts : opts.uri; if (proxyUrl === "bad-proxy") { throw new Error("bad proxy"); } diff --git a/extensions/discord/src/monitor/rest-fetch.ts b/extensions/discord/src/monitor/rest-fetch.ts index 43b4c768381..77b452385f5 100644 --- a/extensions/discord/src/monitor/rest-fetch.ts +++ b/extensions/discord/src/monitor/rest-fetch.ts @@ -12,7 +12,7 @@ export function resolveDiscordRestFetch( return fetch; } try { - const agent = new ProxyAgent(proxy); + const agent = new ProxyAgent({ uri: proxy, connect: { keepAlive: false } }); const fetcher = ((input: RequestInfo | URL, init?: RequestInit) => undiciFetch(input as string | URL, { ...(init as Record), diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 0aa0e8ff36e..32ad39a43d7 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -17,6 +17,10 @@ import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js"; import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js"; import { setVerbose } from "../../globals.js"; import { GatewayLockError } from "../../infra/gateway-lock.js"; +import { + ensureGlobalUndiciEnvProxyDispatcher, + ensureGlobalUndiciStreamTimeouts, +} from "../../infra/net/undici-global-dispatcher.js"; import { formatPortDiagnostics, inspectPortUsage } from "../../infra/ports.js"; import { cleanStaleGatewayProcessesSync } from "../../infra/restart-stale-pids.js"; import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logging/console.js"; @@ -418,6 +422,11 @@ async function runGatewayCommand(opts: GatewayRunOpts) { } : undefined; + // Proxy bootstrap must happen before timeout tuning so the timeouts wrap the + // active EnvHttpProxyAgent instead of being replaced by a bare proxy dispatcher. + ensureGlobalUndiciEnvProxyDispatcher(); + ensureGlobalUndiciStreamTimeouts(); + try { await runGatewayLoop({ runtime: defaultRuntime, diff --git a/src/infra/net/undici-global-dispatcher.test.ts b/src/infra/net/undici-global-dispatcher.test.ts index 47a97dd6fb6..6e00d587bf0 100644 --- a/src/infra/net/undici-global-dispatcher.test.ts +++ b/src/infra/net/undici-global-dispatcher.test.ts @@ -111,6 +111,7 @@ describe("ensureGlobalUndiciStreamTimeouts", () => { expect(next.options?.bodyTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS); expect(next.options?.headersTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS); expect(next.options?.connect).toEqual({ + keepAlive: false, autoSelectFamily: false, autoSelectFamilyAttemptTimeout: 300, }); diff --git a/src/infra/net/undici-global-dispatcher.ts b/src/infra/net/undici-global-dispatcher.ts index 994af564777..4874a8614a6 100644 --- a/src/infra/net/undici-global-dispatcher.ts +++ b/src/infra/net/undici-global-dispatcher.ts @@ -93,7 +93,10 @@ export function ensureGlobalUndiciEnvProxyDispatcher(): void { return; } try { - setGlobalDispatcher(new EnvHttpProxyAgent()); + // Many local proxies (Clash, Surge, etc.) close HTTP CONNECT tunnels after + // the first request, causing ECONNRESET on reuse. Disable keep-alive so + // undici opens a fresh tunnel per request instead of pooling. + setGlobalDispatcher(new EnvHttpProxyAgent({ connect: { keepAlive: false } })); lastAppliedProxyBootstrap = true; } catch { // Best-effort bootstrap only. @@ -120,10 +123,11 @@ export function ensureGlobalUndiciStreamTimeouts(opts?: { timeoutMs?: number }): const connect = resolveConnectOptions(autoSelectFamily); try { if (kind === "env-proxy") { + const proxyConnect = { keepAlive: false, ...connect }; const proxyOptions = { bodyTimeout: timeoutMs, headersTimeout: timeoutMs, - ...(connect ? { connect } : {}), + connect: proxyConnect, } as ConstructorParameters[0]; setGlobalDispatcher(new EnvHttpProxyAgent(proxyOptions)); } else { From cb4d03bb868c02e11f40b7ace1027ef895b21da0 Mon Sep 17 00:00:00 2001 From: "winter.loo" Date: Mon, 16 Mar 2026 01:27:18 +0800 Subject: [PATCH 2/2] fix(gateway): resolve CI TypeScript errors and address review feedback --- src/gateway/node-command-policy.ts | 5 +++-- src/gateway/server-methods/nodes.ts | 7 ++++--- src/infra/net/undici-global-dispatcher.ts | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index 7310dc4ec73..7c5fcbe8d0d 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -203,8 +203,9 @@ export function isNodeCommandAllowed(params: { if (!params.allowlist.has(command)) { return { ok: false, reason: "command not allowlisted" }; } - if (Array.isArray(params.declaredCommands) && params.declaredCommands.length > 0) { - if (!params.declaredCommands.includes(command)) { + const { declaredCommands } = params; + if (Array.isArray(declaredCommands) && declaredCommands.length > 0) { + if (!declaredCommands.includes(command)) { return { ok: false, reason: "command not declared by node" }; } } else { diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index ae6c8090b6c..3fb0ddc7653 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -1003,15 +1003,16 @@ export const nodeHandlers: GatewayRequestHandlers = { `node wake done node=${nodeId} req=${wakeReqId} connected=true totalMs=${totalDurationMs}`, ); } + const session = nodeSession; const cfg = loadConfig(); - const allowlist = resolveNodeCommandAllowlist(cfg, nodeSession); + const allowlist = resolveNodeCommandAllowlist(cfg, session); const allowed = isNodeCommandAllowed({ command, - declaredCommands: nodeSession.commands, + declaredCommands: session.commands, allowlist, }); if (!allowed.ok) { - const hint = buildNodeCommandRejectionHint(allowed.reason, command, nodeSession); + const hint = buildNodeCommandRejectionHint(allowed.reason, command, session); respond( false, undefined, diff --git a/src/infra/net/undici-global-dispatcher.ts b/src/infra/net/undici-global-dispatcher.ts index 4874a8614a6..753e7e5281e 100644 --- a/src/infra/net/undici-global-dispatcher.ts +++ b/src/infra/net/undici-global-dispatcher.ts @@ -123,7 +123,7 @@ export function ensureGlobalUndiciStreamTimeouts(opts?: { timeoutMs?: number }): const connect = resolveConnectOptions(autoSelectFamily); try { if (kind === "env-proxy") { - const proxyConnect = { keepAlive: false, ...connect }; + const proxyConnect = { ...connect, keepAlive: false }; const proxyOptions = { bodyTimeout: timeoutMs, headersTimeout: timeoutMs,