Skip to content

Commit f74bc3f

Browse files
committed
feat: support encrypted PDF extraction
1 parent 2d8cebb commit f74bc3f

9 files changed

Lines changed: 175 additions & 2 deletions

File tree

docs/tools/pdf.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ Analysis prompt.
5353
Page filter like `1-5` or `1,3,7-9`.
5454
</ParamField>
5555

56+
<ParamField path="password" type="string">
57+
Password for encrypted PDFs in extraction fallback mode.
58+
</ParamField>
59+
5660
<ParamField path="model" type="string">
5761
Optional model override in `provider/model` form.
5862
</ParamField>
@@ -66,6 +70,7 @@ Input notes:
6670
- `pdf` and `pdfs` are merged and deduplicated before loading.
6771
- If no PDF input is provided, the tool errors.
6872
- `pages` is parsed as 1-based page numbers, deduped, sorted, and clamped to the configured max pages.
73+
- `password` applies to every PDF in the request and is only used by extraction fallback mode.
6974
- `maxBytesMb` defaults to `agents.defaults.pdfMaxBytesMb` or `10`.
7075

7176
## Supported PDF references
@@ -92,6 +97,7 @@ The tool sends raw PDF bytes directly to provider APIs.
9297
Native mode limits:
9398

9499
- `pages` is not supported. If set, the tool returns an error.
100+
- `password` is not supported. Use a non-native model to analyze encrypted PDFs.
95101
- Multi-PDF input is supported; each PDF is sent as a native document block /
96102
inline PDF part before the prompt.
97103

@@ -108,6 +114,7 @@ Flow:
108114
Fallback details:
109115

110116
- Page image extraction uses a pixel budget of `4,000,000`.
117+
- Encrypted PDFs can be opened with the top-level `password` parameter.
111118
- If the target model does not support image input and there is no extractable text, the tool errors.
112119
- If text extraction succeeds but image extraction would require vision on a
113120
text-only model, OpenClaw drops the rendered images and continues with the
@@ -189,6 +196,17 @@ Page-filtered fallback model:
189196
}
190197
```
191198

199+
Encrypted PDF with extraction fallback:
200+
201+
```json
202+
{
203+
"pdf": "/tmp/locked.pdf",
204+
"password": "example-password",
205+
"model": "openai/gpt-5.4-mini",
206+
"prompt": "Summarize this contract"
207+
}
208+
```
209+
192210
## Related
193211

194212
- [Tools Overview](/tools) - all available agent tools

extensions/document-extract/document-extractor.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,28 @@ describe("PDF document extractor", () => {
108108
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
109109
});
110110

111+
it("opens encrypted PDFs with the request password", async () => {
112+
pdfDocument.extract.mockResolvedValueOnce({ text: "enough text", images: [] });
113+
const extractor = createPdfDocumentExtractor();
114+
115+
await extractor.extract(request({ password: "secret" }));
116+
117+
expect(openPdfMock).toHaveBeenCalledWith(expect.any(Uint8Array), { password: "secret" });
118+
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
119+
});
120+
121+
it("normalizes clawpdf password errors", async () => {
122+
openPdfMock.mockRejectedValueOnce(
123+
Object.assign(new Error("bad password"), { code: "password" }),
124+
);
125+
const extractor = createPdfDocumentExtractor();
126+
127+
await expect(extractor.extract(request({ password: "wrong" }))).rejects.toThrow(
128+
"PDF requires a password or password is incorrect.",
129+
);
130+
expect(pdfDocument.destroy).not.toHaveBeenCalled();
131+
});
132+
111133
it("filters selected pages before passing them to clawpdf", async () => {
112134
pdfDocument.extract
113135
.mockResolvedValueOnce({ text: "", images: [] })

extensions/document-extract/document-extractor.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { PdfEngine, PdfImage } from "clawpdf";
1+
import type { PdfDocument, PdfEngine, PdfImage } from "clawpdf";
22
import type {
33
DocumentExtractedImage,
44
DocumentExtractionRequest,
@@ -33,11 +33,36 @@ function toDocumentImage(image: PdfImage): DocumentExtractedImage {
3333
};
3434
}
3535

36+
function isPdfPasswordError(err: unknown): boolean {
37+
return Boolean(err && typeof err === "object" && (err as { code?: unknown }).code === "password");
38+
}
39+
40+
async function openPdfDocument(params: {
41+
engine: PdfEngine;
42+
input: Uint8Array;
43+
password?: string;
44+
}): Promise<PdfDocument> {
45+
try {
46+
return params.password
47+
? await params.engine.open(params.input, { password: params.password })
48+
: await params.engine.open(params.input);
49+
} catch (err) {
50+
if (isPdfPasswordError(err)) {
51+
throw new Error("PDF requires a password or password is incorrect.", { cause: err });
52+
}
53+
throw err;
54+
}
55+
}
56+
3657
async function extractPdfContent(
3758
request: DocumentExtractionRequest,
3859
): Promise<DocumentExtractionResult> {
3960
const engine = await loadPdfEngine();
40-
const pdf = await engine.open(new Uint8Array(request.buffer));
61+
const pdf = await openPdfDocument({
62+
engine,
63+
input: new Uint8Array(request.buffer),
64+
...(request.password ? { password: request.password } : {}),
65+
});
4166
try {
4267
const pages = request.pageNumbers
4368
? request.pageNumbers

src/agents/tools/pdf-tool.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,22 @@ describe("createPdfTool", () => {
500500
});
501501
});
502502

503+
it("rejects password parameter for native PDF providers", async () => {
504+
await withTempPdfAgentDir(async (agentDir) => {
505+
await stubPdfToolInfra(agentDir, { provider: "anthropic", input: ["text", "document"] });
506+
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
507+
const tool = requirePdfTool((await loadCreatePdfTool())({ config: cfg, agentDir }));
508+
509+
await expect(
510+
tool.execute("t1", {
511+
prompt: "summarize",
512+
pdf: "/tmp/doc.pdf",
513+
password: "secret",
514+
}),
515+
).rejects.toThrow("password is not supported with native PDF providers");
516+
});
517+
});
518+
503519
it("uses extraction fallback for non-native models", async () => {
504520
await withTempPdfAgentDir(async (agentDir) => {
505521
await stubPdfToolInfra(agentDir, { provider: "openai", input: ["text"] });
@@ -531,6 +547,58 @@ describe("createPdfTool", () => {
531547
});
532548
});
533549

550+
it("passes password to PDF extraction fallback", async () => {
551+
await withTempPdfAgentDir(async (agentDir) => {
552+
await stubPdfToolInfra(agentDir, { provider: "openai", input: ["text"] });
553+
const extractSpy = vi.spyOn(pdfExtractModule, "extractPdfContent").mockResolvedValue({
554+
text: "Encrypted content",
555+
images: [],
556+
});
557+
completeMock.mockResolvedValue({
558+
role: "assistant",
559+
stopReason: "stop",
560+
content: [{ type: "text", text: "fallback summary" }],
561+
} as never);
562+
563+
const cfg = withPdfModel(OPENAI_PDF_MODEL);
564+
const tool = requirePdfTool((await loadCreatePdfTool())({ config: cfg, agentDir }));
565+
566+
await tool.execute("t1", {
567+
prompt: "summarize",
568+
pdf: "/tmp/doc.pdf",
569+
password: "secret",
570+
});
571+
572+
expect(extractSpy).toHaveBeenCalledWith(expect.objectContaining({ password: "secret" }));
573+
});
574+
});
575+
576+
it("preserves PDF password whitespace before extraction fallback", async () => {
577+
await withTempPdfAgentDir(async (agentDir) => {
578+
await stubPdfToolInfra(agentDir, { provider: "openai", input: ["text"] });
579+
const extractSpy = vi.spyOn(pdfExtractModule, "extractPdfContent").mockResolvedValue({
580+
text: "Plain content",
581+
images: [],
582+
});
583+
completeMock.mockResolvedValue({
584+
role: "assistant",
585+
stopReason: "stop",
586+
content: [{ type: "text", text: "fallback summary" }],
587+
} as never);
588+
589+
const cfg = withPdfModel(OPENAI_PDF_MODEL);
590+
const tool = requirePdfTool((await loadCreatePdfTool())({ config: cfg, agentDir }));
591+
592+
await tool.execute("t1", {
593+
prompt: "summarize",
594+
pdf: "/tmp/doc.pdf",
595+
password: " secret ",
596+
});
597+
598+
expect(extractSpy).toHaveBeenCalledWith(expect.objectContaining({ password: " secret " }));
599+
});
600+
});
601+
534602
it("adds Codex instructions for PDF extraction fallback requests", async () => {
535603
await withTempPdfAgentDir(async (agentDir) => {
536604
await stubPdfToolInfra(agentDir, {
@@ -615,6 +683,7 @@ describe("createPdfTool", () => {
615683
expect(props).toHaveProperty("pdf");
616684
expect(props).toHaveProperty("pdfs");
617685
expect(props).toHaveProperty("pages");
686+
expect(props).toHaveProperty("password");
618687
expect(props).toHaveProperty("model");
619688
expect(props).toHaveProperty("maxBytesMb");
620689
});

src/agents/tools/pdf-tool.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export const PdfToolSchema = Type.Object({
7171
description: 'Pages, e.g. "1-5", "1,3,5-7"; default all.',
7272
}),
7373
),
74+
password: Type.Optional(Type.String({ description: "Password for encrypted PDFs." })),
7475
model: Type.Optional(Type.String()),
7576
maxBytesMb: Type.Optional(Type.Number()),
7677
});
@@ -144,6 +145,7 @@ async function runPdfPrompt(params: {
144145
modelOverride?: string;
145146
prompt: string;
146147
pdfBuffers: Array<{ base64: string; filename: string }>;
148+
password?: string;
147149
pageNumbers?: number[];
148150
getExtractions: () => Promise<PdfExtractedContent[]>;
149151
}): Promise<{
@@ -181,6 +183,11 @@ async function runPdfPrompt(params: {
181183
});
182184

183185
if (providerSupportsNativePdf(provider)) {
186+
if (params.password) {
187+
throw new Error(
188+
`password is not supported with native PDF providers (${provider}/${modelId}). Remove password, or use a non-native model for encrypted PDFs.`,
189+
);
190+
}
184191
if (params.pageNumbers && params.pageNumbers.length > 0) {
185192
throw new Error(
186193
`pages is not supported with native PDF providers (${provider}/${modelId}). Remove pages, or use a non-native model for page filtering.`,
@@ -356,6 +363,7 @@ export function createPdfTool(options?: {
356363

357364
// Parse page range
358365
const pagesRaw = normalizeOptionalString(record.pages);
366+
const password = typeof record.password === "string" ? record.password : undefined;
359367

360368
const pdfModelConfig =
361369
registrationPdfModelConfig ??
@@ -486,6 +494,7 @@ export function createPdfTool(options?: {
486494
maxPages: configuredMaxPages,
487495
maxPixels: PDF_MAX_PIXELS,
488496
minTextChars: PDF_MIN_TEXT_CHARS,
497+
...(password ? { password } : {}),
489498
pageNumbers,
490499
config: options?.config,
491500
});
@@ -502,6 +511,7 @@ export function createPdfTool(options?: {
502511
modelOverride,
503512
prompt: promptRaw,
504513
pdfBuffers: loadedPdfs.map((p) => ({ base64: p.base64, filename: p.filename })),
514+
...(password ? { password } : {}),
505515
pageNumbers,
506516
getExtractions,
507517
});

src/media/document-extractors.runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export async function extractDocumentContent(
2424
maxPages: params.maxPages,
2525
maxPixels: params.maxPixels,
2626
minTextChars: params.minTextChars,
27+
...(params.password ? { password: params.password } : {}),
2728
...(params.pageNumbers ? { pageNumbers: params.pageNumbers } : {}),
2829
...(params.onImageExtractionError
2930
? { onImageExtractionError: params.onImageExtractionError }

src/media/pdf-extract.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,31 @@ describe("extractPdfContent", () => {
3939
});
4040
});
4141

42+
it("passes PDF passwords through to document extractors", async () => {
43+
extractDocumentContentMock.mockResolvedValue({
44+
text: "encrypted pdf",
45+
images: [],
46+
extractor: "pdf",
47+
});
48+
49+
await extractPdfContent({
50+
buffer: Buffer.from("%PDF-1.4"),
51+
maxPages: 2,
52+
maxPixels: 100,
53+
minTextChars: 10,
54+
password: "secret",
55+
});
56+
57+
expect(extractDocumentContentMock).toHaveBeenCalledWith({
58+
buffer: Buffer.from("%PDF-1.4"),
59+
mimeType: "application/pdf",
60+
maxPages: 2,
61+
maxPixels: 100,
62+
minTextChars: 10,
63+
password: "secret",
64+
});
65+
});
66+
4267
it("throws a clear disabled error when no document extractor is available", async () => {
4368
extractDocumentContentMock.mockResolvedValue(null);
4469

src/media/pdf-extract.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export async function extractPdfContent(params: {
1313
maxPages: number;
1414
maxPixels: number;
1515
minTextChars: number;
16+
password?: string;
1617
pageNumbers?: number[];
1718
config?: OpenClawConfig;
1819
onImageExtractionError?: (error: unknown) => void;
@@ -23,6 +24,7 @@ export async function extractPdfContent(params: {
2324
maxPages: params.maxPages,
2425
maxPixels: params.maxPixels,
2526
minTextChars: params.minTextChars,
27+
...(params.password ? { password: params.password } : {}),
2628
...(params.pageNumbers ? { pageNumbers: params.pageNumbers } : {}),
2729
...(params.config ? { config: params.config } : {}),
2830
...(params.onImageExtractionError

src/plugins/document-extractor-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type DocumentExtractionRequest = {
1010
maxPages: number;
1111
maxPixels: number;
1212
minTextChars: number;
13+
password?: string;
1314
pageNumbers?: number[];
1415
onImageExtractionError?: (error: unknown) => void;
1516
};

0 commit comments

Comments
 (0)