Skip to content

Commit 52c809a

Browse files
authored
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
1 parent f22e398 commit 52c809a

2 files changed

Lines changed: 48 additions & 1 deletion

File tree

src/infra/clipboard.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());
4+
const isWSL2SyncMock = vi.hoisted(() => vi.fn(() => false));
45

56
vi.mock("../process/exec.js", () => ({
67
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
78
}));
89

10+
vi.mock("./wsl.js", () => ({
11+
isWSL2Sync: isWSL2SyncMock,
12+
}));
13+
914
const { copyToClipboard } = await import("./clipboard.js");
1015

1116
describe("copyToClipboard", () => {
1217
beforeEach(() => {
1318
runCommandWithTimeoutMock.mockReset();
19+
isWSL2SyncMock.mockReturnValue(false);
1420
});
1521

1622
it("returns true on the first successful clipboard command", async () => {
@@ -38,6 +44,41 @@ describe("copyToClipboard", () => {
3844
]);
3945
});
4046

47+
it("uses a startup-free WSL2 shell bridge for clip.exe without putting the value in argv", async () => {
48+
isWSL2SyncMock.mockReturnValue(true);
49+
runCommandWithTimeoutMock.mockResolvedValueOnce({ code: 0, killed: false });
50+
51+
const tokenUrl = "http://127.0.0.1:18789/#token=secret-token";
52+
await expect(copyToClipboard(tokenUrl)).resolves.toBe(true);
53+
54+
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(
55+
["/bin/sh", "-c", "exec /mnt/c/Windows/System32/clip.exe"],
56+
{
57+
timeoutMs: 3000,
58+
input: tokenUrl,
59+
},
60+
);
61+
const invokedArgv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[];
62+
expect(invokedArgv.join("\0")).not.toContain("secret-token");
63+
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
64+
});
65+
66+
it("does not prepend the WSL2 bridge outside WSL2", async () => {
67+
runCommandWithTimeoutMock
68+
.mockRejectedValueOnce(new Error("missing pbcopy"))
69+
.mockResolvedValueOnce({ code: 0, killed: true })
70+
.mockRejectedValueOnce(new Error("missing wl-copy"))
71+
.mockResolvedValueOnce({ code: 0, killed: false });
72+
73+
await expect(copyToClipboard("hello")).resolves.toBe(true);
74+
expect(runCommandWithTimeoutMock.mock.calls.map((call) => call[0])).toEqual([
75+
["pbcopy"],
76+
["xclip", "-selection", "clipboard"],
77+
["wl-copy"],
78+
["clip.exe"],
79+
]);
80+
});
81+
4182
it("returns false when every clipboard backend fails or is killed", async () => {
4283
runCommandWithTimeoutMock
4384
.mockResolvedValueOnce({ code: 0, killed: true })

src/infra/clipboard.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { runCommandWithTimeout } from "../process/exec.js";
2+
import { isWSL2Sync } from "./wsl.js";
3+
4+
// WSL interop needs a shell to launch Windows PE binaries; exec keeps the
5+
// clipboard process as the timeout-owned child while values stay on stdin.
6+
const WSL_CLIPBOARD_ARGV = ["/bin/sh", "-c", "exec /mnt/c/Windows/System32/clip.exe"];
27

38
export async function copyToClipboard(value: string): Promise<boolean> {
49
const attempts: Array<{ argv: string[] }> = [
10+
...(isWSL2Sync() ? [{ argv: WSL_CLIPBOARD_ARGV }] : []),
511
{ argv: ["pbcopy"] },
612
{ argv: ["xclip", "-selection", "clipboard"] },
713
{ argv: ["wl-copy"] },
8-
{ argv: ["clip.exe"] }, // WSL / Windows
14+
{ argv: ["clip.exe"] },
915
{ argv: ["powershell", "-NoProfile", "-Command", "Set-Clipboard"] },
1016
];
1117
for (const attempt of attempts) {

0 commit comments

Comments
 (0)