fix: verify terminal clipboard writes so success never lies (#5611)

Terminal copy paths (Ctrl+Shift+C, right-click, context-menu copy,
copy-on-select, and OSC 52) wrote to the clipboard via
window.api.ui.writeClipboardText but swallowed write failures and never
checked that the OS clipboard actually changed. The main-process
clipboard:writeText handler called clipboard.writeText() and returned
without reading back, so a stale OS clipboard (Electron returns without
throwing even when the write did not land, seen on Windows with Copilot
conversation text) looked like success while the clipboard was unchanged.

- clipboard-ipc-handlers.ts: after the existing trusted-sender auth and
  text-size-limit checks, write then read back the standard clipboard and
  throw on mismatch. The X11 PRIMARY "selection" clipboard is deliberately
  not verified, since ownership can pass to another app and a read-back
  would surface false failures on Linux primary-selection copies.
- terminal-selection-copy.ts: shared helper so every renderer copy path
  propagates a write rejection instead of silently claiming success, and
  only clears the xterm selection after the write resolves.
- osc52-clipboard.ts: add onWriteFailure so a TUI yank that never reaches
  the host clipboard surfaces a toast instead of a silent no-op.
- osc52-clipboard-blocked-toast.ts: add showOsc52ClipboardFailedToast.

Supersedes community PR #5634.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
Neil
2026-06-27 23:29:02 -07:00
co-authored by OrcaWin
parent b45cb545c5
commit e3f44dc2f9
16 changed files with 282 additions and 24 deletions
+42 -1
View File
@@ -208,9 +208,18 @@ describe('registerClipboardHandlers', () => {
})
it('registers normal and selection text clipboard IPC handlers', async () => {
let normalClipboard = 'standard text'
let selectionClipboard = 'selection text'
clipboardReadTextMock.mockImplementation((clipboardType?: string) =>
clipboardType === 'selection' ? 'selection text' : 'standard text'
clipboardType === 'selection' ? selectionClipboard : normalClipboard
)
clipboardWriteTextMock.mockImplementation((text: string, clipboardType?: string) => {
if (clipboardType === 'selection') {
selectionClipboard = text
} else {
normalClipboard = text
}
})
registerClipboardHandlers({} as never)
@@ -230,6 +239,35 @@ describe('registerClipboardHandlers', () => {
expect(clipboardWriteTextMock).toHaveBeenCalledWith('primary text', 'selection')
})
it('rejects standard text writes when the clipboard read-back does not match', async () => {
// Why: Electron can return without throwing while the OS clipboard keeps its
// previous contents (#5611), so the handler must surface a failure instead.
clipboardReadTextMock.mockReturnValue('old clipboard')
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
handlers.get('clipboard:writeText')?.(makeClipboardEvent(), 'copilot answer')
).rejects.toThrow('Clipboard write verification failed')
expect(clipboardWriteTextMock).toHaveBeenCalledWith('copilot answer')
})
it('does not verify the X11 PRIMARY selection clipboard read-back', async () => {
// Why: the PRIMARY selection hands ownership to the last selector, so a
// read-back can legitimately differ from what we wrote — verifying it would
// surface false failures on Linux primary-selection copies.
clipboardReadTextMock.mockReturnValue('owned by another app')
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
handlers.get('clipboard:writeSelectionText')?.(makeClipboardEvent(), 'primary text')
).resolves.toBeUndefined()
expect(clipboardWriteTextMock).toHaveBeenCalledWith('primary text', 'selection')
})
it('rejects clipboard IPC from senders outside the current main renderer', async () => {
setTrustedClipboardRendererWebContentsId(17)
registerClipboardHandlers({} as never)
@@ -473,6 +511,9 @@ describe('registerClipboardHandlers', () => {
it('yields before writing large text clipboard IPC payloads', async () => {
vi.useFakeTimers()
const text = 'é'.repeat(300_000)
// Why: the handler reads the clipboard back to verify the write landed, so
// the mock must echo what was written for the success path under test.
clipboardReadTextMock.mockReturnValue(text)
registerClipboardHandlers({} as never)
+24 -2
View File
@@ -71,6 +71,28 @@ function runCommand(command: string, args: string[], stdin?: string): Promise<vo
})
}
// Why: Electron's clipboard.writeText returns without throwing even when the OS
// clipboard keeps its previous contents (observed on Windows with Copilot
// terminal copies), so a renderer "Copied" success state can be a lie. Read the
// value back so a successful write actually means pasteable content.
function writeClipboardTextAndVerify(text: string, clipboardType?: 'selection'): void {
if (clipboardType) {
clipboard.writeText(text, clipboardType)
} else {
clipboard.writeText(text)
}
// Why: only verify the standard clipboard. The X11 PRIMARY "selection"
// clipboard hands ownership to whoever last selected text, so a read-back can
// legitimately differ from what we just wrote — verifying it would surface
// false failures on Linux primary-selection copies.
if (clipboardType === 'selection') {
return
}
if (clipboard.readText() !== text) {
throw new Error('Clipboard write verification failed')
}
}
export function registerClipboardHandlers(store: Store): void {
ipcMain.removeHandler('clipboard:readText')
ipcMain.removeHandler('clipboard:readSelectionText')
@@ -142,11 +164,11 @@ export function registerClipboardHandlers(store: Store): void {
)
ipcMain.handle('clipboard:writeText', async (event, text: string) => {
assertTrustedClipboardSender(event)
return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text))
return writeClipboardTextAndVerify(await assertClipboardTextWriteWithinLimitWithYield(text))
})
ipcMain.handle('clipboard:writeSelectionText', async (event, text: string) => {
assertTrustedClipboardSender(event)
return clipboard.writeText(
return writeClipboardTextAndVerify(
await assertClipboardTextWriteWithinLimitWithYield(text),
'selection'
)
@@ -21,6 +21,7 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { useAppStore } from '@/store'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import { copyTerminalSelection } from './terminal-selection-copy'
import {
markTerminalFollowOutput,
markTerminalPinnedViewport,
@@ -279,13 +280,15 @@ export function useTerminalKeyboardShortcuts({
if (!pane) {
return
}
const selection = pane.terminal.getSelection()
if (!selection) {
if (!pane.terminal.getSelection()) {
return
}
e.preventDefault()
e.stopImmediatePropagation()
void window.api.ui.writeClipboardText(selection).catch(() => {
void copyTerminalSelection({
terminal: pane.terminal,
writeClipboardText: window.api.ui.writeClipboardText
}).catch(() => {
/* ignore clipboard write failures */
})
return
@@ -2,8 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor'
import type * as Osc52ClipboardBlockedToastModule from './osc52-clipboard-blocked-toast'
const { toastInfoMock, storeMock } = vi.hoisted(() => ({
const { toastInfoMock, toastErrorMock, storeMock } = vi.hoisted(() => ({
toastInfoMock: vi.fn(),
toastErrorMock: vi.fn(),
storeMock: {
setSettingsSearchQuery: vi.fn(),
openSettingsTarget: vi.fn(),
@@ -13,7 +14,8 @@ const { toastInfoMock, storeMock } = vi.hoisted(() => ({
vi.mock('sonner', () => ({
toast: {
info: toastInfoMock
info: toastInfoMock,
error: toastErrorMock
}
}))
@@ -31,6 +33,7 @@ describe('showOsc52ClipboardBlockedToast', () => {
beforeEach(() => {
vi.resetModules()
toastInfoMock.mockReset()
toastErrorMock.mockReset()
storeMock.setSettingsSearchQuery.mockReset()
storeMock.openSettingsTarget.mockReset()
storeMock.openSettingsPage.mockReset()
@@ -68,3 +71,30 @@ describe('showOsc52ClipboardBlockedToast', () => {
expect(toastInfoMock).toHaveBeenCalledTimes(1)
})
})
describe('showOsc52ClipboardFailedToast', () => {
beforeEach(() => {
vi.resetModules()
toastErrorMock.mockReset()
})
it('reports that the host clipboard did not update', async () => {
const { showOsc52ClipboardFailedToast } = await importToastModule()
showOsc52ClipboardFailedToast()
expect(toastErrorMock).toHaveBeenCalledWith('Terminal clipboard write failed', {
description: 'The terminal app requested a copy, but the system clipboard did not update.',
duration: 12_000
})
})
it('only shows once per renderer session', async () => {
const { showOsc52ClipboardFailedToast } = await importToastModule()
showOsc52ClipboardFailedToast()
showOsc52ClipboardFailedToast()
expect(toastErrorMock).toHaveBeenCalledTimes(1)
})
})
@@ -4,6 +4,7 @@ import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor'
import { translate } from '@/i18n/i18n'
let hasShownOsc52ClipboardBlockedToast = false
let hasShownOsc52ClipboardFailedToast = false
export function showOsc52ClipboardBlockedToast(): void {
if (hasShownOsc52ClipboardBlockedToast) {
@@ -43,3 +44,24 @@ export function showOsc52ClipboardBlockedToast(): void {
}
)
}
export function showOsc52ClipboardFailedToast(): void {
if (hasShownOsc52ClipboardFailedToast) {
return
}
hasShownOsc52ClipboardFailedToast = true
toast.error(
translate(
'auto.components.terminal.pane.osc52.clipboard.failed.toast.62a0af2cb4',
'Terminal clipboard write failed'
),
{
description: translate(
'auto.components.terminal.pane.osc52.clipboard.failed.toast.fdd3e7e977',
'The terminal app requested a copy, but the system clipboard did not update.'
),
duration: 12_000
}
)
}
@@ -113,4 +113,18 @@ describe('handleOsc52ClipboardRequest', () => {
expect(onBlockedWrite).not.toHaveBeenCalled()
})
it('surfaces host clipboard write failures for OSC 52 requests', async () => {
const onWriteFailure = vi.fn()
handleOsc52ClipboardRequest(`c;${b64('from copilot')}`, {
allowClipboardWrite: true,
writeClipboardText: vi
.fn<(text: string) => Promise<void>>()
.mockRejectedValue(new Error('clipboard unchanged')),
onWriteFailure
})
await vi.waitFor(() => expect(onWriteFailure).toHaveBeenCalledTimes(1))
})
})
@@ -26,6 +26,7 @@ export type Osc52ClipboardRequestOptions = {
allowClipboardWrite: boolean
writeClipboardText: (text: string) => Promise<void>
onBlockedWrite?: () => void
onWriteFailure?: () => void
}
const MAX_OSC52_BYTES = 128 * 1024
@@ -45,7 +46,9 @@ export function handleOsc52ClipboardRequest(
}
void options.writeClipboardText(parsed.text).catch(() => {
/* ignore clipboard write failures */
// Why: a TUI yank (tmux/nvim/fzf/Copilot) that reports success but never
// reached the OS clipboard must surface a toast, not silently no-op.
options.onWriteFailure?.()
})
return true
}
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
import { copyTerminalSelection } from './terminal-selection-copy'
function makeTerminal(selection: string) {
return {
getSelection: vi.fn(() => selection),
clearSelection: vi.fn()
}
}
describe('copyTerminalSelection', () => {
it('writes selected terminal text to the clipboard', async () => {
const terminal = makeTerminal('copilot answer')
const writeClipboardText = vi.fn<(text: string) => Promise<void>>().mockResolvedValue()
await expect(copyTerminalSelection({ terminal, writeClipboardText })).resolves.toBe(true)
expect(writeClipboardText).toHaveBeenCalledWith('copilot answer')
expect(terminal.clearSelection).not.toHaveBeenCalled()
})
it('does not claim success for an empty xterm selection', async () => {
const terminal = makeTerminal('')
const writeClipboardText = vi.fn<(text: string) => Promise<void>>().mockResolvedValue()
await expect(copyTerminalSelection({ terminal, writeClipboardText })).resolves.toBe(false)
expect(writeClipboardText).not.toHaveBeenCalled()
expect(terminal.clearSelection).not.toHaveBeenCalled()
})
it('clears xterm selection only after the clipboard write succeeds', async () => {
const terminal = makeTerminal('copilot answer')
const writeClipboardText = vi
.fn<(text: string) => Promise<void>>()
.mockRejectedValue(new Error('clipboard unchanged'))
await expect(
copyTerminalSelection({ terminal, writeClipboardText, clearSelectionOnSuccess: true })
).rejects.toThrow('clipboard unchanged')
expect(terminal.clearSelection).not.toHaveBeenCalled()
})
it('clears the xterm selection after a successful write when requested', async () => {
const terminal = makeTerminal('copilot answer')
const writeClipboardText = vi.fn<(text: string) => Promise<void>>().mockResolvedValue()
await expect(
copyTerminalSelection({ terminal, writeClipboardText, clearSelectionOnSuccess: true })
).resolves.toBe(true)
expect(terminal.clearSelection).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,29 @@
import type { Terminal } from '@xterm/xterm'
type TerminalSelectionCopyOptions = {
terminal: Pick<Terminal, 'getSelection' | 'clearSelection'>
writeClipboardText: (text: string) => Promise<void>
clearSelectionOnSuccess?: boolean
}
// Why: route every terminal copy path through one helper so a clipboard write
// failure rejects instead of being swallowed — callers can then withhold their
// "Copied" success UI rather than lying about an unchanged clipboard (#5611).
export async function copyTerminalSelection({
terminal,
writeClipboardText,
clearSelectionOnSuccess = false
}: TerminalSelectionCopyOptions): Promise<boolean> {
const selection = terminal.getSelection()
if (!selection) {
return false
}
await writeClipboardText(selection)
// Why: only drop the xterm selection once the write resolved, so a failed
// copy leaves the text selected for the user to retry.
if (clearSelectionOnSuccess) {
terminal.clearSelection()
}
return true
}
@@ -40,6 +40,7 @@ import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import { copyTerminalHandleForPane } from './terminal-handle-copy'
import { copyTerminalSelection } from './terminal-selection-copy'
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
@@ -148,10 +149,10 @@ export function useTerminalPaneContextMenu({
if (!pane) {
return
}
const selection = pane.terminal.getSelection()
if (selection) {
await window.api.ui.writeClipboardText(selection)
}
await copyTerminalSelection({
terminal: pane.terminal,
writeClipboardText: window.api.ui.writeClipboardText
})
// Why: Radix returns focus to the menu trigger (the pane container) on
// close, but xterm.js only accepts input when its own helper textarea is
// focused. Without this, the user has to click the pane again before
@@ -453,10 +454,14 @@ export function useTerminalPaneContextMenu({
if (!clickedPane) {
return
}
const selection = clickedPane.terminal.getSelection()
if (selection) {
void window.api.ui.writeClipboardText(selection)
clickedPane.terminal.clearSelection()
if (clickedPane.terminal.getSelection()) {
void copyTerminalSelection({
terminal: clickedPane.terminal,
writeClipboardText: window.api.ui.writeClipboardText,
clearSelectionOnSuccess: true
}).catch(() => {
/* ignore clipboard write failures */
})
} else {
void pasteResolvedPane('right-click')
}
@@ -51,7 +51,10 @@ import {
mode2031SequenceFor
} from './terminal-appearance'
import { handleOsc52ClipboardRequest } from './osc52-clipboard'
import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast'
import {
showOsc52ClipboardBlockedToast,
showOsc52ClipboardFailedToast
} from './osc52-clipboard-blocked-toast'
import { parseOsc7 } from './parse-osc7'
import { resolveTerminalJisYenInput } from './terminal-jis-yen-input'
import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker'
@@ -97,6 +100,7 @@ import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { seedStartupSessionRestoredBanner } from './session-restored-banner-pane-state'
import { copyTerminalSelection } from './terminal-selection-copy'
export function recordRuntimeCreatedTerminalPaneSplit(
createdPane: unknown,
@@ -650,7 +654,8 @@ export function useTerminalPaneLifecycle({
handleOsc52ClipboardRequest(data, {
allowClipboardWrite: settingsRef.current?.terminalAllowOsc52Clipboard === true,
writeClipboardText: window.api.ui.writeClipboardText,
onBlockedWrite: showOsc52ClipboardBlockedToast
onBlockedWrite: showOsc52ClipboardBlockedToast,
onWriteFailure: showOsc52ClipboardFailedToast
})
)
osc52DisposablesRef.current.set(pane.id, osc52Disposable)
@@ -855,11 +860,10 @@ export function useTerminalPaneLifecycle({
if (!shouldWriteClipboard) {
return
}
const selection = pane.terminal.getSelection()
if (!selection) {
return
}
void window.api.ui.writeClipboardText(selection).catch(() => {
void copyTerminalSelection({
terminal: pane.terminal,
writeClipboardText: window.api.ui.writeClipboardText
}).catch(() => {
/* ignore clipboard write failures */
})
})
+6
View File
@@ -2407,6 +2407,12 @@
"7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, or fzf.",
"89eaa3e80b": "Terminal clipboard write blocked"
}
},
"failed": {
"toast": {
"62a0af2cb4": "Terminal clipboard write failed",
"fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update."
}
}
}
},
+6
View File
@@ -2407,6 +2407,12 @@
"7cf51f74fd": "Habilite las escrituras en el portapapeles TUI en la configuración de Terminal para copiar desde SSH, tmux, Neovim o fzf.",
"89eaa3e80b": "Escritura en el portapapeles del terminal bloqueada"
}
},
"failed": {
"toast": {
"62a0af2cb4": "Terminal clipboard write failed",
"fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update."
}
}
}
},
+6
View File
@@ -2407,6 +2407,12 @@
"7cf51f74fd": "SSH、tmux、Neovim、または fzf からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。",
"89eaa3e80b": "Terminal のクリップボードへの書き込みがブロックされました"
}
},
"failed": {
"toast": {
"62a0af2cb4": "Terminal clipboard write failed",
"fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update."
}
}
}
},
+6
View File
@@ -2407,6 +2407,12 @@
"7cf51f74fd": "SSH, tmux, Neovim 또는 fzf에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.",
"89eaa3e80b": "Terminal 클립보드 쓰기가 차단되었습니다."
}
},
"failed": {
"toast": {
"62a0af2cb4": "Terminal clipboard write failed",
"fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update."
}
}
}
},
+6
View File
@@ -2407,6 +2407,12 @@
"7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim 或 fzf 进行复制。",
"89eaa3e80b": "终端剪贴板写入被阻止"
}
},
"failed": {
"toast": {
"62a0af2cb4": "Terminal clipboard write failed",
"fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update."
}
}
}
},