Merge branch 'feat/discord-provider-endpoint-runtime' of https://github.com/openclaw/openclaw into HEAD

This commit is contained in:
Dallin Romney
2026-08-20 20:18:58 -07:00
3 changed files with 40 additions and 4 deletions
+5 -3
View File
@@ -1724,9 +1724,11 @@ openclaw gateway
```
This is a process-wide credential-routing contract, not an account setting. A complete group
redirects REST, Gateway, media, webhook, voice, OAuth, and Activity traffic for every Discord
account in that Gateway. The provider therefore receives Discord credentials and message data;
use only endpoints controlled by the operator. Non-loopback endpoints require HTTPS/WSS.
redirects REST, Gateway, provider-origin media, webhook, voice, OAuth, and Activity traffic for
every Discord account in that Gateway. The provider therefore receives Discord credentials and
message data; use only endpoints controlled by the operator. Non-loopback endpoints require
HTTPS/WSS. Public Discord CDN assets are never fetched in provider mode: provider-origin
attachments remain available, while Discord-generated avatar and sticker assets are skipped.
The three values are all-or-none and are read once during Discord startup. Put them in the
Gateway supervisor environment or trusted global runtime dotenv, not a workspace `.env`. A
@@ -4,6 +4,7 @@ import type { Client, User } from "../internal/discord.js";
const mocks = vi.hoisted(() => ({
saveRemoteMedia: vi.fn(),
logDebug: vi.fn(),
resolveProviderGuard: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
@@ -14,6 +15,10 @@ vi.mock("openclaw/plugin-sdk/logging-core", () => ({
logDebug: mocks.logDebug,
}));
vi.mock("../provider-endpoint.js", () => ({
resolveDiscordProviderMediaDownloadGuard: mocks.resolveProviderGuard,
}));
const { createDiscordAvatarResolver } = await import("./message-avatar.js");
function deferred<T>() {
@@ -35,9 +40,27 @@ const emptyClient = { fetchGuild: vi.fn() } as unknown as Client;
beforeEach(() => {
mocks.saveRemoteMedia.mockReset();
mocks.logDebug.mockReset();
mocks.resolveProviderGuard.mockReset();
});
describe("createDiscordAvatarResolver", () => {
it("skips public CDN avatars when provider routing is active", () => {
mocks.resolveProviderGuard.mockImplementation(() => {
throw new Error("Discord provider media URL is outside the configured REST origin");
});
const resolver = createDiscordAvatarResolver();
expect(
resolver.resolve({
client: emptyClient,
conversationId: "dm-provider",
author: discordUser("user-1", "hash-1"),
}),
).toBeUndefined();
expect(mocks.saveRemoteMedia).not.toHaveBeenCalled();
expect(mocks.logDebug).toHaveBeenCalledWith(expect.stringContaining("download skipped"));
});
it("downloads a DM avatar in the background and reuses it until the hash changes", async () => {
const firstDownload = deferred<{ path: string }>();
mocks.saveRemoteMedia.mockReturnValueOnce(firstDownload.promise);
@@ -2,6 +2,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { logDebug } from "openclaw/plugin-sdk/logging-core";
import { saveRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
import type { Client, User } from "../internal/discord.js";
import { resolveDiscordProviderMediaDownloadGuard } from "../provider-endpoint.js";
import { resolveDiscordCdnPolicy } from "./media-ssrf-policy.js";
const DISCORD_AVATAR_MAX_BYTES = 256 * 1024;
@@ -46,12 +47,22 @@ export function createDiscordAvatarResolver() {
if (pending.has(key) || pending.size >= DISCORD_AVATAR_CACHE_MAX_ENTRIES) {
return undefined;
}
let providerGuard: ReturnType<typeof resolveDiscordProviderMediaDownloadGuard>;
try {
providerGuard = resolveDiscordProviderMediaDownloadGuard(url);
} catch (error) {
logDebug(`discord conversation avatar download skipped: ${formatErrorMessage(error)}`);
return undefined;
}
pending.add(key);
void saveRemoteMedia({
url,
filePathHint: "conversation-avatar.png",
maxBytes: DISCORD_AVATAR_MAX_BYTES,
ssrfPolicy: resolveDiscordCdnPolicy(),
ssrfPolicy: providerGuard
? resolveDiscordCdnPolicy(providerGuard.policy)
: resolveDiscordCdnPolicy(),
...(providerGuard ? { maxRedirects: providerGuard.maxRedirects } : {}),
})
.then((media) => {
setBoundedEntry(saved, key, media.path);