diff --git a/site/src/modules/terminal/WorkspaceTerminal.tsx b/site/src/modules/terminal/WorkspaceTerminal.tsx index 058fdd7ff7fad..a1f96888b0917 100644 --- a/site/src/modules/terminal/WorkspaceTerminal.tsx +++ b/site/src/modules/terminal/WorkspaceTerminal.tsx @@ -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) { diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx index 74f5caabc7183..4c1269c54f48f 100644 --- a/site/src/pages/TerminalPage/TerminalPage.test.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -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; + 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.