| import fs from "node:fs/promises"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import { describe, expect, it } from "vitest"; |
| import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js"; |
| import { createUnsafeMountedSandbox } from "../../test-helpers/unsafe-mounted-sandbox.js"; |
| import { |
| detectAndLoadPromptImages, |
| detectImageReferences, |
| loadImageFromRef, |
| modelSupportsImages, |
| } from "./images.js"; |
|
|
| describe("detectImageReferences", () => { |
| it("detects absolute file paths with common extensions", () => { |
| const prompt = "Check this image /path/to/screenshot.png and tell me what you see"; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| expect(refs[0]).toEqual({ |
| raw: "/path/to/screenshot.png", |
| type: "path", |
| resolved: "/path/to/screenshot.png", |
| }); |
| }); |
|
|
| it("detects relative paths starting with ./", () => { |
| const prompt = "Look at ./images/photo.jpg"; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| expect(refs[0]?.raw).toBe("./images/photo.jpg"); |
| expect(refs[0]?.type).toBe("path"); |
| }); |
|
|
| it("detects relative paths starting with ../", () => { |
| const prompt = "The file is at ../screenshots/test.jpeg"; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| expect(refs[0]?.raw).toBe("../screenshots/test.jpeg"); |
| expect(refs[0]?.type).toBe("path"); |
| }); |
|
|
| it("detects home directory paths starting with ~/", () => { |
| const prompt = "My photo is at ~/Pictures/vacation.png"; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| expect(refs[0]?.raw).toBe("~/Pictures/vacation.png"); |
| expect(refs[0]?.type).toBe("path"); |
| |
| expect(refs[0]?.resolved?.startsWith("~")).toBe(false); |
| }); |
|
|
| it("detects multiple image references in a prompt", () => { |
| const prompt = ` |
| Compare these two images: |
| 1. /home/user/photo1.png |
| 2. https://mysite.com/photo2.jpg |
| `; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| expect(refs.some((r) => r.type === "path")).toBe(true); |
| }); |
|
|
| it("handles various image extensions", () => { |
| const extensions = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "heic"]; |
| for (const ext of extensions) { |
| const prompt = `Image: /test/image.${ext}`; |
| const refs = detectImageReferences(prompt); |
| expect(refs.length).toBeGreaterThanOrEqual(1); |
| expect(refs[0]?.raw).toContain(`.${ext}`); |
| } |
| }); |
|
|
| it("deduplicates repeated image references", () => { |
| const prompt = "Look at /path/image.png and also /path/image.png again"; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| }); |
|
|
| it("dedupe casing follows host filesystem conventions", () => { |
| const prompt = "Look at /tmp/Image.png and /tmp/image.png"; |
| const refs = detectImageReferences(prompt); |
|
|
| if (process.platform === "win32") { |
| expect(refs).toHaveLength(1); |
| return; |
| } |
| expect(refs).toHaveLength(2); |
| }); |
|
|
| it("returns empty array when no images found", () => { |
| const prompt = "Just some text without any image references"; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(0); |
| }); |
|
|
| it("ignores non-image file extensions", () => { |
| const prompt = "Check /path/to/document.pdf and /code/file.ts"; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(0); |
| }); |
|
|
| it("handles paths inside quotes (without spaces)", () => { |
| const prompt = 'The file is at "/path/to/image.png"'; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| expect(refs[0]?.raw).toBe("/path/to/image.png"); |
| }); |
|
|
| it("handles paths in parentheses", () => { |
| const prompt = "See the image (./screenshot.png) for details"; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| expect(refs[0]?.raw).toBe("./screenshot.png"); |
| }); |
|
|
| it("detects [Image: source: ...] format from messaging systems", () => { |
| const prompt = `What does this image show? |
| [Image: source: /Users/tyleryust/Library/Messages/Attachments/IMG_0043.jpeg]`; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| expect(refs[0]?.raw).toBe("/Users/tyleryust/Library/Messages/Attachments/IMG_0043.jpeg"); |
| expect(refs[0]?.type).toBe("path"); |
| }); |
|
|
| it("handles complex message attachment paths", () => { |
| const prompt = `[Image: source: /Users/tyleryust/Library/Messages/Attachments/23/03/AA4726EA-DB27-4269-BA56-1436936CC134/5E3E286A-F585-4E5E-9043-5BC2AFAFD81BIMG_0043.jpeg]`; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| expect(refs[0]?.resolved).toContain("IMG_0043.jpeg"); |
| }); |
|
|
| it("detects multiple images in [media attached: ...] format", () => { |
| |
| const prompt = `[media attached: 2 files] |
| [media attached 1/2: /Users/tyleryust/.openclaw/media/IMG_6430.jpeg (image/jpeg)] |
| [media attached 2/2: /Users/tyleryust/.openclaw/media/IMG_6431.jpeg (image/jpeg)] |
| what about these images?`; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(2); |
| expect(refs[0]?.resolved).toContain("IMG_6430.jpeg"); |
| expect(refs[1]?.resolved).toContain("IMG_6431.jpeg"); |
| }); |
|
|
| it("does not double-count path and url in same bracket", () => { |
| |
| const prompt = `[media attached: /cache/IMG_6430.jpeg (image/jpeg) | /cache/IMG_6430.jpeg]`; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| expect(refs[0]?.resolved).toContain("IMG_6430.jpeg"); |
| }); |
|
|
| it("ignores remote URLs entirely (local-only)", () => { |
| const prompt = `To send an image: MEDIA:https://example.com/image.jpg |
| Here is my actual image: /path/to/real.png |
| Also https://cdn.mysite.com/img.jpg`; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| expect(refs[0]?.raw).toBe("/path/to/real.png"); |
| }); |
|
|
| it("handles single file format with URL (no index)", () => { |
| const prompt = `[media attached: /cache/photo.jpeg (image/jpeg) | https://example.com/url] |
| what is this?`; |
| const refs = detectImageReferences(prompt); |
|
|
| expect(refs).toHaveLength(1); |
| expect(refs[0]?.resolved).toContain("photo.jpeg"); |
| }); |
|
|
| it("handles paths with spaces in filename", () => { |
| |
| const prompt = `[media attached: /Users/test/.openclaw/media/ChatGPT Image Apr 21, 2025.png (image/png) | https://example.com/same.png] |
| what is this?`; |
| const refs = detectImageReferences(prompt); |
|
|
| |
| expect(refs).toHaveLength(1); |
| expect(refs[0]?.resolved).toContain("ChatGPT Image Apr 21, 2025.png"); |
| }); |
| }); |
|
|
| describe("modelSupportsImages", () => { |
| it("returns true when model input includes image", () => { |
| const model = { input: ["text", "image"] }; |
| expect(modelSupportsImages(model)).toBe(true); |
| }); |
|
|
| it("returns false when model input does not include image", () => { |
| const model = { input: ["text"] }; |
| expect(modelSupportsImages(model)).toBe(false); |
| }); |
|
|
| it("returns false when model input is undefined", () => { |
| const model = {}; |
| expect(modelSupportsImages(model)).toBe(false); |
| }); |
|
|
| it("returns false when model input is empty", () => { |
| const model = { input: [] }; |
| expect(modelSupportsImages(model)).toBe(false); |
| }); |
| }); |
|
|
| describe("loadImageFromRef", () => { |
| it("allows sandbox-validated host paths outside default media roots", async () => { |
| const homeDir = os.homedir(); |
| await fs.mkdir(homeDir, { recursive: true }); |
| const sandboxParent = await fs.mkdtemp(path.join(homeDir, "openclaw-sandbox-image-")); |
| try { |
| const sandboxRoot = path.join(sandboxParent, "sandbox"); |
| await fs.mkdir(sandboxRoot, { recursive: true }); |
| const imagePath = path.join(sandboxRoot, "photo.png"); |
| const pngB64 = |
| "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; |
| await fs.writeFile(imagePath, Buffer.from(pngB64, "base64")); |
|
|
| const image = await loadImageFromRef( |
| { |
| raw: "./photo.png", |
| type: "path", |
| resolved: "./photo.png", |
| }, |
| sandboxRoot, |
| { |
| sandbox: { |
| root: sandboxRoot, |
| bridge: createHostSandboxFsBridge(sandboxRoot), |
| }, |
| }, |
| ); |
|
|
| expect(image).not.toBeNull(); |
| expect(image?.type).toBe("image"); |
| expect(image?.data.length).toBeGreaterThan(0); |
| } finally { |
| await fs.rm(sandboxParent, { recursive: true, force: true }); |
| } |
| }); |
| }); |
|
|
| describe("detectAndLoadPromptImages", () => { |
| it("returns no images for non-vision models even when existing images are provided", async () => { |
| const result = await detectAndLoadPromptImages({ |
| prompt: "ignore", |
| workspaceDir: "/tmp", |
| model: { input: ["text"] }, |
| existingImages: [{ type: "image", data: "abc", mimeType: "image/png" }], |
| }); |
|
|
| expect(result.images).toHaveLength(0); |
| expect(result.detectedRefs).toHaveLength(0); |
| }); |
|
|
| it("returns no detected refs when prompt has no image references", async () => { |
| const result = await detectAndLoadPromptImages({ |
| prompt: "no images here", |
| workspaceDir: "/tmp", |
| model: { input: ["text", "image"] }, |
| }); |
|
|
| expect(result.detectedRefs).toHaveLength(0); |
| expect(result.images).toHaveLength(0); |
| }); |
|
|
| it("blocks prompt image refs outside workspace when sandbox workspaceOnly is enabled", async () => { |
| const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-image-sandbox-")); |
| const sandboxRoot = path.join(stateDir, "sandbox"); |
| const agentRoot = path.join(stateDir, "agent"); |
| await fs.mkdir(sandboxRoot, { recursive: true }); |
| await fs.mkdir(agentRoot, { recursive: true }); |
| const pngB64 = |
| "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; |
| await fs.writeFile(path.join(agentRoot, "secret.png"), Buffer.from(pngB64, "base64")); |
| const sandbox = createUnsafeMountedSandbox({ sandboxRoot, agentRoot }); |
| const bridge = sandbox.fsBridge; |
| if (!bridge) { |
| throw new Error("sandbox fs bridge missing"); |
| } |
|
|
| try { |
| const result = await detectAndLoadPromptImages({ |
| prompt: "Inspect /agent/secret.png", |
| workspaceDir: sandboxRoot, |
| model: { input: ["text", "image"] }, |
| workspaceOnly: true, |
| sandbox: { root: sandbox.workspaceDir, bridge }, |
| }); |
|
|
| expect(result.detectedRefs).toHaveLength(1); |
| expect(result.loadedCount).toBe(0); |
| expect(result.skippedCount).toBe(1); |
| expect(result.images).toHaveLength(0); |
| } finally { |
| await fs.rm(stateDir, { recursive: true, force: true }); |
| } |
| }); |
| }); |
|
|