fix(coding-agent): require advertised X11 clipboard images

closes #9786
This commit is contained in:
Armin Ronacher 2026-09-24 09:15:58 +02:00
parent b45597504e
commit 8676a0dcd8
3 changed files with 41 additions and 20 deletions

View file

@ -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

View file

@ -174,27 +174,20 @@ async function readClipboardImageViaXclip(): Promise<ClipboardImage | null | und
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});
let candidateTypes: string[] = [];
if (targets !== undefined) {
candidateTypes = targets
.toString("utf-8")
.split(/\r?\n/)
.map((t) => 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<ClipboardImage | null | undefined> {

View file

@ -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<string>(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<string>(5).fill("xclip")] : [],
platform === "linux" ? ["wl-paste", "xclip"] : [],
);
},
);