From 6b61226646c5ce416ee14fd05ebcaf91ef749897 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 22 Jun 2026 16:41:36 +0800 Subject: [PATCH] fix(agent-core): reject image extensions whose bytes fail to sniff When ReadMediaFile reads a file whose bytes have no recognisable image magic (for example a `.png` that is actually plain text), detectFileType previously fell back to the extension MIME type and built a data URL whose bytes did not match the declared format, which the model API rejected. Every image format the model accepts (PNG/JPEG/GIF/WebP) has a reliable magic signature, so a failed sniff means the bytes are not a supported image. Report such files as `unknown` so ReadMediaFile returns a clear error instead of a bad data URL. The extension fallback is kept for video containers with no magic signature (for example MPEG-PS `.mpg`); format acceptance beyond that is left to the provider. --- .changeset/fix-read-media-image-format.md | 6 ++ .../agent-core/src/tools/support/file-type.ts | 28 ++++++-- .../agent-core/test/tools/file-type.test.ts | 9 +++ .../agent-core/test/tools/read-media.test.ts | 69 +++++++++++++++++++ 4 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-read-media-image-format.md diff --git a/.changeset/fix-read-media-image-format.md b/.changeset/fix-read-media-image-format.md new file mode 100644 index 0000000000..0f29bcafa1 --- /dev/null +++ b/.changeset/fix-read-media-image-format.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Detect the real image format from file contents when reading media, so a mismatched filename extension no longer produces a data URL the model API rejects. diff --git a/packages/agent-core/src/tools/support/file-type.ts b/packages/agent-core/src/tools/support/file-type.ts index 708cd96784..82edb79f5e 100644 --- a/packages/agent-core/src/tools/support/file-type.ts +++ b/packages/agent-core/src/tools/support/file-type.ts @@ -374,17 +374,31 @@ export function detectFileType( } return sniffed; } - if ( - type === 'media' && - mediaHint !== null && - mediaHint.kind !== 'text' - ) { - return mediaHint; + // 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: '' }; + } } if (buf.includes(0x00)) { return { kind: 'unknown', mimeType: '' }; } - // No sniff and no NUL: fall through to hint / text / unknown logic. + // 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 + // 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 3bdedb180c..a25b2fd1b9 100644 --- a/packages/agent-core/test/tools/file-type.test.ts +++ b/packages/agent-core/test/tools/file-type.test.ts @@ -212,6 +212,15 @@ describe('detectFileType', () => { expect(detectFileType('clip.mpg', mpegProgramStreamHeader).kind).toBe('unknown'); }); + 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`. + const garbage = Buffer.from('plain ascii, definitely not a png'); + expect(detectFileType('fake.png', garbage).kind).toBe('unknown'); + }); + it('extension in NON_TEXT_SUFFIXES → unknown', () => { // A `.zip` file with no header and no image/video hint must not // be treated as text. diff --git a/packages/agent-core/test/tools/read-media.test.ts b/packages/agent-core/test/tools/read-media.test.ts index 8e0e9a6f08..59c53fd9f0 100644 --- a/packages/agent-core/test/tools/read-media.test.ts +++ b/packages/agent-core/test/tools/read-media.test.ts @@ -582,4 +582,73 @@ describe('ReadMediaFileTool', () => { expect(relative.isError).toBe(true); expect(readBytes).not.toHaveBeenCalled(); }); + + it('uses the sniffed MIME over a mismatched image extension', async () => { + // `.png` path but JPEG bytes — the data URL must advertise `image/jpeg` + // (the real bytes), not `image/png` (the extension), otherwise the model + // API rejects it as `application/octet-stream`. + const data = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.from('jpegdata')]); + const tool = makeReadMediaTool({ + stat: vi.fn().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }), + readBytes: vi.fn().mockResolvedValue(data), + }); + + const result = await executeTool(tool, { + turnId: 't1', + toolCallId: 'c_mismatch', + args: { path: '/workspace/actually-jpeg.png' }, + signal, + }); + + const parts = outputParts(result); + expect((parts[2] as { imageUrl: { url: string } }).imageUrl.url).toBe( + `data:image/jpeg;base64,${data.toString('base64')}`, + ); + }); + + it('ships sniffed image formats to the provider without gating', async () => { + // A `.png` file that is actually a BMP is reported as `image/bmp`. The + // tool does not gate on image format — it ships the real bytes with the + // sniffed MIME, and the provider decides which formats it accepts. + const data = Buffer.concat([Buffer.from('BM'), Buffer.from('bmpdata')]); + const tool = makeReadMediaTool({ + stat: vi.fn().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }), + readBytes: vi.fn().mockResolvedValue(data), + }); + + const result = await executeTool(tool, { + turnId: 't1', + toolCallId: 'c_bmp', + args: { path: '/workspace/photo.png' }, + signal, + }); + + const parts = outputParts(result); + expect((parts[2] as { imageUrl: { url: string } }).imageUrl.url).toBe( + `data:image/bmp;base64,${data.toString('base64')}`, + ); + }); + + it('rejects a media-extension file whose bytes are not a supported image', async () => { + // `.png` path with garbage bytes (no NUL) fails to sniff; the tool must + // report "not a supported image or video file" instead of building a + // mismatched data URL. + const data = Buffer.from('this is not an image, just plain ascii text'); + const tool = makeReadMediaTool({ + stat: vi.fn().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }), + readBytes: vi.fn().mockResolvedValue(data), + }); + + const result = await executeTool(tool, { + turnId: 't1', + toolCallId: 'c_garbage', + args: { path: '/workspace/fake.png' }, + signal, + }); + + expect(result.isError).toBe(true); + expect(result.output).toBe( + '"/workspace/fake.png" is not a supported image or video file. Use Read for text files, or Bash or an MCP tool for other binary formats.', + ); + }); });