mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(pdf): bound remote body reads
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user