From 178a82b5e06407b960fd7b340e51fe2fc94b1742 Mon Sep 17 00:00:00 2001 From: gaius-codius Date: Sun, 28 Dec 2025 10:37:57 +0000 Subject: [PATCH 1/3] add OSC 52 clipboard support for terminal applications (#2140) Implements OSC 52 escape sequence handling to allow terminal applications like zellij, tmux, and neovim to copy text to the system clipboard. Features: - Write-only clipboard access (read queries blocked for security) - 75KB size limit matching common terminal implementations - Base64 whitespace handling per RFC 4648 - Dual-phase size validation (estimated + actual) Also fixes StreamCancelFn type mismatch introduced in cfbb39a7 where the implementations were updated but the type definition was not. --- frontend/app/view/term/termwrap.ts | 80 +++++++++++++++++++++++++-- pkg/web/web.go | 7 +-- pkg/wshrpc/wshclient/wshclientutil.go | 4 +- 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index a005a550c7..865adb4f49 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -35,6 +35,7 @@ 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) export const SupportsImageInput = true; // detect webgl support @@ -119,6 +120,74 @@ 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): boolean { + if (!loaded) { + return true; + } + if (!data || data.length === 0) { + console.log("OSC 52: empty data received"); + 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 { @@ -457,13 +526,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.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); }); diff --git a/pkg/web/web.go b/pkg/web/web.go index 28bcccd5a3..c5571c694b 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -5,7 +5,6 @@ package web import ( "bytes" - "context" "encoding/base64" "encoding/json" "fmt" @@ -255,7 +254,7 @@ func handleRemoteStreamFile(w http.ResponseWriter, req *http.Request, conn strin return handleRemoteStreamFileFromCh(w, req, path, rtnCh, rpcOpts.StreamCancelFn, no404) } -func handleRemoteStreamFileFromCh(w http.ResponseWriter, req *http.Request, path string, rtnCh <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData], streamCancelFn func(context.Context) error, no404 bool) error { +func handleRemoteStreamFileFromCh(w http.ResponseWriter, req *http.Request, path string, rtnCh <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData], streamCancelFn func(), no404 bool) error { firstPk := true var fileInfo *wshrpc.FileInfo loopDone := false @@ -271,9 +270,7 @@ func handleRemoteStreamFileFromCh(w http.ResponseWriter, req *http.Request, path select { case <-ctx.Done(): if streamCancelFn != nil { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - streamCancelFn(ctx) + streamCancelFn() } return ctx.Err() case respUnion, ok := <-rtnCh: diff --git a/pkg/wshrpc/wshclient/wshclientutil.go b/pkg/wshrpc/wshclient/wshclientutil.go index 52d311c0a9..ac8d7e2593 100644 --- a/pkg/wshrpc/wshclient/wshclientutil.go +++ b/pkg/wshrpc/wshclient/wshclientutil.go @@ -63,8 +63,8 @@ func sendRpcRequestResponseStreamHelper[T any](w *wshutil.WshRpc, command string rtnErr(respChan, err) return respChan } - opts.StreamCancelFn = func(ctx context.Context) error { - return reqHandler.SendCancel(ctx) + opts.StreamCancelFn = func() { + reqHandler.SendCancel(context.Background()) } go func() { defer func() { From 015251b7bf010c392f285aa7d75fc46157414fca Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 2 Jan 2026 10:40:34 -0800 Subject: [PATCH 2/3] revert changes to web and wshrpcclientutil (fixed in main) --- pkg/web/web.go | 7 +++++-- pkg/wshrpc/wshclient/wshclientutil.go | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/web/web.go b/pkg/web/web.go index c5571c694b..28bcccd5a3 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -5,6 +5,7 @@ package web import ( "bytes" + "context" "encoding/base64" "encoding/json" "fmt" @@ -254,7 +255,7 @@ func handleRemoteStreamFile(w http.ResponseWriter, req *http.Request, conn strin return handleRemoteStreamFileFromCh(w, req, path, rtnCh, rpcOpts.StreamCancelFn, no404) } -func handleRemoteStreamFileFromCh(w http.ResponseWriter, req *http.Request, path string, rtnCh <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData], streamCancelFn func(), no404 bool) error { +func handleRemoteStreamFileFromCh(w http.ResponseWriter, req *http.Request, path string, rtnCh <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData], streamCancelFn func(context.Context) error, no404 bool) error { firstPk := true var fileInfo *wshrpc.FileInfo loopDone := false @@ -270,7 +271,9 @@ func handleRemoteStreamFileFromCh(w http.ResponseWriter, req *http.Request, path select { case <-ctx.Done(): if streamCancelFn != nil { - streamCancelFn() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + streamCancelFn(ctx) } return ctx.Err() case respUnion, ok := <-rtnCh: diff --git a/pkg/wshrpc/wshclient/wshclientutil.go b/pkg/wshrpc/wshclient/wshclientutil.go index ac8d7e2593..52d311c0a9 100644 --- a/pkg/wshrpc/wshclient/wshclientutil.go +++ b/pkg/wshrpc/wshclient/wshclientutil.go @@ -63,8 +63,8 @@ func sendRpcRequestResponseStreamHelper[T any](w *wshutil.WshRpc, command string rtnErr(respChan, err) return respChan } - opts.StreamCancelFn = func() { - reqHandler.SendCancel(context.Background()) + opts.StreamCancelFn = func(ctx context.Context) error { + return reqHandler.SendCancel(ctx) } go func() { defer func() { From fbc7b6d55ba60e23067dbbf9b5d76fca9f9b0c23 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 2 Jan 2026 11:23:44 -0800 Subject: [PATCH 3/3] add block focus check for osc 52 --- frontend/app/view/term/term.tsx | 1 + frontend/app/view/term/termwrap.ts | 29 +++++++++++++++++------------ 2 files changed, 18 insertions(+), 12 deletions(-) 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 865adb4f49..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"; @@ -36,6 +28,7 @@ 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 @@ -56,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 { @@ -122,14 +116,23 @@ function handleOscWaveCommand(data: string, blockId: string, loaded: boolean): b // 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): boolean { +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) { @@ -455,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) @@ -481,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; @@ -531,7 +536,7 @@ export class TermWrap { return handleOsc7Command(data, this.blockId, this.loaded); }); this.terminal.parser.registerOscHandler(52, (data: string) => { - return handleOsc52Command(data, this.blockId, this.loaded); + return handleOsc52Command(data, this.blockId, this.loaded, this); }); this.terminal.parser.registerOscHandler(9283, (data: string) => { return handleOscWaveCommand(data, this.blockId, this.loaded);