93103d11
fix: narrow web osc 52 writes to the clipboard target
a73x 2026-08-18 12:27
Commit message
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -19,6 +19,8 @@ const CLIENT_ACTION = { | |||
| 19 | ignored: 0, terminalModes: 1, clipboard: 2, bell: 3, selection: 4, | 19 | ignored: 0, terminalModes: 1, clipboard: 2, bell: 3, selection: 4, |
| 20 | }; | 20 | }; |
| 21 | const ENV_FRAME = 0x00, ENV_CONTROL = 0x01; | 21 | const ENV_FRAME = 0x00, ENV_CONTROL = 0x01; |
| 22 | // OSC 52's Pc for the system clipboard, the only target this page can honour. | ||
| 23 | const CLIPBOARD_TARGET_C = 0x63; // 'c' | ||
| 22 | 24 | ||
| 23 | // keymap.Key by @intFromEnum (wasm_core.zig pins the table). | 25 | // keymap.Key by @intFromEnum (wasm_core.zig pins the table). |
| 24 | const KEY = { | 26 | const KEY = { |
| @@ -548,6 +550,15 @@ class Tile { | |||
| 548 | 550 | ||
| 549 | onClipboardEffect() { | 551 | onClipboardEffect() { |
| 550 | if (!this.zoomed) return; | 552 | if (!this.zoomed) return; |
| 553 | // The shared core accepts every Pc xterm defines, because the native | ||
| 554 | // client can honour them: `p` is PRIMARY, `s` the selection, `0`-`7` | ||
| 555 | // the cut buffers. A browser has one destination and no way to route | ||
| 556 | // the others, so writing them all to the system clipboard would let an | ||
| 557 | // ordinary X11 mouse drag inside the session — which emits | ||
| 558 | // `ESC]52;p;…` — silently clobber what the human last copied on their | ||
| 559 | // own machine. Non-`c` targets stay valid on the wire and are simply | ||
| 560 | // not this tile's business. | ||
| 561 | if (this.core.mux_clipboard_target() !== CLIPBOARD_TARGET_C) return; | ||
| 551 | 562 | ||
| 552 | // The semantic core's clipboard payload borrows the staging buffer. | 563 | // The semantic core's clipboard payload borrows the staging buffer. |
| 553 | // Copy it before calling or awaiting anything: either can move WASM | 564 | // Copy it before calling or awaiting anything: either can move WASM |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -465,6 +465,7 @@ async function verifyClipboardShell(shell, html) { | |||
| 465 | new Uint8Array(memory.buffer).set(Buffer.from(encoded, 'ascii'), 17); | 465 | new Uint8Array(memory.buffer).set(Buffer.from(encoded, 'ascii'), 17); |
| 466 | tile.core = { | 466 | tile.core = { |
| 467 | memory, | 467 | memory, |
| 468 | mux_clipboard_target: () => 'c'.charCodeAt(0), | ||
| 468 | mux_clipboard_ptr: () => 17, | 469 | mux_clipboard_ptr: () => 17, |
| 469 | mux_clipboard_len: () => encoded.length, | 470 | mux_clipboard_len: () => encoded.length, |
| 470 | }; | 471 | }; |
| @@ -500,6 +501,24 @@ async function verifyClipboardShell(shell, html) { | |||
| 500 | check('invalid UTF-8 effect does not advance version', invalidEffect.tile.clipboardVersion, 0); | 501 | check('invalid UTF-8 effect does not advance version', invalidEffect.tile.clipboardVersion, 0); |
| 501 | check('invalid UTF-8 effect leaves copy control reset', `${invalidEffect.tile.copyButton.className}|${invalidEffect.tile.copyButton.textContent}`, 'copy-request|Copy'); | 502 | check('invalid UTF-8 effect leaves copy control reset', `${invalidEffect.tile.copyButton.className}|${invalidEffect.tile.copyButton.textContent}`, 'copy-request|Copy'); |
| 502 | 503 | ||
| 504 | // The protocol accepts every Pc the native client can route, but this | ||
| 505 | // page has exactly one destination. `p` is what an ordinary X11 mouse | ||
| 506 | // drag writes, and proxying it here would clobber the browser user's | ||
| 507 | // clipboard from a program that only ever asked for PRIMARY. | ||
| 508 | const primaryTarget = tileFor('PRIMARY, not the clipboard'); | ||
| 509 | primaryTarget.tile.core.mux_clipboard_target = () => 'p'.charCodeAt(0); | ||
| 510 | h.navigator.clipboard = { writeText: () => { throw new Error('must not write'); } }; | ||
| 511 | await primaryTarget.tile.onClipboardEffect(); | ||
| 512 | check('non-c OSC 52 target creates no pending clipboard text', primaryTarget.tile.pendingClipboard, null); | ||
| 513 | check('non-c OSC 52 target does not advance the clipboard version', primaryTarget.tile.clipboardVersion, 0); | ||
| 514 | check('non-c OSC 52 target leaves the copy control reset', `${primaryTarget.tile.copyButton.className}|${primaryTarget.tile.copyButton.textContent}`, 'copy-request|Copy'); | ||
| 515 | |||
| 516 | const clipboardTarget = tileFor('the real clipboard'); | ||
| 517 | const clipboardTargetWrites = []; | ||
| 518 | h.navigator.clipboard = { writeText: (text) => { clipboardTargetWrites.push(text); return Promise.resolve(); } }; | ||
| 519 | await clipboardTarget.tile.onClipboardEffect(); | ||
| 520 | check('target c OSC 52 still reaches the system clipboard', clipboardTargetWrites.join('|'), 'the real clipboard'); | ||
| 521 | |||
| 503 | ignored.tile.pendingClipboard = 'must stay local'; | 522 | ignored.tile.pendingClipboard = 'must stay local'; |
| 504 | h.navigator.clipboard = { writeText: () => { throw new Error('must not write'); } }; | 523 | h.navigator.clipboard = { writeText: () => { throw new Error('must not write'); } }; |
| 505 | check('manual clipboard retry is ignored while unzoomed', ignored.tile.copyPendingClipboard(), undefined); | 524 | check('manual clipboard retry is ignored while unzoomed', ignored.tile.copyPendingClipboard(), undefined); |
| @@ -792,6 +811,7 @@ async function verifySelectionShell(shell, html) { | |||
| 792 | mux_init: () => 0, | 811 | mux_init: () => 0, |
| 793 | mux_attach_payload: () => 20, | 812 | mux_attach_payload: () => 20, |
| 794 | mux_client_frame: (type) => type === 0x90 ? 4 : 0, | 813 | mux_client_frame: (type) => type === 0x90 ? 4 : 0, |
| 814 | mux_clipboard_target: () => 'c'.charCodeAt(0), | ||
| 795 | mux_selection_id: () => result.id | 0, | 815 | mux_selection_id: () => result.id | 0, |
| 796 | mux_selection_status: () => result.status, | 816 | mux_selection_status: () => result.status, |
| 797 | mux_selection_history_rows: () => result.historyRows | 0, | 817 | mux_selection_history_rows: () => result.historyRows | 0, |