diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 7e6272003a..aae7fe1295 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -286,6 +286,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => keydownHandler: model.handleTerminalKeydown.bind(model), useWebGl: !termSettings?.["term:disablewebgl"], sendDataHandler: model.sendDataToController.bind(model), + nodeModel: model.nodeModel, } ); (window as any).term = termWrap; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index a005a550c7..b676a98361 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -1,20 +1,12 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import type { BlockNodeModel } from "@/app/block/blocktypes"; import { getFileSubject } from "@/app/store/wps"; import { sendWSCommand } from "@/app/store/ws"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { - WOS, - atoms, - fetchWaveFile, - getApi, - getSettingsKeyAtom, - globalStore, - openLink, - recordTEvent, -} from "@/store/global"; +import { WOS, fetchWaveFile, getApi, getSettingsKeyAtom, globalStore, openLink, recordTEvent } from "@/store/global"; import * as services from "@/store/services"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { base64ToArray, base64ToString, fireAndForget } from "@/util/util"; @@ -35,6 +27,8 @@ const dlog = debug("wave:termwrap"); const TermFileName = "term"; const TermCacheFileName = "cache:term:full"; const MinDataProcessedForCache = 100 * 1024; +const Osc52MaxDecodedSize = 75 * 1024; // max clipboard size for OSC 52 (matches common terminal implementations) +const Osc52MaxRawLength = 128 * 1024; // includes selector + base64 + whitespace (rough check) export const SupportsImageInput = true; // detect webgl support @@ -55,6 +49,7 @@ type TermWrapOptions = { keydownHandler?: (e: KeyboardEvent) => boolean; useWebGl?: boolean; sendDataHandler?: (data: string) => void; + nodeModel?: BlockNodeModel; }; function handleOscWaveCommand(data: string, blockId: string, loaded: boolean): boolean { @@ -119,6 +114,83 @@ function handleOscWaveCommand(data: string, blockId: string, loaded: boolean): b return true; } +// for xterm OSC handlers, we return true always because we "own" the OSC number. +// even if data is invalid we don't want to propagate to other handlers. +function handleOsc52Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean { + if (!loaded) { + return true; + } + const isBlockFocused = termWrap.nodeModel ? globalStore.get(termWrap.nodeModel.isFocused) : false; + if (!document.hasFocus() || !isBlockFocused) { + console.log("OSC 52: rejected, window or block not focused"); + return true; + } + if (!data || data.length === 0) { + console.log("OSC 52: empty data received"); + return true; + } + if (data.length > Osc52MaxRawLength) { + console.log("OSC 52: raw data too large", data.length); + return true; + } + + const semicolonIndex = data.indexOf(";"); + if (semicolonIndex === -1) { + console.log("OSC 52: invalid format (no semicolon)", data.substring(0, 50)); + return true; + } + + const clipboardSelection = data.substring(0, semicolonIndex); + const base64Data = data.substring(semicolonIndex + 1); + + // clipboard query ("?") is not supported for security (prevents clipboard theft) + if (base64Data === "?") { + console.log("OSC 52: clipboard query not supported"); + return true; + } + + if (base64Data.length === 0) { + return true; + } + + if (clipboardSelection.length > 10) { + console.log("OSC 52: clipboard selection too long", clipboardSelection); + return true; + } + + const estimatedDecodedSize = Math.ceil(base64Data.length * 0.75); + if (estimatedDecodedSize > Osc52MaxDecodedSize) { + console.log("OSC 52: data too large", estimatedDecodedSize, "bytes"); + return true; + } + + try { + // strip whitespace from base64 data (some terminals chunk with newlines per RFC 4648) + const cleanBase64Data = base64Data.replace(/\s+/g, ""); + const decodedText = base64ToString(cleanBase64Data); + + // validate actual decoded size (base64 estimate can be off for multi-byte UTF-8) + const actualByteSize = new TextEncoder().encode(decodedText).length; + if (actualByteSize > Osc52MaxDecodedSize) { + console.log("OSC 52: decoded text too large", actualByteSize, "bytes"); + return true; + } + + fireAndForget(async () => { + try { + await navigator.clipboard.writeText(decodedText); + dlog("OSC 52: copied", decodedText.length, "characters to clipboard"); + } catch (err) { + console.error("OSC 52: clipboard write failed:", err); + } + }); + } catch (e) { + console.error("OSC 52: base64 decode error:", e); + } + + return true; +} + // for xterm handlers, we return true always because we "own" OSC 7. // even if it is invalid we dont want to propagate to other handlers function handleOsc7Command(data: string, blockId: string, loaded: boolean): boolean { @@ -386,6 +458,7 @@ export class TermWrap { promptMarkers: TermTypes.IMarker[] = []; shellIntegrationStatusAtom: jotai.PrimitiveAtom<"ready" | "running-command" | null>; lastCommandAtom: jotai.PrimitiveAtom; + nodeModel: BlockNodeModel; // this can be null // IME composition state tracking // Prevents duplicate input when switching input methods during composition (e.g., using Capslock) @@ -412,6 +485,7 @@ export class TermWrap { this.tabId = tabId; this.blockId = blockId; this.sendDataHandler = waveOptions.sendDataHandler; + this.nodeModel = waveOptions.nodeModel; this.ptyOffset = 0; this.dataBytesProcessed = 0; this.hasResized = false; @@ -457,13 +531,16 @@ export class TermWrap { loggedWebGL = true; } } - // Register OSC 9283 handler - this.terminal.parser.registerOscHandler(9283, (data: string) => { - return handleOscWaveCommand(data, this.blockId, this.loaded); - }); + // Register OSC handlers this.terminal.parser.registerOscHandler(7, (data: string) => { return handleOsc7Command(data, this.blockId, this.loaded); }); + this.terminal.parser.registerOscHandler(52, (data: string) => { + return handleOsc52Command(data, this.blockId, this.loaded, this); + }); + this.terminal.parser.registerOscHandler(9283, (data: string) => { + return handleOscWaveCommand(data, this.blockId, this.loaded); + }); this.terminal.parser.registerOscHandler(16162, (data: string) => { return handleOsc16162Command(data, this.blockId, this.loaded, this); });