From 2f3517c73f4fafa1a68a86e696ae4734973fdf02 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Sun, 12 Jul 2026 23:53:00 +0800
Subject: [PATCH] fix(qqbot): add timeout to remote media URL fetches (#103018)
* fix(qqbot): add timeout to remote media URL fetches
* fix(qqbot): use response header media timeout
---------
Co-authored-by: llagy009 <0668001470@xydigit.com>
---
extensions/qqbot/src/bridge/bootstrap.test.ts | 35 +++++++++++++++++++
extensions/qqbot/src/bridge/bootstrap.ts | 2 ++
extensions/qqbot/src/engine/adapter/types.ts | 4 +++
.../qqbot/src/engine/utils/file-utils.test.ts | 2 ++
.../qqbot/src/engine/utils/file-utils.ts | 3 ++
5 files changed, 46 insertions(+)
diff --git a/extensions/qqbot/src/bridge/bootstrap.test.ts b/extensions/qqbot/src/bridge/bootstrap.test.ts
index 9354a439af66..acc6d528f038 100644
--- a/extensions/qqbot/src/bridge/bootstrap.test.ts
+++ b/extensions/qqbot/src/bridge/bootstrap.test.ts
@@ -6,9 +6,14 @@ import { ensurePlatformAdapter } from "./bootstrap.js";
const mocks = vi.hoisted(() => ({
getRuntimeConfig: vi.fn(),
+ readRemoteMediaBuffer: vi.fn(),
resolveApprovalOverGateway: vi.fn(),
}));
+vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
+ readRemoteMediaBuffer: (...args: unknown[]) => mocks.readRemoteMediaBuffer(...args),
+}));
+
vi.mock("openclaw/plugin-sdk/runtime-config-snapshot", () => ({
getRuntimeConfig: mocks.getRuntimeConfig,
}));
@@ -46,6 +51,36 @@ describe("QQBot built-in platform adapter", () => {
ensurePlatformAdapter();
});
+ it("forwards response header deadlines to the media runtime", async () => {
+ mocks.readRemoteMediaBuffer.mockResolvedValueOnce({
+ buffer: Buffer.from("image"),
+ fileName: "remote.png",
+ });
+
+ const result = await getPlatformAdapter().fetchMedia({
+ url: "https://media.qq.com/assets/photo.png",
+ filePathHint: "photo.png",
+ maxBytes: 1024,
+ maxRedirects: 2,
+ timeoutMs: 5_000,
+ responseHeaderTimeoutMs: 120_000,
+ ssrfPolicy: { hostnameAllowlist: ["*.qq.com"] },
+ requestInit: { headers: { accept: "image/png" } },
+ });
+
+ expect(result).toEqual({ buffer: Buffer.from("image"), fileName: "remote.png" });
+ expect(mocks.readRemoteMediaBuffer).toHaveBeenCalledWith({
+ url: "https://media.qq.com/assets/photo.png",
+ filePathHint: "photo.png",
+ maxBytes: 1024,
+ maxRedirects: 2,
+ timeoutMs: 5_000,
+ responseHeaderTimeoutMs: 120_000,
+ ssrfPolicy: { hostnameAllowlist: ["*.qq.com"] },
+ requestInit: { headers: { accept: "image/png" } },
+ });
+ });
+
it("preserves plugin ownership and the canonical first-answer result", async () => {
const adapter = getPlatformAdapter();
diff --git a/extensions/qqbot/src/bridge/bootstrap.ts b/extensions/qqbot/src/bridge/bootstrap.ts
index b89e9e740dbd..8917180a210a 100644
--- a/extensions/qqbot/src/bridge/bootstrap.ts
+++ b/extensions/qqbot/src/bridge/bootstrap.ts
@@ -77,6 +77,8 @@ function createBuiltinAdapter(): PlatformAdapter {
filePathHint: options.filePathHint,
maxBytes: options.maxBytes,
maxRedirects: options.maxRedirects,
+ timeoutMs: options.timeoutMs,
+ responseHeaderTimeoutMs: options.responseHeaderTimeoutMs,
ssrfPolicy: options.ssrfPolicy,
requestInit: options.requestInit,
});
diff --git a/extensions/qqbot/src/engine/adapter/types.ts b/extensions/qqbot/src/engine/adapter/types.ts
index 61e62f008bef..f945aebf61f2 100644
--- a/extensions/qqbot/src/engine/adapter/types.ts
+++ b/extensions/qqbot/src/engine/adapter/types.ts
@@ -17,6 +17,10 @@ export interface FetchMediaOptions {
maxBytes?: number;
/** Maximum redirects to follow. */
maxRedirects?: number;
+ /** Abort the complete remote media request after this many milliseconds. */
+ timeoutMs?: number;
+ /** Abort if final response headers have not arrived after this many milliseconds. */
+ responseHeaderTimeoutMs?: number;
/** SSRF policy configuration. */
ssrfPolicy?: SsrfPolicyConfig;
/** Extra fetch() RequestInit options. */
diff --git a/extensions/qqbot/src/engine/utils/file-utils.test.ts b/extensions/qqbot/src/engine/utils/file-utils.test.ts
index 7637d67df597..fd8121fe6ace 100644
--- a/extensions/qqbot/src/engine/utils/file-utils.test.ts
+++ b/extensions/qqbot/src/engine/utils/file-utils.test.ts
@@ -28,6 +28,7 @@ vi.mock("../adapter/index.js", () => ({
import {
QQBOT_MEDIA_SSRF_POLICY,
+ QQBOT_REMOTE_MEDIA_RESPONSE_HEADER_TIMEOUT_MS,
checkFileSize,
downloadFile,
fileExistsAsync,
@@ -95,6 +96,7 @@ describe("qqbot file-utils downloadFile", () => {
url: "https://media.qq.com/assets/photo.png",
filePathHint: "photo.png",
ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY,
+ responseHeaderTimeoutMs: QQBOT_REMOTE_MEDIA_RESPONSE_HEADER_TIMEOUT_MS,
});
expect(QQBOT_MEDIA_SSRF_POLICY).toEqual({
hostnameAllowlist: [
diff --git a/extensions/qqbot/src/engine/utils/file-utils.ts b/extensions/qqbot/src/engine/utils/file-utils.ts
index eb980526bc25..74e26538abf7 100644
--- a/extensions/qqbot/src/engine/utils/file-utils.ts
+++ b/extensions/qqbot/src/engine/utils/file-utils.ts
@@ -70,6 +70,8 @@ export const QQBOT_MEDIA_SSRF_POLICY: SsrfPolicyConfig = {
allowRfc2544BenchmarkRange: true,
};
+export const QQBOT_REMOTE_MEDIA_RESPONSE_HEADER_TIMEOUT_MS = 120_000;
+
/** Result of local file-size validation. */
interface FileSizeCheckResult {
ok: boolean;
@@ -183,6 +185,7 @@ export async function downloadFile(
url: parsedUrl.toString(),
filePathHint: originalFilename,
ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY,
+ responseHeaderTimeoutMs: QQBOT_REMOTE_MEDIA_RESPONSE_HEADER_TIMEOUT_MS,
});
let filename = normalizeOptionalString(originalFilename) ?? "";