diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index a005a550c..865adb4f4 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 28bcccd5a..c5571c694 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 52d311c0a..ac8d7e259 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() {