mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-03 09:48:31 +08:00
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:
parent
eba921ff6f
commit
8551f4b0aa
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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: ``,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 =
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user