Skip to content

Commit 4440226

Browse files
svuppala2006sallyomjoshavant
authored
test(discord): clarify and guardrail gateway proxy selection (#99126)
* feat(discord): add HTTP_PROXY/HTTPS_PROXY env fallback for gateway WebSocket Discord Gateway WebSocket connections now honor HTTP_PROXY/HTTPS_PROXY environment variables as fallback before direct connection, matching the existing pattern from Discord REST API and WhatsApp. This enables Discord channel deployment in Kubernetes/OpenShift environments with network policy egress restrictions, removing the need for broad :443 egress as a workaround. - Add createEnvProxyDiscordGatewayAgent() helper (mirrors rest-fetch.ts pattern) - Try env proxy before direct HttpsAgent in createDiscordGatewayPlugin() - Explicit channels.discord.proxy still overrides env proxy - Add formatErrorMessage and resolveEnvHttpProxyAgentOptions imports Fixes #98266 * test(discord): keep gateway proxy explicit Signed-off-by: sallyom <[email protected]> * fix(discord): allow explicit remote proxy URLs --------- Signed-off-by: sallyom <[email protected]> Co-authored-by: sallyom <[email protected]> Co-authored-by: joshavant <[email protected]>
1 parent 381b44a commit 4440226

6 files changed

Lines changed: 320 additions & 52 deletions

File tree

docs/channels/discord.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,7 @@ Default slash command settings:
927927

928928
<Accordion title="Gateway proxy">
929929
Route Discord gateway WebSocket traffic and startup REST lookups (application ID + allowlist resolution) through an HTTP(S) proxy with `channels.discord.proxy`.
930+
Discord Gateway WebSocket proxying is explicit; WebSocket connections do not inherit ambient proxy environment variables from the Gateway process. Startup REST lookups use this proxy when `channels.discord.proxy` is configured.
930931

931932
```json5
932933
{

extensions/discord/src/client.proxy.test.ts

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22
import http from "node:http";
33
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
44
import { fetch as undiciFetch } from "undici";
5-
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import { createDiscordRestClient } from "./client.js";
77
import { createDiscordRequestClient } from "./proxy-request-client.js";
88

99
const makeProxyFetchMock = vi.hoisted(() => vi.fn());
10-
1110
vi.mock("openclaw/plugin-sdk/fetch-runtime", async () => {
1211
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/fetch-runtime")>(
1312
"openclaw/plugin-sdk/fetch-runtime",
@@ -26,9 +25,14 @@ vi.mock("openclaw/plugin-sdk/fetch-runtime", async () => {
2625

2726
describe("createDiscordRestClient proxy support", () => {
2827
beforeEach(() => {
28+
vi.unstubAllEnvs();
2929
makeProxyFetchMock.mockClear();
3030
});
3131

32+
afterEach(() => {
33+
vi.unstubAllEnvs();
34+
});
35+
3236
it("injects a custom fetch into RequestClient when a Discord proxy is configured", () => {
3337
const cfg = {
3438
channels: {
@@ -50,6 +54,86 @@ describe("createDiscordRestClient proxy support", () => {
5054
expect(requestClient.customFetch).toBe(requestClient.options?.fetch);
5155
});
5256

57+
it("accepts configured DNS proxy hosts", () => {
58+
const cfg = {
59+
channels: {
60+
discord: {
61+
token: "Bot test-token",
62+
proxy: "http://mitm-proxy:8080",
63+
},
64+
},
65+
} as OpenClawConfig;
66+
67+
const { rest } = createDiscordRestClient({ cfg });
68+
const requestClient = rest as unknown as {
69+
customFetch?: typeof fetch;
70+
options?: { fetch?: typeof fetch };
71+
};
72+
73+
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://mitm-proxy:8080");
74+
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
75+
expect(requestClient.customFetch).toBe(requestClient.options?.fetch);
76+
});
77+
78+
it("accepts configured HTTPS proxy hosts", () => {
79+
const cfg = {
80+
channels: {
81+
discord: {
82+
token: "Bot test-token",
83+
proxy: "https://proxy.example:8443",
84+
},
85+
},
86+
} as OpenClawConfig;
87+
88+
const { rest } = createDiscordRestClient({ cfg });
89+
const requestClient = rest as unknown as {
90+
customFetch?: typeof fetch;
91+
options?: { fetch?: typeof fetch };
92+
};
93+
94+
expect(makeProxyFetchMock).toHaveBeenCalledWith("https://proxy.example:8443");
95+
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
96+
expect(requestClient.customFetch).toBe(requestClient.options?.fetch);
97+
});
98+
99+
it("accepts configured proxy URLs with credentials", () => {
100+
const cfg = {
101+
channels: {
102+
discord: {
103+
token: "Bot test-token",
104+
proxy: "http://user:secret@mitm-proxy:8080",
105+
},
106+
},
107+
} as OpenClawConfig;
108+
109+
const { rest } = createDiscordRestClient({ cfg });
110+
const requestClient = rest as unknown as {
111+
options?: { fetch?: typeof fetch };
112+
};
113+
114+
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://user:secret@mitm-proxy:8080");
115+
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
116+
});
117+
118+
it("accepts arbitrary configured DNS proxy hosts", () => {
119+
const cfg = {
120+
channels: {
121+
discord: {
122+
token: "Bot test-token",
123+
proxy: "http://proxy.test:8080",
124+
},
125+
},
126+
} as OpenClawConfig;
127+
128+
const { rest } = createDiscordRestClient({ cfg });
129+
const requestClient = rest as unknown as {
130+
options?: { fetch?: typeof fetch };
131+
};
132+
133+
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://proxy.test:8080");
134+
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
135+
});
136+
53137
it("does not inject fetch when no proxy is configured", () => {
54138
const cfg = {
55139
channels: {
@@ -86,12 +170,12 @@ describe("createDiscordRestClient proxy support", () => {
86170
expect(requestClient.options?.fetch).toBeUndefined();
87171
});
88172

89-
it("falls back to direct fetch when the Discord proxy URL is remote", () => {
173+
it("accepts configured non-loopback IP proxy URLs", () => {
90174
const cfg = {
91175
channels: {
92176
discord: {
93177
token: "Bot test-token",
94-
proxy: "http://proxy.test:8080",
178+
proxy: "http://10.0.0.10:8080",
95179
},
96180
},
97181
} as OpenClawConfig;
@@ -101,8 +185,8 @@ describe("createDiscordRestClient proxy support", () => {
101185
options?: { fetch?: typeof fetch };
102186
};
103187

104-
expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://proxy.test:8080");
105-
expect(requestClient.options?.fetch).toBeUndefined();
188+
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://10.0.0.10:8080");
189+
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
106190
});
107191

108192
it("accepts IPv6 loopback Discord proxy URLs", () => {

extensions/discord/src/monitor/provider.proxy.test.ts

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,17 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
181181
}));
182182

183183
describe("createDiscordGatewayPlugin", () => {
184+
const proxyEnvKeys = [
185+
"OPENCLAW_PROXY_URL",
186+
"ALL_PROXY",
187+
"HTTPS_PROXY",
188+
"HTTP_PROXY",
189+
"NO_PROXY",
190+
"all_proxy",
191+
"https_proxy",
192+
"http_proxy",
193+
"no_proxy",
194+
] as const;
184195
let createDiscordGatewayPlugin: typeof import("./gateway-plugin.js").createDiscordGatewayPlugin;
185196
let waitForDiscordGatewayPluginRegistration: typeof import("./gateway-plugin.js").waitForDiscordGatewayPluginRegistration;
186197

@@ -322,6 +333,9 @@ describe("createDiscordGatewayPlugin", () => {
322333

323334
beforeEach(() => {
324335
vi.unstubAllEnvs();
336+
for (const key of proxyEnvKeys) {
337+
vi.stubEnv(key, undefined);
338+
}
325339
vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "");
326340
vi.stubEnv("OPENCLAW_DEBUG_PROXY_URL", "");
327341
vi.stubGlobal("fetch", globalFetchMock);
@@ -506,6 +520,91 @@ describe("createDiscordGatewayPlugin", () => {
506520
expect(runtime.error).not.toHaveBeenCalled();
507521
});
508522

523+
it("accepts configured DNS proxy hosts for gateway WebSocket", () => {
524+
const runtime = createRuntime();
525+
526+
const plugin = createDiscordGatewayPlugin({
527+
discordConfig: { proxy: "http://mitm-proxy:8080" },
528+
runtime,
529+
testing: createProxyTestingOverrides(),
530+
});
531+
532+
const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
533+
.createWebSocket;
534+
createWebSocket("wss://gateway.discord.gg");
535+
536+
expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://mitm-proxy:8080");
537+
expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
538+
agent: getLastProxyAgent(),
539+
handshakeTimeout: 30_000,
540+
});
541+
expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled");
542+
expect(runtime.error).not.toHaveBeenCalled();
543+
});
544+
545+
it("uses the configured gateway proxy when proxy is arbitrary DNS", () => {
546+
const runtime = createRuntime();
547+
548+
const plugin = createDiscordGatewayPlugin({
549+
discordConfig: { proxy: "http://proxy.test:8080" },
550+
runtime,
551+
testing: createProxyTestingOverrides(),
552+
});
553+
554+
const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
555+
.createWebSocket;
556+
createWebSocket("wss://gateway.discord.gg");
557+
558+
expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080");
559+
expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
560+
agent: getLastProxyAgent(),
561+
handshakeTimeout: 30_000,
562+
});
563+
expect(runtime.error).not.toHaveBeenCalled();
564+
expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled");
565+
});
566+
567+
it("keeps gateway WebSocket direct when only ambient proxy env is configured", () => {
568+
vi.stubEnv("https_proxy", "env-proxy.test:8080");
569+
const runtime = createRuntime();
570+
const plugin = createDiscordGatewayPlugin({
571+
discordConfig: {},
572+
runtime,
573+
});
574+
575+
const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
576+
.createWebSocket;
577+
createWebSocket("wss://gateway.discord.gg");
578+
579+
expect(httpsAgentSpy).toHaveBeenCalledTimes(1);
580+
expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
581+
agent: getLastAgent(),
582+
handshakeTimeout: 30_000,
583+
});
584+
expect(runtime.log).not.toHaveBeenCalled();
585+
});
586+
587+
it("uses explicit gateway proxy even when ambient proxy env is configured", () => {
588+
vi.stubEnv("https_proxy", "http://env-proxy.test:8080");
589+
const runtime = createRuntime();
590+
const plugin = createDiscordGatewayPlugin({
591+
discordConfig: { proxy: "http://127.0.0.1:8080" },
592+
runtime,
593+
testing: createProxyTestingOverrides(),
594+
});
595+
596+
const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
597+
.createWebSocket;
598+
createWebSocket("wss://gateway.discord.gg");
599+
600+
expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
601+
agent: getLastProxyAgent(),
602+
handshakeTimeout: 30_000,
603+
});
604+
expect(runtime.log).toHaveBeenCalledTimes(1);
605+
expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled");
606+
});
607+
509608
it("falls back to the default gateway plugin when proxy is invalid", () => {
510609
const runtime = createRuntime();
511610

@@ -575,18 +674,26 @@ describe("createDiscordGatewayPlugin", () => {
575674
expect(runtime.error).not.toHaveBeenCalled();
576675
});
577676

578-
it("falls back to the default gateway plugin when proxy is remote", () => {
677+
it("uses the configured gateway proxy when proxy is a non-loopback IP", () => {
579678
const runtime = createRuntime();
580679

581680
const plugin = createDiscordGatewayPlugin({
582-
discordConfig: { proxy: "http://proxy.test:8080" },
681+
discordConfig: { proxy: "http://10.0.0.10:8080" },
583682
runtime,
683+
testing: createProxyTestingOverrides(),
584684
});
585685

586-
expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype);
587-
expect(runtime.error).toHaveBeenCalledTimes(1);
588-
expect(String(firstMockArg(runtime.error, "runtime.error"))).toContain("loopback host");
589-
expect(runtime.log).not.toHaveBeenCalled();
686+
const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
687+
.createWebSocket;
688+
createWebSocket("wss://gateway.discord.gg");
689+
690+
expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://10.0.0.10:8080");
691+
expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
692+
agent: getLastProxyAgent(),
693+
handshakeTimeout: 30_000,
694+
});
695+
expect(runtime.error).not.toHaveBeenCalled();
696+
expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled");
590697
});
591698

592699
it("maps body read failures to fetch failed", async () => {

extensions/discord/src/monitor/provider.rest-proxy.test.ts

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ function installUndiciRuntimeDeps(): void {
120120

121121
describe("resolveDiscordRestFetch", () => {
122122
const proxyEnvKeys = [
123+
"OPENCLAW_PROXY_URL",
123124
"HTTP_PROXY",
124125
"HTTPS_PROXY",
125126
"ALL_PROXY",
@@ -140,7 +141,7 @@ describe("resolveDiscordRestFetch", () => {
140141
beforeEach(() => {
141142
vi.unstubAllEnvs();
142143
for (const key of proxyEnvKeys) {
143-
vi.stubEnv(key, "");
144+
vi.stubEnv(key, undefined);
144145
}
145146
undiciFetchMock.mockReset();
146147
agentSpy.mockReset();
@@ -190,6 +191,43 @@ describe("resolveDiscordRestFetch", () => {
190191
expect(runtime.error).not.toHaveBeenCalled();
191192
});
192193

194+
it("uses undici proxy fetch when the configured proxy is a DNS host", async () => {
195+
const runtime = {
196+
log: vi.fn(),
197+
error: vi.fn(),
198+
exit: vi.fn(),
199+
} as const;
200+
undiciFetchMock.mockClear().mockResolvedValue(new Response("ok", { status: 200 }));
201+
proxyAgentSpy.mockClear();
202+
const fetcher = resolveDiscordRestFetch("http://mitm-proxy:8080", runtime);
203+
204+
await fetcher("https://discord.com/api/v10/oauth2/applications/@me");
205+
206+
const proxyOptions = objectArgAt(proxyAgentSpy, 0, 0);
207+
expect(proxyOptions.uri).toBe("http://mitm-proxy:8080");
208+
expect(proxyOptions.allowH2).toBe(false);
209+
expect(runtime.log).toHaveBeenCalledWith("discord: rest proxy enabled");
210+
expect(runtime.error).not.toHaveBeenCalled();
211+
});
212+
213+
it("uses undici proxy fetch when proxy URL is arbitrary DNS", async () => {
214+
const runtime = {
215+
log: vi.fn(),
216+
error: vi.fn(),
217+
exit: vi.fn(),
218+
} as const;
219+
undiciFetchMock.mockClear().mockResolvedValue(new Response("ok", { status: 200 }));
220+
221+
const fetcher = resolveDiscordRestFetch("http://proxy.test:8080", runtime);
222+
await fetcher("https://discord.com/api/v10/oauth2/applications/@me");
223+
224+
const proxyOptions = objectArgAt(proxyAgentSpy, 0, 0);
225+
expect(proxyOptions.uri).toBe("http://proxy.test:8080");
226+
expect(proxyOptions.allowH2).toBe(false);
227+
expect(runtime.log).toHaveBeenCalledWith("discord: rest proxy enabled");
228+
expect(runtime.error).not.toHaveBeenCalled();
229+
});
230+
193231
it("uses managed proxy CA trust when a configured REST proxy matches the managed proxy", async () => {
194232
const caFile = writeTempCa("discord-rest-configured-proxy-ca");
195233
vi.stubEnv("HTTPS_PROXY", "https://127.0.0.1:8443");
@@ -228,19 +266,22 @@ describe("resolveDiscordRestFetch", () => {
228266
expect(runtime.log).not.toHaveBeenCalled();
229267
});
230268

231-
it("falls back to global fetch when proxy URL is remote", () => {
269+
it("uses undici proxy fetch when proxy URL is a non-loopback IP", async () => {
232270
const runtime = {
233271
log: vi.fn(),
234272
error: vi.fn(),
235273
exit: vi.fn(),
236274
} as const;
275+
undiciFetchMock.mockResolvedValue(new Response("ok", { status: 200 }));
237276

238-
const fetcher = resolveDiscordRestFetch("http://proxy.test:8080", runtime);
277+
const fetcher = resolveDiscordRestFetch("http://10.0.0.10:8080", runtime);
278+
await fetcher("https://discord.com/api/v10/oauth2/applications/@me");
239279

240-
expect(fetcher).toBe(fetch);
241-
expect(proxyAgentSpy).not.toHaveBeenCalled();
242-
expect(String(argAt(runtime.error, 0, 0))).toContain("loopback host");
243-
expect(runtime.log).not.toHaveBeenCalled();
280+
const proxyOptions = objectArgAt(proxyAgentSpy, 0, 0);
281+
expect(proxyOptions.uri).toBe("http://10.0.0.10:8080");
282+
expect(proxyOptions.allowH2).toBe(false);
283+
expect(runtime.log).toHaveBeenCalledWith("discord: rest proxy enabled");
284+
expect(runtime.error).not.toHaveBeenCalled();
244285
});
245286

246287
it("uses undici proxy fetch when the proxy URL is IPv6 loopback", async () => {

0 commit comments

Comments
 (0)