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>
This commit is contained in:
Alix-007
2026-07-12 23:53:00 +08:00
committed by GitHub
parent ef29fe1270
commit 2f3517c73f
5 changed files with 46 additions and 0 deletions

View File

@@ -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();

View File

@@ -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,
});

View File

@@ -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. */

View File

@@ -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: [

View File

@@ -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) ?? "";