fix: route Discord gateway metadata through proxy (#86601)

* fix: route Discord gateway metadata through proxy

* fix: keep Discord gateway proxy fetch guarded
This commit is contained in:
Peter Steinberger
2026-05-25 19:59:51 +01:00
committed by GitHub
parent f00a912c25
commit 5b6d409248
4 changed files with 78 additions and 39 deletions
+1
View File
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Discord: route gateway metadata REST lookups through the configured Discord proxy so proxied accounts do not fall back to direct `discord.com` connections before opening the WebSocket. Fixes #80227. Thanks @Clivilwalker.
- Agents/media: hydrate current-turn image attachments from filename-derived MIME types so active vision can see generated or forwarded images whose source omitted an image content type. (#84812) Thanks @marchpure.
- Agents/fs: point workspace-only scratch-path guidance at in-workspace temp directories while keeping host-root writes rejected by the tool guard. (#86501) Thanks @tianxiaochannel-oss88.
- Agents/media: keep async cron media completions scoped to their run session while preserving direct delivery for stale generated-media success and failure notifications. (#86529) Thanks @ai-hpc.
@@ -24,6 +24,10 @@ export type DiscordGatewayFetch = (
input: string,
init?: DiscordGatewayFetchInit,
) => Promise<DiscordGatewayMetadataResponse>;
export type DiscordGatewayMetadataFetchOptions = {
capture?: false | { flowId: string; meta: Record<string, unknown> };
proxyUrl?: string;
};
type DiscordGatewayMetadataError = Error & { transient?: boolean };
@@ -265,10 +269,10 @@ export function resolveGatewayInfoWithFallback(params: { runtime?: RuntimeEnv; e
};
}
export async function fetchDiscordGatewayMetadataDirect(
export async function fetchDiscordGatewayMetadataGuarded(
input: string,
init?: DiscordGatewayFetchInit,
capture?: false | { flowId: string; meta: Record<string, unknown> },
options?: DiscordGatewayMetadataFetchOptions,
): Promise<Response> {
const guarded = await fetchWithSsrFGuard({
url: resolveFetchInputUrl(input),
@@ -276,6 +280,16 @@ export async function fetchDiscordGatewayMetadataDirect(
policy: { allowedHostnames: [DISCORD_API_HOST] },
capture: false,
auditContext: "discord.gateway.metadata",
...(options?.proxyUrl
? {
mode: "trusted_explicit_proxy" as const,
dispatcherPolicy: {
mode: "explicit-proxy" as const,
proxyUrl: options.proxyUrl,
allowPrivateProxy: true,
},
}
: {}),
});
let response: Response;
try {
@@ -283,15 +297,15 @@ export async function fetchDiscordGatewayMetadataDirect(
} finally {
await guarded.release();
}
if (capture) {
if (options?.capture) {
captureHttpExchange({
url: input,
method: (init?.method as string | undefined) ?? "GET",
requestHeaders: init?.headers as Headers | Record<string, string> | undefined,
requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null,
response,
flowId: capture.flowId,
meta: capture.meta,
flowId: options.capture.flowId,
meta: options.capture.meta,
});
}
return response;
@@ -17,7 +17,7 @@ import { resolveDiscordVoiceEnabled } from "../voice/config.js";
import { DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT } from "./gateway-handle.js";
import {
fetchDiscordGatewayInfoWithTimeout,
fetchDiscordGatewayMetadataDirect,
fetchDiscordGatewayMetadataGuarded,
resolveDiscordGatewayInfoTimeoutMs,
resolveGatewayInfoWithFallback,
type DiscordGatewayFetch,
@@ -230,18 +230,22 @@ function createGatewayPlugin(params: {
return new OpenClawGatewayPlugin();
}
function createDiscordGatewayMetadataFetch(debugCaptureEnabled: boolean): DiscordGatewayFetch {
function createDiscordGatewayMetadataFetch(
debugCaptureEnabled: boolean,
proxyUrl?: string,
): DiscordGatewayFetch {
return (input, init) =>
fetchDiscordGatewayMetadataDirect(
input,
init,
debugCaptureEnabled
? false
fetchDiscordGatewayMetadataGuarded(input, init, {
...(debugCaptureEnabled
? {}
: {
flowId: randomUUID(),
meta: { subsystem: "discord-gateway-metadata" },
},
);
capture: {
flowId: randomUUID(),
meta: { subsystem: "discord-gateway-metadata" },
},
}),
...(proxyUrl ? { proxyUrl } : {}),
});
}
export function waitForDiscordGatewayPluginRegistration(
@@ -279,10 +283,12 @@ export function createDiscordGatewayPlugin(params: {
const HttpsProxyAgentCtor =
params.testing?.HttpsProxyAgentCtor ?? httpsProxyAgent.HttpsProxyAgent;
wsAgent = new HttpsProxyAgentCtor<string>(proxy);
fetchImpl = createDiscordGatewayMetadataFetch(debugProxySettings.enabled, proxy);
params.runtime.log?.("discord: gateway proxy enabled");
} catch (err) {
params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`));
fetchImpl = (input, init) => fetchDiscordGatewayMetadataDirect(input, init, false);
fetchImpl = (input, init) =>
fetchDiscordGatewayMetadataGuarded(input, init, { capture: false });
}
}
@@ -37,6 +37,7 @@ const {
globalFetchMock,
HttpsAgent,
HttpsProxyAgent,
fetchWithSsrFGuardMock,
getLastAgent,
getLastProxyAgent,
resolveDebugProxySettingsMock,
@@ -53,6 +54,18 @@ const {
const captureHttpExchangeSpy = vi.fn();
const captureWsEventSpy = vi.fn();
const resolveDebugProxySettingsMock = vi.fn(() => ({ enabled: false }));
const fetchWithSsrFGuardMock = vi.fn(async (params: { url: string; init?: RequestInit }) => {
const source = (await globalFetchMock(params.url, params.init)) as Response;
const body = await source.text();
return {
response: new Response(body, {
status: source.status,
statusText: source.statusText,
headers: source.headers,
}),
release: vi.fn(),
};
});
const GatewayIntents = {
Guilds: 1 << 0,
@@ -114,6 +127,7 @@ const {
globalFetchMock,
HttpsAgent,
HttpsProxyAgent,
fetchWithSsrFGuardMock,
getLastAgent: () => HttpsAgent.lastCreated,
getLastProxyAgent: () => HttpsProxyAgent.lastCreated,
captureHttpExchangeSpy,
@@ -166,18 +180,7 @@ vi.mock("openclaw/plugin-sdk/proxy-capture", () => ({
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => {
const source = (await globalFetchMock(params.url, params.init)) as Response;
const body = await source.text();
return {
response: new Response(body, {
status: source.status,
statusText: source.statusText,
headers: source.headers,
}),
release: vi.fn(),
};
}),
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
describe("createDiscordGatewayPlugin", () => {
@@ -213,6 +216,16 @@ describe("createDiscordGatewayPlugin", () => {
return firstMockCall(mock, label)[index];
}
function firstGuardedFetchCall() {
return firstMockArg(fetchWithSsrFGuardMock, "fetchWithSsrFGuardMock") as {
url: string;
init?: RequestInit & { signal?: unknown };
mode?: string;
dispatcherPolicy?: unknown;
policy?: unknown;
};
}
function createProxyTestingOverrides() {
return {
HttpsProxyAgentCtor:
@@ -318,6 +331,7 @@ describe("createDiscordGatewayPlugin", () => {
vi.useRealTimers();
baseRegisterClientSpy.mockClear();
globalFetchMock.mockClear();
fetchWithSsrFGuardMock.mockClear();
httpsAgentSpy.mockClear();
wsProxyAgentSpy.mockClear();
webSocketSpy.mockClear();
@@ -504,9 +518,10 @@ describe("createDiscordGatewayPlugin", () => {
expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype);
expect(runtime.error).toHaveBeenCalled();
expect(runtime.log).not.toHaveBeenCalled();
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
});
it("keeps gateway metadata lookup on the guarded direct fetch when proxy is configured", async () => {
it("routes gateway metadata lookup through the guarded proxy dispatcher", async () => {
const runtime = createRuntime();
const plugin = createDiscordGatewayPlugin({
discordConfig: { proxy: "http://127.0.0.1:8080" },
@@ -516,15 +531,18 @@ describe("createDiscordGatewayPlugin", () => {
await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock });
expect(globalFetchMock).toHaveBeenCalledTimes(1);
const fetchInit = firstMockArg(globalFetchMock, "globalFetchMock", 1) as
| { headers?: Record<string, string>; signal?: unknown }
| undefined;
expect(firstMockArg(globalFetchMock, "globalFetchMock")).toBe(
"https://discord.com/api/v10/gateway/bot",
);
expect(fetchInit?.headers).toEqual({ Authorization: "Bot token-123" });
expect(fetchInit?.signal).toBeInstanceOf(AbortSignal);
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1);
const guardedFetch = firstGuardedFetchCall();
expect(guardedFetch.url).toBe("https://discord.com/api/v10/gateway/bot");
expect(guardedFetch.mode).toBe("trusted_explicit_proxy");
expect(guardedFetch.dispatcherPolicy).toEqual({
mode: "explicit-proxy",
proxyUrl: "http://127.0.0.1:8080",
allowPrivateProxy: true,
});
expect(guardedFetch.policy).toEqual({ allowedHostnames: ["discord.com"] });
expect(guardedFetch.init?.headers).toEqual({ Authorization: "Bot token-123" });
expect(guardedFetch.init?.signal).toBeInstanceOf(AbortSignal);
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
});