From b6ab09691d7d655b2d20714ffc7cd965620f86cd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:00:20 -0700 Subject: [PATCH 1/4] Add a failing test for duplicate pasted-image attachments Repeated presses of the paste-image shortcut on an unchanged clipboard should not add a second copy of the same screenshot, but nothing currently compares attachments by content, so identical pastes stack up unbounded. Also carries the content hash onto PendingImageAttachment so the test can express identical bytes independent of filename or id. --- src/tui-opentui/prompt-features.test.ts | 58 +++++++++++++++++++++++++ src/tui/image-attachments.ts | 13 ++++++ 2 files changed, 71 insertions(+) diff --git a/src/tui-opentui/prompt-features.test.ts b/src/tui-opentui/prompt-features.test.ts index 350059262..4f1e08d34 100644 --- a/src/tui-opentui/prompt-features.test.ts +++ b/src/tui-opentui/prompt-features.test.ts @@ -10,6 +10,7 @@ import { withTestRenderer, type Harness } from "./harness" import { acceptOverlaySelection, attachClipboardImage, + clearPendingAttachments, createAppShell, moveOverlaySelection, noticeText, @@ -27,6 +28,25 @@ const CLIP: PendingImageAttachment = { name: "clipboard.png", contentType: "image/png", data: new Uint8Array([137, 80, 78, 71]), + contentHash: "hash-a", +} + +// A second read of the same clipboard content: distinct id/name/timestamp +// (as a real re-paste would produce) but identical decoded bytes and hash. +const CLIP_SAME_CONTENT: PendingImageAttachment = { + id: "clip-2", + name: "clipboard-later.png", + contentType: "image/png", + data: new Uint8Array([137, 80, 78, 71]), + contentHash: "hash-a", +} + +const CLIP_OTHER: PendingImageAttachment = { + id: "clip-3", + name: "clipboard-other.png", + contentType: "image/png", + data: new Uint8Array([1, 2, 3, 4]), + contentHash: "hash-b", } function withShell( @@ -140,6 +160,44 @@ describe("image attachments", () => { }) } + test("re-pasting the same clipboard content does not attach a second copy", async () => { + await withShell(async (shell) => { + let calls = 0 + setPromptImageSource(shell, async () => { + calls += 1 + return { ok: true, attachment: calls === 1 ? CLIP : CLIP_SAME_CONTENT } + }) + expect(await attachClipboardImage(shell)).toBe(true) + expect(await attachClipboardImage(shell)).toBe(false) + expect(shell.pendingAttachments).toHaveLength(1) + expect(shell.pendingAttachments[0]?.id).toBe("clip-1") + expect(shell.statusFlash).toContain("already attached") + }) + }) + + test("a genuinely different image still attaches alongside the first", async () => { + await withShell(async (shell) => { + let calls = 0 + setPromptImageSource(shell, async () => { + calls += 1 + return { ok: true, attachment: calls === 1 ? CLIP : CLIP_OTHER } + }) + expect(await attachClipboardImage(shell)).toBe(true) + expect(await attachClipboardImage(shell)).toBe(true) + expect(shell.pendingAttachments).toHaveLength(2) + }) + }) + + test("removing all attachments and re-pasting the same content re-attaches it", async () => { + await withShell(async (shell) => { + setPromptImageSource(shell, async () => ({ ok: true, attachment: CLIP })) + expect(await attachClipboardImage(shell)).toBe(true) + clearPendingAttachments(shell) + expect(await attachClipboardImage(shell)).toBe(true) + expect(shell.pendingAttachments).toHaveLength(1) + }) + }) + test("submit hands pending attachments to the bridge and clears them", async () => { await withShell(async (shell) => { const seen: Array = [] diff --git a/src/tui/image-attachments.ts b/src/tui/image-attachments.ts index ea3b56036..f77ede2cc 100644 --- a/src/tui/image-attachments.ts +++ b/src/tui/image-attachments.ts @@ -27,6 +27,8 @@ const IMAGE_MIME_BY_EXT: Readonly> = { export type PendingImageAttachment = MessageAttachment & { id: string; path?: string; + /** SHA-256 of the decoded image bytes, used to dedupe repeat pastes. */ + contentHash: string; }; export type AttachImageResult = @@ -90,6 +92,10 @@ export async function imageAttachmentFromPath(path: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + /** * Downscale/recompress an image before it enters a turn. Only shells out to * `sips` (macOS) when the source exceeds `DOWNSCALE_THRESHOLD_BYTES` -- From 791d4e8fcce2f31db428f6843ba016203e8b0ecc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:00:44 -0700 Subject: [PATCH 2/4] Reject a pasted image whose content already matches an attachment The clipboard is read into a timestamped file every time, so filename gave no identity to dedupe against and an unchanged clipboard produced unlimited distinct attachments -- an operator hit eleven copies of one screenshot. Comparing the SHA-256 of the decoded bytes against the pending set before adding catches an identical re-paste while leaving genuinely different images untouched, and flashes a clear "already attached" message instead of failing silently. --- src/tui-opentui/shell.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 47efdee0d..bfe0e914b 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -932,6 +932,13 @@ export async function attachClipboardImage(shell: AppShell): Promise { setStatusFlash(shell, `image attach failed: ${result.reason}`) return false } + const isDuplicate = shell.pendingAttachments.some( + (attachment) => attachment.contentHash === result.attachment.contentHash, + ) + if (isDuplicate) { + setStatusFlash(shell, `${result.attachment.name} is already attached`) + return false + } addPendingAttachment(shell, result.attachment) setStatusFlash(shell, `attached ${result.attachment.name}`) return true From 52ca3c20b8678c77d33c7ecc9fb902633863b988 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:04:28 -0700 Subject: [PATCH 3/4] Fix up other attachment fixtures for the new content hash field PendingImageAttachment now always carries a contentHash, so every test fixture that built one by hand needs a value too, and the SHA-256 digest call needs a plain ArrayBuffer-backed view to satisfy BufferSource's type. --- src/tui-opentui/keybindings.test.ts | 1 + src/tui-opentui/live-session-port.test.ts | 1 + src/tui-opentui/prompt-attachments.test.ts | 1 + src/tui/image-attachments.ts | 2 +- src/tui/submit-handler.test.ts | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tui-opentui/keybindings.test.ts b/src/tui-opentui/keybindings.test.ts index c44967776..59ef251d0 100644 --- a/src/tui-opentui/keybindings.test.ts +++ b/src/tui-opentui/keybindings.test.ts @@ -557,6 +557,7 @@ function attachOnNextPrompt(shell: AppShell, id: string): Promise { name: `${id}.png`, contentType: "image/png", data: new Uint8Array([137, 80, 78, 71]), + contentHash: `hash-${id}`, }, } }) diff --git a/src/tui-opentui/live-session-port.test.ts b/src/tui-opentui/live-session-port.test.ts index a0aaf6074..1de856db5 100644 --- a/src/tui-opentui/live-session-port.test.ts +++ b/src/tui-opentui/live-session-port.test.ts @@ -104,6 +104,7 @@ describe("attachment passthrough", () => { name: "clipboard.png", contentType: "image/png", data: new Uint8Array([1]), + contentHash: "hash-1", } test("sendImmediate forwards attachments to the host send", () => { diff --git a/src/tui-opentui/prompt-attachments.test.ts b/src/tui-opentui/prompt-attachments.test.ts index 0c266d511..62f7360b5 100644 --- a/src/tui-opentui/prompt-attachments.test.ts +++ b/src/tui-opentui/prompt-attachments.test.ts @@ -12,6 +12,7 @@ function attachment(name: string): PendingImageAttachment { name, contentType: "image/png", data: new Uint8Array([1, 2, 3]), + contentHash: `hash-${name}`, } } diff --git a/src/tui/image-attachments.ts b/src/tui/image-attachments.ts index f77ede2cc..f9a391f15 100644 --- a/src/tui/image-attachments.ts +++ b/src/tui/image-attachments.ts @@ -112,7 +112,7 @@ export async function imageAttachmentFromPath(path: string): Promise { - const digest = await crypto.subtle.digest("SHA-256", bytes); + const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes)); return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); } diff --git a/src/tui/submit-handler.test.ts b/src/tui/submit-handler.test.ts index 38ed0951e..7ce181be9 100644 --- a/src/tui/submit-handler.test.ts +++ b/src/tui/submit-handler.test.ts @@ -101,6 +101,7 @@ describe("image attachment submits", () => { name: "clipboard.png", contentType: "image/png", data: new Uint8Array([1, 2, 3]), + contentHash: "hash-1", }; function attachmentHarness() { From 69765af058c331f1ab2aca27580fe363eff9cb14 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:17:37 -0700 Subject: [PATCH 4/4] Name the existing attachment in the duplicate-paste flash and prove the hash source The rejection flash was reporting the rejected paste's own name, which the operator never saw -- every clipboard read gets a fresh clipboard-.png, so the message pointed at a filename that didn't match the chip already in their prompt. Report the attachment already in the pending set instead. Also add a producer-level test against imageAttachmentFromPath with an oversized fixture, so the pre-cap hashing is pinned by more than the comment above it; make the hash helper module-private since nothing outside this file needs it. --- src/tui-opentui/prompt-features.test.ts | 6 ++++- src/tui-opentui/shell.ts | 6 ++--- src/tui/image-attachments.test.ts | 30 +++++++++++++++++++++++++ src/tui/image-attachments.ts | 11 +++++---- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/tui-opentui/prompt-features.test.ts b/src/tui-opentui/prompt-features.test.ts index 4f1e08d34..c8dfe3e37 100644 --- a/src/tui-opentui/prompt-features.test.ts +++ b/src/tui-opentui/prompt-features.test.ts @@ -171,7 +171,11 @@ describe("image attachments", () => { expect(await attachClipboardImage(shell)).toBe(false) expect(shell.pendingAttachments).toHaveLength(1) expect(shell.pendingAttachments[0]?.id).toBe("clip-1") - expect(shell.statusFlash).toContain("already attached") + // Names the attachment already sitting in the pending set (CLIP), not + // the rejected paste (CLIP_SAME_CONTENT) -- the operator never saw the + // rejected paste's filename, so naming it would read as a bug. + expect(shell.statusFlash).toContain(`${CLIP.name} is already attached`) + expect(shell.statusFlash).not.toContain(CLIP_SAME_CONTENT.name) }) }) diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index bfe0e914b..30077c418 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -932,11 +932,11 @@ export async function attachClipboardImage(shell: AppShell): Promise { setStatusFlash(shell, `image attach failed: ${result.reason}`) return false } - const isDuplicate = shell.pendingAttachments.some( + const duplicate = shell.pendingAttachments.find( (attachment) => attachment.contentHash === result.attachment.contentHash, ) - if (isDuplicate) { - setStatusFlash(shell, `${result.attachment.name} is already attached`) + if (duplicate !== undefined) { + setStatusFlash(shell, `${duplicate.name} is already attached`) return false } addPendingAttachment(shell, result.attachment) diff --git a/src/tui/image-attachments.test.ts b/src/tui/image-attachments.test.ts index e0c1500a8..2b7281b7f 100644 --- a/src/tui/image-attachments.test.ts +++ b/src/tui/image-attachments.test.ts @@ -1,11 +1,14 @@ import { describe, expect, test } from "bun:test"; import { deflateSync } from "node:zlib"; import { unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { extractPastedImagePaths, findImagePathMentions, imageMimeTypeForPath, capImageForIngestion, + imageAttachmentFromPath, MAX_IMAGE_DIMENSION, } from "./image-attachments.js"; @@ -150,4 +153,31 @@ describe("image attachment helpers", () => { expect(result.data).toBe(large); expect(result.contentType).toBe("image/png"); }); + + // The dedupe fix (see shell.ts attachClipboardImage) rests entirely on this: + // two ingests of identical source bytes must hash identically even though + // capImageForIngestion re-encodes oversized images through `sips`, whose + // JPEG output is not byte-stable across runs. Sizing the fixture above + // DOWNSCALE_THRESHOLD_BYTES (300 KB) exercises that re-encode path -- a + // small fixture would pass even if the hash were taken after capping. + test("hashes identical source bytes the same regardless of filename, even through the sips recompression path", async () => { + const bytes = buildTestPng(1200, 1200); + expect(bytes.byteLength).toBeGreaterThan(300 * 1024); + + const pathA = join(tmpdir(), `corbits-hash-test-a-${process.pid}-${Date.now()}.png`); + const pathB = join(tmpdir(), `corbits-hash-test-b-${process.pid}-${Date.now()}.png`); + await Bun.write(pathA, bytes); + await Bun.write(pathB, bytes); + try { + const resultA = await imageAttachmentFromPath(pathA); + const resultB = await imageAttachmentFromPath(pathB); + if (!resultA.ok || !resultB.ok) throw new Error("expected both ingests to succeed"); + + expect(resultA.attachment.contentHash).toBe(resultB.attachment.contentHash); + expect(resultA.attachment.id).not.toBe(resultB.attachment.id); + } finally { + await unlink(pathA).catch(() => undefined); + await unlink(pathB).catch(() => undefined); + } + }); }); diff --git a/src/tui/image-attachments.ts b/src/tui/image-attachments.ts index f9a391f15..bec5f28f4 100644 --- a/src/tui/image-attachments.ts +++ b/src/tui/image-attachments.ts @@ -27,7 +27,7 @@ const IMAGE_MIME_BY_EXT: Readonly> = { export type PendingImageAttachment = MessageAttachment & { id: string; path?: string; - /** SHA-256 of the decoded image bytes, used to dedupe repeat pastes. */ + /** SHA-256 of the source image file's bytes, used to dedupe repeat pastes. */ contentHash: string; }; @@ -110,9 +110,12 @@ export async function imageAttachmentFromPath(path: string): Promise { - const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes)); +/** SHA-256 of the source image file's bytes, used to identify identical pastes regardless of filename or timing. */ +async function hashImageBytes(bytes: Buffer): Promise { + // Buffer's type parameter is the looser ArrayBufferLike (it may back onto a + // pooled allocation), but readFile never actually hands back a + // SharedArrayBuffer-backed view, so this is a type-only cast, not a copy. + const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource); return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); }