on main help sign in
fix(tools/view): detect image mime type; don't rely on extension (#2757)
commitac79e5bb0343722d4403607ff6bc6072e6e61081 authorChristian Rocha <christian@rocha.is> dateSat 9 May 2026, 20:23 (5 months ago) parent8f2fd3d
2 files changed+61
internal/agent/tools/view.go+29 internal/agent/tools/view_test.go+32
internal/agent/tools/view.go+29
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "io"
99 "io/fs"
10+ "net/http"
1011 "os"
1112 "path/filepath"
1213 "strings"
@@ -188,6 +189,14 @@ func NewViewTool(
188189 return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", readErr)
189190 }
190191
192+ // Some tools save files with a mismatched extension
193+ // (e.g. pinchtab writes JPEG bytes to a .png file).
194+ // Providers like Anthropic strictly validate the
195+ // media type against the base64 magic bytes and 400
196+ // on mismatch, so prefer the sniffed type whenever
197+ // it identifies a supported image format.
198+ mimeType = sniffImageMimeType(imageData, mimeType)
199+
191200 return fantasy.NewImageResponse(imageData, mimeType), nil
192201 }
193202
@@ -313,6 +322,26 @@ func getImageMimeType(filePath string) (bool, string) {
313322 }
314323}
315324
325+// sniffImageMimeType returns the content-sniffed MIME type when it identifies
326+// a supported image format. Otherwise it returns the provided fallback, which
327+// is usually the extension-derived type. Providers that validate the image
328+// media type against the base64 magic bytes (e.g. Anthropic) reject mismatched
329+// requests with a 400, so trusting the filename alone is unsafe.
330+func sniffImageMimeType(data []byte, fallback string) string {
331+ sniffed := http.DetectContentType(data)
332+ // http.DetectContentType may return the MIME with a ";" parameter
333+ // (e.g. "image/svg+xml; charset=utf-8") although current image sniffers
334+ // return bare types; strip defensively.
335+ if i := strings.IndexByte(sniffed, ';'); i >= 0 {
336+ sniffed = strings.TrimSpace(sniffed[:i])
337+ }
338+ switch sniffed {
339+ case "image/jpeg", "image/png", "image/gif", "image/webp":
340+ return sniffed
341+ }
342+ return fallback
343+}
344+
316345type LineScanner struct {
317346 scanner *bufio.Scanner
318347}
internal/agent/tools/view_test.go+32
@@ -137,3 +137,35 @@ func TestReadBuiltinFile(t *testing.T) {
137137 require.NotContains(t, resp.Content, " 1|")
138138 })
139139}
140+
141+func TestSniffImageMimeType(t *testing.T) {
142+ t.Parallel()
143+
144+ jpegMagic := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
145+ pngMagic := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
146+ gifMagic := []byte("GIF89a")
147+ // Minimal RIFF/WEBP header.
148+ webpMagic := append([]byte("RIFF\x00\x00\x00\x00WEBPVP8 "), make([]byte, 16)...)
149+ random := []byte("not an image at all, just text")
150+
151+ cases := []struct {
152+ name string
153+ data []byte
154+ fallback string
155+ want string
156+ }{
157+ {"jpeg bytes in .png file uses sniffed", jpegMagic, "image/png", "image/jpeg"},
158+ {"png bytes in .jpg file uses sniffed", pngMagic, "image/jpeg", "image/png"},
159+ {"gif bytes uses sniffed", gifMagic, "image/png", "image/gif"},
160+ {"webp bytes uses sniffed", webpMagic, "image/png", "image/webp"},
161+ {"matching extension and content keeps sniffed", pngMagic, "image/png", "image/png"},
162+ {"unsniffable content falls back", random, "image/png", "image/png"},
163+ {"empty content falls back", nil, "image/jpeg", "image/jpeg"},
164+ }
165+ for _, tc := range cases {
166+ t.Run(tc.name, func(t *testing.T) {
167+ t.Parallel()
168+ require.Equal(t, tc.want, sniffImageMimeType(tc.data, tc.fallback))
169+ })
170+ }
171+}