1use async_trait::async_trait;
2use serde_json::json;
3use std::fmt::Write;
4use std::sync::Arc;
5use zeroclaw_api::tool::{Tool, ToolResult};
6use zeroclaw_config::policy::SecurityPolicy;
7
8const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
20
21pub struct ImageInfoTool {
27 security: Arc<SecurityPolicy>,
31}
32
33impl ImageInfoTool {
34 pub fn new(security: Arc<SecurityPolicy>) -> Self {
35 Self { security }
36 }
37
38 fn strip_windows_verbatim_prefix(path: &str) -> std::borrow::Cow<'_, str> {
51 if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
52 std::borrow::Cow::Owned(format!(r"\\{rest}"))
53 } else if let Some(rest) = path.strip_prefix(r"\\?\") {
54 std::borrow::Cow::Borrowed(rest)
55 } else {
56 std::borrow::Cow::Borrowed(path)
57 }
58 }
59
60 fn detect_format(bytes: &[u8]) -> &'static str {
62 if bytes.len() < 4 {
63 return "unknown";
64 }
65 if bytes.starts_with(b"\x89PNG") {
66 "png"
67 } else if bytes.starts_with(b"\xFF\xD8\xFF") {
68 "jpeg"
69 } else if bytes.starts_with(b"GIF8") {
70 "gif"
71 } else if bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
72 "webp"
73 } else if bytes.starts_with(b"BM") {
74 "bmp"
75 } else {
76 "unknown"
77 }
78 }
79
80 fn extract_dimensions(bytes: &[u8], format: &str) -> Option<(u32, u32)> {
83 match format {
84 "png" => {
85 if bytes.len() >= 24 {
87 let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
88 let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
89 Some((w, h))
90 } else {
91 None
92 }
93 }
94 "gif" => {
95 if bytes.len() >= 10 {
97 let w = u32::from(u16::from_le_bytes([bytes[6], bytes[7]]));
98 let h = u32::from(u16::from_le_bytes([bytes[8], bytes[9]]));
99 Some((w, h))
100 } else {
101 None
102 }
103 }
104 "bmp" => {
105 if bytes.len() >= 26 {
107 let w = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]);
108 let h_raw = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
109 let h = h_raw.unsigned_abs();
110 Some((w, h))
111 } else {
112 None
113 }
114 }
115 "jpeg" => Self::jpeg_dimensions(bytes),
116 _ => None,
117 }
118 }
119
120 fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
122 let mut i = 2; while i + 1 < bytes.len() {
124 if bytes[i] != 0xFF {
125 return None;
126 }
127 let marker = bytes[i + 1];
128 i += 2;
129
130 if (0xC0..=0xC3).contains(&marker) {
132 if i + 7 <= bytes.len() {
133 let h = u32::from(u16::from_be_bytes([bytes[i + 3], bytes[i + 4]]));
134 let w = u32::from(u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]));
135 return Some((w, h));
136 }
137 return None;
138 }
139
140 if i + 1 < bytes.len() {
142 let seg_len = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as usize;
143 if seg_len < 2 {
144 return None; }
146 i += seg_len;
147 } else {
148 return None;
149 }
150 }
151 None
152 }
153}
154
155#[async_trait]
156impl Tool for ImageInfoTool {
157 fn name(&self) -> &str {
158 "image_info"
159 }
160
161 fn description(&self) -> &str {
162 "Read image file metadata (format, dimensions, size). The image is also made available to vision-capable models via an inline image marker."
163 }
164
165 fn parameters_schema(&self) -> serde_json::Value {
166 json!({
167 "type": "object",
168 "properties": {
169 "path": {
170 "type": "string",
171 "description": "Path to the image file (absolute or relative to workspace)"
172 }
173 },
174 "required": ["path"]
175 })
176 }
177
178 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
179 let path_str = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
180 ::zeroclaw_log::record!(
181 WARN,
182 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
183 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
184 .with_attrs(::serde_json::json!({"param": "path"})),
185 "image_info: missing path parameter"
186 );
187 anyhow::Error::msg("Missing 'path' parameter")
188 })?;
189
190 let full_path = self.security.resolve_tool_path(path_str);
197 let resolved_path = match tokio::fs::canonicalize(&full_path).await {
198 Ok(path) => path,
199 Err(e) => {
200 let _ = self.security.record_action();
201 let error = if e.kind() == std::io::ErrorKind::NotFound {
202 format!("File not found: {path_str}")
203 } else {
204 format!("Failed to resolve file path: {e}")
205 };
206 return Ok(ToolResult {
207 success: false,
208 output: String::new(),
209 error: Some(error),
210 });
211 }
212 };
213
214 if !self.security.is_resolved_path_readable(&resolved_path) {
215 return Ok(ToolResult {
216 success: false,
217 output: String::new(),
218 error: Some(
219 "Resolved image path is outside the allowed readable roots.".to_string(),
220 ),
221 });
222 }
223
224 let metadata = tokio::fs::metadata(&resolved_path).await.map_err(|e| {
225 ::zeroclaw_log::record!(
226 ERROR,
227 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
228 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
229 .with_attrs(::serde_json::json!({
230 "path": path_str,
231 "error": format!("{}", e),
232 })),
233 "image_info: failed to read file metadata"
234 );
235 anyhow::Error::msg(format!("Failed to read file metadata: {e}"))
236 })?;
237
238 let file_size = metadata.len();
239
240 if file_size > MAX_IMAGE_BYTES {
241 return Ok(ToolResult {
242 success: false,
243 output: String::new(),
244 error: Some(format!(
245 "Image too large: {file_size} bytes (max {MAX_IMAGE_BYTES} bytes)"
246 )),
247 });
248 }
249
250 let bytes = tokio::fs::read(&resolved_path).await.map_err(|e| {
251 ::zeroclaw_log::record!(
252 ERROR,
253 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
254 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
255 .with_attrs(::serde_json::json!({
256 "path": path_str,
257 "error": format!("{}", e),
258 })),
259 "image_info: failed to read image file"
260 );
261 anyhow::Error::msg(format!("Failed to read image file: {e}"))
262 })?;
263
264 let format = Self::detect_format(&bytes);
265 let dimensions = Self::extract_dimensions(&bytes, format);
266
267 let resolved_display = resolved_path.display().to_string();
291 let marker_path = Self::strip_windows_verbatim_prefix(&resolved_display);
292 let mut output = format!("File: {marker_path}\nFormat: {format}\nSize: {file_size} bytes");
293
294 if let Some((w, h)) = dimensions {
295 let _ = write!(output, "\nDimensions: {w}x{h}");
296 }
297
298 let _ = write!(output, "\n[IMAGE:{marker_path}]");
299
300 Ok(ToolResult {
301 success: true,
302 output,
303 error: None,
304 })
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::wrappers::{PathGuardedTool, RateLimitedTool};
312 use std::path::{Component, Path, PathBuf};
313 use tempfile::TempDir;
314 use zeroclaw_config::autonomy::AutonomyLevel;
315 use zeroclaw_config::policy::SecurityPolicy;
316
317 const MINIMAL_PNG: &[u8] = &[
318 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
319 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
320 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8,
321 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00,
322 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
323 ];
324
325 fn test_security() -> Arc<SecurityPolicy> {
326 Arc::new(SecurityPolicy {
327 autonomy: AutonomyLevel::Full,
328 workspace_dir: std::env::temp_dir(),
329 workspace_only: false,
330 forbidden_paths: vec![],
331 ..SecurityPolicy::default()
332 })
333 }
334
335 fn workspace_security(workspace: std::path::PathBuf) -> Arc<SecurityPolicy> {
338 Arc::new(SecurityPolicy {
339 autonomy: AutonomyLevel::Full,
340 workspace_dir: workspace,
341 workspace_only: true,
342 ..SecurityPolicy::default()
343 })
344 }
345
346 fn rootless_path(path: &Path) -> PathBuf {
347 let mut relative = PathBuf::new();
348 for component in path.components() {
349 match component {
350 Component::Prefix(_) | Component::RootDir | Component::CurDir => {}
351 Component::ParentDir => panic!("test path must not contain parent components"),
352 Component::Normal(part) => relative.push(part),
353 }
354 }
355 relative
356 }
357
358 fn wrapped_tool(workspace: std::path::PathBuf) -> Box<dyn Tool> {
363 let security = workspace_security(workspace);
364 wrapped_tool_with_security(security)
365 }
366
367 fn wrapped_tool_with_security(security: Arc<SecurityPolicy>) -> Box<dyn Tool> {
368 Box::new(RateLimitedTool::new(
369 PathGuardedTool::new(ImageInfoTool::new(security.clone()), security.clone()),
370 security,
371 ))
372 }
373
374 #[test]
375 fn image_info_tool_name() {
376 let tool = ImageInfoTool::new(test_security());
377 assert_eq!(tool.name(), "image_info");
378 }
379
380 #[test]
381 fn image_info_tool_description() {
382 let tool = ImageInfoTool::new(test_security());
383 assert!(!tool.description().is_empty());
384 assert!(tool.description().contains("image"));
385 }
386
387 #[test]
388 fn image_info_tool_schema() {
389 let tool = ImageInfoTool::new(test_security());
390 let schema = tool.parameters_schema();
391 assert!(schema["properties"]["path"].is_object());
392 assert!(schema["properties"]["include_base64"].is_null());
395 let required = schema["required"].as_array().unwrap();
396 assert!(required.contains(&json!("path")));
397 }
398
399 #[test]
400 fn image_info_tool_spec() {
401 let tool = ImageInfoTool::new(test_security());
402 let spec = tool.spec();
403 assert_eq!(spec.name, "image_info");
404 assert!(spec.parameters.is_object());
405 }
406
407 #[test]
410 fn strip_verbatim_disk_prefix() {
411 assert_eq!(
414 ImageInfoTool::strip_windows_verbatim_prefix(r"\\?\C:\Users\me\Downloads\a.png"),
415 r"C:\Users\me\Downloads\a.png"
416 );
417 }
418
419 #[test]
420 fn strip_verbatim_unc_prefix() {
421 assert_eq!(
423 ImageInfoTool::strip_windows_verbatim_prefix(r"\\?\UNC\server\share\pic.png"),
424 r"\\server\share\pic.png"
425 );
426 }
427
428 #[test]
429 fn strip_verbatim_prefix_leaves_plain_paths_unchanged() {
430 for input in [
433 "/home/me/pictures/a.png",
434 r"C:\Users\me\a.png",
435 "relative/a.png",
436 ] {
437 assert!(matches!(
438 ImageInfoTool::strip_windows_verbatim_prefix(input),
439 std::borrow::Cow::Borrowed(_)
440 ));
441 assert_eq!(ImageInfoTool::strip_windows_verbatim_prefix(input), input);
442 }
443 }
444
445 #[test]
448 fn detect_png() {
449 let bytes = b"\x89PNG\r\n\x1a\n";
450 assert_eq!(ImageInfoTool::detect_format(bytes), "png");
451 }
452
453 #[test]
454 fn detect_jpeg() {
455 let bytes = b"\xFF\xD8\xFF\xE0";
456 assert_eq!(ImageInfoTool::detect_format(bytes), "jpeg");
457 }
458
459 #[test]
460 fn detect_gif() {
461 let bytes = b"GIF89a";
462 assert_eq!(ImageInfoTool::detect_format(bytes), "gif");
463 }
464
465 #[test]
466 fn detect_webp() {
467 let bytes = b"RIFF\x00\x00\x00\x00WEBP";
468 assert_eq!(ImageInfoTool::detect_format(bytes), "webp");
469 }
470
471 #[test]
472 fn detect_bmp() {
473 let bytes = b"BM\x00\x00";
474 assert_eq!(ImageInfoTool::detect_format(bytes), "bmp");
475 }
476
477 #[test]
478 fn detect_unknown_short() {
479 let bytes = b"\x00\x01";
480 assert_eq!(ImageInfoTool::detect_format(bytes), "unknown");
481 }
482
483 #[test]
484 fn detect_unknown_garbage() {
485 let bytes = b"this is not an image";
486 assert_eq!(ImageInfoTool::detect_format(bytes), "unknown");
487 }
488
489 #[test]
492 fn png_dimensions() {
493 let mut bytes = vec![
495 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x03, 0x20, 0x00, 0x00, 0x02, 0x58, ];
501 bytes.extend_from_slice(&[0u8; 10]); let dims = ImageInfoTool::extract_dimensions(&bytes, "png");
503 assert_eq!(dims, Some((800, 600)));
504 }
505
506 #[test]
507 fn gif_dimensions() {
508 let bytes = [
509 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x40, 0x01, 0xF0, 0x00, ];
513 let dims = ImageInfoTool::extract_dimensions(&bytes, "gif");
514 assert_eq!(dims, Some((320, 240)));
515 }
516
517 #[test]
518 fn bmp_dimensions() {
519 let mut bytes = vec![0u8; 26];
520 bytes[0] = b'B';
521 bytes[1] = b'M';
522 bytes[18] = 0x00;
524 bytes[19] = 0x04;
525 bytes[20] = 0x00;
526 bytes[21] = 0x00;
527 bytes[22] = 0x00;
529 bytes[23] = 0x03;
530 bytes[24] = 0x00;
531 bytes[25] = 0x00;
532 let dims = ImageInfoTool::extract_dimensions(&bytes, "bmp");
533 assert_eq!(dims, Some((1024, 768)));
534 }
535
536 #[test]
537 fn jpeg_dimensions() {
538 let mut bytes: Vec<u8> = vec![
540 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, ];
544 bytes.extend_from_slice(&[0u8; 14]); bytes.extend_from_slice(&[
546 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x01, 0xE0, 0x02, 0x80, ]);
552 let dims = ImageInfoTool::extract_dimensions(&bytes, "jpeg");
553 assert_eq!(dims, Some((640, 480)));
554 }
555
556 #[test]
557 fn jpeg_malformed_zero_length_segment() {
558 let bytes: Vec<u8> = vec![
560 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x00, ];
564 let dims = ImageInfoTool::extract_dimensions(&bytes, "jpeg");
565 assert!(dims.is_none());
566 }
567
568 #[test]
569 fn unknown_format_no_dimensions() {
570 let bytes = b"random data here";
571 let dims = ImageInfoTool::extract_dimensions(bytes, "unknown");
572 assert!(dims.is_none());
573 }
574
575 #[tokio::test]
578 async fn execute_missing_path() {
579 let tool = ImageInfoTool::new(test_security());
580 let result = tool.execute(json!({})).await;
581 assert!(result.is_err());
582 }
583
584 #[tokio::test]
585 async fn execute_nonexistent_file() {
586 let tool = ImageInfoTool::new(test_security());
587 let result = tool
588 .execute(json!({"path": "/tmp/nonexistent_image_xyz.png"}))
589 .await
590 .unwrap();
591 assert!(!result.success);
592 assert!(result.error.as_ref().unwrap().contains("not found"));
593 }
594
595 #[tokio::test]
596 async fn execute_real_file() {
597 let dir = TempDir::new().unwrap();
598 let png_path = dir.path().join("test.png");
599 tokio::fs::write(&png_path, MINIMAL_PNG).await.unwrap();
600
601 let tool = ImageInfoTool::new(test_security());
602 let result = tool
603 .execute(json!({"path": png_path.to_string_lossy()}))
604 .await
605 .unwrap();
606 assert!(result.success);
607 assert!(result.output.contains("Format: png"));
608 assert!(result.output.contains("Dimensions: 1x1"));
609 assert!(!result.output.contains("data:"));
610 let canonical = tokio::fs::canonicalize(&png_path).await.unwrap();
613 assert!(
614 result
615 .output
616 .contains(&format!("[IMAGE:{}]", canonical.display())),
617 "expected absolute-path image marker, got: {}",
618 result.output
619 );
620 }
621
622 #[tokio::test]
623 async fn wrapped_blocks_external_absolute_path() {
624 let workspace = std::env::temp_dir().join("zeroclaw_image_info_wrap");
628 let _ = std::fs::create_dir_all(&workspace);
629 let tool = wrapped_tool(workspace);
630
631 #[cfg(unix)]
632 let target = "/etc/passwd";
633 #[cfg(windows)]
634 let target = {
635 let sysroot = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
636 format!(r"{sysroot}\System32\drivers\etc\hosts")
637 };
638
639 let result = tool.execute(json!({"path": target})).await.unwrap();
640 assert!(!result.success, "external path must be blocked");
641 assert!(
642 result
643 .error
644 .as_deref()
645 .unwrap_or("")
646 .contains("Path blocked"),
647 "expected 'Path blocked' error, got: {:?}",
648 result.error
649 );
650 }
651
652 #[tokio::test]
653 async fn wrapped_blocks_path_traversal() {
654 let workspace = std::env::temp_dir().join("zeroclaw_image_info_trav");
657 let _ = std::fs::create_dir_all(&workspace);
658 let tool = wrapped_tool(workspace);
659
660 let result = tool
661 .execute(json!({"path": "../../../etc/passwd"}))
662 .await
663 .unwrap();
664 assert!(!result.success, "path traversal must be blocked");
665 assert!(
666 result
667 .error
668 .as_deref()
669 .unwrap_or("")
670 .contains("Path blocked"),
671 "expected 'Path blocked' error, got: {:?}",
672 result.error
673 );
674 }
675
676 #[tokio::test]
677 async fn wrapped_normalizes_workspace_prefixed_relative_path() {
678 let root = TempDir::new().unwrap();
679 let workspace = root.path().join("zeroclaw-data").join("workspace");
680 let images_dir = workspace.join("images");
681 tokio::fs::create_dir_all(&images_dir).await.unwrap();
682
683 let png_path = images_dir.join("one.png");
684 tokio::fs::write(&png_path, MINIMAL_PNG).await.unwrap();
685
686 let workspace_prefixed = rootless_path(&workspace).join("images").join("one.png");
687 let tool = wrapped_tool(workspace);
688
689 let result = tool
690 .execute(json!({"path": workspace_prefixed.to_string_lossy()}))
691 .await
692 .unwrap();
693
694 assert!(
695 result.success,
696 "workspace-prefixed image path should resolve through security policy, error: {:?}",
697 result.error
698 );
699 assert!(result.output.contains("Format: png"));
700 let canonical = tokio::fs::canonicalize(&png_path).await.unwrap();
705 assert!(
706 result
707 .output
708 .contains(&format!("[IMAGE:{}]", canonical.display())),
709 "expected absolute-path image marker, got: {}",
710 result.output
711 );
712 assert!(
713 canonical.is_absolute(),
714 "marker path must be absolute so the multimodal pipeline can load it"
715 );
716 }
717
718 #[cfg(unix)]
719 #[tokio::test]
720 async fn wrapped_blocks_symlink_escape_after_resolution() {
721 use std::os::unix::fs::symlink;
722
723 let root = TempDir::new().unwrap();
724 let workspace = root.path().join("workspace");
725 let outside = root.path().join("outside");
726 tokio::fs::create_dir_all(&workspace).await.unwrap();
727 tokio::fs::create_dir_all(&outside).await.unwrap();
728 tokio::fs::write(outside.join("secret.png"), MINIMAL_PNG)
729 .await
730 .unwrap();
731 symlink(outside.join("secret.png"), workspace.join("link.png")).unwrap();
732
733 let tool = wrapped_tool(workspace);
734 let result = tool.execute(json!({"path": "link.png"})).await.unwrap();
735
736 assert!(!result.success, "symlink escape must be blocked");
737 let error = result.error.as_deref().unwrap_or("");
738 assert!(
739 error.contains("outside the allowed readable roots"),
740 "expected readable-roots error, got: {:?}",
741 error
742 );
743 assert!(
744 !error.contains(&outside.to_string_lossy().to_string()),
745 "policy error must not disclose resolved outside path, got: {error}"
746 );
747 }
748
749 #[tokio::test]
750 async fn wrapped_blocks_write_only_allowed_root_read() {
751 let root = TempDir::new().unwrap();
752 let workspace = root.path().join("workspace");
753 let write_only = root.path().join("write-only");
754 tokio::fs::create_dir_all(&workspace).await.unwrap();
755 tokio::fs::create_dir_all(&write_only).await.unwrap();
756 let png_path = write_only.join("one.png");
757 tokio::fs::write(&png_path, MINIMAL_PNG).await.unwrap();
758
759 let security = Arc::new(SecurityPolicy {
760 autonomy: AutonomyLevel::Full,
761 workspace_dir: workspace,
762 workspace_only: true,
763 allowed_roots_write_only: vec![write_only],
764 ..SecurityPolicy::default()
765 });
766 let tool = wrapped_tool_with_security(security);
767 let result = tool
768 .execute(json!({"path": png_path.to_string_lossy()}))
769 .await
770 .unwrap();
771
772 assert!(!result.success, "write-only root must not be readable");
773 assert!(
774 result
775 .error
776 .as_deref()
777 .unwrap_or("")
778 .contains("outside the allowed readable roots"),
779 "expected readable-roots error, got: {:?}",
780 result.error
781 );
782 }
783
784 #[tokio::test]
785 async fn missing_file_probe_consumes_action_budget() {
786 let root = TempDir::new().unwrap();
787 let security = Arc::new(SecurityPolicy {
788 autonomy: AutonomyLevel::Full,
789 workspace_dir: root.path().to_path_buf(),
790 workspace_only: true,
791 max_actions_per_hour: 1,
792 ..SecurityPolicy::default()
793 });
794 let tool = ImageInfoTool::new(security.clone());
795
796 assert!(!security.is_rate_limited());
797 let result = tool.execute(json!({"path": "missing.png"})).await.unwrap();
798
799 assert!(!result.success);
800 assert!(security.is_rate_limited());
801 }
802
803 #[tokio::test]
804 async fn emits_inline_image_marker_with_absolute_path() {
805 let dir = TempDir::new().unwrap();
809 let png_path = dir.path().join("marker.png");
810 tokio::fs::write(&png_path, MINIMAL_PNG).await.unwrap();
811
812 let tool = ImageInfoTool::new(test_security());
813 let result = tool
814 .execute(json!({"path": png_path.to_string_lossy()}))
815 .await
816 .unwrap();
817
818 assert!(result.success);
819 let canonical = tokio::fs::canonicalize(&png_path).await.unwrap();
820 assert!(
821 result
822 .output
823 .contains(&format!("[IMAGE:{}]", canonical.display())),
824 "expected absolute-path image marker, got: {}",
825 result.output
826 );
827 assert!(!result.output.contains("base64,"));
829 }
830}