mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): copy OSC 52 terminal writes to the phone clipboard (#32042)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-4
@@ -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
|
||||
Reference in New Issue
Block a user