Ctrl K
ComunidadGuías…

utils/assets.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { log } from "./cli.ts";

// github image hosts that appear in issue/PR/review/comment markdown.
//  - github.com/user-attachments/assets/... and github.com/<owner>/<repo>/assets/...
//    are the raw urls present in *unrendered* bodies (the MCP tools return these);
//    fetching them needs the installation token.
//  - {private-user-images,user-images,camo}.githubusercontent.com/... are the signed
//    urls produced by body_html→turndown (see resolveBody); they self-authenticate via
//    a jwt/signature in the query string and must be fetched WITHOUT an Authorization
//    header (sending the token to the CDN would leak it).
const ASSET_HOST = String.raw`(?:github\.com\/(?:user-attachments\/assets|[^/\s]+\/[^/\s]+\/assets)\/|(?:private-user-images|user-images|camo)\.githubusercontent\.com\/)`;
const MARKDOWN_IMAGE = new RegExp(String.raw`!\[[^\]]*\]\((https:\/\/${ASSET_HOST}[^\s"')]+)`, "g");
const HTML_IMAGE = new RegExp(String.raw`<img[^>]+src=["'](https:\/\/${ASSET_HOST}[^"'\s]+)`, "g");

const ALLOWED_EXTENSIONS = new Set([
  ".png",
  ".jpg",
  ".gif",
  ".webp",
  ".svg",
  ".mp4",
  ".mov",
  ".webm",
]);

/**
 * downloads any github-hosted image/video assets referenced in `markdown` to
 * `<tmpdir>/assets` and rewrites the urls to the local file paths, so the agent can
 * read screenshots directly instead of relying on remote (often short-lived, signed)
 * urls. unique urls are downloaded once and every occurrence is rewritten. assets that
 * fail to download are left untouched.
 */
export async function downloadAssetsInMarkdown(
  markdown: string,
  tmpdir: string,
  githubToken: string
): Promise<string> {
  const urls = new Set<string>();
  for (const match of markdown.matchAll(MARKDOWN_IMAGE)) urls.add(match[1]);
  for (const match of markdown.matchAll(HTML_IMAGE)) urls.add(match[1]);

  if (urls.size === 0) return markdown;

  const assetsDir = path.join(tmpdir, "assets");
  fs.mkdirSync(assetsDir, { recursive: true });

  log.debug(`[assets] found ${urls.size} asset(s) to download`);

  let result = markdown;
  for (const url of urls) {
    const localPath = await downloadAsset(url, assetsDir, githubToken);
    if (localPath) result = result.replaceAll(url, localPath);
  }
  return result;
}

async function downloadAsset(
  url: string,
  assetsDir: string,
  githubToken: string
): Promise<string | null> {
  // only github.com itself needs the installation token; the githubusercontent CDN
  // urls carry their own signature. `redirect: "follow"` (undici default) strips the
  // Authorization header on cross-origin hops, so the token never reaches S3/the CDN.
  const needsAuth = new URL(url).hostname === "github.com";

  try {
    // unbounded, this fetch runs once per asset per comment inside
    // `get_review_comments` — a serial loop over every comment on every thread.
    // one slow CDN response wedged the whole tool past the 300s MCP client
    // deadline, and the agent got an opaque `Request timed out` for the rest of
    // the session (#1154). an undownloaded asset degrades to the original url,
    // which is a far better outcome than a dead tool.
    const res = await fetch(url, {
      headers: needsAuth ? { Authorization: `Bearer ${githubToken}` } : {},
      signal: AbortSignal.timeout(20_000),
    });
    if (!res.ok) {
      log.warning(`[assets] failed to download ${url}: ${res.status}`);
      return null;
    }

    const buffer = Buffer.from(await res.arrayBuffer());
    const ext = resolveExtension(url, res.headers.get("content-type"));
    const filename = `${crypto.createHash("sha256").update(url).digest("hex").slice(0, 16)}${ext}`;
    const localPath = path.join(assetsDir, filename);
    fs.writeFileSync(localPath, buffer);
    log.debug(`[assets] downloaded ${url} to ${localPath}`);
    return localPath;
  } catch (e) {
    log.warning(`[assets] error downloading ${url}: ${e}`);
    return null;
  }
}

/** picks a safe, whitelisted file extension from the url path or response content-type. */
function resolveExtension(url: string, contentType: string | null): string {
  const fromPath = path.extname(new URL(url).pathname).toLowerCase();
  if (ALLOWED_EXTENSIONS.has(fromPath)) return fromPath;
  if (fromPath === ".jpeg") return ".jpg";

  const ct = contentType?.toLowerCase() ?? "";
  if (ct.includes("jpeg") || ct.includes("jpg")) return ".jpg";
  if (ct.includes("gif")) return ".gif";
  if (ct.includes("webp")) return ".webp";
  if (ct.includes("svg")) return ".svg";
  if (ct.includes("mp4")) return ".mp4";
  if (ct.includes("quicktime") || ct.includes("mov")) return ".mov";
  if (ct.includes("webm")) return ".webm";
  return ".png";
}