paperclipai/paperclip · error · GitHubAttachmentUnavailableError

github_attachment_canonical_target_denied

github_attachment_canonical_target_denied

Error message

github_attachment_canonical_target_denied

What it means

GitHubAttachmentUnavailableError with code github_attachment_canonical_target_denied is thrown when the candidate signed image URL extracted from a matching anchor is rejected: either the img src does not resolve to a valid signed private-user-images URL (single 'jwt' query param), or the anchor's href form fails the consistency check (href must equal the locator URL, or be the same signed URL as the img src). This prevents choosing an unverifiable or mixed rendering target.

Source

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

      href !== locator.url &&
      !sameAssetImage(href) &&
      ![...images].some((image) => sameAssetImage(image.getAttribute("src")!))
    )
      continue;
    if (images.length !== 1)
      throw new GitHubAttachmentUnavailableError(
        "github_attachment_canonical_image_count_invalid",
      );
    const src = images[0]!.getAttribute("src")!;
    const target = signedImage(src);
    // The second form was observed in the exact App-rendered live comment.
    // Both the original-anchor and signed-anchor forms enter one candidate set
    // so duplicated or mixed renderings cannot silently choose a target.
    if (
      !target ||
      (href !== locator.url && (href !== src || !signedImage(href)))
    )
      throw new GitHubAttachmentUnavailableError(
        "github_attachment_canonical_target_denied",
      );
    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")!),

View on GitHub (pinned to 01ad858492)

Solutions

  1. Re-fetch the comment so body, body_html, and signed jwt URLs are mutually consistent (a fresh render pairs anchors with current jwts).
  2. Ensure the anchor href is either the original locator URL or byte-identical to the signed img src.
  3. Remove any proxy rewriting of private-user-images URLs (extra params break the single-jwt contract).
  4. Verify the jwt in the signed URL has the standard header.payload.signature three-segment shape.

Example fix

// before (proxy added params)
https://private-user-images.../1-abc.png?jwt=eyJ...&x-id=GetObject
// after (unproxied GitHub render)
https://private-user-images.../1-abc.png?jwt=eyJ...
Defensive patterns

Strategy: try-catch

Validate before calling

const isCleanSignedUrl = (u: string) => {
  try { const p = new URL(u); return p.hostname === 'private-user-images.githubusercontent.com' && [...p.searchParams.keys()].join(',') === 'jwt'; } catch { return false; }
};
if (!isCleanSignedUrl(imgSrc)) console.warn('signed URL altered; resolution will be denied');

Type guard

const hasSingleJwt = (u: string): boolean => {
  try { const url = new URL(u); return /^[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+$/i.test(url.searchParams.get('jwt') ?? ''); } catch { return false; }
};

Try / catch

const target = resolveGitHubCommentAttachmentTarget(attachment, row);
if (target === null) {
  // denied: refetch the comment to get a consistent render, then retry once
  const freshRow = await refetchComment(row.url);
  retryResolve(attachment, freshRow);
}

Prevention

When it happens

Trigger: A matching anchor exists but its img src is an unsigned /assets/ URL, a signed URL with extra/missing query parameters, or a jwt that fails the three-segment format check; or the anchor href is a signed URL different from the img src and not the original locator URL.

Common situations: GitHub rendering with expired/refreshed jwt query params mixed with cached HTML; comments whose markdown hot-links the signed URL directly with altered params; proxy/CDN rewrites injecting extra query parameters; fixtures using fake signed URLs.

Understand the failure class

Background: "Invalid URL" / "URL cannot be empty": fix the malformed or missing URL behind request-construction failures — this error's family across 50 libraries.

Related errors


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