From 8551f4b0aa500ecf8d089328f7e4855ec4f88b97 Mon Sep 17 00:00:00 2001 From: 0xyjkim Date: Fri, 12 Jun 2026 12:23:35 +0800 Subject: [PATCH] fix(image-downloader): detect image type from magic bytes (#1396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub serves pasted attachments from /user-attachments/assets/ with no file extension, so getImageExtension() silently defaulted to ".png". When the bytes are actually JPEG/GIF/WebP the downloaded file is mislabeled, and the Read tool then sends a base64 image whose declared media_type doesn't match its magic bytes — which the Anthropic API rejects with `400 invalid_request_error` ("image was specified using the image/png media type, but the image appears to be a image/jpeg image"). Sniff the real format from the buffer's magic bytes after download and only fall back to the URL-based extension when the signature is unrecognized. Adds a regression test for a JPEG at an extensionless URL. Co-authored-by: Claude Opus 4.8 (1M context) --- src/github/utils/image-downloader.ts | 70 ++++++++++++++++++++++++++-- test/image-downloader.test.ts | 49 +++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/github/utils/image-downloader.ts b/src/github/utils/image-downloader.ts index 1e819fff..4cfa11c4 100644 --- a/src/github/utils/image-downloader.ts +++ b/src/github/utils/image-downloader.ts @@ -192,10 +192,6 @@ export async function downloadCommentImages( continue; } - const fileExtension = getImageExtension(originalUrl); - const filename = `image-${Date.now()}-${i}${fileExtension}`; - const localPath = path.join(downloadsDir, filename); - try { console.log(`Downloading ${originalUrl}...`); @@ -209,6 +205,19 @@ export async function downloadCommentImages( const arrayBuffer = await imageResponse.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); + // GitHub user-attachment URLs (/user-attachments/assets/) carry + // no file extension, so the URL-based guess silently falls back to + // ".png". When the bytes are actually JPEG/GIF/WebP, the saved file is + // mislabeled and the Read tool sends a base64 image with the wrong + // media_type, which the Anthropic API rejects (400 invalid_request). + // Detect the real type from the magic bytes and only fall back to the + // URL extension when the signature is unrecognized. + const fileExtension = + detectImageExtensionFromBuffer(buffer) ?? + getImageExtension(originalUrl); + const filename = `image-${Date.now()}-${i}${fileExtension}`; + const localPath = path.join(downloadsDir, filename); + await fs.writeFile(localPath, buffer); console.log(`✓ Saved: ${localPath}`); @@ -244,3 +253,56 @@ function getImageExtension(url: string): string { const match = filename.match(/\.(png|jpg|jpeg|gif|webp|svg)$/i); return match ? match[0] : ".png"; } + +/** + * Determine an image's file extension from its magic bytes, independent of the + * (often extensionless) source URL. Returns undefined when the signature is not + * a format we can confidently identify, so the caller can fall back to the + * URL-based extension. Covers the raster formats the Anthropic API accepts. + */ +function detectImageExtensionFromBuffer(buffer: Buffer): string | undefined { + // PNG: 89 50 4E 47 0D 0A 1A 0A + if ( + buffer.length >= 8 && + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 + ) { + return ".png"; + } + // JPEG: FF D8 FF + if ( + buffer.length >= 3 && + buffer[0] === 0xff && + buffer[1] === 0xd8 && + buffer[2] === 0xff + ) { + return ".jpg"; + } + // GIF: "GIF8" (47 49 46 38) + if ( + buffer.length >= 6 && + buffer[0] === 0x47 && + buffer[1] === 0x49 && + buffer[2] === 0x46 && + buffer[3] === 0x38 + ) { + return ".gif"; + } + // WebP: "RIFF" (52 49 46 46) .... "WEBP" (57 45 42 50) at offset 8 + if ( + buffer.length >= 12 && + buffer[0] === 0x52 && + buffer[1] === 0x49 && + buffer[2] === 0x46 && + buffer[3] === 0x46 && + buffer[8] === 0x57 && + buffer[9] === 0x45 && + buffer[10] === 0x42 && + buffer[11] === 0x50 + ) { + return ".webp"; + } + return undefined; +} diff --git a/test/image-downloader.test.ts b/test/image-downloader.test.ts index e00b6d05..50f9c200 100644 --- a/test/image-downloader.test.ts +++ b/test/image-downloader.test.ts @@ -158,6 +158,55 @@ describe("downloadCommentImages", () => { ); }); + test("should save a JPEG from an extensionless URL with a .jpg extension", async () => { + // Regression for the case where a JPEG screenshot is pasted into an issue. + // GitHub serves it from /user-attachments/assets/ (no extension), so + // the URL-based guess used to default to ".png" while the bytes are JPEG — + // producing a mislabeled file that the Anthropic API rejected with a 400. + const mockOctokit = createMockOctokit(); + const imageUrl = + "https://github.com/user-attachments/assets/f871c23e-a84d-4f1f-b9a0-86626c63f161"; + const signedUrl = + "https://private-user-images.githubusercontent.com/screenshot?jwt=token"; + + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.get = jest.fn().mockResolvedValue({ + data: { + body_html: ``, + }, + }); + + // JPEG magic bytes: FF D8 FF, then arbitrary padding. + const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + fetchSpy = spyOn(global, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => jpegBytes.buffer, + } as Response); + + const comments: CommentWithImages[] = [ + { + type: "issue_body", + issueNumber: "143", + body: `![Screenshot_20260607_204205_Chrome.jpg](${imageUrl})`, + }, + ]; + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + comments, + ); + + expect(fsWriteFileSpy).toHaveBeenCalledWith( + "/tmp/github-images/image-1704067200000-0.jpg", + Buffer.from(jpegBytes.buffer), + ); + expect(result.get(imageUrl)).toBe( + "/tmp/github-images/image-1704067200000-0.jpg", + ); + }); + test("should handle review comments", async () => { const mockOctokit = createMockOctokit(); const imageUrl =