From d70edf571ee1cc9874dee7d1f2196bd22578251f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 17 Dec 2025 17:11:56 +0100 Subject: [PATCH] fix(coding-agent): detect image MIME via file-type (#205) Co-authored-by: Mario Zechner --- package-lock.json | 106 +++++++++++++++++- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/package.json | 1 + .../coding-agent/src/cli/file-processor.ts | 34 ++---- packages/coding-agent/src/core/tools/read.ts | 25 +---- packages/coding-agent/src/main.ts | 8 +- packages/coding-agent/src/utils/mime.ts | 30 +++++ packages/coding-agent/test/tools.test.ts | 33 ++++++ 8 files changed, 191 insertions(+), 50 deletions(-) create mode 100644 packages/coding-agent/src/utils/mime.ts diff --git a/package-lock.json b/package-lock.json index f7c0d745..9ae69cc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -248,6 +248,16 @@ "node": ">=14.21.3" } }, + "node_modules/@borewit/text-codec": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", + "integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -2141,6 +2151,29 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3371,6 +3404,24 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/file-type": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.1.1.tgz", + "integrity": "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3729,6 +3780,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz", "integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -3772,7 +3824,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -4299,6 +4350,7 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -5342,6 +5394,22 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -5363,6 +5431,7 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -5391,7 +5460,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -5509,6 +5579,24 @@ "node": ">=8.0" } }, + "node_modules/token-types": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", + "integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.1.0", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5588,6 +5676,18 @@ "@webreflection/alien-signals": "^0.3.2" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -5605,6 +5705,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6147,6 +6248,7 @@ "@mariozechner/pi-tui": "^0.23.1", "chalk": "^5.5.0", "diff": "^8.0.2", + "file-type": "^21.1.1", "glob": "^11.0.3", "jiti": "^2.6.1" }, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e064a421..b05d53dc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Detect image MIME type via file magic (read tool and `@file` attachments), not filename extension. + ## [0.23.1] - 2025-12-17 ### Fixed diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 809e2bef..b59bf1aa 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -45,6 +45,7 @@ "@mariozechner/pi-tui": "^0.23.1", "chalk": "^5.5.0", "diff": "^8.0.2", + "file-type": "^21.1.1", "glob": "^11.0.3", "jiti": "^2.6.1" }, diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index 09c39470..9cb33b19 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -2,26 +2,12 @@ * Process @file CLI arguments into text content and image attachments */ +import { access, readFile, stat } from "node:fs/promises"; import type { Attachment } from "@mariozechner/pi-agent-core"; import chalk from "chalk"; -import { existsSync, readFileSync, statSync } from "fs"; -import { extname, resolve } from "path"; +import { resolve } from "path"; import { resolveReadPath } from "../core/tools/path-utils.js"; - -/** Map of file extensions to MIME types for common image formats */ -const IMAGE_MIME_TYPES: Record = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", -}; - -/** Check if a file is an image based on its extension, returns MIME type or null */ -function isImageFile(filePath: string): string | null { - const ext = extname(filePath).toLowerCase(); - return IMAGE_MIME_TYPES[ext] || null; -} +import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.js"; export interface ProcessedFiles { textContent: string; @@ -29,7 +15,7 @@ export interface ProcessedFiles { } /** Process @file arguments into text content and image attachments */ -export function processFileArguments(fileArgs: string[]): ProcessedFiles { +export async function processFileArguments(fileArgs: string[]): Promise { let textContent = ""; const imageAttachments: Attachment[] = []; @@ -38,23 +24,25 @@ export function processFileArguments(fileArgs: string[]): ProcessedFiles { const absolutePath = resolve(resolveReadPath(fileArg)); // Check if file exists - if (!existsSync(absolutePath)) { + try { + await access(absolutePath); + } catch { console.error(chalk.red(`Error: File not found: ${absolutePath}`)); process.exit(1); } // Check if file is empty - const stats = statSync(absolutePath); + const stats = await stat(absolutePath); if (stats.size === 0) { // Skip empty files continue; } - const mimeType = isImageFile(absolutePath); + const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath); if (mimeType) { // Handle image file - const content = readFileSync(absolutePath); + const content = await readFile(absolutePath); const base64Content = content.toString("base64"); const attachment: Attachment = { @@ -73,7 +61,7 @@ export function processFileArguments(fileArgs: string[]): ProcessedFiles { } else { // Handle text file try { - const content = readFileSync(absolutePath, "utf-8"); + const content = await readFile(absolutePath, "utf-8"); textContent += `\n${content}\n\n`; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index cf27ea25..678b4535 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -2,29 +2,11 @@ import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { constants } from "fs"; import { access, readFile } from "fs/promises"; -import { extname, resolve as resolvePath } from "path"; +import { resolve as resolvePath } from "path"; +import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; import { resolveReadPath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; -/** - * Map of file extensions to MIME types for common image formats - */ -const IMAGE_MIME_TYPES: Record = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", -}; - -/** - * Check if a file is an image based on its extension - */ -function isImageFile(filePath: string): string | null { - const ext = extname(filePath).toLowerCase(); - return IMAGE_MIME_TYPES[ext] || null; -} - const readSchema = Type.Object({ path: Type.String({ description: "Path to the file to read (relative or absolute)" }), offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })), @@ -46,7 +28,6 @@ export const readTool: AgentTool = { signal?: AbortSignal, ) => { const absolutePath = resolvePath(resolveReadPath(path)); - const mimeType = isImageFile(absolutePath); return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>( (resolve, reject) => { @@ -79,6 +60,8 @@ export const readTool: AgentTool = { return; } + const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath); + // Read the file based on type let content: (TextContent | ImageContent)[]; let details: ReadToolDetails | undefined; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 7be2f093..456e1505 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -115,15 +115,15 @@ async function runInteractiveMode( } /** Prepare initial message from @file arguments */ -function prepareInitialMessage(parsed: Args): { +async function prepareInitialMessage(parsed: Args): Promise<{ initialMessage?: string; initialAttachments?: Attachment[]; -} { +}> { if (parsed.fileArgs.length === 0) { return {}; } - const { textContent, imageAttachments } = processFileArguments(parsed.fileArgs); + const { textContent, imageAttachments } = await processFileArguments(parsed.fileArgs); // Combine file content with first plain text message (if any) let initialMessage: string; @@ -181,7 +181,7 @@ export async function main(args: string[]) { } // Process @file arguments - const { initialMessage, initialAttachments } = prepareInitialMessage(parsed); + const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed); // Determine if we're in interactive mode (needed for theme watcher) const isInteractive = !parsed.print && parsed.mode === undefined; diff --git a/packages/coding-agent/src/utils/mime.ts b/packages/coding-agent/src/utils/mime.ts new file mode 100644 index 00000000..f9ded46e --- /dev/null +++ b/packages/coding-agent/src/utils/mime.ts @@ -0,0 +1,30 @@ +import { open } from "node:fs/promises"; +import { fileTypeFromBuffer } from "file-type"; + +const IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]); + +const FILE_TYPE_SNIFF_BYTES = 4100; + +export async function detectSupportedImageMimeTypeFromFile(filePath: string): Promise { + const fileHandle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES); + const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0); + if (bytesRead === 0) { + return null; + } + + const fileType = await fileTypeFromBuffer(buffer.subarray(0, bytesRead)); + if (!fileType) { + return null; + } + + if (!IMAGE_MIME_TYPES.has(fileType.mime)) { + return null; + } + + return fileType.mime; + } finally { + await fileHandle.close(); + } +} diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 91213774..119309aa 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -153,6 +153,39 @@ describe("Coding Agent Tools", () => { expect(result.details?.truncation?.totalLines).toBe(2500); expect(result.details?.truncation?.outputLines).toBe(2000); }); + + it("should detect image MIME type from file magic (not extension)", async () => { + const png1x1Base64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2Z0AAAAASUVORK5CYII="; + const pngBuffer = Buffer.from(png1x1Base64, "base64"); + + const testFile = join(testDir, "image.txt"); + writeFileSync(testFile, pngBuffer); + + const result = await readTool.execute("test-call-img-1", { path: testFile }); + + expect(result.content[0]?.type).toBe("text"); + expect(getTextOutput(result)).toContain("Read image file [image/png]"); + + const imageBlock = result.content.find( + (c): c is { type: "image"; mimeType: string; data: string } => c.type === "image", + ); + expect(imageBlock).toBeDefined(); + expect(imageBlock?.mimeType).toBe("image/png"); + expect(typeof imageBlock?.data).toBe("string"); + expect((imageBlock?.data ?? "").length).toBeGreaterThan(0); + }); + + it("should treat files with image extension but non-image content as text", async () => { + const testFile = join(testDir, "not-an-image.png"); + writeFileSync(testFile, "definitely not a png"); + + const result = await readTool.execute("test-call-img-2", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("definitely not a png"); + expect(result.content.some((c: any) => c.type === "image")).toBe(false); + }); }); describe("write tool", () => {