fix(pdf): bound remote body reads

This commit is contained in:
luoyanglang
2026-05-20 15:28:07 +00:00
committed by Shakker
parent 88fe39bc8b
commit 248169b646
4 changed files with 110 additions and 1 deletions
+24
View File
@@ -388,11 +388,35 @@ describe("createPdfTool", () => {
const [loadRef, loadOptions] = firstMockCall(loadSpy, "loadWebMediaRaw");
expect(loadRef).toBe("http://198.18.0.153/doc.pdf");
expectFields(loadOptions, {
readIdleTimeoutMs: 120_000,
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
});
});
it("passes the shared remote read idle timeout when loading remote PDFs", async () => {
await withTempPdfAgentDir(async (agentDir) => {
const { loadSpy } = await stubPdfToolInfra(agentDir, {
provider: "anthropic",
input: ["text", "document"],
});
vi.spyOn(pdfNativeProviders, "anthropicAnalyzePdf").mockResolvedValue("native summary");
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
const tool = requirePdfTool((await loadCreatePdfTool())({ config: cfg, agentDir }));
await tool.execute("t1", {
prompt: "summarize",
pdf: "https://example.com/stalled.pdf",
});
const [loadRef, loadOptions] = firstMockCall(loadSpy, "loadWebMediaRaw");
expect(loadRef).toBe("https://example.com/stalled.pdf");
expectFields(loadOptions, {
readIdleTimeoutMs: 120_000,
});
});
});
it("allows managed inbound absolute PDF paths when workspaceOnly is enabled", async () => {
await withManagedInboundPdf(async ({ mediaPath }) => {
await withTempPdfAgentDir(async (agentDir) => {
+2
View File
@@ -52,6 +52,7 @@ const DEFAULT_PROMPT = "Analyze this PDF document.";
const DEFAULT_MAX_PDFS = 10;
const DEFAULT_MAX_BYTES_MB = 10;
const DEFAULT_MAX_PAGES = 20;
const PDF_REMOTE_READ_IDLE_TIMEOUT_MS = 120_000;
const PDF_MIN_TEXT_CHARS = 200;
const PDF_MAX_PIXELS = 4_000_000;
@@ -444,6 +445,7 @@ export function createPdfTool(options?: {
: await loadWebMediaRaw(resolvedPathInfo.resolved, {
maxBytes,
localRoots,
...(isHttpUrl ? { readIdleTimeoutMs: PDF_REMOTE_READ_IDLE_TIMEOUT_MS } : {}),
ssrfPolicy: remoteMediaSsrfPolicy,
});
+81 -1
View File
@@ -10,6 +10,7 @@ import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plug
let LocalMediaAccessError: typeof import("./web-media.js").LocalMediaAccessError;
let loadWebMedia: typeof import("./web-media.js").loadWebMedia;
let loadWebMediaRaw: typeof import("./web-media.js").loadWebMediaRaw;
let optimizeImageToJpeg: typeof import("./web-media.js").optimizeImageToJpeg;
const TINY_PNG_BASE64 =
@@ -39,7 +40,8 @@ function installCanvasMediaResolver() {
}
beforeAll(async () => {
({ LocalMediaAccessError, loadWebMedia, optimizeImageToJpeg } = await import("./web-media.js"));
({ LocalMediaAccessError, loadWebMedia, loadWebMediaRaw, optimizeImageToJpeg } =
await import("./web-media.js"));
fixtureRoot = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "web-media-core-"));
tinyPngFile = path.join(fixtureRoot, "tiny.png");
await fs.writeFile(tinyPngFile, Buffer.from(TINY_PNG_BASE64, "base64"));
@@ -75,6 +77,47 @@ afterAll(async () => {
});
describe("loadWebMedia", () => {
function makeStallingFetch(firstChunk: Uint8Array) {
return vi.fn(
async () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(firstChunk);
},
}),
{
status: 200,
headers: { "content-type": "application/pdf" },
},
),
);
}
async function expectWebMediaIdleTimeout(
createLoadPromise: () => Promise<unknown>,
idleTimeoutMs: number,
) {
vi.useFakeTimers();
try {
const outcome = createLoadPromise().then(
() => ({ status: "resolved" as const }),
(error: unknown) => ({ status: "rejected" as const, error }),
);
await vi.advanceTimersByTimeAsync(idleTimeoutMs + 5);
await expect(
Promise.race([outcome, Promise.resolve({ status: "pending" as const })]),
).resolves.toMatchObject({ status: "rejected" });
const result = await outcome;
expect(result.status).toBe("rejected");
if (result.status === "rejected") {
expect(String(result.error)).toMatch(/stalled|no data received/i);
}
} finally {
vi.useRealTimers();
}
}
function createLocalWebMediaOptions() {
return {
maxBytes: 1024 * 1024,
@@ -689,6 +732,43 @@ describe("loadWebMedia", () => {
}
});
it("applies the shared remote read idle timeout for raw web media loads", async () => {
const readIdleTimeoutMs = 20;
const fetchImpl = makeStallingFetch(new Uint8Array([0x25, 0x50, 0x44, 0x46]));
await expectWebMediaIdleTimeout(
() =>
loadWebMediaRaw("https://example.test/stalled.pdf", {
maxBytes: 1024 * 1024,
fetchImpl,
readIdleTimeoutMs,
ssrfPolicy: { allowedHostnames: ["example.test"] },
}),
readIdleTimeoutMs,
);
});
it("loads a valid remote PDF when the raw web media read stays active", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(Buffer.from("%PDF-1.4\n%%EOF"), {
status: 200,
headers: { "content-type": "application/pdf" },
}),
);
const result = await loadWebMediaRaw("https://example.test/ok.pdf", {
maxBytes: 1024 * 1024,
fetchImpl,
readIdleTimeoutMs: 20,
ssrfPolicy: { allowedHostnames: ["example.test"] },
});
expect(result.kind).toBe("document");
expect(result.contentType).toBe("application/pdf");
expect(result.buffer.toString()).toContain("%PDF-1.4");
});
it("rejects unsupported media store URI locations", async () => {
await expectLoadWebMediaErrorCode(
loadWebMedia("media://outbound/tiny.png"),
+3
View File
@@ -49,6 +49,7 @@ type WebMediaOptions = {
proxyUrl?: string;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
requestInit?: RequestInit;
readIdleTimeoutMs?: number;
trustExplicitProxyDns?: boolean;
workspaceDir?: string;
/** Allowed root directories for local path reads. "any" is deprecated; prefer sandboxValidated + readFile. */
@@ -388,6 +389,7 @@ async function loadWebMediaInternal(
proxyUrl,
fetchImpl,
requestInit,
readIdleTimeoutMs,
trustExplicitProxyDns,
workspaceDir,
localRoots,
@@ -521,6 +523,7 @@ async function loadWebMediaInternal(
url: mediaUrl,
fetchImpl,
requestInit,
readIdleTimeoutMs,
maxBytes: fetchCap,
ssrfPolicy,
dispatcherPolicy,