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-11 21:23:35 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent eba921ff6f
commit 8551f4b0aa
2 changed files with 115 additions and 4 deletions
+49
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 () => {
const mockOctokit = createMockOctokit();
const imageUrl =