Skip to content

Commit 0add562

Browse files
committed
test(discord): keep gateway proxy explicit
Signed-off-by: sallyom <[email protected]>
1 parent f1cb895 commit 0add562

7 files changed

Lines changed: 385 additions & 58 deletions

File tree

docs/channels/discord.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -927,12 +927,13 @@ 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 proxying is explicit; it does not inherit ambient proxy environment variables from the Gateway process. Proxy URLs must target loopback or exactly match the configured process proxy URL, such as `OPENCLAW_PROXY_URL`, `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY`.
930931

931932
```json5
932933
{
933934
channels: {
934935
discord: {
935-
proxy: "http://proxy.example:8080",
936+
proxy: "http://127.0.0.1:8080",
936937
},
937938
},
938939
}
@@ -946,7 +947,7 @@ Default slash command settings:
946947
discord: {
947948
accounts: {
948949
primary: {
949-
proxy: "http://proxy.example:8080",
950+
proxy: "http://127.0.0.1:8080",
950951
},
951952
},
952953
},

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

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
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

@@ -26,9 +26,14 @@ vi.mock("openclaw/plugin-sdk/fetch-runtime", async () => {
2626

2727
describe("createDiscordRestClient proxy support", () => {
2828
beforeEach(() => {
29+
vi.unstubAllEnvs();
2930
makeProxyFetchMock.mockClear();
3031
});
3132

33+
afterEach(() => {
34+
vi.unstubAllEnvs();
35+
});
36+
3237
it("injects a custom fetch into RequestClient when a Discord proxy is configured", () => {
3338
const cfg = {
3439
channels: {
@@ -50,6 +55,110 @@ describe("createDiscordRestClient proxy support", () => {
5055
expect(requestClient.customFetch).toBe(requestClient.options?.fetch);
5156
});
5257

58+
it("accepts the configured process proxy DNS host", () => {
59+
vi.stubEnv("HTTPS_PROXY", "http://mitm-proxy:8080");
60+
const cfg = {
61+
channels: {
62+
discord: {
63+
token: "Bot test-token",
64+
proxy: "http://mitm-proxy:8080",
65+
},
66+
},
67+
} as OpenClawConfig;
68+
69+
const { rest } = createDiscordRestClient({ cfg });
70+
const requestClient = rest as unknown as {
71+
customFetch?: typeof fetch;
72+
options?: { fetch?: typeof fetch };
73+
};
74+
75+
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://mitm-proxy:8080");
76+
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
77+
expect(requestClient.customFetch).toBe(requestClient.options?.fetch);
78+
});
79+
80+
it("accepts a proxy URL that matches ALL_PROXY", () => {
81+
vi.stubEnv("ALL_PROXY", "http://mitm-proxy:8080");
82+
const cfg = {
83+
channels: {
84+
discord: {
85+
token: "Bot test-token",
86+
proxy: "http://mitm-proxy:8080",
87+
},
88+
},
89+
} as OpenClawConfig;
90+
91+
const { rest } = createDiscordRestClient({ cfg });
92+
const requestClient = rest as unknown as {
93+
customFetch?: typeof fetch;
94+
options?: { fetch?: typeof fetch };
95+
};
96+
97+
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://mitm-proxy:8080");
98+
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
99+
expect(requestClient.customFetch).toBe(requestClient.options?.fetch);
100+
});
101+
102+
it("rejects a configured process proxy host when credentials do not match", () => {
103+
vi.stubEnv("HTTPS_PROXY", "http://user:secret@mitm-proxy:8080");
104+
const cfg = {
105+
channels: {
106+
discord: {
107+
token: "Bot test-token",
108+
proxy: "http://mitm-proxy:8080",
109+
},
110+
},
111+
} as OpenClawConfig;
112+
113+
const { rest } = createDiscordRestClient({ cfg });
114+
const requestClient = rest as unknown as {
115+
options?: { fetch?: typeof fetch };
116+
};
117+
118+
expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://mitm-proxy:8080");
119+
expect(requestClient.options?.fetch).toBeUndefined();
120+
});
121+
122+
it("rejects a shadowed uppercase proxy env URL", () => {
123+
vi.stubEnv("HTTPS_PROXY", "http://mitm-proxy:8080");
124+
vi.stubEnv("https_proxy", "http://active-proxy:8080");
125+
const cfg = {
126+
channels: {
127+
discord: {
128+
token: "Bot test-token",
129+
proxy: "http://mitm-proxy:8080",
130+
},
131+
},
132+
} as OpenClawConfig;
133+
134+
const { rest } = createDiscordRestClient({ cfg });
135+
const requestClient = rest as unknown as {
136+
options?: { fetch?: typeof fetch };
137+
};
138+
139+
expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://mitm-proxy:8080");
140+
expect(requestClient.options?.fetch).toBeUndefined();
141+
});
142+
143+
it("falls back to direct fetch when the Discord proxy URL is arbitrary DNS", () => {
144+
const cfg = {
145+
channels: {
146+
discord: {
147+
token: "Bot test-token",
148+
proxy: "http://proxy.test:8080",
149+
},
150+
},
151+
} as OpenClawConfig;
152+
153+
const { rest } = createDiscordRestClient({ cfg });
154+
const requestClient = rest as unknown as {
155+
options?: { fetch?: typeof fetch };
156+
};
157+
158+
expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://proxy.test:8080");
159+
expect(requestClient.options?.fetch).toBeUndefined();
160+
});
161+
53162
it("does not inject fetch when no proxy is configured", () => {
54163
const cfg = {
55164
channels: {
@@ -86,12 +195,12 @@ describe("createDiscordRestClient proxy support", () => {
86195
expect(requestClient.options?.fetch).toBeUndefined();
87196
});
88197

89-
it("falls back to direct fetch when the Discord proxy URL is remote", () => {
198+
it("falls back to direct fetch when the Discord proxy URL is a non-loopback IP", () => {
90199
const cfg = {
91200
channels: {
92201
discord: {
93202
token: "Bot test-token",
94-
proxy: "http://proxy.test:8080",
203+
proxy: "http://10.0.0.10:8080",
95204
},
96205
},
97206
} as OpenClawConfig;
@@ -101,7 +210,7 @@ describe("createDiscordRestClient proxy support", () => {
101210
options?: { fetch?: typeof fetch };
102211
};
103212

104-
expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://proxy.test:8080");
213+
expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://10.0.0.10:8080");
105214
expect(requestClient.options?.fetch).toBeUndefined();
106215
});
107216

extensions/discord/src/monitor/gateway-plugin.ts

Lines changed: 4 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,7 @@ import { randomUUID } from "node:crypto";
33
import type { Agent as HttpAgent } from "node:http";
44
import { Agent as HttpsAgent } from "node:https";
55
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-contracts";
6-
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
7-
import {
8-
createNodeProxyAgent,
9-
resolveEnvHttpProxyAgentOptions,
10-
} from "openclaw/plugin-sdk/fetch-runtime";
6+
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime";
117
import {
128
captureWsEvent,
139
resolveEffectiveDebugProxyUrl,
@@ -387,29 +383,6 @@ export function waitForDiscordGatewayPluginRegistration(
387383
return registrationPromises.get(plugin as discordGateway.GatewayPlugin);
388384
}
389385

390-
function createEnvProxyDiscordGatewayAgent(
391-
runtime: RuntimeEnv,
392-
): DiscordGatewayWebSocketAgent | undefined {
393-
const envProxyOptions = resolveEnvHttpProxyAgentOptions();
394-
if (!envProxyOptions) {
395-
return undefined;
396-
}
397-
try {
398-
return createNodeProxyAgent({
399-
mode: "env",
400-
targetUrl: "wss://gateway.discord.gg",
401-
protocol: "https",
402-
});
403-
} catch (err) {
404-
runtime.error?.(
405-
danger(
406-
`discord: env proxy unavailable for gateway WebSocket; using direct agent: ${formatErrorMessage(err)}`,
407-
),
408-
);
409-
return undefined;
410-
}
411-
}
412-
413386
export function createDiscordGatewayPlugin(params: {
414387
discordConfig: DiscordAccountConfig;
415388
runtime: RuntimeEnv;
@@ -426,21 +399,10 @@ export function createDiscordGatewayPlugin(params: {
426399
env: process.env,
427400
});
428401
let fetchImpl = createDiscordGatewayMetadataFetch(debugProxySettings.enabled);
402+
let wsAgent: DiscordGatewayWebSocketAgent = new HttpsAgent({
403+
lookup: discordDnsLookup,
404+
});
429405

430-
// Try env proxy first (HTTP_PROXY/HTTPS_PROXY)
431-
let wsAgent: DiscordGatewayWebSocketAgent;
432-
const envProxyAgent = createEnvProxyDiscordGatewayAgent(params.runtime);
433-
if (envProxyAgent) {
434-
wsAgent = envProxyAgent;
435-
params.runtime.log?.("discord: gateway WebSocket using env proxy");
436-
} else {
437-
// Fallback to direct connection
438-
wsAgent = new HttpsAgent({
439-
lookup: discordDnsLookup,
440-
});
441-
}
442-
443-
// Explicit channels.discord.proxy overrides env
444406
if (proxy) {
445407
try {
446408
validateDiscordProxyUrl(proxy);

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

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

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

@@ -322,6 +332,9 @@ describe("createDiscordGatewayPlugin", () => {
322332

323333
beforeEach(() => {
324334
vi.unstubAllEnvs();
335+
for (const key of proxyEnvKeys) {
336+
vi.stubEnv(key, "");
337+
}
325338
vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "");
326339
vi.stubEnv("OPENCLAW_DEBUG_PROXY_URL", "");
327340
vi.stubGlobal("fetch", globalFetchMock);
@@ -506,6 +519,86 @@ describe("createDiscordGatewayPlugin", () => {
506519
expect(runtime.error).not.toHaveBeenCalled();
507520
});
508521

522+
it("accepts the configured process proxy DNS host for gateway WebSocket", () => {
523+
vi.stubEnv("HTTPS_PROXY", "http://mitm-proxy:8080");
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("falls back to the default gateway plugin when proxy is arbitrary DNS", () => {
546+
const runtime = createRuntime();
547+
548+
const plugin = createDiscordGatewayPlugin({
549+
discordConfig: { proxy: "http://proxy.test:8080" },
550+
runtime,
551+
});
552+
553+
expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype);
554+
expect(runtime.error).toHaveBeenCalledTimes(1);
555+
expect(String(firstMockArg(runtime.error, "runtime.error"))).toContain(
556+
"configured process proxy",
557+
);
558+
expect(runtime.log).not.toHaveBeenCalled();
559+
});
560+
561+
it("keeps gateway WebSocket direct when only ambient proxy env is configured", () => {
562+
vi.stubEnv("https_proxy", "env-proxy.test:8080");
563+
const runtime = createRuntime();
564+
const plugin = createDiscordGatewayPlugin({
565+
discordConfig: {},
566+
runtime,
567+
});
568+
569+
const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
570+
.createWebSocket;
571+
createWebSocket("wss://gateway.discord.gg");
572+
573+
expect(httpsAgentSpy).toHaveBeenCalledTimes(1);
574+
expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
575+
agent: getLastAgent(),
576+
handshakeTimeout: 30_000,
577+
});
578+
expect(runtime.log).not.toHaveBeenCalled();
579+
});
580+
581+
it("does not create env proxy agent when explicit gateway proxy is configured", () => {
582+
vi.stubEnv("https_proxy", "http://env-proxy.test:8080");
583+
const runtime = createRuntime();
584+
const plugin = createDiscordGatewayPlugin({
585+
discordConfig: { proxy: "http://127.0.0.1:8080" },
586+
runtime,
587+
testing: createProxyTestingOverrides(),
588+
});
589+
590+
const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
591+
.createWebSocket;
592+
createWebSocket("wss://gateway.discord.gg");
593+
594+
expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
595+
agent: getLastProxyAgent(),
596+
handshakeTimeout: 30_000,
597+
});
598+
expect(runtime.log).toHaveBeenCalledTimes(1);
599+
expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled");
600+
});
601+
509602
it("falls back to the default gateway plugin when proxy is invalid", () => {
510603
const runtime = createRuntime();
511604

@@ -575,17 +668,19 @@ describe("createDiscordGatewayPlugin", () => {
575668
expect(runtime.error).not.toHaveBeenCalled();
576669
});
577670

578-
it("falls back to the default gateway plugin when proxy is remote", () => {
671+
it("falls back to the default gateway plugin when proxy is a non-loopback IP", () => {
579672
const runtime = createRuntime();
580673

581674
const plugin = createDiscordGatewayPlugin({
582-
discordConfig: { proxy: "http://proxy.test:8080" },
675+
discordConfig: { proxy: "http://10.0.0.10:8080" },
583676
runtime,
584677
});
585678

586679
expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype);
587680
expect(runtime.error).toHaveBeenCalledTimes(1);
588-
expect(String(firstMockArg(runtime.error, "runtime.error"))).toContain("loopback host");
681+
expect(String(firstMockArg(runtime.error, "runtime.error"))).toContain(
682+
"configured process proxy",
683+
);
589684
expect(runtime.log).not.toHaveBeenCalled();
590685
});
591686

0 commit comments

Comments
 (0)