Skip to content

Commit 87f2ae8

Browse files
committed
Merge remote-tracking branch 'origin/main' into sawka/remove-wave-osc
2 parents 9180421 + a92a483 commit 87f2ae8

File tree

4 files changed

+91
-3
lines changed

4 files changed

+91
-3
lines changed

frontend/app/view/term/term.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
286286
keydownHandler: model.handleTerminalKeydown.bind(model),
287287
useWebGl: !termSettings?.["term:disablewebgl"],
288288
sendDataHandler: model.sendDataToController.bind(model),
289+
nodeModel: model.nodeModel,
289290
}
290291
);
291292
(window as any).term = termWrap;

frontend/app/view/term/termwrap.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright 2025, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import type { BlockNodeModel } from "@/app/block/blocktypes";
45
import { getFileSubject } from "@/app/store/wps";
56
import { sendWSCommand } from "@/app/store/ws";
67
import { RpcApi } from "@/app/store/wshclientapi";
@@ -26,6 +27,8 @@ const dlog = debug("wave:termwrap");
2627
const TermFileName = "term";
2728
const TermCacheFileName = "cache:term:full";
2829
const 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)
2932
export const SupportsImageInput = true;
3033

3134
// detect webgl support
@@ -46,8 +49,86 @@ type TermWrapOptions = {
4649
keydownHandler?: (e: KeyboardEvent) => boolean;
4750
useWebGl?: boolean;
4851
sendDataHandler?: (data: string) => void;
52+
nodeModel?: BlockNodeModel;
4953
};
5054

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 {
58+
if (!loaded) {
59+
return true;
60+
}
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+
}
66+
if (!data || data.length === 0) {
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);
72+
return true;
73+
}
74+
75+
const semicolonIndex = data.indexOf(";");
76+
if (semicolonIndex === -1) {
77+
console.log("OSC 52: invalid format (no semicolon)", data.substring(0, 50));
78+
return true;
79+
}
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");
87+
return true;
88+
}
89+
90+
if (base64Data.length === 0) {
91+
return true;
92+
}
93+
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+
}
124+
});
125+
} catch (e) {
126+
console.error("OSC 52: base64 decode error:", e);
127+
}
128+
129+
return true;
130+
}
131+
51132
// for xterm handlers, we return true always because we "own" OSC 7.
52133
// even if it is invalid we dont want to propagate to other handlers
53134
function handleOsc7Command(data: string, blockId: string, loaded: boolean): boolean {
@@ -315,6 +396,7 @@ export class TermWrap {
315396
promptMarkers: TermTypes.IMarker[] = [];
316397
shellIntegrationStatusAtom: jotai.PrimitiveAtom<"ready" | "running-command" | null>;
317398
lastCommandAtom: jotai.PrimitiveAtom<string | null>;
399+
nodeModel: BlockNodeModel; // this can be null
318400

319401
// IME composition state tracking
320402
// Prevents duplicate input when switching input methods during composition (e.g., using Capslock)
@@ -341,6 +423,7 @@ export class TermWrap {
341423
this.tabId = tabId;
342424
this.blockId = blockId;
343425
this.sendDataHandler = waveOptions.sendDataHandler;
426+
this.nodeModel = waveOptions.nodeModel;
344427
this.ptyOffset = 0;
345428
this.dataBytesProcessed = 0;
346429
this.hasResized = false;
@@ -386,9 +469,13 @@ export class TermWrap {
386469
loggedWebGL = true;
387470
}
388471
}
472+
// Register OSC handlers
389473
this.terminal.parser.registerOscHandler(7, (data: string) => {
390474
return handleOsc7Command(data, this.blockId, this.loaded);
391475
});
476+
this.terminal.parser.registerOscHandler(52, (data: string) => {
477+
return handleOsc52Command(data, this.blockId, this.loaded, this);
478+
});
392479
this.terminal.parser.registerOscHandler(16162, (data: string) => {
393480
return handleOsc16162Command(data, this.blockId, this.loaded, this);
394481
});

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ require (
2828
github.com/mitchellh/mapstructure v1.5.0
2929
github.com/sashabaranov/go-openai v1.41.2
3030
github.com/sawka/txwrap v0.2.0
31-
github.com/shirou/gopsutil/v4 v4.25.11
31+
github.com/shirou/gopsutil/v4 v4.25.12
3232
github.com/skeema/knownhosts v1.3.1
3333
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
3434
github.com/spf13/cobra v1.10.2

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,8 @@ github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TV
162162
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
163163
github.com/sawka/txwrap v0.2.0 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94=
164164
github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA=
165-
github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY=
166-
github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
165+
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY=
166+
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
167167
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
168168
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
169169
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=

0 commit comments

Comments
 (0)