diff --git a/CHANGELOG.md b/CHANGELOG.md index d9a3585ef0d5..db700a5c7565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ Code v99.99.999 ## Unreleased +### Fixed + +- Copying from the terminal with OSC 52 (used by tmux, vim, Claude Code and + similar) now writes to the system clipboard instead of being silently + discarded. Requires a secure context (HTTPS or localhost). + ## [4.137.0](https://github.com/coder/code-server/releases/tag/v4.137.0) - 2026-09-11 Code v1.137.0 diff --git a/patches/osc52-clipboard.diff b/patches/osc52-clipboard.diff new file mode 100644 index 000000000000..5bce3a2cddd4 --- /dev/null +++ b/patches/osc52-clipboard.diff @@ -0,0 +1,43 @@ +Make terminal OSC 52 copy reach the system clipboard + +Programs running in the integrated terminal (tmux, vim, Claude Code...) copy by +emitting OSC 52. xterm's clipboard addon is always loaded and hands the payload +to IClipboardService.writeText(text, 'clipboard') (see xtermTerminal.ts), but +BrowserClipboardService files *any* typed write into an in-memory map and never +touches navigator.clipboard, so on web the copy silently does nothing. + +'clipboard' is not an alternate clipboard, it is the system one, so let it fall +through. 'selection' (OSC 52 's'/'p', the X11 primary selection) keeps the +in-memory behaviour since browsers have no equivalent. + +Only the write path is changed. readText('clipboard') deliberately keeps +returning the in-memory value, so an OSC 52 read query (ESC]52;c;? BEL) cannot +be used by a remote program to exfiltrate the user's clipboard back into the +terminal as input. + +This needs a secure context (HTTPS or localhost); without one +navigator.clipboard is undefined and the execCommand fallback cannot run, +because OSC 52 arrives asynchronously with no user gesture attached. + +To reproduce: open a terminal and run + printf '\033]52;c;%s\a' "$(printf hello | base64)" +then paste. Before this patch the clipboard is unchanged. + +Fixes the web half of https://github.com/microsoft/vscode/issues/318254 -- drop +this patch once that lands upstream. +Index: code-server/lib/vscode/src/vs/platform/clipboard/browser/clipboardService.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/platform/clipboard/browser/clipboardService.ts ++++ code-server/lib/vscode/src/vs/platform/clipboard/browser/clipboardService.ts +@@ -124,8 +124,9 @@ export class BrowserClipboardService ext + // Clear resources given we are writing text + this.clearResourcesState(); + +- // With type: only in-memory is supported +- if (type) { ++ // With type: only in-memory is supported, except for 'clipboard' which ++ // targets the system clipboard and is handled below. ++ if (type && type !== 'clipboard') { + this.mapTextToType.set(type, text); + this.logService.trace('BrowserClipboardService#writeText'); + return; diff --git a/patches/series b/patches/series index 5b548e3900dd..80a08e4e6346 100644 --- a/patches/series +++ b/patches/series @@ -25,3 +25,4 @@ signature-verification.diff copilot.diff app-name.diff csp-hashes.diff +osc52-clipboard.diff diff --git a/test/e2e/terminal.test.ts b/test/e2e/terminal.test.ts index 0de4f222a2a3..1c15b0553995 100644 --- a/test/e2e/terminal.test.ts +++ b/test/e2e/terminal.test.ts @@ -30,6 +30,29 @@ describe("Integrated Terminal", ["--disable-workspace-trust"], {}, () => { expect(stdout).toMatch(address) }) + // Programs in the terminal (tmux, vim, Claude Code...) copy by emitting OSC + // 52. VS Code hands that to the clipboard service with a type, which the + // browser implementation used to keep in memory instead of writing out. + test("should copy to the clipboard via OSC 52", async ({ codeServerPage }) => { + const origin = new URL(codeServerPage.page.url()).origin + await codeServerPage.page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin }) + + const expected = `osc52-${Date.now()}` + const encoded = Buffer.from(expected).toString("base64") + + await codeServerPage.focusTerminal() + + // The clipboard is only writable in a secure context, which the tests get + // by being served from localhost. printf turns \033 and \a into the + // escape and bell that delimit the sequence. + await codeServerPage.page.keyboard.type(`printf '\\033]52;c;${encoded}\\a'`) + await codeServerPage.page.keyboard.press("Enter") + + await expect + .poll(() => codeServerPage.page.evaluate(() => navigator.clipboard.readText()), { timeout: 20000 }) + .toBe(expected) + }) + // TODO@jsjoeio - add test to make sure full code-server path works test("should be able to invoke `code-server` to open a file", async ({ codeServerPage }) => { const tmpFolderPath = await tmpdir(testName)