Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 76 additions & 4 deletions frontend/app/view/term/termwrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
});
Expand Down
7 changes: 2 additions & 5 deletions pkg/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package web

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions pkg/wshrpc/wshclient/wshclientutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down