feat(site): add OSC 52 clipboard support to web terminal (#26437)

Registers an OSC 52 handler on the xterm.js terminal parser so that
programs like tmux can copy text to the browser's system clipboard via
escape sequences (e.g. `printf "\\033]52;c;$(echo -n 'Coder is Cool' |
base64)\\a"`).

The handler decodes the base64 payload and writes it using the existing
`copyToClipboard` utility, which provides an HTTP fallback for insecure
contexts. Clipboard read queries (`?`) are ignored since responding
would require writing back to the PTY.

No new dependencies are needed; xterm.js 5.5.0 already exposes
`terminal.parser.registerOscHandler`.

Closes https://github.com/coder/coder/issues/16577

Generated by Coder Agents on behalf of @aqandrew.

## relevant context from agent chat, summarized by me

ref DEVEX-465

Installing
[@xterm/addon-clipboard](https://npmx.dev/package/@xterm/addon-clipboard)
would have been another way to implement this feature. This would follow
established addon-loading patterns in `WorkspaceTerminal`:


https://github.com/coder/coder/blob/612b6d4e95ac4eb1c4227dfd5979a04c2de6e600/site/src/modules/terminal/WorkspaceTerminal.tsx#L232-L241

However, registering a custom handler instead has a few advantages re:
security and flexibility:

- security
- **ignoring OSC 52 with clipboard read queries** (`payload === "?"`)
prevents processes in the workspace from reading the user's clipboard
without consent
- flexibility
- **ignoring OSC 52 with invalid base64** -- If the addon catches an
error while decoding base64, an empty string will be copied to the
clipboard, i.e., the clipboard contents get cleared, which would be
surprising/frustrating to users. The custom handler doesn't write
anything to the clipboard in this case.
- **ignoring OSC 52 with missing separator** -- The addon returns true
in this case (marks the OSC 52 as handled); the custom handler returns
false (doesn't mark the OSC 52 as handled)

The approach/tests seem sound to me. I would just ask that someone among
reviewers manually test this with tmux, since I'm not a tmux user 🙏🏽
This commit is contained in:
Andrew Aquino
2026-07-06 12:27:43 -07:00
committed by GitHub
parent 6e0bbb5fff
commit 95fde35194
2 changed files with 98 additions and 1 deletions
@@ -240,6 +240,30 @@ export const WorkspaceTerminal = ({
}),
);
// OSC 52 clipboard support. Programs like tmux send this escape
// sequence to copy text to the host's system clipboard.
nextTerminal.parser.registerOscHandler(52, (data) => {
const separatorIndex = data.indexOf(";");
if (separatorIndex === -1) {
return false;
}
const payload = data.slice(separatorIndex + 1);
// A "?" payload is a clipboard read query. Responding would
// require writing back to the PTY, which is not supported.
if (!payload || payload === "?") {
return false;
}
try {
const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0));
const decoded = new TextDecoder().decode(bytes);
void copyToClipboard(decoded);
} catch {
// Invalid base64; ignore.
return false;
}
return true;
});
const copySelection = () => {
const selection = nextTerminal.getSelection();
if (selection) {
@@ -1,7 +1,7 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HttpResponse, http } from "msw";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { API } from "#/api/api";
import type { Workspace } from "#/api/typesGenerated";
import {
@@ -249,6 +249,79 @@ describe("TerminalPage", () => {
expect(closeSpy).toHaveBeenCalled();
});
describe("OSC 52 clipboard", () => {
let writeTextMock: ReturnType<typeof vi.fn>;
let originalClipboard: PropertyDescriptor | undefined;
beforeEach(() => {
originalClipboard = Object.getOwnPropertyDescriptor(
navigator,
"clipboard",
);
writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText: writeTextMock, readText: vi.fn() },
writable: true,
configurable: true,
});
});
afterEach(() => {
if (originalClipboard) {
Object.defineProperty(navigator, "clipboard", originalClipboard);
}
});
it("copies to clipboard via OSC 52", async () => {
const ws = createWorkspaceTerminalWebSocket();
await renderTerminal();
await ws.nextMessage;
// OSC 52 sequence per #16577:
// printf "\033]52;c;$(echo -n 'Coder is Cool' | base64)\a"
const base64 = btoa("Coder is Cool");
ws.send(`\x1b]52;c;${base64}\x07`);
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith("Coder is Cool");
});
});
it("ignores OSC 52 with missing separator", async () => {
const ws = createWorkspaceTerminalWebSocket();
await renderTerminal();
await ws.nextMessage;
ws.send("\x1b]52;no-separator\x07");
// Give the parser time to process, then verify no clipboard write.
await new Promise((r) => setTimeout(r, 100));
expect(writeTextMock).not.toHaveBeenCalled();
});
it("ignores OSC 52 clipboard read query", async () => {
const ws = createWorkspaceTerminalWebSocket();
await renderTerminal();
await ws.nextMessage;
ws.send("\x1b]52;c;?\x07");
await new Promise((r) => setTimeout(r, 100));
expect(writeTextMock).not.toHaveBeenCalled();
});
it("ignores OSC 52 with invalid base64", async () => {
const ws = createWorkspaceTerminalWebSocket();
await renderTerminal();
await ws.nextMessage;
ws.send("\x1b]52;c;!!!invalid-base64!!!\x07");
await new Promise((r) => setTimeout(r, 100));
expect(writeTextMock).not.toHaveBeenCalled();
});
});
it("skips confirmation dialog for trusted app commands", async () => {
// Override the workspace response so the agent has an app with
// a command that matches the ?app= slug.