{"record":{"id":"c8cad471951dbff5","repo":"paperclipai/paperclip","slug":"github-attachment-canonical-body-mismatch","errorCode":"github_attachment_canonical_body_mismatch","errorMessage":"github_attachment_canonical_body_mismatch","messagePattern":"github_attachment_canonical_body_mismatch","errorType":"error_code","errorClass":"GitHubAttachmentUnavailableError","httpStatus":null,"severity":"error","filePath":"server/src/services/chat-github-attachments.ts","lineNumber":568,"sourceCode":"  const thread =\n    /^github:([^:]+):(?:(issue):)?([1-9][0-9]*)(?::rc:([1-9][0-9]*))?$/i.exec(\n      locator.sourceThreadId,\n    )!;\n  if (\n    String(row.id) !== locator.sourceMessageId ||\n    typeof row.url !== \"string\" ||\n    row.url.toLowerCase() !== request.url.toLowerCase()\n  )\n    throw new GitHubAttachmentUnavailableError(\n      \"github_attachment_canonical_source_mismatch\",\n    );\n  if (\n    typeof row.body !== \"string\" ||\n    row.body.length > 200_000 ||\n    createHash(\"sha256\").update(row.body).digest(\"hex\") !==\n      locator.sourceBodySha256\n  )\n    throw new GitHubAttachmentUnavailableError(\n      \"github_attachment_canonical_body_mismatch\",\n    );\n  if (typeof row.body_html !== \"string\" || row.body_html.length > 600_000)\n    throw new GitHubAttachmentUnavailableError(\n      \"github_attachment_canonical_html_unavailable\",\n    );\n  if (thread[4]) {\n    if (\n      row.pull_request_url !==\n        `https://api.github.com/repos/${thread[1]}/pulls/${thread[3]}` ||\n      String(row.in_reply_to_id ?? row.id) !== thread[4]\n    )\n      throw new GitHubAttachmentUnavailableError(\n        \"github_attachment_canonical_source_mismatch\",\n      );\n  } else if (\n    row.issue_url !==\n    `https://api.github.com/repos/${thread[1]}/issues/${thread[3]}`","sourceCodeStart":550,"sourceCodeEnd":586,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/server/src/services/chat-github-attachments.ts#L550-L586","documentation":"resolveCanonicalAttachmentTargetOrThrow verifies that the canonical comment body is the exact text the attachment was created from: row.body must be a string of at most 200,000 characters and its SHA-256 hex digest must equal locator.sourceBodySha256. If the body is missing, oversized, or its hash differs from the pinned hash, it throws github_attachment_canonical_body_mismatch, because the HTML rendering would no longer be evidence for the unchanged source the attachment points at.","triggerScenarios":"The GitHub comment body was edited after the attachment locator recorded sourceBodySha256; row.body is absent or not a string in the API response; the comment body exceeds 200,000 characters; the body hash computation differs (e.g. body normalized/transformed between capture and verification).","commonSituations":"A user edited or reflowed the comment after the file was attached; re-resolution happens against an older/newer revision of the comment; a proxy or API version changes whitespace/encoding in body so the SHA-256 no longer matches; extremely long comments exceed the 200k cap.","solutions":["Re-attach the image/file from the current version of the comment so sourceBodySha256 is recomputed against the edited body.","Restore the comment body to its original content if the edit was unintentional (GitHub keeps edit history for comments).","If bodies are legitimately large, confirm they stay under the 200,000-character limit or adjust the limit in your deployment.","Check that no middleware rewrites the body field (e.g. HTML-escaping or whitespace normalization) between fetch and verification; compare hashes locally with `sha256sum` against the stored value."],"exampleFix":"// before: verifying against a stale hash after the comment was edited\nconst ok = sha256(row.body) === locator.sourceBodySha256; // fails silently every time\n// after: refresh the locator when the body legitimately changed\nconst fresh = await captureAttachmentLocator(attachment.url); // recompute sha256\nawait updateAttachmentLocator(attachment.id, fresh);","handlingStrategy":"validation","validationCode":"// Pre-verify the body hash before resolution\nimport { createHash } from 'node:crypto';\nconst digest = createHash('sha256').update(String(row.body ?? '')).digest('hex');\nif (typeof row.body !== 'string' || row.body.length > 200_000 || digest !== locator.sourceBodySha256)\n  throw new Error('comment body changed or exceeds limit');","typeGuard":"function bodyIntact(row: Record<string, unknown>, expectedSha256: string): boolean {\n  return typeof row.body === 'string' &&\n    row.body.length <= 200_000 &&\n    createHash('sha256').update(row.body).digest('hex') === expectedSha256;\n}","tryCatchPattern":"const target = resolveGitHubCommentAttachmentTarget(attachment, value);\nif (target === null) {\n  // body mismatch mapped to null: the source changed — require re-attachment, do not retry\n  notifyUserToReattach(attachment);\n  return null;\n}","preventionTips":["Do not edit a comment after attaching files to it, or re-attach immediately after an edit.","Store the SHA-256 of the raw body at attach time and compare before every resolution.","Keep bodies under the 200,000-character cap; split oversized content into multiple comments.","Ensure no middleware rewrites row.body (whitespace, HTML-escaping) between fetch and verification, which would break the hash for identical content."],"tags":["github","integrity","hash-mismatch","stale-data"],"backgroundTag":"checksum-mismatch","analyzedSha":"01ad8584922b5d85292b1723cae71fa0d9b07a19","analyzedAt":"2026-09-10T03:14:50.855Z","contentChangedAt":"2026-09-10T03:14:50.855Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}