Skip to content

Commit cb82693

Browse files
committed
fix(discord): allow explicit remote proxy URLs
1 parent e6e3af3 commit cb82693

6 files changed

Lines changed: 77 additions & 221 deletions

File tree

docs/channels/discord.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -927,13 +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 active process proxy URL from `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_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.
931931

932932
```json5
933933
{
934934
channels: {
935935
discord: {
936-
proxy: "http://127.0.0.1:8080",
936+
proxy: "http://proxy.example:8080",
937937
},
938938
},
939939
}
@@ -947,7 +947,7 @@ Default slash command settings:
947947
discord: {
948948
accounts: {
949949
primary: {
950-
proxy: "http://127.0.0.1:8080",
950+
proxy: "http://proxy.example:8080",
951951
},
952952
},
953953
},

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

Lines changed: 14 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,6 @@ import { createDiscordRestClient } from "./client.js";
77
import { createDiscordRequestClient } from "./proxy-request-client.js";
88

99
const makeProxyFetchMock = vi.hoisted(() => vi.fn());
10-
const proxyEnvKeys = [
11-
"OPENCLAW_PROXY_URL",
12-
"HTTP_PROXY",
13-
"HTTPS_PROXY",
14-
"ALL_PROXY",
15-
"http_proxy",
16-
"https_proxy",
17-
"all_proxy",
18-
] as const;
19-
2010
vi.mock("openclaw/plugin-sdk/fetch-runtime", async () => {
2111
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/fetch-runtime")>(
2212
"openclaw/plugin-sdk/fetch-runtime",
@@ -36,9 +26,6 @@ vi.mock("openclaw/plugin-sdk/fetch-runtime", async () => {
3626
describe("createDiscordRestClient proxy support", () => {
3727
beforeEach(() => {
3828
vi.unstubAllEnvs();
39-
for (const key of proxyEnvKeys) {
40-
vi.stubEnv(key, undefined);
41-
}
4229
makeProxyFetchMock.mockClear();
4330
});
4431

@@ -67,8 +54,7 @@ describe("createDiscordRestClient proxy support", () => {
6754
expect(requestClient.customFetch).toBe(requestClient.options?.fetch);
6855
});
6956

70-
it("accepts the configured process proxy DNS host", () => {
71-
vi.stubEnv("HTTPS_PROXY", "http://mitm-proxy:8080");
57+
it("accepts configured DNS proxy hosts", () => {
7258
const cfg = {
7359
channels: {
7460
discord: {
@@ -89,13 +75,12 @@ describe("createDiscordRestClient proxy support", () => {
8975
expect(requestClient.customFetch).toBe(requestClient.options?.fetch);
9076
});
9177

92-
it("accepts a proxy URL that matches ALL_PROXY", () => {
93-
vi.stubEnv("ALL_PROXY", "http://mitm-proxy:8080");
78+
it("accepts configured HTTPS proxy hosts", () => {
9479
const cfg = {
9580
channels: {
9681
discord: {
9782
token: "Bot test-token",
98-
proxy: "http://mitm-proxy:8080",
83+
proxy: "https://proxy.example:8443",
9984
},
10085
},
10186
} as OpenClawConfig;
@@ -106,60 +91,17 @@ describe("createDiscordRestClient proxy support", () => {
10691
options?: { fetch?: typeof fetch };
10792
};
10893

109-
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://mitm-proxy:8080");
94+
expect(makeProxyFetchMock).toHaveBeenCalledWith("https://proxy.example:8443");
11095
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
11196
expect(requestClient.customFetch).toBe(requestClient.options?.fetch);
11297
});
11398

114-
it("rejects a configured process proxy host when credentials do not match", () => {
115-
vi.stubEnv("HTTPS_PROXY", "http://user:secret@mitm-proxy:8080");
116-
const cfg = {
117-
channels: {
118-
discord: {
119-
token: "Bot test-token",
120-
proxy: "http://mitm-proxy:8080",
121-
},
122-
},
123-
} as OpenClawConfig;
124-
125-
const { rest } = createDiscordRestClient({ cfg });
126-
const requestClient = rest as unknown as {
127-
options?: { fetch?: typeof fetch };
128-
};
129-
130-
expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://mitm-proxy:8080");
131-
expect(requestClient.options?.fetch).toBeUndefined();
132-
});
133-
134-
it("rejects a shadowed uppercase proxy env URL", () => {
135-
vi.stubEnv("HTTPS_PROXY", "http://mitm-proxy:8080");
136-
vi.stubEnv("https_proxy", "http://active-proxy:8080");
137-
const cfg = {
138-
channels: {
139-
discord: {
140-
token: "Bot test-token",
141-
proxy: "http://mitm-proxy:8080",
142-
},
143-
},
144-
} as OpenClawConfig;
145-
146-
const { rest } = createDiscordRestClient({ cfg });
147-
const requestClient = rest as unknown as {
148-
options?: { fetch?: typeof fetch };
149-
};
150-
151-
expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://mitm-proxy:8080");
152-
expect(requestClient.options?.fetch).toBeUndefined();
153-
});
154-
155-
it("rejects stale OPENCLAW_PROXY_URL when the active process proxy differs", () => {
156-
vi.stubEnv("OPENCLAW_PROXY_URL", "http://stale-proxy:8080");
157-
vi.stubEnv("HTTPS_PROXY", "http://active-proxy:8080");
99+
it("accepts configured proxy URLs with credentials", () => {
158100
const cfg = {
159101
channels: {
160102
discord: {
161103
token: "Bot test-token",
162-
proxy: "http://stale-proxy:8080",
104+
proxy: "http://user:secret@mitm-proxy:8080",
163105
},
164106
},
165107
} as OpenClawConfig;
@@ -169,11 +111,11 @@ describe("createDiscordRestClient proxy support", () => {
169111
options?: { fetch?: typeof fetch };
170112
};
171113

172-
expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://stale-proxy:8080");
173-
expect(requestClient.options?.fetch).toBeUndefined();
114+
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://user:secret@mitm-proxy:8080");
115+
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
174116
});
175117

176-
it("falls back to direct fetch when the Discord proxy URL is arbitrary DNS", () => {
118+
it("accepts arbitrary configured DNS proxy hosts", () => {
177119
const cfg = {
178120
channels: {
179121
discord: {
@@ -188,8 +130,8 @@ describe("createDiscordRestClient proxy support", () => {
188130
options?: { fetch?: typeof fetch };
189131
};
190132

191-
expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://proxy.test:8080");
192-
expect(requestClient.options?.fetch).toBeUndefined();
133+
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://proxy.test:8080");
134+
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
193135
});
194136

195137
it("does not inject fetch when no proxy is configured", () => {
@@ -228,7 +170,7 @@ describe("createDiscordRestClient proxy support", () => {
228170
expect(requestClient.options?.fetch).toBeUndefined();
229171
});
230172

231-
it("falls back to direct fetch when the Discord proxy URL is a non-loopback IP", () => {
173+
it("accepts configured non-loopback IP proxy URLs", () => {
232174
const cfg = {
233175
channels: {
234176
discord: {
@@ -243,8 +185,8 @@ describe("createDiscordRestClient proxy support", () => {
243185
options?: { fetch?: typeof fetch };
244186
};
245187

246-
expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://10.0.0.10:8080");
247-
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);
248190
});
249191

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

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

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -520,8 +520,7 @@ describe("createDiscordGatewayPlugin", () => {
520520
expect(runtime.error).not.toHaveBeenCalled();
521521
});
522522

523-
it("accepts the configured process proxy DNS host for gateway WebSocket", () => {
524-
vi.stubEnv("HTTPS_PROXY", "http://mitm-proxy:8080");
523+
it("accepts configured DNS proxy hosts for gateway WebSocket", () => {
525524
const runtime = createRuntime();
526525

527526
const plugin = createDiscordGatewayPlugin({
@@ -543,20 +542,26 @@ describe("createDiscordGatewayPlugin", () => {
543542
expect(runtime.error).not.toHaveBeenCalled();
544543
});
545544

546-
it("falls back to the default gateway plugin when proxy is arbitrary DNS", () => {
545+
it("uses the configured gateway proxy when proxy is arbitrary DNS", () => {
547546
const runtime = createRuntime();
548547

549548
const plugin = createDiscordGatewayPlugin({
550549
discordConfig: { proxy: "http://proxy.test:8080" },
551550
runtime,
551+
testing: createProxyTestingOverrides(),
552552
});
553553

554-
expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype);
555-
expect(runtime.error).toHaveBeenCalledTimes(1);
556-
expect(String(firstMockArg(runtime.error, "runtime.error"))).toContain(
557-
"configured process proxy",
558-
);
559-
expect(runtime.log).not.toHaveBeenCalled();
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");
560565
});
561566

562567
it("keeps gateway WebSocket direct when only ambient proxy env is configured", () => {
@@ -579,7 +584,7 @@ describe("createDiscordGatewayPlugin", () => {
579584
expect(runtime.log).not.toHaveBeenCalled();
580585
});
581586

582-
it("does not create env proxy agent when explicit gateway proxy is configured", () => {
587+
it("uses explicit gateway proxy even when ambient proxy env is configured", () => {
583588
vi.stubEnv("https_proxy", "http://env-proxy.test:8080");
584589
const runtime = createRuntime();
585590
const plugin = createDiscordGatewayPlugin({
@@ -669,20 +674,26 @@ describe("createDiscordGatewayPlugin", () => {
669674
expect(runtime.error).not.toHaveBeenCalled();
670675
});
671676

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

675680
const plugin = createDiscordGatewayPlugin({
676681
discordConfig: { proxy: "http://10.0.0.10:8080" },
677682
runtime,
683+
testing: createProxyTestingOverrides(),
678684
});
679685

680-
expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype);
681-
expect(runtime.error).toHaveBeenCalledTimes(1);
682-
expect(String(firstMockArg(runtime.error, "runtime.error"))).toContain(
683-
"configured process proxy",
684-
);
685-
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");
686697
});
687698

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

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,7 @@ describe("resolveDiscordRestFetch", () => {
191191
expect(runtime.error).not.toHaveBeenCalled();
192192
});
193193

194-
it("uses undici proxy fetch when the configured proxy is the process proxy", async () => {
195-
vi.stubEnv("HTTPS_PROXY", "http://mitm-proxy:8080");
194+
it("uses undici proxy fetch when the configured proxy is a DNS host", async () => {
196195
const runtime = {
197196
log: vi.fn(),
198197
error: vi.fn(),
@@ -211,19 +210,22 @@ describe("resolveDiscordRestFetch", () => {
211210
expect(runtime.error).not.toHaveBeenCalled();
212211
});
213212

214-
it("falls back to global fetch when proxy URL is arbitrary DNS", () => {
213+
it("uses undici proxy fetch when proxy URL is arbitrary DNS", async () => {
215214
const runtime = {
216215
log: vi.fn(),
217216
error: vi.fn(),
218217
exit: vi.fn(),
219218
} as const;
219+
undiciFetchMock.mockClear().mockResolvedValue(new Response("ok", { status: 200 }));
220220

221221
const fetcher = resolveDiscordRestFetch("http://proxy.test:8080", runtime);
222+
await fetcher("https://discord.com/api/v10/oauth2/applications/@me");
222223

223-
expect(fetcher).toBe(fetch);
224-
expect(proxyAgentSpy).not.toHaveBeenCalled();
225-
expect(String(argAt(runtime.error, 0, 0))).toContain("configured process proxy");
226-
expect(runtime.log).not.toHaveBeenCalled();
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();
227229
});
228230

229231
it("uses managed proxy CA trust when a configured REST proxy matches the managed proxy", async () => {
@@ -264,19 +266,22 @@ describe("resolveDiscordRestFetch", () => {
264266
expect(runtime.log).not.toHaveBeenCalled();
265267
});
266268

267-
it("falls back to global fetch when proxy URL is a non-loopback IP", () => {
269+
it("uses undici proxy fetch when proxy URL is a non-loopback IP", async () => {
268270
const runtime = {
269271
log: vi.fn(),
270272
error: vi.fn(),
271273
exit: vi.fn(),
272274
} as const;
275+
undiciFetchMock.mockResolvedValue(new Response("ok", { status: 200 }));
273276

274277
const fetcher = resolveDiscordRestFetch("http://10.0.0.10:8080", runtime);
278+
await fetcher("https://discord.com/api/v10/oauth2/applications/@me");
275279

276-
expect(fetcher).toBe(fetch);
277-
expect(proxyAgentSpy).not.toHaveBeenCalled();
278-
expect(String(argAt(runtime.error, 0, 0))).toContain("configured process proxy");
279-
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();
280285
});
281286

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

0 commit comments

Comments
 (0)