Skip to content

Commit f1f8c1e

Browse files
lwy-2steipete
andauthored
fix(providers): bound successful OAuth and webhook responses (#98098)
Co-authored-by: Peter Steinberger <[email protected]>
1 parent 3d837cc commit f1f8c1e

5 files changed

Lines changed: 115 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
2424

2525
- **Agent helper downloads:** bound fd and ripgrep archive downloads and extraction with declared and streamed byte caps, extraction limits, timeouts, traversal-safe unpacking, and partial-file cleanup. (#98988) Thanks @LeonidasLux.
2626
- **Control UI cron actions:** localize the overflow-menu label and due-only run action across all supported locales.
27+
- **OpenRouter OAuth and Discord webhook response bounds:** cap successful JSON response bodies while preserving Discord's no-body webhook mode. (#98098) Thanks @lwy-2.
2728
- **OpenAI Realtime Codex auth:** reuse external Codex OAuth profiles for Realtime voice sessions when no explicit OpenAI API key is configured.
2829
- **OpenAI-compatible TTS voice notes:** route configured MP3 speech output through native voice-message delivery when the channel supports it, while keeping WAV output on the audio-file path. (#83227, #80317) Thanks @HemantSudarshan.
2930
- **Talk transcription providers:** cold-load explicitly configured Voice Call streaming providers, including runtime aliases, when another provider registry is already active, keeping catalog and session selection aligned. (#97170, #97738) Thanks @solavrc.

extensions/discord/src/send.webhook.proxy.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,61 @@ describe("sendWebhookMessageDiscord proxy support", () => {
212212
globalFetchMock.mockRestore();
213213
});
214214

215+
it("accepts Discord's no-body webhook response when wait is false", async () => {
216+
const response = new Response(null, { status: 204 });
217+
const jsonSpy = vi.spyOn(response, "json");
218+
const globalFetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
219+
220+
const result = await sendWebhookMessageDiscord("hello", {
221+
cfg: { channels: { discord: { token: "Bot test-token" } } } as OpenClawConfig,
222+
accountId: "default",
223+
webhookId: "123",
224+
webhookToken: "abc",
225+
wait: false,
226+
});
227+
228+
expect(result.messageId).toBe("unknown");
229+
expect(jsonSpy).not.toHaveBeenCalled();
230+
globalFetchMock.mockRestore();
231+
});
232+
233+
it("keeps a successful send when the webhook response body exceeds the limit", async () => {
234+
const tracked = cancelTrackedResponse(`{"id":"${"x".repeat(16 * 1024 * 1024)}"}`, {
235+
status: 200,
236+
headers: { "content-type": "application/json" },
237+
});
238+
const globalFetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(tracked.response);
239+
240+
const result = await sendWebhookMessageDiscord("hello", {
241+
cfg: { channels: { discord: { token: "Bot test-token" } } } as OpenClawConfig,
242+
accountId: "default",
243+
webhookId: "123",
244+
webhookToken: "abc",
245+
wait: true,
246+
});
247+
248+
expect(result.messageId).toBe("unknown");
249+
expect(tracked.wasCanceled()).toBe(true);
250+
globalFetchMock.mockRestore();
251+
});
252+
253+
it("keeps a successful send when the webhook response body is malformed", async () => {
254+
const globalFetchMock = vi
255+
.spyOn(globalThis, "fetch")
256+
.mockResolvedValue(new Response("not json", { status: 200 }));
257+
258+
const result = await sendWebhookMessageDiscord("hello", {
259+
cfg: { channels: { discord: { token: "Bot test-token" } } } as OpenClawConfig,
260+
accountId: "default",
261+
webhookId: "123",
262+
webhookToken: "abc",
263+
wait: true,
264+
});
265+
266+
expect(result.messageId).toBe("unknown");
267+
globalFetchMock.mockRestore();
268+
});
269+
215270
it("throws typed rate limit errors for webhook 429 responses", async () => {
216271
const globalFetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
217272
new Response(JSON.stringify({ message: "Slow down", retry_after: 0.25, global: false }), {

extensions/discord/src/send.webhook.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// Discord plugin module implements send.webhook behavior.
22
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
33
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
4-
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
4+
import {
5+
readProviderJsonResponse,
6+
readResponseTextLimited,
7+
} from "openclaw/plugin-sdk/provider-http";
58
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
69
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
710
import { resolveDiscordClientAccountContext } from "./client.js";
@@ -121,10 +124,16 @@ export async function sendWebhookMessageDiscord(
121124
await throwWebhookResponseError(response);
122125
}
123126

124-
const payload = (await response.json().catch(() => ({}))) as {
127+
const payload: {
125128
id?: string;
126129
channel_id?: string;
127-
};
130+
} =
131+
response.status === 204
132+
? {}
133+
: await readProviderJsonResponse<{ id?: string; channel_id?: string }>(
134+
response,
135+
"Discord webhook send",
136+
).catch(() => ({}));
128137
try {
129138
recordChannelActivity({
130139
channel: "discord",

extensions/openrouter/oauth.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ function jsonResponse(value: unknown, init?: ResponseInit): Response {
2424
});
2525
}
2626

27-
function boundedTextErrorResponse(body: string, status = 502): {
27+
function boundedTextErrorResponse(
28+
body: string,
29+
status = 502,
30+
): {
2831
response: Response;
2932
cancel: ReturnType<typeof vi.fn>;
3033
releaseLock: ReturnType<typeof vi.fn>;
@@ -60,6 +63,25 @@ function boundedTextErrorResponse(body: string, status = 502): {
6063
return { response, cancel, releaseLock, text };
6164
}
6265

66+
function oversizedJsonResponse(): { response: Response; wasCanceled: () => boolean } {
67+
let canceled = false;
68+
const body = new TextEncoder().encode(`{"key":"${"x".repeat(16 * 1024 * 1024)}"}`);
69+
return {
70+
response: new Response(
71+
new ReadableStream<Uint8Array>({
72+
start(controller) {
73+
controller.enqueue(body);
74+
},
75+
cancel() {
76+
canceled = true;
77+
},
78+
}),
79+
{ status: 200, headers: { "Content-Type": "application/json" } },
80+
),
81+
wasCanceled: () => canceled,
82+
};
83+
}
84+
6385
function requestUrl(input: RequestInfo | URL): string {
6486
if (typeof input === "string") {
6587
return input;
@@ -190,6 +212,19 @@ describe("OpenRouter OAuth", () => {
190212
});
191213
});
192214

215+
it("bounds successful OpenRouter OAuth responses", async () => {
216+
const oversized = oversizedJsonResponse();
217+
218+
await expect(
219+
exchangeOpenRouterOAuthCode({
220+
code: "AUTHCODE",
221+
codeVerifier: "verifier-1",
222+
fetchImpl: vi.fn(async () => oversized.response),
223+
}),
224+
).rejects.toThrow("OpenRouter OAuth key exchange: JSON response exceeds 16777216 bytes");
225+
expect(oversized.wasCanceled()).toBe(true);
226+
});
227+
193228
it("surfaces OpenRouter OAuth exchange errors without credential material", async () => {
194229
const fetchImpl = vi
195230
.fn<typeof fetch>()

extensions/openrouter/oauth.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import {
88
type ProviderAuthResult,
99
} from "openclaw/plugin-sdk/provider-auth";
1010
import { generateOAuthState } from "openclaw/plugin-sdk/provider-auth-runtime";
11-
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
11+
import {
12+
readProviderJsonResponse,
13+
readResponseTextLimited,
14+
} from "openclaw/plugin-sdk/provider-http";
1215
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
1316
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
1417

@@ -71,11 +74,13 @@ function extractOpenRouterError(value: unknown): string | undefined {
7174
}
7275

7376
async function readResponseBody(response: Response): Promise<unknown> {
74-
const text = response.ok
75-
? await response.text()
76-
: await readResponseTextLimited(response, OPENROUTER_OAUTH_ERROR_BODY_LIMIT_BYTES).catch(
77-
() => "",
78-
);
77+
if (response.ok) {
78+
return await readProviderJsonResponse(response, "OpenRouter OAuth key exchange");
79+
}
80+
const text = await readResponseTextLimited(
81+
response,
82+
OPENROUTER_OAUTH_ERROR_BODY_LIMIT_BYTES,
83+
).catch(() => "");
7984
if (!text.trim()) {
8085
return null;
8186
}

0 commit comments

Comments
 (0)