Skip to content

Commit c19f43c

Browse files
fix(read): treat utf-8 pdf names as text and quote pdftotext (#1238)
* fix(read): lead file-inspection diagnosis with the cause Collapsed tool rows clip to 72 characters, so a long path buried the gap. The first sentence now names missing extractor, permission boundary, or malformed file, and the message uses the call path. * fix(read): treat utf-8 pdf names as text and quote pdftotext A .pdf suffix without %PDF magic is only malformed when the first chunk is binary. extractor_ready quotes the path so it pastes into bash. * test(read): expect quoted call-path in extractor_ready diagnosis
1 parent 4bbd78c commit c19f43c

5 files changed

Lines changed: 133 additions & 41 deletions

File tree

‎src/agent/tool-aliases.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export function withAuthzParityDefinitions<
141141

142142
const SHELL_WRAPPERS = new Set(["bash", "sh", "zsh"]);
143143

144-
function shellQuote(arg: string): string {
144+
export function shellQuote(arg: string): string {
145145
if (/^[A-Za-z0-9_\-./:=@%]+$/.test(arg)) return arg;
146146
return `'${arg.replace(/'/g, `'\\''`)}'`;
147147
}

‎src/plugins/file-inspection-diagnosis.test.ts‎

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,17 @@ describe("PDF sniffing", () => {
1717
).toBe("pdf");
1818
});
1919

20-
test("a .pdf name without magic is malformed, not a capability miss", () => {
20+
test("a .pdf name with NUL and no magic is malformed; a text .pdf is not refused", () => {
2121
expect(pathLooksLikePdf("report.PDF")).toBe(true);
2222
expect(
2323
inspectionKindFromFirstChunk("report.pdf", Buffer.from("not-a-pdf\0")),
2424
).toBe("pdf-malformed");
25+
});
26+
27+
test("plain UTF-8 named .pdf without magic is not an inspection block", () => {
2528
expect(
2629
inspectionKindFromFirstChunk("report.pdf", Buffer.from("plain text")),
27-
).toBe("pdf-malformed");
30+
).toBeUndefined();
2831
});
2932

3033
test("NUL without PDF markers is ordinary binary", () => {
@@ -51,7 +54,7 @@ describe("diagnoseBlockedFileInspection", () => {
5154
expect(result.code).toBe("missing_extractor");
5255
expect(result.message).toContain("PDF");
5356
expect(result.message).toContain(PDF_EXTRACTOR_BIN);
54-
expect(result.message).toContain("missing");
57+
expect(result.message).toContain("Missing PDF extractor");
5558
expect(result.message.toLowerCase()).toContain("poppler");
5659
expect(result.message).toContain("not a malformed file");
5760
expect(result.message).not.toContain("permission boundary");
@@ -76,7 +79,7 @@ describe("diagnoseBlockedFileInspection", () => {
7679
});
7780
expect(result.code).toBe("permission_boundary");
7881
expect(result.message).toContain("installed");
79-
expect(result.message).toContain("permission boundary");
82+
expect(result.message).toContain("Permission boundary");
8083
expect(result.message).toContain("run_shell");
8184
expect(result.message).toContain("not a malformed file");
8285
expect(result.message.toLowerCase()).not.toContain("brew install");
@@ -94,6 +97,18 @@ describe("diagnoseBlockedFileInspection", () => {
9497
expect(result.message).toContain(`${PDF_EXTRACTOR_BIN} ${PATH} -`);
9598
});
9699

100+
test("extractor_ready quotes a path that would break a pasted bash command", () => {
101+
const spaced = "/tmp/my report.pdf";
102+
const result = diagnoseBlockedFileInspection({
103+
path: spaced,
104+
kind: "pdf",
105+
extractorAvailable: true,
106+
canExecuteHostCommands: true,
107+
});
108+
expect(result.code).toBe("extractor_ready");
109+
expect(result.message).toContain(`${PDF_EXTRACTOR_BIN} '${spaced}' -`);
110+
});
111+
97112
test("malformed PDF is not blamed on tooling or permission", () => {
98113
const result = diagnoseBlockedFileInspection({
99114
path: PATH,
@@ -102,7 +117,7 @@ describe("diagnoseBlockedFileInspection", () => {
102117
canExecuteHostCommands: false,
103118
});
104119
expect(result.code).toBe("malformed");
105-
expect(result.message).toContain("not a valid PDF");
120+
expect(result.message).toContain("Malformed PDF");
106121
expect(result.message).toContain("not a missing extractor");
107122
expect(result.message).not.toContain("brew install");
108123
expect(result.message).not.toContain("grant this worker");
@@ -116,7 +131,7 @@ describe("diagnoseBlockedFileInspection", () => {
116131
canExecuteHostCommands: true,
117132
});
118133
expect(result.code).toBe("unreadable");
119-
expect(result.message).toContain("unreadable");
134+
expect(result.message).toContain("Unreadable file");
120135
expect(result.message).toContain("filesystem permission");
121136
expect(result.message).not.toContain("grant this worker");
122137
expect(result.message.toLowerCase()).not.toContain("poppler");
@@ -131,6 +146,34 @@ describe("diagnoseBlockedFileInspection", () => {
131146
});
132147
expect(result.code).toBe("binary");
133148
expect(result.message).toContain("refusing to read binary file");
134-
expect(result.message).toContain("not a missing-tool");
149+
expect(result.message).toContain("Not a missing-tool");
150+
});
151+
152+
test("the cause leads so a 72-character collapsed preview still names the gap", () => {
153+
const missing = diagnoseBlockedFileInspection({
154+
path: "/very/long/workspace/path/to/a/nested/report.pdf",
155+
kind: "pdf",
156+
extractorAvailable: false,
157+
canExecuteHostCommands: true,
158+
});
159+
expect(missing.message.slice(0, 72).toLowerCase()).toContain("extractor");
160+
161+
const permission = diagnoseBlockedFileInspection({
162+
path: "/very/long/workspace/path/to/a/nested/report.pdf",
163+
kind: "pdf",
164+
extractorAvailable: true,
165+
canExecuteHostCommands: false,
166+
});
167+
expect(permission.message.slice(0, 72).toLowerCase()).toContain(
168+
"permission",
169+
);
170+
171+
const malformed = diagnoseBlockedFileInspection({
172+
path: "/very/long/workspace/path/to/a/nested/report.pdf",
173+
kind: "pdf-malformed",
174+
extractorAvailable: false,
175+
canExecuteHostCommands: false,
176+
});
177+
expect(malformed.message.slice(0, 72).toLowerCase()).toContain("malformed");
135178
});
136179
});

‎src/plugins/file-inspection-diagnosis.ts‎

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { extname } from "node:path";
2+
import { shellQuote } from "../agent/tool-aliases.js";
23

34
/** Host binary that extracts text from a PDF (poppler). */
45
export const PDF_EXTRACTOR_BIN = "pdftotext";
@@ -45,16 +46,18 @@ export function pathLooksLikePdf(filePath: string): boolean {
4546

4647
/**
4748
* Classify the first streamed chunk. PDF magic wins even without a .pdf
48-
* suffix. A .pdf name without magic is malformed, whether or not the chunk
49-
* looks binary. NUL without PDF markers is an ordinary binary file.
49+
* suffix. A .pdf name with NUL but no magic is malformed. A .pdf that is
50+
* otherwise valid UTF-8 is left as a normal text read so a misnamed text
51+
* file is not a new refusal.
5052
*/
5153
export function inspectionKindFromFirstChunk(
5254
filePath: string,
5355
chunk: Uint8Array,
5456
): FileInspectionKind | undefined {
5557
if (chunkLooksLikePdf(chunk)) return "pdf";
56-
if (pathLooksLikePdf(filePath)) return "pdf-malformed";
57-
if (chunk.includes(0)) return "binary";
58+
if (chunk.includes(0)) {
59+
return pathLooksLikePdf(filePath) ? "pdf-malformed" : "binary";
60+
}
5861
return undefined;
5962
}
6063

@@ -76,25 +79,25 @@ export function diagnoseBlockedFileInspection(
7679
return {
7780
code: "unreadable",
7881
message:
79-
`Cannot inspect ${path}: the file is unreadable (filesystem permission denied), not a missing extractor or worker-tool gap. ` +
80-
`Check ownership and mode, or copy the file into a readable workspace path.`,
82+
`Unreadable file (filesystem permission denied), not a missing extractor or worker-tool gap. ` +
83+
`Check ownership and mode, or copy it into a readable workspace path. File: ${path}`,
8184
};
8285
}
8386

8487
if (input.kind === "pdf-malformed") {
8588
return {
8689
code: "malformed",
8790
message:
88-
`Cannot inspect ${path}: the file is not a valid PDF (malformed or unreadable bytes), not a missing extractor or permission boundary. ` +
89-
`Open it in a PDF reader or re-export it, then retry.`,
91+
`Malformed PDF, not a missing extractor or permission boundary. ` +
92+
`Open it in a PDF reader or re-export it. File: ${path}`,
9093
};
9194
}
9295

9396
if (input.kind === "binary") {
9497
return {
9598
code: "binary",
9699
message:
97-
`refusing to read binary file: ${path}. This is a binary file, not a missing-tool or permission failure. ` +
100+
`refusing to read binary file: ${path}. Not a missing-tool or permission failure. ` +
98101
`Use a format-specific extractor if you need text from it.`,
99102
};
100103
}
@@ -104,24 +107,22 @@ export function diagnoseBlockedFileInspection(
104107
return {
105108
code: "missing_extractor",
106109
message:
107-
`Cannot inspect PDF ${path}: ${extractor} is not installed (missing PDF-extraction capability), not a malformed file. ` +
108-
`Install poppler so ${extractor} is on PATH (for example \`brew install poppler\` or \`apt-get install poppler-utils\`), then retry.`,
110+
`Missing PDF extractor: ${extractor} is not on PATH (capability gap), not a malformed file. ` +
111+
`Install poppler (\`brew install poppler\` or \`apt-get install poppler-utils\`), then retry. File: ${path}`,
109112
};
110113
}
111114

112115
if (!input.canExecuteHostCommands) {
113116
return {
114117
code: "permission_boundary",
115118
message:
116-
`Cannot inspect PDF ${path}: ${extractor} is installed, but this worker cannot execute it (run_shell is not mounted — a permission boundary, not a malformed file). ` +
117-
`Re-dispatch to a director that mounts run_shell, or grant this worker run_shell, then retry.`,
119+
`Permission boundary: ${extractor} is installed but this worker cannot execute it (run_shell is not mounted), not a malformed file. ` +
120+
`Re-dispatch to a director that mounts run_shell, or grant this worker run_shell. File: ${path}`,
118121
};
119122
}
120123

121124
return {
122125
code: "extractor_ready",
123-
message:
124-
`Cannot inspect PDF ${path} as text via read_file. ${extractor} is installed and this session can run host commands. ` +
125-
`Run \`${extractor} ${path} -\` via bash to extract text.`,
126+
message: `read_file cannot decode PDFs. ${extractor} is installed — run \`${extractor} ${shellQuote(path)} -\` via bash.`,
126127
};
127128
}

‎src/plugins/read-file-guard-plugin.test.ts‎

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ describe("readFileBounded", () => {
126126
);
127127
expect(isError).toBe(true);
128128
expect(content).toContain("binary");
129-
expect(content).toContain("not a missing-tool");
129+
expect(content).toContain("Not a missing-tool");
130130
});
131131

132132
test("a PDF without pdftotext names the missing extractor, not a malformed file", async () => {
@@ -140,7 +140,7 @@ describe("readFileBounded", () => {
140140
);
141141
expect(isError).toBe(true);
142142
expect(content).toContain("pdftotext");
143-
expect(content).toContain("missing");
143+
expect(content).toContain("Missing PDF extractor");
144144
expect(content).toContain("not a malformed file");
145145
expect(content).not.toContain("permission boundary");
146146
});
@@ -159,7 +159,7 @@ describe("readFileBounded", () => {
159159
);
160160
expect(isError).toBe(true);
161161
expect(content).toContain("installed");
162-
expect(content).toContain("permission boundary");
162+
expect(content).toContain("Permission boundary");
163163
expect(content).toContain("run_shell");
164164
expect(content).toContain("not a malformed file");
165165
});
@@ -174,11 +174,27 @@ describe("readFileBounded", () => {
174174
{ whichExtractor: () => null, canExecuteHostCommands: () => false },
175175
);
176176
expect(isError).toBe(true);
177-
expect(content).toContain("not a valid PDF");
177+
expect(content).toContain("Malformed PDF");
178178
expect(content).toContain("not a missing extractor");
179179
expect(content).not.toContain("brew install");
180180
});
181181

182+
test("UTF-8 .pdf without magic still reads as text", async () => {
183+
const p = await fixture("utf8.pdf", "plain utf-8 notes\nsecond line");
184+
const { content, isError } = await readFileBounded(
185+
p,
186+
0,
187+
2000,
188+
neverAbort(),
189+
{ whichExtractor: () => null, canExecuteHostCommands: () => false },
190+
);
191+
expect(isError).toBeUndefined();
192+
expect(content).toContain("plain utf-8 notes");
193+
expect(content).toContain("second line");
194+
expect(content).not.toContain("not a valid PDF");
195+
expect(content).not.toContain("malformed");
196+
});
197+
182198
test("a NUL deep in an otherwise-valid file does not discard streamed content", async () => {
183199
// Enough valid text (>64KB) to guarantee the NUL lands in a later chunk.
184200
const head = Array.from(
@@ -807,8 +823,9 @@ describe("readFileGuardPlugin", () => {
807823
);
808824
expect(missingResult.isError).toBe(true);
809825
expect(String(missingResult.content)).toContain("pdftotext");
810-
expect(String(missingResult.content)).toContain("missing");
826+
expect(String(missingResult.content)).toContain("Missing PDF extractor");
811827
expect(String(missingResult.content)).toContain("not a malformed file");
828+
expect(String(missingResult.content)).toContain("flow.pdf");
812829

813830
const blocked = readFileGuardPlugin(dir, {
814831
whichExtractor: () => "/opt/homebrew/bin/pdftotext",
@@ -820,10 +837,41 @@ describe("readFileGuardPlugin", () => {
820837
);
821838
expect(blockedResult.isError).toBe(true);
822839
expect(String(blockedResult.content)).toContain("installed");
823-
expect(String(blockedResult.content)).toContain("permission boundary");
840+
expect(String(blockedResult.content)).toContain("Permission boundary");
824841
expect(String(blockedResult.content)).toContain("run_shell");
825842
expect(String(blockedResult.content)).not.toContain("brew install");
826843
});
844+
845+
test("UTF-8 .pdf without magic still reads as text through the plugin", async () => {
846+
await fixture("notes.pdf", "hello from a misnamed text file");
847+
const result = await run({
848+
id: "utf8-pdf",
849+
name: "read_file",
850+
arguments: { path: "notes.pdf" },
851+
});
852+
expect(result.isError).toBeFalsy();
853+
expect(result.content).toContain("hello from a misnamed text file");
854+
expect(String(result.content)).not.toContain("not a valid PDF");
855+
expect(String(result.content)).not.toContain("malformed");
856+
});
857+
858+
test("extractor_ready quotes a PDF path that contains spaces", async () => {
859+
await fixture("my report.pdf", Buffer.from("%PDF-1.4\n"));
860+
const plugin = readFileGuardPlugin(dir, {
861+
whichExtractor: () => "/usr/bin/pdftotext",
862+
canExecuteHostCommands: () => true,
863+
});
864+
const result = await defined(plugin.middleware)(fallback)(
865+
{
866+
id: "quoted-pdf",
867+
name: "read_file",
868+
arguments: { path: "my report.pdf" },
869+
},
870+
neverAbort(),
871+
);
872+
expect(result.isError).toBe(true);
873+
expect(String(result.content)).toContain(`pdftotext 'my report.pdf' -`);
874+
});
827875
});
828876

829877
describe("CL-8980 single-way path+offset resume", () => {

‎src/plugins/read-file-guard-plugin.ts‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export interface ReadFileGuardPluginOptions {
6060
* Workers flip this after the capability filter; omitted means yes.
6161
*/
6262
canExecuteHostCommands?: () => boolean;
63+
/** Model-facing path for diagnosis text (the call argument, not the absolute). */
64+
displayPath?: string;
6365
}
6466

6567
// A truncated read tells the model to continue with the same path and the
@@ -366,23 +368,24 @@ export function readFileBounded(
366368
signal: AbortSignal,
367369
inspection?: Pick<
368370
ReadFileGuardPluginOptions,
369-
"whichExtractor" | "canExecuteHostCommands"
371+
"whichExtractor" | "canExecuteHostCommands" | "displayPath"
370372
>,
371373
): Promise<BoundedRead> {
374+
const labeledPath = inspection?.displayPath ?? absolutePath;
372375
return readStreamBounded(
373376
createReadStream(absolutePath),
374377
absolutePath,
375378
offset,
376379
limit,
377380
signal,
378381
{
379-
mapStreamError: (err) => mapFilesystemStreamError(absolutePath, err),
382+
mapStreamError: (err) => mapFilesystemStreamError(labeledPath, err),
380383
windowHugeLines: true,
381384
diagnoseFirstChunk: (chunk) => {
382385
const kind = inspectionKindFromFirstChunk(absolutePath, chunk);
383386
if (kind === undefined) return undefined;
384387
return diagnoseBlockedFileInspection({
385-
path: absolutePath,
388+
path: labeledPath,
386389
kind,
387390
extractorAvailable: resolveExtractorProbe(inspection?.whichExtractor),
388391
canExecuteHostCommands:
@@ -537,7 +540,7 @@ export function readFileGuardPlugin(
537540
return {
538541
callId: call.id,
539542
content: diagnoseBlockedFileInspection({
540-
path: rawPath,
543+
path: absolutePath,
541544
kind: "unreadable",
542545
extractorAvailable: false,
543546
canExecuteHostCommands: false,
@@ -549,13 +552,10 @@ export function readFileGuardPlugin(
549552
}
550553

551554
try {
552-
const res = await readFileBounded(
553-
absolutePath,
554-
offset,
555-
limit,
556-
signal,
557-
inspection,
558-
);
555+
const res = await readFileBounded(absolutePath, offset, limit, signal, {
556+
...inspection,
557+
displayPath: rawPath,
558+
});
559559
return res.isError
560560
? { callId: call.id, content: res.content, isError: true }
561561
: { callId: call.id, content: res.content };

0 commit comments

Comments
 (0)