fix(mobile): copy OSC 52 terminal writes to the phone clipboard (#32042)

This commit is contained in:
Kelvin Amoaba
2026-10-22 23:02:30 +00:00
committed by GitHub
parent 1ac2c6a827
commit 5288d26a10
14 changed files with 114 additions and 6 deletions
@@ -70,6 +70,7 @@ export function MobileSessionActiveContent({
handleSelectionMode,
handleSelectionCopy,
handleSelectionEvicted,
handleClipboardWrite,
handleModesChanged,
handleKeyboardAvoidanceMetrics,
handleHaptic,
@@ -223,6 +224,7 @@ export function MobileSessionActiveContent({
onSelectionMode={handleSelectionMode}
onSelectionCopy={handleSelectionCopy}
onSelectionEvicted={handleSelectionEvicted}
onClipboardWrite={handleClipboardWrite}
onModesChanged={handleModesChanged}
onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics}
onHaptic={handleHaptic}
+3
View File
@@ -20,6 +20,7 @@ type TerminalPaneViewProps = {
onSelectionMode: (handle: string, active: boolean) => void
onSelectionCopy: (handle: string, text: string) => void
onSelectionEvicted: (handle: string) => void
onClipboardWrite: (handle: string, text: string) => void
onModesChanged: (handle: string, modes: TerminalModes) => void
onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
@@ -43,6 +44,7 @@ export function TerminalPaneView({
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onClipboardWrite,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
@@ -82,6 +84,7 @@ export function TerminalPaneView({
onSelectionMode={(a) => onSelectionMode(handle, a)}
onSelectionCopy={(t) => onSelectionCopy(handle, t)}
onSelectionEvicted={() => onSelectionEvicted(handle)}
onClipboardWrite={(t) => onClipboardWrite(handle, t)}
onModesChanged={(m) => onModesChanged(handle, m)}
onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
onHaptic={onHaptic}
@@ -144,6 +144,20 @@ export function useMobileSessionAccessorySelection(scope: MobileSessionTerminalI
[clipboard, showToast]
)
const handleClipboardWrite = useCallback(
async (handle: string, text: string) => {
if (handle !== activeHandleRef.current) {
return
}
try {
await clipboard.writeText(text)
} catch {
showToast("Couldn't copy", 1500)
}
},
[activeHandleRef, clipboard, showToast]
)
const handleSelectionEvicted = useCallback(
(handle: string) => {
if (handle !== activeHandleRef.current) {
@@ -214,6 +228,7 @@ export function useMobileSessionAccessorySelection(scope: MobileSessionTerminalI
handleSelectionMode,
handleSelectionCopy,
handleSelectionEvicted,
handleClipboardWrite,
handleModesChanged,
handleKeyboardAvoidanceMetrics,
handleHaptic,
@@ -91,6 +91,12 @@ export type TerminalDocumentTerminal = {
readonly unicode: { activeVersion: string }
attachCustomKeyEventHandler: (handler: () => boolean) => void
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
readonly parser?: {
registerOscHandler: (
ident: number,
callback: (data: string) => boolean
) => TerminalDocumentDisposable
}
readonly textarea?: {
readOnly: boolean
tabIndex: number
@@ -316,7 +316,8 @@ describe('the bundled native document', () => {
// both shared with the app. 52: `cell-metrics` and `selection-mode` split out to break cycles.
// 53: the bottom row backdrop extends a uniform row into the fractional gap.
// 54: `src/shared/prose-url-boundary`, so a tapped url ends where desktop's link does.
expect(inputs).toHaveLength(54)
// 56: OSC 52 copies reach the device through desktop's own parser, `src/shared/osc52-clipboard`.
expect(inputs).toHaveLength(56)
expect(script).not.toContain('__commonJS')
// `__esm` wrappers are esbuild's answer to a cycle, and a cycle would make a module's top level
// run at first import rather than where the bundle places it.
@@ -0,0 +1,46 @@
// @vitest-environment happy-dom
import { Terminal } from '@xterm/xterm'
import { afterEach, describe, expect, it } from 'vitest'
import { createTerminalDocumentScope, type TerminalDocumentScope } from './document-scope'
import { attachOsc52ClipboardBridge } from './osc52-clipboard-bridge'
import { resetTerminalDataReplyAuthority, resumeTerminalDataReplyAuthority } from './query-reply'
const terminals: Terminal[] = []
afterEach(() => {
terminals.splice(0).forEach((term) => term.dispose())
})
function fixture() {
const posted: unknown[] = []
const scope = createTerminalDocumentScope({ postToHost: (message) => posted.push(message) })
const term = new Terminal({ cols: 80, rows: 4, allowProposedApi: true })
terminals.push(term)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real xterm exposes the narrower document terminal API.
attachOsc52ClipboardBridge(scope, term as unknown as NonNullable<TerminalDocumentScope['term']>)
const write = async (data: string) => {
await new Promise<void>((resolve) => term.write(data, resolve))
// The handler coalesces a parse's copies into one post on the next microtask.
await Promise.resolve()
}
return { scope, posted, write }
}
// What Claude Code 2.1.308 writes when `c` is pressed in its /btw panel.
const BTW_COPY = `\x1b]52;c;${Buffer.from('hello from btw').toString('base64')}\x07`
describe('OSC 52 in the mobile terminal document', () => {
it('posts a live copy to native as clipboard text', async () => {
const { scope, posted, write } = fixture()
resumeTerminalDataReplyAuthority(scope)
await write(BTW_COPY)
expect(posted).toEqual([{ type: 'clipboard-write', text: 'hello from btw' }])
})
it('drops a copy parsed before the replay boundary', async () => {
const { scope, posted, write } = fixture()
resetTerminalDataReplyAuthority(scope)
await write(BTW_COPY)
expect(posted).toEqual([])
})
})
@@ -0,0 +1,23 @@
import { createOsc52OscHandler } from '../../../../src/shared/osc52-clipboard'
import { notify } from './host-notify'
import type { TerminalDocumentScope, TerminalDocumentTerminal } from './document-scope'
/**
* OSC 52 copies (Claude Code's /btw `c`, tmux, Neovim over SSH) land on the device viewing the
* terminal, through the parser and limits desktop uses.
*/
export function attachOsc52ClipboardBridge(
scope: TerminalDocumentScope,
term: TerminalDocumentTerminal
) {
term.parser?.registerOscHandler(
52,
createOsc52OscHandler({
// Why: the toggle belongs to the viewing device, and the phone has none yet; desktop's default is on.
getSettingEnabled: () => true,
// Why: parser replies earn authority only past the replay boundary; a copy needs the same fresh intent.
getReplaying: () => !scope.terminalDataRepliesEnabled,
writeClipboardText: async (text) => notify(scope, { type: 'clipboard-write', text })
})
)
}
@@ -16,6 +16,7 @@ import {
updateMouseModeFromData
} from './mouse-mode-decset-scan'
import { captureInitialOscLinkTexts } from './osc-link-tap'
import { attachOsc52ClipboardBridge } from './osc52-clipboard-bridge'
import { attachTerminalQueryReplyBridge, resetTerminalDataReplyAuthority } from './query-reply'
import { cancelSelect } from './selection-mode'
import { resetEvictionCounter } from './selection-state-and-eviction'
@@ -50,6 +51,7 @@ function openTerminal(scope: TerminalDocumentScope, cols: number, rows: number)
allowProposedApi: true
})
scope.term = term
attachOsc52ClipboardBridge(scope, term)
term.open(scope.surface!)
attachWebglAddon(scope, true)
try {
@@ -72,6 +72,8 @@ export type TerminalSelectionEvents = {
onSelectionMode?: (active: boolean) => void
onSelectionCopy?: (text: string) => void
onSelectionEvicted?: () => void
// A program in the terminal copied text through OSC 52.
onClipboardWrite?: (text: string) => void
onModesChanged?: (modes: TerminalModes) => void
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
@@ -29,6 +29,10 @@ export function dispatchTerminalWebViewNotification(
} else if (msg.type === 'selection') {
const text = typeof msg.text === 'string' ? msg.text : ''
handlers.onSelectionCopy?.(text)
} else if (msg.type === 'clipboard-write') {
if (typeof msg.text === 'string') {
handlers.onClipboardWrite?.(msg.text)
}
} else if (msg.type === 'selection-evicted') {
handlers.onSelectionEvicted?.()
} else if (msg.type === 'modes') {
@@ -59,6 +59,7 @@ export function useTerminalWebViewController(
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onClipboardWrite,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
@@ -189,6 +190,7 @@ export function useTerminalWebViewController(
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onClipboardWrite,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
@@ -207,6 +209,7 @@ export function useTerminalWebViewController(
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onClipboardWrite,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
@@ -2,7 +2,7 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
import type { PaneSpawnHints } from '@/lib/pane-manager/pane-manager-types'
import { useAppStore } from '@/store'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { createOsc52OscHandler } from './osc52-clipboard'
import { createOsc52OscHandler } from '../../../../shared/osc52-clipboard'
import {
showOsc52ClipboardBlockedToast,
showOsc52ClipboardFailedToast
@@ -1,6 +1,6 @@
// OSC 52 — "Manipulate Selection Data". xterm.js does not implement this
// handler itself; applications register it to let TUIs (Zellij, tmux, Neovim,
// fzf, Grok) copy to the host clipboard over SSH or through the PTY.
// fzf, Grok) copy to the viewing device's clipboard over SSH or through the PTY.
//
// Wire format (xterm.js strips the leading `\x1b]52;` and trailing BEL/ST
// before handing us the payload string):
@@ -19,8 +19,9 @@
//
// Safety: OSC 52 is a classic clipboard-overwrite vector — piping an
// attacker-controlled log into the terminal could silently replace the user's
// clipboard. Callers gate on `terminalAllowOsc52Clipboard` (default on; query
// stays blocked, so nothing is exfiltrated; payload size is capped).
// clipboard. Desktop gates on `terminalAllowOsc52Clipboard`; mobile has no toggle
// yet and follows its default (on). Query stays blocked, so nothing is
// exfiltrated; payload size is capped.
//
// Accepted residual risk of default-on: the decoded text is written verbatim,
// newlines and all, so a hostile PTY can stage an execute-on-paste payload.
@@ -88,7 +89,7 @@ export function createOsc52OscHandler(deps: {
getSettingEnabled: () => boolean | null | undefined
getReplaying: () => boolean
writeClipboardText: (text: string) => Promise<void>
showBlockedWriteToast: () => void
showBlockedWriteToast?: () => void
showWriteFailedToast?: () => void
}): (data: string) => boolean {
// Why coalesce: each sequence is only ~15 bytes, so one hostile chunk can fire a