fix(image-downloader): detect image type from magic bytes (#1396)

GitHub serves pasted attachments from /user-attachments/assets/<uuid>
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) <noreply@anthropic.com>
This commit is contained in:
0xyjkim 2026-06-12 12:23:35 +08:00 committed by GitHub
parent eba921ff6f
commit 8551f4b0aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 115 additions and 4 deletions

View File

@ -192,10 +192,6 @@ export async function downloadCommentImages(
continue; continue;
} }
const fileExtension = getImageExtension(originalUrl);
const filename = `image-${Date.now()}-${i}${fileExtension}`;
const localPath = path.join(downloadsDir, filename);
try { try {
console.log(`Downloading ${originalUrl}...`); console.log(`Downloading ${originalUrl}...`);
@ -209,6 +205,19 @@ export async function downloadCommentImages(
const arrayBuffer = await imageResponse.arrayBuffer(); const arrayBuffer = await imageResponse.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
// GitHub user-attachment URLs (/user-attachments/assets/<uuid>) 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); await fs.writeFile(localPath, buffer);
console.log(`✓ Saved: ${localPath}`); console.log(`✓ Saved: ${localPath}`);
@ -244,3 +253,56 @@ function getImageExtension(url: string): string {
const match = filename.match(/\.(png|jpg|jpeg|gif|webp|svg)$/i); const match = filename.match(/\.(png|jpg|jpeg|gif|webp|svg)$/i);
return match ? match[0] : ".png"; 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;
}

View File

@ -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/<uuid> (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: `<img src="${signedUrl}">`,
},
});
// 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 () => { test("should handle review comments", async () => {
const mockOctokit = createMockOctokit(); const mockOctokit = createMockOctokit();
const imageUrl = const imageUrl =