11// Copyright 2025, Command Line Inc.
22// SPDX-License-Identifier: Apache-2.0
33
4+ import type { BlockNodeModel } from "@/app/block/blocktypes" ;
45import { getFileSubject } from "@/app/store/wps" ;
56import { sendWSCommand } from "@/app/store/ws" ;
67import { RpcApi } from "@/app/store/wshclientapi" ;
78import { TabRpcClient } from "@/app/store/wshrpcutil" ;
8- import {
9- WOS ,
10- atoms ,
11- fetchWaveFile ,
12- getApi ,
13- getSettingsKeyAtom ,
14- globalStore ,
15- openLink ,
16- recordTEvent ,
17- } from "@/store/global" ;
9+ import { WOS , fetchWaveFile , getApi , getSettingsKeyAtom , globalStore , openLink , recordTEvent } from "@/store/global" ;
1810import * as services from "@/store/services" ;
1911import { PLATFORM , PlatformMacOS } from "@/util/platformutil" ;
2012import { base64ToArray , base64ToString , fireAndForget } from "@/util/util" ;
@@ -35,6 +27,8 @@ const dlog = debug("wave:termwrap");
3527const TermFileName = "term" ;
3628const TermCacheFileName = "cache:term:full" ;
3729const MinDataProcessedForCache = 100 * 1024 ;
30+ const Osc52MaxDecodedSize = 75 * 1024 ; // max clipboard size for OSC 52 (matches common terminal implementations)
31+ const Osc52MaxRawLength = 128 * 1024 ; // includes selector + base64 + whitespace (rough check)
3832export const SupportsImageInput = true ;
3933
4034// detect webgl support
@@ -55,67 +49,83 @@ type TermWrapOptions = {
5549 keydownHandler ?: ( e : KeyboardEvent ) => boolean ;
5650 useWebGl ?: boolean ;
5751 sendDataHandler ?: ( data : string ) => void ;
52+ nodeModel ?: BlockNodeModel ;
5853} ;
5954
60- function handleOscWaveCommand ( data : string , blockId : string , loaded : boolean ) : boolean {
55+ // for xterm OSC handlers, we return true always because we "own" the OSC number.
56+ // even if data is invalid we don't want to propagate to other handlers.
57+ function handleOsc52Command ( data : string , blockId : string , loaded : boolean , termWrap : TermWrap ) : boolean {
6158 if ( ! loaded ) {
6259 return true ;
6360 }
61+ const isBlockFocused = termWrap . nodeModel ? globalStore . get ( termWrap . nodeModel . isFocused ) : false ;
62+ if ( ! document . hasFocus ( ) || ! isBlockFocused ) {
63+ console . log ( "OSC 52: rejected, window or block not focused" ) ;
64+ return true ;
65+ }
6466 if ( ! data || data . length === 0 ) {
65- console . log ( "Invalid Wave OSC command received (empty)" ) ;
67+ console . log ( "OSC 52: empty data received" ) ;
68+ return true ;
69+ }
70+ if ( data . length > Osc52MaxRawLength ) {
71+ console . log ( "OSC 52: raw data too large" , data . length ) ;
6672 return true ;
6773 }
6874
69- // Expected formats:
70- // "setmeta;{JSONDATA}"
71- // "setmeta;[wave-id];{JSONDATA}"
72- const parts = data . split ( ";" ) ;
73- if ( parts [ 0 ] !== "setmeta" ) {
74- console . log ( "Invalid Wave OSC command received (bad command)" , data ) ;
75+ const semicolonIndex = data . indexOf ( ";" ) ;
76+ if ( semicolonIndex === - 1 ) {
77+ console . log ( "OSC 52: invalid format (no semicolon)" , data . substring ( 0 , 50 ) ) ;
7578 return true ;
7679 }
77- let jsonPayload : string ;
78- let waveId : string | undefined ;
79- if ( parts . length === 2 ) {
80- jsonPayload = parts [ 1 ] ;
81- } else if ( parts . length >= 3 ) {
82- waveId = parts [ 1 ] ;
83- jsonPayload = parts . slice ( 2 ) . join ( ";" ) ;
84- } else {
85- console . log ( "Invalid Wave OSC command received (1 part)" , data ) ;
80+
81+ const clipboardSelection = data . substring ( 0 , semicolonIndex ) ;
82+ const base64Data = data . substring ( semicolonIndex + 1 ) ;
83+
84+ // clipboard query ("?") is not supported for security (prevents clipboard theft)
85+ if ( base64Data === "?" ) {
86+ console . log ( "OSC 52: clipboard query not supported" ) ;
8687 return true ;
8788 }
8889
89- let meta : any ;
90- try {
91- meta = JSON . parse ( jsonPayload ) ;
92- } catch ( e ) {
93- console . error ( "Invalid JSON in Wave OSC command:" , e ) ;
90+ if ( base64Data . length === 0 ) {
9491 return true ;
9592 }
9693
97- if ( waveId ) {
98- // Resolve the wave id to an ORef using our ResolveIdsCommand.
99- fireAndForget ( ( ) => {
100- return RpcApi . ResolveIdsCommand ( TabRpcClient , { blockid : blockId , ids : [ waveId ] } )
101- . then ( ( response : { resolvedids : { [ key : string ] : any } } ) => {
102- const oref = response . resolvedids [ waveId ] ;
103- if ( ! oref ) {
104- console . error ( "Failed to resolve wave id:" , waveId ) ;
105- return ;
106- }
107- services . ObjectService . UpdateObjectMeta ( oref , meta ) ;
108- } )
109- . catch ( ( err : any ) => {
110- console . error ( "Error resolving wave id" , waveId , err ) ;
111- } ) ;
112- } ) ;
113- } else {
114- // No wave id provided; update using the current block id.
115- fireAndForget ( ( ) => {
116- return services . ObjectService . UpdateObjectMeta ( WOS . makeORef ( "block" , blockId ) , meta ) ;
94+ if ( clipboardSelection . length > 10 ) {
95+ console . log ( "OSC 52: clipboard selection too long" , clipboardSelection ) ;
96+ return true ;
97+ }
98+
99+ const estimatedDecodedSize = Math . ceil ( base64Data . length * 0.75 ) ;
100+ if ( estimatedDecodedSize > Osc52MaxDecodedSize ) {
101+ console . log ( "OSC 52: data too large" , estimatedDecodedSize , "bytes" ) ;
102+ return true ;
103+ }
104+
105+ try {
106+ // strip whitespace from base64 data (some terminals chunk with newlines per RFC 4648)
107+ const cleanBase64Data = base64Data . replace ( / \s + / g, "" ) ;
108+ const decodedText = base64ToString ( cleanBase64Data ) ;
109+
110+ // validate actual decoded size (base64 estimate can be off for multi-byte UTF-8)
111+ const actualByteSize = new TextEncoder ( ) . encode ( decodedText ) . length ;
112+ if ( actualByteSize > Osc52MaxDecodedSize ) {
113+ console . log ( "OSC 52: decoded text too large" , actualByteSize , "bytes" ) ;
114+ return true ;
115+ }
116+
117+ fireAndForget ( async ( ) => {
118+ try {
119+ await navigator . clipboard . writeText ( decodedText ) ;
120+ dlog ( "OSC 52: copied" , decodedText . length , "characters to clipboard" ) ;
121+ } catch ( err ) {
122+ console . error ( "OSC 52: clipboard write failed:" , err ) ;
123+ }
117124 } ) ;
125+ } catch ( e ) {
126+ console . error ( "OSC 52: base64 decode error:" , e ) ;
118127 }
128+
119129 return true ;
120130}
121131
@@ -386,6 +396,7 @@ export class TermWrap {
386396 promptMarkers : TermTypes . IMarker [ ] = [ ] ;
387397 shellIntegrationStatusAtom : jotai . PrimitiveAtom < "ready" | "running-command" | null > ;
388398 lastCommandAtom : jotai . PrimitiveAtom < string | null > ;
399+ nodeModel : BlockNodeModel ; // this can be null
389400
390401 // IME composition state tracking
391402 // Prevents duplicate input when switching input methods during composition (e.g., using Capslock)
@@ -412,6 +423,7 @@ export class TermWrap {
412423 this . tabId = tabId ;
413424 this . blockId = blockId ;
414425 this . sendDataHandler = waveOptions . sendDataHandler ;
426+ this . nodeModel = waveOptions . nodeModel ;
415427 this . ptyOffset = 0 ;
416428 this . dataBytesProcessed = 0 ;
417429 this . hasResized = false ;
@@ -457,13 +469,13 @@ export class TermWrap {
457469 loggedWebGL = true ;
458470 }
459471 }
460- // Register OSC 9283 handler
461- this . terminal . parser . registerOscHandler ( 9283 , ( data : string ) => {
462- return handleOscWaveCommand ( data , this . blockId , this . loaded ) ;
463- } ) ;
472+ // Register OSC handlers
464473 this . terminal . parser . registerOscHandler ( 7 , ( data : string ) => {
465474 return handleOsc7Command ( data , this . blockId , this . loaded ) ;
466475 } ) ;
476+ this . terminal . parser . registerOscHandler ( 52 , ( data : string ) => {
477+ return handleOsc52Command ( data , this . blockId , this . loaded , this ) ;
478+ } ) ;
467479 this . terminal . parser . registerOscHandler ( 16162 , ( data : string ) => {
468480 return handleOsc16162Command ( data , this . blockId , this . loaded , this ) ;
469481 } ) ;
0 commit comments