Skip to main content

agent_engine/tools/
read.rs

1use super::{expand_path, Tool, ToolContext, ToolOutput};
2use crate::{Result, RuntimeError};
3use serde_json::{json, Value};
4
5/// Raw-byte cap. 3.5 MiB × 4/3 base64 = 4.89 MB < Anthropic's 5 MB/image.
6pub(crate) const MAX_IMAGE_BYTES: usize = 3_670_016;
7/// Anthropic rejects any side > 8000 px.
8const MAX_IMAGE_SIDE_PX: u32 = 8000;
9/// Above this long edge Anthropic downscales server-side (coordinate caveat).
10const PROVIDER_DOWNSCALE_LONG_EDGE: u32 = 1568;
11/// Seatbelt: refuse to load anything larger than this into memory at all.
12/// Checked via `fs::metadata` BEFORE the read, so a 200 MB `.tif` or a
13/// device file never gets pulled in.
14pub(crate) const MAX_READ_BYTES: u64 = 64 * 1024 * 1024;
15
16pub struct ReadTool;
17
18#[async_trait::async_trait]
19impl Tool for ReadTool {
20    fn effect(&self) -> crate::tools::catalog::ToolEffect {
21        crate::tools::catalog::ToolEffect::ReadOnly
22    }
23
24    fn origin(&self) -> crate::tools::ToolOrigin {
25        crate::tools::ToolOrigin::Builtin
26    }
27
28    fn name(&self) -> &str {
29        "read"
30    }
31
32    fn description(&self) -> &str {
33        "Read the contents of a file. Returns lines with line numbers. Reads up to 500 lines by default. For large files, use offset and limit to read in sections. Image files (PNG, JPEG, GIF, WebP) are returned as an image the model can see, up to 3.5 MB."
34    }
35
36    fn parameters(&self) -> Value {
37        json!({
38            "type": "object",
39            "properties": {
40                "path": {
41                    "type": "string",
42                    "description": "Path to the file to read"
43                },
44                "offset": {
45                    "type": "integer",
46                    "description": "Line number to start reading from (0-indexed, default: 0)"
47                },
48                "limit": {
49                    "type": "integer",
50                    "description": "Maximum number of lines to read (default: all lines)"
51                }
52            },
53            "required": ["path"]
54        })
55    }
56
57    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
58        self.execute_rich(params, ctx)
59            .await
60            .map(ToolOutput::into_summary)
61    }
62
63    async fn execute_rich(&self, params: Value, _ctx: ToolContext) -> Result<ToolOutput> {
64        let raw_path = params["path"]
65            .as_str()
66            .ok_or_else(|| RuntimeError::Tool("Missing path parameter".to_string()))?;
67        let path = expand_path(raw_path);
68
69        // Size guard BEFORE reading: one stat, no bytes loaded.
70        let meta = tokio::fs::metadata(&path).await.map_err(|e| {
71            RuntimeError::Tool(format!("Failed to read file '{}': {}", path.display(), e))
72        })?;
73        if !meta.is_file() {
74            return Err(RuntimeError::Tool(format!(
75                "'{}' is not a regular file. Use `bash` (ls, file, head) to inspect it.",
76                path.display()
77            )));
78        }
79        if meta.len() > MAX_READ_BYTES {
80            return Err(RuntimeError::Tool(format!(
81                "File '{}' is {} KB; the read tool refuses files over {} KB. Use `bash` with `head`, `tail`, `xxd`, or `sed -n` to read a slice, or shrink an image with `convert`.",
82                path.display(),
83                meta.len().div_ceil(1024),
84                MAX_READ_BYTES / 1024
85            )));
86        }
87
88        // Read raw bytes first to detect binary files
89        let bytes = tokio::fs::read(&path).await.map_err(|e| {
90            RuntimeError::Tool(format!("Failed to read file '{}': {}", path.display(), e))
91        })?;
92
93        if let Some(mime) = sniff_image_mime(&bytes) {
94            return image_output(&path, mime, &bytes);
95        }
96
97        let content = match String::from_utf8(bytes) {
98            Ok(s) => s,
99            Err(_) => return Err(RuntimeError::Tool(format!(
100                "File '{}' appears to be binary (not valid UTF-8). Use `bash` with `xxd` or `file` to inspect binary files. Image files (png/jpg/gif/webp) are returned as images automatically.",
101                path.display()
102            ))),
103        };
104
105        let lines: Vec<&str> = content.lines().collect();
106        let total_lines = lines.len();
107
108        let offset = params["offset"].as_u64().unwrap_or(0) as usize;
109        let limit = params["limit"]
110            .as_u64()
111            .map(|l| l as usize)
112            .unwrap_or(500.min(total_lines));
113
114        let start = offset.min(total_lines);
115        let end = (start + limit).min(total_lines);
116
117        let mut result = String::new();
118        for (i, line) in lines[start..end].iter().enumerate() {
119            result.push_str(&format!("{}\t{}\n", start + i + 1, line));
120        }
121
122        if total_lines > end {
123            result.push_str(&format!("\n... ({} more lines)", total_lines - end));
124        }
125
126        Ok(ToolOutput::Text(result))
127    }
128}
129
130/// Magic-byte sniff — content, never extension.
131pub(crate) fn sniff_image_mime(b: &[u8]) -> Option<&'static str> {
132    if b.len() >= 8 && b[..8] == [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] {
133        return Some("image/png");
134    }
135    if b.len() >= 3 && b[..3] == [0xFF, 0xD8, 0xFF] {
136        return Some("image/jpeg");
137    }
138    if b.len() >= 6 && (&b[..6] == b"GIF87a" || &b[..6] == b"GIF89a") {
139        return Some("image/gif");
140    }
141    if b.len() >= 12 && &b[..4] == b"RIFF" && &b[8..12] == b"WEBP" {
142        return Some("image/webp");
143    }
144    None
145}
146
147/// Header-only dimension parse. `None` → `image_output` rejects the file
148/// (an unsized image bypasses the 8000 px guard → provider 400 → poison pill).
149pub(crate) fn image_dimensions(mime: &str, b: &[u8]) -> Option<(u32, u32)> {
150    match mime {
151        "image/png" if b.len() >= 24 => Some((be32(&b[16..20]), be32(&b[20..24]))),
152        "image/gif" if b.len() >= 10 => Some((le16(&b[6..8]) as u32, le16(&b[8..10]) as u32)),
153        "image/webp" if b.len() >= 30 => match &b[12..16] {
154            b"VP8 " => Some((
155                (le16(&b[26..28]) & 0x3FFF) as u32,
156                (le16(&b[28..30]) & 0x3FFF) as u32,
157            )),
158            b"VP8L" => {
159                let x = [b[21], b[22], b[23], b[24]];
160                Some((
161                    1 + (((x[1] & 0x3F) as u32) << 8 | x[0] as u32),
162                    1 + (((x[3] & 0x0F) as u32) << 10
163                        | (x[2] as u32) << 2
164                        | ((x[1] & 0xC0) as u32) >> 6),
165                ))
166            }
167            b"VP8X" => Some((1 + le24(&b[24..27]), 1 + le24(&b[27..30]))),
168            _ => None,
169        },
170        "image/jpeg" => jpeg_dimensions(b),
171        _ => None,
172    }
173}
174
175fn jpeg_dimensions(b: &[u8]) -> Option<(u32, u32)> {
176    // Walk segments from offset 2 until a SOFn marker (C0–CF except C4, C8, CC).
177    let mut i = 2usize;
178    while i + 4 <= b.len() {
179        if b[i] != 0xFF {
180            return None;
181        }
182        let m = b[i + 1];
183        if m == 0xFF {
184            i += 1; // fill byte
185            continue;
186        }
187        if m == 0xD8 || m == 0x01 || (0xD0..=0xD7).contains(&m) {
188            i += 2; // standalone marker
189            continue;
190        }
191        if m == 0xD9 || m == 0xDA {
192            return None; // EOI / SOS: no SOF found
193        }
194        let len = be16(&b[i + 2..i + 4]) as usize;
195        if matches!(m, 0xC0..=0xCF) && !matches!(m, 0xC4 | 0xC8 | 0xCC) {
196            if i + 9 > b.len() {
197                return None;
198            }
199            // SOFn payload: Lf(2) P(1) Y(2) X(2) — height then width.
200            return Some((be16(&b[i + 7..i + 9]) as u32, be16(&b[i + 5..i + 7]) as u32));
201        }
202        i += 2 + len;
203    }
204    None
205}
206
207fn be16(b: &[u8]) -> u16 {
208    u16::from_be_bytes([b[0], b[1]])
209}
210fn be32(b: &[u8]) -> u32 {
211    u32::from_be_bytes([b[0], b[1], b[2], b[3]])
212}
213fn le16(b: &[u8]) -> u16 {
214    u16::from_le_bytes([b[0], b[1]])
215}
216fn le24(b: &[u8]) -> u32 {
217    u32::from_le_bytes([b[0], b[1], b[2], 0])
218}
219fn le32(b: &[u8]) -> u32 {
220    u32::from_le_bytes([b[0], b[1], b[2], b[3]])
221}
222
223/// Cheap structural integrity check — trailer/size sanity only, no decode.
224/// A truncated or corrupt image shipped to the provider is a poison pill:
225/// the 400 repeats on every request until the session is cleared, because
226/// a `tool_result` user message always survives history repair. Reject here.
227pub(crate) fn image_integrity_error(mime: &str, b: &[u8]) -> Option<&'static str> {
228    match mime {
229        "image/png" => {
230            if b.len() < 16 || &b[12..16] != b"IHDR" {
231                return Some("first chunk is not IHDR");
232            }
233            if !b.ends_with(b"IEND\xAE\x42\x60\x82") {
234                return Some("missing IEND trailer (truncated?)");
235            }
236        }
237        "image/jpeg" if !b.ends_with(&[0xFF, 0xD9]) => {
238            return Some("missing EOI marker (truncated?)");
239        }
240        "image/gif" if !b.ends_with(&[0x3B]) => {
241            return Some("missing GIF trailer (truncated?)");
242        }
243        "image/webp" => {
244            let riff = le32(&b[4..8]) as usize;
245            // RIFF size = file length − 8; allow a single pad byte of slack.
246            if riff + 8 != b.len() && riff + 9 != b.len() {
247                return Some("RIFF size does not match file length (truncated?)");
248            }
249        }
250        _ => {}
251    }
252    None
253}
254
255fn image_output(path: &std::path::Path, mime: &'static str, bytes: &[u8]) -> Result<ToolOutput> {
256    use base64::Engine as _;
257    let kb = bytes.len().div_ceil(1024);
258    if bytes.len() > MAX_IMAGE_BYTES {
259        return Err(RuntimeError::Tool(format!(
260            "Image '{p}' is {kb} KB; the read tool accepts images up to {cap} KB. \
261             Shrink it with bash and read the new file, e.g.:\n  \
262             convert '{p}' -resize 1568x1568\\> -quality 85 /tmp/shrunk.jpg   (ImageMagick)\n  \
263             sips -Z 1568 '{p}' --out /tmp/shrunk.png                        (macOS)",
264            p = path.display(),
265            kb = kb,
266            cap = MAX_IMAGE_BYTES / 1024
267        )));
268    }
269    if let Some(why) = image_integrity_error(mime, bytes) {
270        return Err(RuntimeError::Tool(format!(
271            "Image '{}' ({mime}, {kb} KB) appears truncated or corrupt: {why}. \
272             Not sent to the model. Verify with `file` / `identify`, or re-export it and read the new file.",
273            path.display()
274        )));
275    }
276    // Unknown dims → reject. Shipping an image we cannot size means the
277    // 8000 px guard is bypassed and a provider 400 poisons the session.
278    let Some((w, h)) = image_dimensions(mime, bytes) else {
279        return Err(RuntimeError::Tool(format!(
280            "Image '{}' ({mime}, {kb} KB): could not parse dimensions from the header. \
281             Not sent to the model. Re-export it (e.g. `convert '{}' /tmp/fixed.png`) and read the new file.",
282            path.display(),
283            path.display()
284        )));
285    };
286    // Zero-sized images are invalid for every supported format and are
287    // rejected by providers — same poison-pill class as unknown dims.
288    if w == 0 || h == 0 {
289        return Err(RuntimeError::Tool(format!(
290            "Image '{}' ({mime}, {kb} KB) declares invalid dimensions {w}x{h} (zero-sized).              Not sent to the model. Re-export it and read the new file.",
291            path.display()
292        )));
293    }
294    if w > MAX_IMAGE_SIDE_PX || h > MAX_IMAGE_SIDE_PX {
295        return Err(RuntimeError::Tool(format!(
296            "Image '{}' is {w}x{h}; the provider rejects images with any side over {MAX_IMAGE_SIDE_PX} px. \
297             Downscale it with bash (e.g. `convert ... -resize 1568x1568\\>`) and read the new file.",
298            path.display()
299        )));
300    }
301    let mut summary = format!("Image: {} ({w}x{h}, {mime}, {kb} KB)", path.display());
302    if w.max(h) > PROVIDER_DOWNSCALE_LONG_EDGE {
303        summary.push_str("\nNote: long edge exceeds 1568 px; the provider downscales before viewing, so pixel coordinates read from the image are approximate.");
304    }
305    let data = base64::engine::general_purpose::STANDARD.encode(bytes);
306    Ok(ToolOutput::Blocks {
307        blocks: vec![
308            // FIRST — invariant: blocks[0] is text == summary.
309            json!({ "type": "text", "text": summary }),
310            json!({ "type": "image", "source": { "type": "base64", "media_type": mime, "data": data } }),
311        ],
312        summary,
313    })
314}
315
316#[cfg(test)]
317mod tests {
318    use super::super::test_helpers::create_tool_context;
319    use super::*;
320    use crate::tools::Tool;
321    use serde_json::json;
322
323    #[test]
324    fn test_read_tool_schema() {
325        let tool = ReadTool;
326        assert_eq!(tool.name(), "read");
327        assert!(!tool.description().is_empty());
328
329        let params = tool.parameters();
330        assert_eq!(params["type"], "object");
331        assert!(params["properties"].is_object());
332        assert!(params["required"].is_array());
333    }
334
335    #[tokio::test]
336    async fn test_read_tool_execution() {
337        let temp_dir = std::env::temp_dir();
338        let test_file = temp_dir.join("read_tool_test.txt");
339
340        // Create temp file with known content
341        let content = "line 1\nline 2\nline 3\nline 4\nline 5";
342        std::fs::write(&test_file, content).unwrap();
343
344        let tool = ReadTool;
345        let ctx = create_tool_context();
346
347        // Test basic read
348        let params = json!({
349            "path": test_file.to_string_lossy()
350        });
351        let result = tool.execute(params, ctx).await.unwrap();
352
353        // Verify line numbers and content
354        assert!(result.contains("1\tline 1"));
355        assert!(result.contains("2\tline 2"));
356        assert!(result.contains("5\tline 5"));
357
358        // Test with offset and limit
359        let ctx = create_tool_context();
360        let params = json!({
361            "path": test_file.to_string_lossy(),
362            "offset": 2,
363            "limit": 2
364        });
365        let result = tool.execute(params, ctx).await.unwrap();
366
367        assert!(result.contains("3\tline 3"));
368        assert!(result.contains("4\tline 4"));
369        assert!(!result.contains("1\tline 1"));
370        assert!(!result.contains("5\tline 5"));
371
372        // Cleanup
373        let _ = std::fs::remove_file(&test_file);
374    }
375
376    #[tokio::test]
377    async fn test_read_tool_offset() {
378        let temp_dir = std::env::temp_dir();
379        let test_file = temp_dir.join("test_read_tool_offset.txt");
380
381        // Write 10 lines
382        let lines = (1..=10).map(|i| format!("line {}", i)).collect::<Vec<_>>();
383        let content = lines.join("\n");
384        std::fs::write(&test_file, &content).unwrap();
385
386        let tool = ReadTool;
387        let ctx = create_tool_context();
388
389        // Read with offset=5 (0-indexed, so starts at line 6)
390        let params = json!({
391            "path": test_file.to_string_lossy(),
392            "offset": 5
393        });
394
395        let result = tool.execute(params, ctx).await.unwrap();
396
397        // First line shown should be line 6 (1-indexed in output)
398        assert!(result.contains("6\tline 6"));
399        // Should not contain earlier lines
400        assert!(!result.contains("1\tline 1"));
401        assert!(!result.contains("5\tline 5"));
402
403        // Cleanup
404        let _ = std::fs::remove_file(&test_file);
405    }
406
407    // ── image support ───────────────────────────────────────────────────────
408
409    const PNG_SIG: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
410
411    /// PNG signature + IHDR chunk header with the given dims (24 bytes total).
412    fn png_header(w: u32, h: u32) -> Vec<u8> {
413        let mut v = PNG_SIG.to_vec();
414        v.extend_from_slice(&13u32.to_be_bytes()); // IHDR length
415        v.extend_from_slice(b"IHDR");
416        v.extend_from_slice(&w.to_be_bytes());
417        v.extend_from_slice(&h.to_be_bytes());
418        v
419    }
420
421    const PNG_IEND: [u8; 12] = [0, 0, 0, 0, b'I', b'E', b'N', b'D', 0xAE, 0x42, 0x60, 0x82];
422
423    /// Structurally complete PNG with the given dims: IHDR + IEND, no IDAT.
424    fn png_with_dims(w: u32, h: u32) -> Vec<u8> {
425        let mut v = png_header(w, h);
426        v.extend_from_slice(&[8, 6, 0, 0, 0]); // depth, color, comp, filter, interlace
427        v.extend_from_slice(&[0, 0, 0, 0]); // crc (not validated)
428        v.extend_from_slice(&PNG_IEND);
429        v
430    }
431
432    /// Minimal 1×1 PNG.
433    fn tiny_png() -> Vec<u8> {
434        png_with_dims(1, 1)
435    }
436
437    fn tmp(name: &str, bytes: &[u8]) -> std::path::PathBuf {
438        let p = std::env::temp_dir().join(format!("read_img_{}_{name}", std::process::id()));
439        std::fs::write(&p, bytes).unwrap();
440        p
441    }
442
443    #[test]
444    fn sniff_png_jpeg_gif_webp() {
445        assert_eq!(sniff_image_mime(&PNG_SIG), Some("image/png"));
446        assert_eq!(
447            sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]),
448            Some("image/jpeg")
449        );
450        assert_eq!(
451            sniff_image_mime(b"GIF89a\x01\x00\x01\x00"),
452            Some("image/gif")
453        );
454        assert_eq!(
455            sniff_image_mime(b"GIF87a\x01\x00\x01\x00"),
456            Some("image/gif")
457        );
458        assert_eq!(
459            sniff_image_mime(b"RIFF\x00\x00\x00\x00WEBPVP8 "),
460            Some("image/webp")
461        );
462        assert_eq!(sniff_image_mime(b"hello"), None);
463        assert_eq!(
464            sniff_image_mime(b"BM\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"),
465            None
466        );
467        assert_eq!(sniff_image_mime(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3"), None);
468        assert_eq!(sniff_image_mime(b"RIFF\x00\x00\x00\x00WAVEfmt "), None);
469        assert_eq!(sniff_image_mime(b""), None);
470    }
471
472    #[tokio::test]
473    async fn sniff_ignores_extension() {
474        let text_as_png = tmp("notes.png", b"just text\nline two");
475        let out = ReadTool
476            .execute_rich(
477                json!({"path": text_as_png.to_string_lossy()}),
478                create_tool_context(),
479            )
480            .await
481            .unwrap();
482        match out {
483            ToolOutput::Text(s) => assert!(s.contains("1\tjust text"), "{s}"),
484            other => panic!("expected Text, got {other:?}"),
485        }
486
487        let png_as_txt = tmp("data.txt", &tiny_png());
488        let out = ReadTool
489            .execute_rich(
490                json!({"path": png_as_txt.to_string_lossy()}),
491                create_tool_context(),
492            )
493            .await
494            .unwrap();
495        assert!(matches!(out, ToolOutput::Blocks { .. }), "{out:?}");
496        let _ = std::fs::remove_file(text_as_png);
497        let _ = std::fs::remove_file(png_as_txt);
498    }
499
500    #[test]
501    fn png_dimensions_from_ihdr() {
502        assert_eq!(image_dimensions("image/png", &tiny_png()), Some((1, 1)));
503        assert_eq!(
504            image_dimensions("image/png", &png_header(1024, 1536)),
505            Some((1024, 1536))
506        );
507        assert_eq!(image_dimensions("image/png", &PNG_SIG), None);
508    }
509
510    #[test]
511    fn jpeg_dimensions_from_sof0() {
512        let mut j = vec![0xFF, 0xD8]; // SOI
513        j.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]); // APP0, len 16
514        j.extend_from_slice(b"JFIF\0");
515        j.extend_from_slice(&[1, 1, 0, 0, 1, 0, 1, 0, 0]); // 14 payload bytes total
516        j.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]); // SOF0, len 17, precision
517        j.extend_from_slice(&480u16.to_be_bytes()); // height
518        j.extend_from_slice(&640u16.to_be_bytes()); // width
519        j.extend_from_slice(&[3, 1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1]);
520        assert_eq!(image_dimensions("image/jpeg", &j), Some((640, 480)));
521
522        // SOI + SOS only → no SOF → None, no panic.
523        let sos_only = [0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x08, 0, 0, 0, 0, 0, 0];
524        assert_eq!(image_dimensions("image/jpeg", &sos_only), None);
525
526        // Truncated SOF header → None, no panic.
527        let trunc = [0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x01];
528        assert_eq!(image_dimensions("image/jpeg", &trunc), None);
529
530        // Garbage after SOI: deterministic pseudo-random bytes, must never panic.
531        let mut seed = 0x9E37_79B9u32;
532        for _ in 0..200 {
533            let mut buf = vec![0xFF, 0xD8, 0xFF];
534            for _ in 0..64 {
535                seed ^= seed << 13;
536                seed ^= seed >> 17;
537                seed ^= seed << 5;
538                buf.push((seed & 0xFF) as u8);
539            }
540            let _ = image_dimensions("image/jpeg", &buf);
541        }
542    }
543
544    #[test]
545    fn webp_dimensions_vp8_vp8l_vp8x() {
546        // VP8 (lossy): frame tag (3) + start code 9D 01 2A + w(2) + h(2)
547        let mut vp8 = b"RIFF\x00\x00\x00\x00WEBPVP8 \x00\x00\x00\x00".to_vec();
548        vp8.extend_from_slice(&[0x00, 0x00, 0x00, 0x9D, 0x01, 0x2A]);
549        vp8.extend_from_slice(&320u16.to_le_bytes());
550        vp8.extend_from_slice(&240u16.to_le_bytes());
551        assert_eq!(image_dimensions("image/webp", &vp8), Some((320, 240)));
552
553        // VP8L (lossless): signature 0x2F then 14-bit w-1, 14-bit h-1.
554        let (w, h) = (100u32, 50u32);
555        let bits: u32 = (w - 1) | ((h - 1) << 14);
556        let mut vp8l = b"RIFF\x00\x00\x00\x00WEBPVP8L\x00\x00\x00\x00\x2F".to_vec();
557        vp8l.extend_from_slice(&bits.to_le_bytes());
558        vp8l.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
559        assert_eq!(image_dimensions("image/webp", &vp8l), Some((100, 50)));
560
561        // VP8X (extended): flags(4) + 24-bit w-1 + 24-bit h-1.
562        let mut vp8x = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0A\x00\x00\x00".to_vec();
563        vp8x.extend_from_slice(&[0, 0, 0, 0]);
564        vp8x.extend_from_slice(&(1999u32).to_le_bytes()[..3]);
565        vp8x.extend_from_slice(&(999u32).to_le_bytes()[..3]);
566        assert_eq!(image_dimensions("image/webp", &vp8x), Some((2000, 1000)));
567    }
568
569    #[tokio::test]
570    async fn image_read_block_ordering() {
571        use base64::Engine as _;
572        let bytes = tiny_png();
573        let p = tmp("order.png", &bytes);
574        let out = ReadTool
575            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
576            .await
577            .unwrap();
578        let ToolOutput::Blocks { blocks, summary } = out else {
579            panic!("expected Blocks");
580        };
581        assert_eq!(blocks.len(), 2);
582        assert_eq!(blocks[0]["type"], "text");
583        assert_eq!(blocks[0]["text"], summary);
584        assert_eq!(blocks[1]["type"], "image");
585        assert_eq!(blocks[1]["source"]["type"], "base64");
586        assert_eq!(blocks[1]["source"]["media_type"], "image/png");
587        let decoded = base64::engine::general_purpose::STANDARD
588            .decode(blocks[1]["source"]["data"].as_str().unwrap())
589            .unwrap();
590        assert_eq!(decoded, bytes);
591        let _ = std::fs::remove_file(p);
592    }
593
594    #[tokio::test]
595    async fn image_over_cap_rejected() {
596        let mut bytes = png_header(10, 10);
597        bytes.resize(MAX_IMAGE_BYTES + 1, 0);
598        let p = tmp("big.png", &bytes);
599        let err = ReadTool
600            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
601            .await
602            .unwrap_err();
603        let RuntimeError::Tool(msg) = err else {
604            panic!("expected Tool error, got {err:?}");
605        };
606        assert!(msg.contains("convert"), "{msg}");
607        assert!(msg.contains("KB"), "{msg}");
608        assert!(msg.len() < 1024, "{}", msg.len());
609        let _ = std::fs::remove_file(p);
610    }
611
612    #[tokio::test]
613    async fn image_over_8000px_rejected() {
614        let p = tmp("wide.png", &png_with_dims(8001, 10));
615        let err = ReadTool
616            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
617            .await
618            .unwrap_err();
619        assert!(err.to_string().contains("8000"), "{err}");
620        let _ = std::fs::remove_file(p);
621    }
622
623    #[tokio::test]
624    async fn legacy_execute_returns_summary_for_images() {
625        let p = tmp("legacy.png", &tiny_png());
626        let s = ReadTool
627            .execute(json!({"path": p.to_string_lossy()}), create_tool_context())
628            .await
629            .unwrap();
630        assert!(s.starts_with("Image: "), "{s}");
631        assert!(s.len() < 512);
632        let _ = std::fs::remove_file(p);
633    }
634
635    #[tokio::test]
636    async fn label_format() {
637        let p = tmp("label.png", &tiny_png());
638        let out = ReadTool
639            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
640            .await
641            .unwrap();
642        assert_eq!(
643            out.summary(),
644            format!("Image: {} (1x1, image/png, 1 KB)", p.display())
645        );
646        let _ = std::fs::remove_file(p);
647
648        let p = tmp("wide2.png", &png_with_dims(2000, 100));
649        let out = ReadTool
650            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
651            .await
652            .unwrap();
653        assert!(out.summary().starts_with(&format!(
654            "Image: {} (2000x100, image/png, 1 KB)",
655            p.display()
656        )));
657        assert!(out.summary().contains("1568 px"), "{}", out.summary());
658        let _ = std::fs::remove_file(p);
659    }
660
661    /// Spec §5.7 scripted integration check against the real test asset.
662    /// Skips (passes) if the asset is absent so CI without ~/Jawz stays green.
663    #[tokio::test]
664    async fn real_avatar_png_round_trips_as_image_blocks() {
665        let Some(home) = std::env::var_os("HOME") else {
666            return;
667        };
668        let p = std::path::PathBuf::from(home).join("Jawz/media/jawz-avatar-v1.png");
669        if !p.exists() {
670            eprintln!(
671                "SKIPPED real_avatar_png_round_trips_as_image_blocks: {} absent",
672                p.display()
673            );
674            return;
675        }
676        let out = ReadTool
677            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
678            .await
679            .unwrap();
680        let ToolOutput::Blocks { blocks, summary } = out else {
681            panic!("expected Blocks");
682        };
683        assert!(
684            summary.starts_with(&format!(
685                "Image: {} (1024x1536, image/png, 2292 KB)",
686                p.display()
687            )),
688            "{summary}"
689        );
690        assert_eq!(blocks[0]["text"], summary);
691        assert_eq!(blocks[1]["source"]["media_type"], "image/png");
692        let data = blocks[1]["source"]["data"].as_str().unwrap();
693        assert!(data.starts_with("iVBORw0KGgo"));
694        assert!(serde_json::to_vec(&blocks[1]).unwrap().len() < 5_000_000);
695    }
696
697    /// Integration evidence: a real .rs file still reads as numbered text,
698    /// and (when `SYNAPS_TEST_BIG_IMAGE` points at a >3.5 MiB image) the
699    /// oversize path rejects in-slot with the shrink hint and no base64.
700    #[tokio::test]
701    async fn real_rs_file_and_optional_big_image() {
702        let out = ReadTool
703            .execute_rich(
704                json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/src/tools/read.rs"), "limit": 3}),
705                create_tool_context(),
706            )
707            .await
708            .unwrap();
709        let ToolOutput::Text(text) = out else {
710            panic!("expected Text")
711        };
712        assert!(text.starts_with("1\tuse super::"), "{text}");
713
714        let Ok(big) = std::env::var("SYNAPS_TEST_BIG_IMAGE") else {
715            eprintln!("SKIPPED big-image half of real_rs_file_and_optional_big_image: SYNAPS_TEST_BIG_IMAGE unset");
716            return;
717        };
718        let err = ReadTool
719            .execute_rich(json!({"path": big}), create_tool_context())
720            .await
721            .unwrap_err()
722            .to_string();
723        assert!(err.contains("accepts images up to 3584 KB"), "{err}");
724        assert!(err.contains("convert"));
725        assert!(err.len() < 1024);
726    }
727
728    // ── S2: poison-pill integrity checks ────────────────────────────────────
729
730    #[test]
731    fn integrity_check_per_format() {
732        // PNG
733        assert_eq!(image_integrity_error("image/png", &tiny_png()), None);
734        assert!(image_integrity_error("image/png", &png_header(1, 1)).is_some()); // no IEND
735        let mut bad_chunk = tiny_png();
736        bad_chunk[12..16].copy_from_slice(b"iTXt");
737        assert!(image_integrity_error("image/png", &bad_chunk).is_some());
738        assert!(image_integrity_error("image/png", &PNG_SIG).is_some());
739        // JPEG
740        assert_eq!(
741            image_integrity_error("image/jpeg", &[0xFF, 0xD8, 0xFF, 0xD9]),
742            None
743        );
744        assert!(image_integrity_error("image/jpeg", &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0]).is_some());
745        // GIF
746        assert_eq!(
747            image_integrity_error("image/gif", b"GIF89a\x01\x00\x01\x00\x3B"),
748            None
749        );
750        assert!(image_integrity_error("image/gif", b"GIF89a\x01\x00\x01\x00").is_some());
751        // WebP: RIFF size must equal len - 8.
752        let mut webp = b"RIFF\x00\x00\x00\x00WEBPVP8 ".to_vec();
753        webp.extend_from_slice(&[0u8; 20]);
754        assert!(image_integrity_error("image/webp", &webp).is_some());
755        let riff = (webp.len() - 8) as u32;
756        webp[4..8].copy_from_slice(&riff.to_le_bytes());
757        assert_eq!(image_integrity_error("image/webp", &webp), None);
758        webp.pop();
759        assert!(image_integrity_error("image/webp", &webp).is_some());
760    }
761
762    #[tokio::test]
763    async fn truncated_png_rejected_no_image_block() {
764        // Header + 3 KB of zeros, no IEND — the "half-written screenshot" case.
765        let mut bytes = png_header(10, 10);
766        bytes.resize(3_000, 0);
767        let p = tmp("trunc.png", &bytes);
768        let err = ReadTool
769            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
770            .await
771            .unwrap_err();
772        let RuntimeError::Tool(msg) = err else {
773            panic!("expected Tool error, got {err:?}");
774        };
775        assert!(msg.contains("truncated or corrupt"), "{msg}");
776        assert!(msg.contains("IEND"), "{msg}");
777        assert!(!msg.contains("iVBORw0KGgo"), "no base64 in error");
778        assert!(msg.len() < 1024);
779        // Legacy path: same rejection, plain string, no image anywhere.
780        let s = ReadTool
781            .execute(json!({"path": p.to_string_lossy()}), create_tool_context())
782            .await
783            .unwrap_err()
784            .to_string();
785        assert!(s.contains("truncated or corrupt"), "{s}");
786        let _ = std::fs::remove_file(p);
787    }
788
789    #[tokio::test]
790    async fn truncated_jpeg_and_gif_rejected() {
791        let jpeg = [
792            0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0, 1, 1, 0, 0, 1, 0, 1, 0,
793            0,
794        ];
795        let p = tmp("trunc.jpg", &jpeg);
796        let err = ReadTool
797            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
798            .await
799            .unwrap_err()
800            .to_string();
801        assert!(err.contains("EOI"), "{err}");
802        let _ = std::fs::remove_file(p);
803
804        let p = tmp("trunc.gif", b"GIF89a\x01\x00\x01\x00\x00\x00\x00");
805        let err = ReadTool
806            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
807            .await
808            .unwrap_err()
809            .to_string();
810        assert!(err.contains("GIF trailer"), "{err}");
811        let _ = std::fs::remove_file(p);
812    }
813
814    #[tokio::test]
815    async fn unparseable_dims_rejected_not_shipped() {
816        // Structurally complete JPEG (SOI … EOI) but SOS before any SOF →
817        // dims None → must NOT become an image block.
818        let jpeg = [
819            0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x08, 0, 0, 0, 0, 0, 0, 0xFF, 0xD9,
820        ];
821        let p = tmp("nodims.jpg", &jpeg);
822        let err = ReadTool
823            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
824            .await
825            .unwrap_err()
826            .to_string();
827        assert!(err.contains("could not parse dimensions"), "{err}");
828        assert!(err.contains("Not sent to the model"), "{err}");
829        let _ = std::fs::remove_file(p);
830    }
831
832    #[tokio::test]
833    async fn zero_dimension_images_rejected_not_shipped() {
834        for (name, w, h) in [
835            ("w0.png", 0u32, 7u32),
836            ("h0.png", 7, 0),
837            ("both0.png", 0, 0),
838        ] {
839            let p = tmp(name, &png_with_dims(w, h));
840            let err = ReadTool
841                .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
842                .await
843                .unwrap_err()
844                .to_string();
845            assert!(err.contains("zero-sized"), "{name}: {err}");
846            assert!(err.contains(&format!("{w}x{h}")), "{name}: {err}");
847            assert!(err.contains("Not sent to the model"), "{name}: {err}");
848            let _ = std::fs::remove_file(p);
849        }
850    }
851
852    #[tokio::test]
853    async fn complete_gif_and_jpeg_round_trip_through_execute_rich() {
854        let p = tmp("ok.gif", b"GIF89a\x02\x00\x03\x00\x00\x00\x00\x3B");
855        let out = ReadTool
856            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
857            .await
858            .unwrap();
859        assert!(
860            out.summary().contains("(2x3, image/gif, 1 KB)"),
861            "{}",
862            out.summary()
863        );
864        assert!(matches!(out, ToolOutput::Blocks { .. }));
865        let _ = std::fs::remove_file(p);
866
867        let mut j = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08];
868        j.extend_from_slice(&480u16.to_be_bytes());
869        j.extend_from_slice(&640u16.to_be_bytes());
870        j.extend_from_slice(&[3, 1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1, 0xFF, 0xD9]);
871        let p = tmp("ok.jpg", &j);
872        let out = ReadTool
873            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
874            .await
875            .unwrap();
876        assert!(
877            out.summary().contains("(640x480, image/jpeg, 1 KB)"),
878            "{}",
879            out.summary()
880        );
881        let _ = std::fs::remove_file(p);
882    }
883
884    // ── S4: metadata guard before the read ──────────────────────────────────
885
886    #[tokio::test]
887    async fn oversize_file_rejected_by_metadata_without_reading() {
888        // Sparse file: set_len allocates no blocks, so this is instant on
889        // disk but would be a 65 MiB read if the guard ran after the read.
890        let p = std::env::temp_dir().join(format!("read_sparse_{}.bin", std::process::id()));
891        let f = std::fs::File::create(&p).unwrap();
892        f.set_len(MAX_READ_BYTES + 1).unwrap();
893        drop(f);
894        let start = std::time::Instant::now();
895        let err = ReadTool
896            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
897            .await
898            .unwrap_err()
899            .to_string();
900        assert!(err.contains("refuses files over"), "{err}");
901        assert!(err.contains("head"), "{err}");
902        assert!(start.elapsed() < std::time::Duration::from_secs(2));
903        let _ = std::fs::remove_file(p);
904    }
905
906    #[tokio::test]
907    async fn directory_and_device_rejected_as_not_regular_file() {
908        let err = ReadTool
909            .execute_rich(
910                json!({"path": std::env::temp_dir().to_string_lossy()}),
911                create_tool_context(),
912            )
913            .await
914            .unwrap_err()
915            .to_string();
916        assert!(err.contains("not a regular file"), "{err}");
917
918        #[cfg(unix)]
919        if std::path::Path::new("/dev/zero").exists() {
920            let start = std::time::Instant::now();
921            let err = ReadTool
922                .execute_rich(json!({"path": "/dev/zero"}), create_tool_context())
923                .await
924                .unwrap_err()
925                .to_string();
926            assert!(err.contains("not a regular file"), "{err}");
927            assert!(start.elapsed() < std::time::Duration::from_secs(2));
928        }
929    }
930
931    #[tokio::test]
932    async fn non_image_binary_mentions_image_support() {
933        let p = tmp("blob.bin", &[0xFF, 0xFE, 0x00, 0x80, 0x81]);
934        let err = ReadTool
935            .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
936            .await
937            .unwrap_err();
938        assert!(
939            err.to_string().contains("returned as images automatically"),
940            "{err}"
941        );
942        let _ = std::fs::remove_file(p);
943    }
944}