From 52c809a759f3a60989cb121a3a9bd0ebc513da71 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 1 Jun 2026 02:22:08 +0100 Subject: [PATCH] fix(infra): bridge WSL clipboard through shell * fix(infra): bridge WSL2 clipboard through shell * test(infra): assert wsl clipboard argv stays token-free * fix(infra): keep wsl clipboard timeout ownership --- src/infra/clipboard.test.ts | 41 +++++++++++++++++++++++++++++++++++++ src/infra/clipboard.ts | 8 +++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/infra/clipboard.test.ts b/src/infra/clipboard.test.ts index c511d430c3b..ab5f544a33c 100644 --- a/src/infra/clipboard.test.ts +++ b/src/infra/clipboard.test.ts @@ -1,16 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); +const isWSL2SyncMock = vi.hoisted(() => vi.fn(() => false)); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); +vi.mock("./wsl.js", () => ({ + isWSL2Sync: isWSL2SyncMock, +})); + const { copyToClipboard } = await import("./clipboard.js"); describe("copyToClipboard", () => { beforeEach(() => { runCommandWithTimeoutMock.mockReset(); + isWSL2SyncMock.mockReturnValue(false); }); it("returns true on the first successful clipboard command", async () => { @@ -38,6 +44,41 @@ describe("copyToClipboard", () => { ]); }); + it("uses a startup-free WSL2 shell bridge for clip.exe without putting the value in argv", async () => { + isWSL2SyncMock.mockReturnValue(true); + runCommandWithTimeoutMock.mockResolvedValueOnce({ code: 0, killed: false }); + + const tokenUrl = "http://127.0.0.1:18789/#token=secret-token"; + await expect(copyToClipboard(tokenUrl)).resolves.toBe(true); + + expect(runCommandWithTimeoutMock).toHaveBeenCalledWith( + ["/bin/sh", "-c", "exec /mnt/c/Windows/System32/clip.exe"], + { + timeoutMs: 3000, + input: tokenUrl, + }, + ); + const invokedArgv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[]; + expect(invokedArgv.join("\0")).not.toContain("secret-token"); + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); + }); + + it("does not prepend the WSL2 bridge outside WSL2", async () => { + runCommandWithTimeoutMock + .mockRejectedValueOnce(new Error("missing pbcopy")) + .mockResolvedValueOnce({ code: 0, killed: true }) + .mockRejectedValueOnce(new Error("missing wl-copy")) + .mockResolvedValueOnce({ code: 0, killed: false }); + + await expect(copyToClipboard("hello")).resolves.toBe(true); + expect(runCommandWithTimeoutMock.mock.calls.map((call) => call[0])).toEqual([ + ["pbcopy"], + ["xclip", "-selection", "clipboard"], + ["wl-copy"], + ["clip.exe"], + ]); + }); + it("returns false when every clipboard backend fails or is killed", async () => { runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, killed: true }) diff --git a/src/infra/clipboard.ts b/src/infra/clipboard.ts index c7daebf22d9..46ed96bff17 100644 --- a/src/infra/clipboard.ts +++ b/src/infra/clipboard.ts @@ -1,11 +1,17 @@ import { runCommandWithTimeout } from "../process/exec.js"; +import { isWSL2Sync } from "./wsl.js"; + +// WSL interop needs a shell to launch Windows PE binaries; exec keeps the +// clipboard process as the timeout-owned child while values stay on stdin. +const WSL_CLIPBOARD_ARGV = ["/bin/sh", "-c", "exec /mnt/c/Windows/System32/clip.exe"]; export async function copyToClipboard(value: string): Promise { const attempts: Array<{ argv: string[] }> = [ + ...(isWSL2Sync() ? [{ argv: WSL_CLIPBOARD_ARGV }] : []), { argv: ["pbcopy"] }, { argv: ["xclip", "-selection", "clipboard"] }, { argv: ["wl-copy"] }, - { argv: ["clip.exe"] }, // WSL / Windows + { argv: ["clip.exe"] }, { argv: ["powershell", "-NoProfile", "-Command", "Set-Clipboard"] }, ]; for (const attempt of attempts) {