fix(coding-agent): restore OSC 52 clipboard fallback for headless sessions

3349e1db1 (#9618) limited OSC 52 to SSH/mosh so a desktop terminal that ignores
it no longer reports a false success. That also disabled the only clipboard
route for containers and WSL without WSLg, which have no display to gate on.

Emit OSC 52 again on display-less Linux. On WSL, write the Windows clipboard
through PowerShell (verified, no OSC 52 size cap), preferring OSC 52 in
Windows Terminal where it is known to work. Desktop sessions with a display
still report the failure.

closes #9688
This commit is contained in:
Armin Ronacher
2026-09-20 12:42:15 +02:00
parent ee2df312e5
commit 6dff740fab
5 changed files with 167 additions and 35 deletions
+1
View File
@@ -18,6 +18,7 @@
- Fixed `/bug` descriptions dropping line breaks from pasted diagnostics.
- Fixed `/bug` hints appearing for user cancellations and retryable provider failures such as service unavailability.
- Fixed clipboard copy failing in containers and WSL without WSLg by restoring the OSC 52 fallback when no display is available, and added a verified Windows clipboard backend for WSL ([#9688](https://github.com/earendil-works/pi/issues/9688)).
## [0.86.0] - 2026-09-19
@@ -7,6 +7,7 @@ import { join } from "path";
import { runClipboardCommand } from "./clipboard-command.ts";
import { detectSupportedImageMimeType } from "./mime.ts";
import { loadPhoton } from "./photon.ts";
import { isWSL } from "./wsl.ts";
export type ClipboardImage = {
bytes: Uint8Array;
@@ -109,19 +110,6 @@ async function readClipboardImageViaWlPaste(): Promise<ClipboardImage | null | u
return { bytes: data, mimeType: baseMimeType(selectedType) };
}
function isWSL(env: NodeJS.ProcessEnv = process.env): boolean {
if (env.WSL_DISTRO_NAME || env.WSLENV) {
return true;
}
try {
const release = readFileSync("/proc/version", "utf-8");
return /microsoft|wsl/i.test(release);
} catch {
return false;
}
}
/**
* On WSL, the Linux clipboard (Wayland/X11) does not receive image data from
* Windows screenshots (Win+Shift+S). PowerShell can access the Windows clipboard
+67 -20
View File
@@ -1,10 +1,14 @@
import { platform } from "node:os";
import { randomUUID } from "node:crypto";
import { unlinkSync, writeFileSync } from "node:fs";
import { platform, tmpdir } from "node:os";
import { join } from "node:path";
import { getNativeClipboard } from "@earendil-works/pi-tui";
import { runClipboardCommand } from "./clipboard-command.ts";
import { isWSL } from "./wsl.ts";
const MAX_OSC52_ENCODED_LENGTH = 100_000;
function isRemoteSession(env: NodeJS.ProcessEnv = process.env): boolean {
function isRemoteSession(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.MOSH_CONNECTION);
}
@@ -17,6 +21,35 @@ function emitOsc52(text: string): boolean {
return true;
}
/**
* WSL without WSLg has no Linux display, so the Windows clipboard is written through
* interop. PowerShell reads the text from a file because `clip.exe` and PowerShell stdin
* decode piped bytes with the console code page, which mangles non-ASCII UTF-8.
*/
async function copyViaWindowsClipboard(text: string): Promise<boolean> {
const tmpFile = join(tmpdir(), `pi-wsl-clip-${randomUUID()}.txt`);
try {
writeFileSync(tmpFile, text, { encoding: "utf8", mode: 0o600 });
const winPath = (await runClipboardCommand("wslpath", ["-w", tmpFile], { timeoutMs: 1000 }))
?.toString("utf8")
.trim();
if (!winPath) return false;
const script = `Set-Clipboard -Value ([System.IO.File]::ReadAllText('${winPath.replaceAll("'", "''")}', [System.Text.Encoding]::UTF8))`;
const result = await runClipboardCommand("powershell.exe", ["-NoProfile", "-Command", script], {
timeoutMs: 5000,
});
return result !== undefined;
} catch {
return false;
} finally {
try {
unlinkSync(tmpFile);
} catch {
// The file may not have been created.
}
}
}
/** Read plain text from the system clipboard. */
export async function readClipboardText(): Promise<string | null> {
if (platform() === "linux") {
@@ -40,6 +73,7 @@ export async function readClipboardText(): Promise<string | null> {
export async function copyToClipboard(text: string): Promise<void> {
const p = platform();
const env = process.env;
let copied = false;
// Direct writes precede OSC 52 so the terminal cannot race the native writer.
// Linux tools retain clipboard selection ownership after this call returns.
@@ -59,9 +93,9 @@ export async function copyToClipboard(text: string): Promise<void> {
if (p === "darwin") commands.push(["pbcopy", []]);
else if (p === "win32") commands.push(["clip", []]);
else {
if (process.env.TERMUX_VERSION) commands.push(["termux-clipboard-set", []]);
if (process.env.WAYLAND_DISPLAY) commands.push(["wl-copy", []]);
if (process.env.DISPLAY) {
if (env.TERMUX_VERSION) commands.push(["termux-clipboard-set", []]);
if (env.WAYLAND_DISPLAY) commands.push(["wl-copy", []]);
if (env.DISPLAY) {
commands.push(["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]);
}
}
@@ -72,20 +106,33 @@ export async function copyToClipboard(text: string): Promise<void> {
}
}
}
if (isRemoteSession()) copied = emitOsc52(text) || copied;
if (!copied) {
if (p === "linux") {
if (process.env.TERMUX_VERSION) {
throw new Error("Clipboard unavailable: install the Termux:API app and `termux-api` package");
}
if (process.env.WAYLAND_DISPLAY) {
throw new Error("Clipboard unavailable: install `wl-clipboard` (`wl-copy`) or check Wayland access");
}
if (process.env.DISPLAY) {
throw new Error("Clipboard unavailable: install `xclip` or `xsel`, or check X11 access");
}
throw new Error("Clipboard unavailable: no Wayland or X11 display detected");
}
throw new Error("Clipboard unavailable");
let osc52Emitted = false;
if (!copied && p === "linux" && isWSL(env)) {
// Windows Terminal supports OSC 52; prefer it over the slower PowerShell round trip.
if (env.WT_SESSION) osc52Emitted = emitOsc52(text);
copied = osc52Emitted || (await copyViaWindowsClipboard(text));
}
// OSC 52 cannot be verified, so a desktop session with a display reports the failure
// instead (#9618). Without a display the terminal is the only clipboard route (containers,
// WSL without WSLg), and remote sessions always emit it to reach the client clipboard.
const headless = p === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY && !env.TERMUX_VERSION;
let oversized = false;
if (!osc52Emitted && (isRemoteSession(env) || (!copied && headless))) {
if (emitOsc52(text)) copied = true;
else oversized = true;
}
if (copied) return;
if (oversized) throw new Error("Clipboard unavailable: text exceeds the OSC 52 size limit");
if (p === "linux") {
if (env.TERMUX_VERSION) {
throw new Error("Clipboard unavailable: install the Termux:API app and `termux-api` package");
}
if (env.WAYLAND_DISPLAY) {
throw new Error("Clipboard unavailable: install `wl-clipboard` (`wl-copy`) or check Wayland access");
}
if (env.DISPLAY) {
throw new Error("Clipboard unavailable: install `xclip` or `xsel`, or check X11 access");
}
}
throw new Error("Clipboard unavailable");
}
+15
View File
@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
/** Windows Subsystem for Linux, where Windows executables are reachable through interop. */
export function isWSL(env: NodeJS.ProcessEnv = process.env): boolean {
if (env.WSL_DISTRO_NAME || env.WSLENV) {
return true;
}
try {
const release = readFileSync("/proc/version", "utf-8");
return /microsoft|wsl/i.test(release);
} catch {
return false;
}
}
+83 -2
View File
@@ -1,3 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import type * as OsModule from "node:os";
import type { NativeClipboard } from "@earendil-works/pi-tui";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { copyToClipboard, readClipboardText } from "../src/utils/clipboard.ts";
@@ -21,7 +23,10 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("@earendil-works/pi-tui", () => ({ getNativeClipboard: mocks.getNativeClipboard }));
vi.mock("../src/utils/clipboard-command.ts", () => ({ runClipboardCommand: mocks.command }));
vi.mock("node:os", () => ({ platform: mocks.platform }));
vi.mock("node:os", async () => ({
...(await vi.importActual<typeof OsModule>("node:os")),
platform: mocks.platform,
}));
let originalWrite: typeof process.stdout.write;
let osc52Writes: string[];
@@ -34,6 +39,9 @@ beforeEach(() => {
"WAYLAND_DISPLAY",
"DISPLAY",
"TERMUX_VERSION",
"WT_SESSION",
"WSL_DISTRO_NAME",
"WSLENV",
])
vi.stubEnv(name, "");
mocks.platform.mockReturnValue("darwin");
@@ -168,6 +176,77 @@ describe("copyToClipboard", () => {
expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(["xclip", "xsel"]);
expect(osc52Writes).toHaveLength(0);
});
test("display-less Linux falls back to OSC 52", async () => {
// Regression test for #9688: containers without X11/Wayland access.
mocks.platform.mockReturnValue("linux");
await copyToClipboard("hello");
expect(mocks.command).not.toHaveBeenCalled();
expect(osc52Writes).toHaveLength(1);
});
test("WSL without a display writes the Windows clipboard through PowerShell", async () => {
// Regression test for #9688: WSL with WSLg disabled.
mocks.platform.mockReturnValue("linux");
vi.stubEnv("WSL_DISTRO_NAME", "Ubuntu");
let written: string | undefined;
mocks.command.mockImplementation(async (name, args) => {
if (name !== "wslpath") return Buffer.alloc(0);
written = readFileSync(args[1]!, "utf8");
return Buffer.from("\\\\wsl.localhost\\Ubuntu\\tmp\\clip.txt\n");
});
await copyToClipboard("héllo");
expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(["wslpath", "powershell.exe"]);
expect(written).toBe("héllo");
const [, wslpathArgs] = mocks.command.mock.calls[0]!;
expect(existsSync(wslpathArgs[1]!)).toBe(false);
const [, powershellArgs] = mocks.command.mock.calls[1]!;
expect(powershellArgs[2]).toContain("Set-Clipboard");
expect(powershellArgs[2]).toContain("'\\\\wsl.localhost\\Ubuntu\\tmp\\clip.txt'");
expect(osc52Writes).toHaveLength(0);
});
test("WSL falls back to OSC 52 when Windows interop is unavailable", async () => {
mocks.platform.mockReturnValue("linux");
vi.stubEnv("WSL_DISTRO_NAME", "Ubuntu");
mocks.command.mockResolvedValue(undefined);
await copyToClipboard("hello");
expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(["wslpath"]);
expect(osc52Writes).toHaveLength(1);
});
test("WSL in Windows Terminal prefers OSC 52 over PowerShell", async () => {
mocks.platform.mockReturnValue("linux");
vi.stubEnv("WSL_DISTRO_NAME", "Ubuntu");
vi.stubEnv("WT_SESSION", "session");
await copyToClipboard("hello");
expect(mocks.command).not.toHaveBeenCalled();
expect(osc52Writes).toHaveLength(1);
});
test("WSL in Windows Terminal emits OSC 52 once in a remote session", async () => {
mocks.platform.mockReturnValue("linux");
vi.stubEnv("WSL_DISTRO_NAME", "Ubuntu");
vi.stubEnv("WT_SESSION", "session");
vi.stubEnv("SSH_CONNECTION", "client server");
await copyToClipboard("hello");
expect(mocks.command).not.toHaveBeenCalled();
expect(osc52Writes).toHaveLength(1);
});
test("WSL in Windows Terminal uses PowerShell for oversized OSC 52 payloads", async () => {
mocks.platform.mockReturnValue("linux");
vi.stubEnv("WSL_DISTRO_NAME", "Ubuntu");
vi.stubEnv("WT_SESSION", "session");
mocks.command.mockImplementation(async (name) =>
name === "wslpath" ? Buffer.from("C:\\clip.txt") : Buffer.alloc(0),
);
await copyToClipboard("x".repeat(80_000));
expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(["wslpath", "powershell.exe"]);
expect(osc52Writes).toHaveLength(0);
});
test("WSL with a display prefers the Linux clipboard tools", async () => {
mocks.platform.mockReturnValue("linux");
vi.stubEnv("WSL_DISTRO_NAME", "Ubuntu");
vi.stubEnv("WAYLAND_DISPLAY", "wayland-0");
await copyToClipboard("hello");
expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(["wl-copy"]);
expect(osc52Writes).toHaveLength(0);
});
test("reports the Wayland clipboard tool instead of the X11 fallback", async () => {
mocks.platform.mockReturnValue("linux");
vi.stubEnv("WAYLAND_DISPLAY", "wayland-0");
@@ -189,7 +268,9 @@ describe("copyToClipboard", () => {
vi.stubEnv("SSH_CONNECTION", "client server");
mocks.clipboard.setText.mockRejectedValue(new Error("native failed"));
mocks.command.mockResolvedValue(undefined);
await expect(copyToClipboard("x".repeat(80_000))).rejects.toThrow("Clipboard unavailable");
await expect(copyToClipboard("x".repeat(80_000))).rejects.toThrow(
"Clipboard unavailable: text exceeds the OSC 52 size limit",
);
expect(osc52Writes).toHaveLength(0);
});
});