diff --git a/.changeset/unify-image-sniff-fallback.md b/.changeset/unify-image-sniff-fallback.md new file mode 100644 index 000000000..9c25666e1 --- /dev/null +++ b/.changeset/unify-image-sniff-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Unify image format detection when sniffing fails. diff --git a/packages/agent-core/src/tools/support/file-type.ts b/packages/agent-core/src/tools/support/file-type.ts index 82edb79f5..49b6d1980 100644 --- a/packages/agent-core/src/tools/support/file-type.ts +++ b/packages/agent-core/src/tools/support/file-type.ts @@ -374,31 +374,27 @@ export function detectFileType( } return sniffed; } - // Sniff failed. In media mode, only video falls back to the extension: - // some video containers (e.g. MPEG-PS `.mpg`) have no magic we recognise, - // so the extension is the only signal. Every image format the model - // accepts (PNG/JPEG/GIF/WebP) has a reliable magic signature, so a failed - // sniff on an image means it is not a supported image — returning the - // extension MIME would only produce a mismatched data URL the model API - // rejects. (Runs before the NUL check so a video extension wins even when - // the header happens to contain a 0x00 byte.) - if (type === 'media') { - if (mediaHint?.kind === 'video') { - return mediaHint; - } - if (mediaHint?.kind === 'image') { - return { kind: 'unknown', mimeType: '' }; - } + // Sniff failed. + // An image extension without confirming magic is not an image in any mode. + // Every image format the model accepts (PNG/JPEG/GIF/WebP) has a reliable + // signature, so trusting the extension would only mislead: in media mode it + // builds a mismatched data URL the model API rejects; in text mode it + // redirects the user to ReadMediaFile for a file that is not an image. + if (mediaHint?.kind === 'image') { + return { kind: 'unknown', mimeType: '' }; + } + // In media mode, fall back to the extension for video: some containers + // (e.g. MPEG-PS `.mpg`) have no magic we recognise, so the extension is + // the only signal. Runs before the NUL check so a video extension wins + // even when the header happens to contain a 0x00 byte. + if (type === 'media' && mediaHint?.kind === 'video') { + return mediaHint; } if (buf.includes(0x00)) { return { kind: 'unknown', mimeType: '' }; } - // No sniff and no NUL (text mode): still do not trust an image extension - // hint, for the same reason as above. Anything else falls through to the + // No sniff, not an image hint, no NUL: fall through to the // hint / text / unknown logic. - if (mediaHint?.kind === 'image') { - return { kind: 'unknown', mimeType: '' }; - } } if (mediaHint) return mediaHint; diff --git a/packages/agent-core/test/tools/file-type.test.ts b/packages/agent-core/test/tools/file-type.test.ts index a25b2fd1b..c0587411a 100644 --- a/packages/agent-core/test/tools/file-type.test.ts +++ b/packages/agent-core/test/tools/file-type.test.ts @@ -214,10 +214,12 @@ describe('detectFileType', () => { it('returns unknown for an image extension whose bytes fail to sniff', () => { // A `.png` file with no recognisable image magic and no NUL byte must not - // be reported as `image/png` — otherwise a media reader would ship a - // mismatched data URL that the model API rejects as - // `application/octet-stream`. + // be reported as `image/png` in either mode. In media mode it would build + // a mismatched data URL the model API rejects as + // `application/octet-stream`; in text mode it would redirect the user to + // ReadMediaFile for a file that is not an image. const garbage = Buffer.from('plain ascii, definitely not a png'); + expect(detectFileType('fake.png', garbage, 'media').kind).toBe('unknown'); expect(detectFileType('fake.png', garbage).kind).toBe('unknown'); }); diff --git a/packages/agent-core/test/tools/read.test.ts b/packages/agent-core/test/tools/read.test.ts index 4ab97c1bb..54e1fabdd 100644 --- a/packages/agent-core/test/tools/read.test.ts +++ b/packages/agent-core/test/tools/read.test.ts @@ -384,6 +384,33 @@ describe('ReadTool', () => { expect(readText).not.toHaveBeenCalled(); }); + it('rejects an image-extension file whose bytes are not an image as not readable', async () => { + // A `.png` file with no recognisable image magic and no NUL byte is not a + // real image; it must fall through to the generic "not readable" error + // rather than being misidentified as an image and sent to ReadMediaFile. + const plainText = Buffer.from('this is plain ascii text, not a png'); + const readText = vi + .fn() + .mockRejectedValue(new Error('readText should not be called for non-image files')); + const tool = new ReadTool( + createFakeKaos({ + stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), + readBytes: vi.fn().mockResolvedValue(plainText), + readText, + }), + PERMISSIVE_WORKSPACE, + ); + + const result = await executeTool(tool, context({ path: '/tmp/fake.png' })); + const output = toolContentString(result); + + expect(result.isError).toBe(true); + expect(output).toBe( + '"/tmp/fake.png" is not readable as UTF-8 text. If it is an image or video, use ReadMediaFile. For other binary formats, use Bash or an MCP tool if available.', + ); + expect(readText).not.toHaveBeenCalled(); + }); + it('rejects extensionless image files using magic-byte sniffing', async () => { const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const readText = vi