paperclipai/paperclip · error · GitHubAttachmentUnavailableError

github_attachment_canonical_image_without_source_anchor

github_attachment_canonical_image_without_source_anchor

Error message

github_attachment_canonical_image_without_source_anchor

What it means

GitHubAttachmentUnavailableError with code github_attachment_canonical_image_without_source_anchor is thrown when no valid candidate anchor was found, but the HTML does contain at least one img whose src is a valid signed same-asset image. That means the signed image exists in the rendering but is not wrapped by an anchor matching the original locator URL — the expected anchor-to-image mapping is broken.

Source

Thrown at server/src/services/chat-github-attachments.ts:665

    candidates.push(target);
  }
  const sameAssetImages = [...fragment.querySelectorAll("img[src]")].filter(
    (image) =>
      image.getAttribute("src") === locator.url ||
      sameAssetImage(image.getAttribute("src")!),
  );
  if (candidates.length > 1 || sameAssetImages.length > 1)
    throw new GitHubAttachmentUnavailableError(
      "github_attachment_canonical_mapping_ambiguous",
    );
  if (candidates.length === 1 && sameAssetImages.length === 1)
    return candidates[0]!;
  if (
    [...fragment.querySelectorAll("img[src]")].some((image) =>
      signedImage(image.getAttribute("src")!),
    )
  )
    throw new GitHubAttachmentUnavailableError(
      "github_attachment_canonical_image_without_source_anchor",
    );
  throw new GitHubAttachmentUnavailableError(
    "github_attachment_canonical_anchor_missing",
  );
}

function imageSignatureMatches(body: Buffer, mime: string): boolean {
  if (mime === "image/png")
    return body
      .subarray(0, 8)
      .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
  if (mime === "image/jpeg" || mime === "image/jpg")
    return body[0] === 255 && body[1] === 216 && body[2] === 255;
  if (mime === "image/gif")
    return /^(GIF87a|GIF89a)$/.test(body.subarray(0, 6).toString("ascii"));
  if (mime === "image/webp")
    return (

View on GitHub (pinned to 01ad858492)

Solutions

  1. Restore the standard GitHub image-anchor form in the comment: anchor href = original /assets/ URL wrapping the image.
  2. Re-record the attachment locator against the comment's current body/SHA-256 after any edit.
  3. If the signed URL was pasted manually, replace it with the original github.com/user-attachments/assets/<id> URL.

Example fix

// before
<img src="https://private-user-images.../1-abc.png?jwt=...">
// after
<a href="https://github.com/user-attachments/assets/abc"><img src="https://private-user-images.../1-abc.png?jwt=..."></a>
Defensive patterns

Strategy: try-catch

Validate before calling

// markdown-level pre-check: image should appear in GitHub anchor form [![..](assets-url)](assets-url)
const anchorForm = new RegExp(`\\[!\\[[^\\]]*\\]\\(${escapedAssetUrl}\\)\\]\\(${escapedAssetUrl}\\)`);
if (!anchorForm.test(commentBody)) console.warn('image not in standard anchor form');

Try / catch

const target = resolveGitHubCommentAttachmentTarget(attachment, row);
if (target === null) {
  // bare signed img without source anchor: re-record locator against current body
  await rederiveLocator(attachment);
}

Prevention

When it happens

Trigger: The comment's rendered HTML contains a bare signed <img> (not inside an <a href> matching the locator URL), e.g. markdown that inlines the signed URL directly instead of the original /assets/ link, or the original anchor was edited away while the signed img remains.

Common situations: Users replacing the markdown link form [![img](assets-url)](assets-url) with a direct signed-URL image; GitHub caching mixing old/new render forms; comment edits removing the anchor while keeping the image.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10). Data as JSON: /api/errors/7a05668c906af7d5. Report an issue: GitHub.