Skip to content

Commit f222faf

Browse files
authored
fix(terminal): OSC 52 clipboard writes over plain HTTP (#516)
When Kolu is served over plain HTTP to a non-loopback host, selecting text inside a terminal pane (or any program that emits OSC 52) produced a silent `TypeError: Cannot read properties of undefined (reading 'writeText')` and nothing reached the system clipboard. xterm's `ClipboardAddon` calls `navigator.clipboard.writeText` directly, and `navigator.clipboard` *itself* is undefined outside a secure context — https, localhost, or 127.0.0.1. This PR ships a **`SafeClipboardProvider`** that hands xterm a writer which tries `navigator.clipboard` first and falls back to a hidden textarea plus `document.execCommand("copy")` when the API is missing or rejects. The same helper replaces the now-duplicate fallback block that `handleCopyTerminalText` already carried in `useTerminalCrud.ts`. Coverage lands two new e2e scenarios in `osc52-clipboard.feature`: one asserts the happy path writes through `navigator.clipboard`, the other stubs `writeText` to reject and verifies the fallback still lands the text on the clipboard with no page errors. ### Test plan - [x] `just check` passes - [x] `just test-quick features/osc52-clipboard.feature` — 2/2 scenarios pass - [x] `just test-quick features/copy-pane-text.feature features/clipboard.feature` — existing suites unaffected - [x] `just ci` — all 12 contexts green on 6e09d92 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent d149764 commit f222faf

5 files changed

Lines changed: 106 additions & 19 deletions

File tree

packages/client/src/terminal/Terminal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { WebglAddon } from "@xterm/addon-webgl";
2222
import { WebLinksAddon } from "@xterm/addon-web-links";
2323
import { SearchAddon } from "@xterm/addon-search";
2424
import { ClipboardAddon } from "@xterm/addon-clipboard";
25+
import { SafeClipboardProvider } from "./clipboard";
2526
import { Unicode11Addon } from "@xterm/addon-unicode11";
2627
import { ImageAddon } from "@xterm/addon-image";
2728
import { SerializeAddon } from "@xterm/addon-serialize";
@@ -237,7 +238,7 @@ const Terminal: Component<{
237238
const search = new SearchAddon();
238239
term.loadAddon(search);
239240
setSearchAddon(search);
240-
term.loadAddon(new ClipboardAddon());
241+
term.loadAddon(new ClipboardAddon(undefined, new SafeClipboardProvider()));
241242
term.loadAddon(new Unicode11Addon());
242243
term.unicode.activeVersion = "11";
243244
term.loadAddon(new ImageAddon());
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Clipboard write with a fallback for non-secure contexts.
3+
*
4+
* `navigator.clipboard` is only defined in secure contexts (https, localhost,
5+
* 127.0.0.1). On plain http to any other host it is undefined, so both
6+
* direct `navigator.clipboard.writeText` calls and xterm's `ClipboardAddon`
7+
* OSC 52 handler throw `TypeError: Cannot read properties of undefined`.
8+
*
9+
* The fallback selects a hidden textarea and runs `document.execCommand("copy")`,
10+
* which works in any browsing context at the cost of a brief focus steal.
11+
*/
12+
13+
import type {
14+
IClipboardProvider,
15+
ClipboardSelectionType,
16+
} from "@xterm/addon-clipboard";
17+
18+
/** Write `text` to the system clipboard, falling back to execCommand when
19+
* navigator.clipboard is unavailable or throws. Throws if both paths fail. */
20+
export async function writeTextToClipboard(text: string): Promise<void> {
21+
if (navigator.clipboard?.writeText) {
22+
try {
23+
await navigator.clipboard.writeText(text);
24+
return;
25+
} catch {
26+
// Fall through to execCommand — navigator.clipboard can reject for
27+
// reasons other than missing secure context (permission denied, etc.).
28+
}
29+
}
30+
const textarea = document.createElement("textarea");
31+
textarea.value = text;
32+
textarea.style.position = "fixed";
33+
textarea.style.opacity = "0";
34+
document.body.appendChild(textarea);
35+
try {
36+
textarea.select();
37+
const ok = document.execCommand("copy");
38+
if (!ok) throw new Error("clipboard access blocked");
39+
} finally {
40+
document.body.removeChild(textarea);
41+
}
42+
}
43+
44+
/** xterm `IClipboardProvider` that uses `writeTextToClipboard` for writes
45+
* (survives non-secure contexts) and returns empty on reads when
46+
* navigator.clipboard is unavailable. OSC 52 read queries (`?`) are rare
47+
* and have no safe fallback. */
48+
export class SafeClipboardProvider implements IClipboardProvider {
49+
public async readText(selection: ClipboardSelectionType): Promise<string> {
50+
if (selection !== "c") return "";
51+
if (!navigator.clipboard?.readText) return "";
52+
return navigator.clipboard.readText();
53+
}
54+
55+
public async writeText(
56+
selection: ClipboardSelectionType,
57+
text: string,
58+
): Promise<void> {
59+
if (selection !== "c") return;
60+
await writeTextToClipboard(text);
61+
}
62+
}

packages/client/src/terminal/useTerminalCrud.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { toast } from "solid-sonner";
77
import { availableThemes } from "../theme";
88
import { client } from "../rpc/rpc";
99
import { useSubPanel } from "./useSubPanel";
10+
import { writeTextToClipboard } from "./clipboard";
1011
import { useTips } from "../settings/useTips";
1112
import { useServerState } from "../settings/useServerState";
1213
import { CONTEXTUAL_TIPS } from "../settings/tips";
@@ -149,24 +150,7 @@ export function useTerminalCrud(deps: {
149150
if (id === null) return;
150151
try {
151152
const text = await client.terminal.screenText({ id });
152-
if (navigator.clipboard?.writeText) {
153-
await navigator.clipboard.writeText(text);
154-
} else {
155-
// Fallback for non-secure contexts (HTTP on non-localhost)
156-
// where navigator.clipboard is undefined.
157-
const textarea = document.createElement("textarea");
158-
textarea.value = text;
159-
textarea.style.position = "fixed";
160-
textarea.style.opacity = "0";
161-
document.body.appendChild(textarea);
162-
try {
163-
textarea.select();
164-
const ok = document.execCommand("copy");
165-
if (!ok) throw new Error("clipboard access blocked");
166-
} finally {
167-
document.body.removeChild(textarea);
168-
}
169-
}
153+
await writeTextToClipboard(text);
170154
toast.success("Copied terminal text to clipboard");
171155
} catch (err) {
172156
console.error("Failed to copy terminal text:", err);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Feature: OSC 52 clipboard writes
2+
xterm's OSC 52 handler decodes the base64 payload and writes it to the
3+
system clipboard. Works in secure contexts via navigator.clipboard and
4+
falls back to document.execCommand when navigator.clipboard is unavailable
5+
(non-secure HTTP contexts) or rejects.
6+
7+
Background:
8+
Given the terminal is ready
9+
10+
Scenario: OSC 52 writes to the clipboard via navigator.clipboard
11+
When I run "printf '\x1b]52;c;aGVsbG8tc2VjdXJl\x07'"
12+
Then the clipboard should contain "hello-secure"
13+
And there should be no page errors
14+
15+
Scenario: OSC 52 falls back to execCommand when navigator.clipboard is unavailable
16+
When I disable navigator.clipboard.writeText
17+
And I run "printf '\x1b]52;c;aGVsbG8tZmFsbGJhY2s=\x07'"
18+
Then the clipboard should contain "hello-fallback"
19+
And there should be no page errors
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { When } from "@cucumber/cucumber";
2+
import { KoluWorld } from "../support/world.ts";
3+
4+
/** Force navigator.clipboard.writeText to reject so the ClipboardAddon provider
5+
* exercises the execCommand fallback path. Leaves readText intact so the
6+
* clipboard-contents assertion can still read the system clipboard after
7+
* the fallback writes to it. */
8+
When(
9+
"I disable navigator.clipboard.writeText",
10+
async function (this: KoluWorld) {
11+
// Pass evaluate a string to sidestep tsx/esbuild's `__name` helper
12+
// injection, which breaks when the serialized function is evaluated
13+
// in the browser (see playwright/playwright#31105).
14+
await this.page.evaluate(`
15+
Object.defineProperty(navigator.clipboard, "writeText", {
16+
configurable: true,
17+
value: () => Promise.reject(new Error("clipboard disabled for test"))
18+
});
19+
`);
20+
},
21+
);

0 commit comments

Comments
 (0)