mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-05 02:38:30 +08:00
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>
309 lines
8.5 KiB
TypeScript
309 lines
8.5 KiB
TypeScript
import fs from "fs/promises";
|
|
import path from "path";
|
|
import type { Octokits } from "../api/client";
|
|
import { GITHUB_SERVER_URL } from "../api/config";
|
|
|
|
const escapedUrl = GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const IMAGE_REGEX = new RegExp(
|
|
`!\\[[^\\]]*\\]\\((${escapedUrl}\\/user-attachments\\/assets\\/[^)]+)\\)`,
|
|
"g",
|
|
);
|
|
|
|
const HTML_IMG_REGEX = new RegExp(
|
|
`<img[^>]+src=["']([^"']*${escapedUrl}\\/user-attachments\\/assets\\/[^"']+)["'][^>]*>`,
|
|
"gi",
|
|
);
|
|
|
|
type IssueComment = {
|
|
type: "issue_comment";
|
|
id: string;
|
|
body: string;
|
|
};
|
|
|
|
type ReviewComment = {
|
|
type: "review_comment";
|
|
id: string;
|
|
body: string;
|
|
};
|
|
|
|
type ReviewBody = {
|
|
type: "review_body";
|
|
id: string;
|
|
pullNumber: string;
|
|
body: string;
|
|
};
|
|
|
|
type IssueBody = {
|
|
type: "issue_body";
|
|
issueNumber: string;
|
|
body: string;
|
|
};
|
|
|
|
type PullRequestBody = {
|
|
type: "pr_body";
|
|
pullNumber: string;
|
|
body: string;
|
|
};
|
|
|
|
export type CommentWithImages =
|
|
| IssueComment
|
|
| ReviewComment
|
|
| ReviewBody
|
|
| IssueBody
|
|
| PullRequestBody;
|
|
|
|
export async function downloadCommentImages(
|
|
octokits: Octokits,
|
|
owner: string,
|
|
repo: string,
|
|
comments: CommentWithImages[],
|
|
): Promise<Map<string, string>> {
|
|
const urlToPathMap = new Map<string, string>();
|
|
const downloadsDir = "/tmp/github-images";
|
|
|
|
await fs.mkdir(downloadsDir, { recursive: true });
|
|
|
|
const commentsWithImages: Array<{
|
|
comment: CommentWithImages;
|
|
urls: string[];
|
|
}> = [];
|
|
|
|
for (const comment of comments) {
|
|
// Extract URLs from Markdown format
|
|
const markdownMatches = [...comment.body.matchAll(IMAGE_REGEX)];
|
|
const markdownUrls = markdownMatches.map((match) => match[1] as string);
|
|
|
|
// Extract URLs from HTML format
|
|
const htmlMatches = [...comment.body.matchAll(HTML_IMG_REGEX)];
|
|
const htmlUrls = htmlMatches.map((match) => match[1] as string);
|
|
|
|
// Combine and deduplicate URLs
|
|
const urls = [...new Set([...markdownUrls, ...htmlUrls])];
|
|
|
|
if (urls.length > 0) {
|
|
commentsWithImages.push({ comment, urls });
|
|
const id =
|
|
comment.type === "issue_body"
|
|
? comment.issueNumber
|
|
: comment.type === "pr_body"
|
|
? comment.pullNumber
|
|
: comment.id;
|
|
console.log(`Found ${urls.length} image(s) in ${comment.type} ${id}`);
|
|
}
|
|
}
|
|
|
|
// Process each comment with images
|
|
for (const { comment, urls } of commentsWithImages) {
|
|
try {
|
|
let bodyHtml: string | undefined;
|
|
|
|
// Get the HTML version based on comment type
|
|
switch (comment.type) {
|
|
case "issue_comment": {
|
|
const response = await octokits.rest.issues.getComment({
|
|
owner,
|
|
repo,
|
|
comment_id: parseInt(comment.id),
|
|
mediaType: {
|
|
format: "full+json",
|
|
},
|
|
});
|
|
bodyHtml = response.data.body_html;
|
|
break;
|
|
}
|
|
case "review_comment": {
|
|
const response = await octokits.rest.pulls.getReviewComment({
|
|
owner,
|
|
repo,
|
|
comment_id: parseInt(comment.id),
|
|
mediaType: {
|
|
format: "full+json",
|
|
},
|
|
});
|
|
bodyHtml = response.data.body_html;
|
|
break;
|
|
}
|
|
case "review_body": {
|
|
const response = await octokits.rest.pulls.getReview({
|
|
owner,
|
|
repo,
|
|
pull_number: parseInt(comment.pullNumber),
|
|
review_id: parseInt(comment.id),
|
|
mediaType: {
|
|
format: "full+json",
|
|
},
|
|
});
|
|
bodyHtml = response.data.body_html;
|
|
break;
|
|
}
|
|
case "issue_body": {
|
|
const response = await octokits.rest.issues.get({
|
|
owner,
|
|
repo,
|
|
issue_number: parseInt(comment.issueNumber),
|
|
mediaType: {
|
|
format: "full+json",
|
|
},
|
|
});
|
|
bodyHtml = response.data.body_html;
|
|
break;
|
|
}
|
|
case "pr_body": {
|
|
const response = await octokits.rest.pulls.get({
|
|
owner,
|
|
repo,
|
|
pull_number: parseInt(comment.pullNumber),
|
|
mediaType: {
|
|
format: "full+json",
|
|
},
|
|
});
|
|
// Type here seems to be wrong
|
|
bodyHtml = (response.data as any).body_html;
|
|
break;
|
|
}
|
|
}
|
|
if (!bodyHtml) {
|
|
const id =
|
|
comment.type === "issue_body"
|
|
? comment.issueNumber
|
|
: comment.type === "pr_body"
|
|
? comment.pullNumber
|
|
: comment.id;
|
|
console.warn(`No HTML body found for ${comment.type} ${id}`);
|
|
continue;
|
|
}
|
|
|
|
// Extract signed URLs from HTML
|
|
const signedUrlRegex =
|
|
/https:\/\/private-user-images\.githubusercontent\.com\/[^"]+\?jwt=[^"]+/g;
|
|
const signedUrls = bodyHtml.match(signedUrlRegex) || [];
|
|
|
|
// Download each image
|
|
for (let i = 0; i < Math.min(signedUrls.length, urls.length); i++) {
|
|
const signedUrl = signedUrls[i];
|
|
const originalUrl = urls[i];
|
|
|
|
if (!signedUrl || !originalUrl) {
|
|
continue;
|
|
}
|
|
|
|
// Check if we've already downloaded this URL
|
|
if (urlToPathMap.has(originalUrl)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
console.log(`Downloading ${originalUrl}...`);
|
|
|
|
const imageResponse = await fetch(signedUrl);
|
|
if (!imageResponse.ok) {
|
|
throw new Error(
|
|
`HTTP ${imageResponse.status}: ${imageResponse.statusText}`,
|
|
);
|
|
}
|
|
|
|
const arrayBuffer = await imageResponse.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);
|
|
console.log(`✓ Saved: ${localPath}`);
|
|
|
|
urlToPathMap.set(originalUrl, localPath);
|
|
} catch (error) {
|
|
console.error(`✗ Failed to download ${originalUrl}:`, error);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const id =
|
|
comment.type === "issue_body"
|
|
? comment.issueNumber
|
|
: comment.type === "pr_body"
|
|
? comment.pullNumber
|
|
: comment.id;
|
|
console.error(
|
|
`Failed to process images for ${comment.type} ${id}:`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
return urlToPathMap;
|
|
}
|
|
|
|
function getImageExtension(url: string): string {
|
|
const urlParts = url.split("/");
|
|
const filename = urlParts[urlParts.length - 1];
|
|
if (!filename) {
|
|
throw new Error("Invalid URL: No filename found");
|
|
}
|
|
|
|
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;
|
|
}
|