From 8676a0dcd8f9f6bca78835e63c8cd31493c4154d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 24 Sep 2026 09:15:58 +0200 Subject: [PATCH] fix(coding-agent): require advertised X11 clipboard images closes #9786 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/utils/clipboard-image.ts | 29 +++++++---------- .../coding-agent/test/clipboard-image.test.ts | 31 +++++++++++++++++-- 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8856920fe..1a965f3b1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed +- Fixed X11 clipboard text being misidentified as an image when the clipboard owner accepts unadvertised image targets ([#9786](https://github.com/earendil-works/pi/issues/9786)). - Prevented managed git packages from automatically installing Pi peer dependencies and added warnings for extension packages that list host-provided modules in `dependencies` ([#9863](https://github.com/earendil-works/pi/issues/9863)). ## [0.87.1] - 2026-09-22 diff --git a/packages/coding-agent/src/utils/clipboard-image.ts b/packages/coding-agent/src/utils/clipboard-image.ts index 086f1fadf..f6e7ecd54 100644 --- a/packages/coding-agent/src/utils/clipboard-image.ts +++ b/packages/coding-agent/src/utils/clipboard-image.ts @@ -174,27 +174,20 @@ async function readClipboardImageViaXclip(): Promise t.trim()) - .filter(Boolean); - } + if (targets === undefined) return undefined; + const candidateTypes = targets + .toString("utf-8") + .split(/\r?\n/) + .map((t) => t.trim()) + .filter(Boolean); const preferred = selectPreferredImageMimeType(candidateTypes); - if (targets !== undefined && !preferred) return null; - const tryTypes = new Set(preferred ? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES] : SUPPORTED_IMAGE_MIME_TYPES); + if (!preferred) return null; - for (const mimeType of tryTypes) { - const data = await runClipboardCommand("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]); - if (data !== undefined && data.length > 0) { - return { bytes: data, mimeType: baseMimeType(mimeType) }; - } - } - - return undefined; + const data = await runClipboardCommand("xclip", ["-selection", "clipboard", "-t", preferred, "-o"]); + if (data === undefined) return undefined; + if (data.length === 0) return null; + return { bytes: data, mimeType: baseMimeType(preferred) }; } async function readClipboardImageViaNativeClipboard(): Promise { diff --git a/packages/coding-agent/test/clipboard-image.test.ts b/packages/coding-agent/test/clipboard-image.test.ts index e7aacc4fb..89319142d 100644 --- a/packages/coding-agent/test/clipboard-image.test.ts +++ b/packages/coding-agent/test/clipboard-image.test.ts @@ -44,6 +44,33 @@ describe("readClipboardImage", () => { }); } + test("X11 does not probe image types when TARGETS fails", async () => { + // Regression test for #9786. + mocks.getImage.mockResolvedValue(null); + mocks.command.mockImplementation(async (_command, args) => + args.includes("TARGETS") ? undefined : Buffer.from("hello"), + ); + expect(await readClipboardImage({ platform: "linux", env: { DISPLAY: ":0" } })).toBeNull(); + expect(mocks.command).toHaveBeenCalledExactlyOnceWith( + "xclip", + ["-selection", "clipboard", "-t", "TARGETS", "-o"], + { timeoutMs: 1000 }, + ); + expect(mocks.getImage).toHaveBeenCalledOnce(); + }); + + test("X11 does not probe unadvertised image types", async () => { + mocks.getImage.mockResolvedValue(null); + mocks.command.mockImplementation(async (_command, args) => { + if (args.includes("TARGETS")) return Buffer.from("image/png\n"); + if (args.includes("image/png")) return undefined; + return Buffer.from("hello"); + }); + expect(await readClipboardImage({ platform: "linux", env: { DISPLAY: ":0" } })).toBeNull(); + expect(mocks.command.mock.calls.map(([, args]) => args[3])).toEqual(["TARGETS", "image/png"]); + expect(mocks.getImage).toHaveBeenCalledOnce(); + }); + test.each([png, null, new Uint8Array()])("native X11 result %j stops fallback", async (bytes) => { mocks.getImage.mockResolvedValue(bytes); expect(await readClipboardImage({ platform: "linux", env: { DISPLAY: ":0" } })).toEqual( @@ -51,7 +78,7 @@ describe("readClipboardImage", () => { ); expect(mocks.getNativeClipboard).toHaveBeenCalledExactlyOnceWith(); expect(mocks.getImage).toHaveBeenCalledOnce(); - expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(Array(5).fill("xclip")); + expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(["xclip"]); }); test.each(["missing module", "unavailable display"])("Wayland: falls back to X11 after %s", async (failure) => { if (failure === "missing module") mocks.getNativeClipboard.mockReturnValue(undefined); @@ -121,7 +148,7 @@ describe("readClipboardImage", () => { error, ); expect(mocks.command.mock.calls.map(([name]) => name)).toEqual( - platform === "linux" ? ["wl-paste", ...Array(5).fill("xclip")] : [], + platform === "linux" ? ["wl-paste", "xclip"] : [], ); }, );