on main help sign in
fix(tools/view): detect image mime type; don't rely on extension (#2757)
ac79e5bChristian Rocha
internal/agent/tools/view.go+29
@@ -7,6 +7,7 @@ import (
7 "fmt"
7 "fmt"
8 "io"
8 "io"
9 "io/fs"
9 "io/fs"
10+ "net/http"
10 "os"
11 "os"
11 "path/filepath"
12 "path/filepath"
12 "strings"
13 "strings"
@@ -188,6 +189,14 @@ func NewViewTool(
188 return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", readErr)
189 return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", readErr)
189 }
190 }
190
191
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+
191 return fantasy.NewImageResponse(imageData, mimeType), nil
200 return fantasy.NewImageResponse(imageData, mimeType), nil
192 }
201 }
193
202
@@ -313,6 +322,26 @@ func getImageMimeType(filePath string) (bool, string) {
313 }
322 }
314}
323}
315
324
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+
316type LineScanner struct {
345type LineScanner struct {
317 scanner *bufio.Scanner
346 scanner *bufio.Scanner
318}
347}
internal/agent/tools/view_test.go+32
@@ -137,3 +137,35 @@ func TestReadBuiltinFile(t *testing.T) {
137 require.NotContains(t, resp.Content, " 1|")
137 require.NotContains(t, resp.Content, " 1|")
138 })
138 })
139}
139}
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+}