mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user