From a0fe73bada33e573cf164660a07caaeac5c1e3d6 Mon Sep 17 00:00:00 2001 From: romanornr <6548898+romanornr@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:51:37 +0200 Subject: [PATCH] fix(cli): strip leaked bracketed-paste wrappers --- cli.py | 41 +++++++++++++++- .../cli/test_cli_bracketed_paste_sanitizer.py | 49 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 tests/cli/test_cli_bracketed_paste_sanitizer.py diff --git a/cli.py b/cli.py index 4f8db69a6c..1971cc3c9b 100644 --- a/cli.py +++ b/cli.py @@ -15,6 +15,7 @@ Usage: import logging import os +import re import shutil import sys import json @@ -1547,6 +1548,32 @@ def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool: return not pasted_text.strip() +def _strip_leaked_bracketed_paste_wrappers(text: str) -> str: + """Strip leaked bracketed-paste wrapper markers from user-visible text. + + Defensive normalization for cases where terminal/prompt_toolkit parsing + fails and bracketed-paste markers end up in the buffer as literal text. + + We strip canonical wrappers unconditionally and also handle degraded + visible forms like ``[200~`` / ``[201~`` and ``00~`` / ``01~`` when they + look like wrapper boundaries, not arbitrary user content. + """ + if not text: + return text + + text = ( + text.replace("\x1b[200~", "") + .replace("\x1b[201~", "") + .replace("^[[200~", "") + .replace("^[[201~", "") + ) + text = re.sub(r"(^|[\s\n>:\]\)])\[200~", r"\1", text) + text = re.sub(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])", "", text) + text = re.sub(r"(^|[\s\n>:\]\)])00~", r"\1", text) + text = re.sub(r"01~(?=$|[\s\n<\[\(\):;.,!?])", "", text) + return text + + def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]: """Collect local image attachments for single-query CLI flows.""" message = query or "" @@ -9759,6 +9786,7 @@ class HermesCLI: # Normalise line endings — Windows \r\n and old Mac \r both become \n # so the 5-line collapse threshold and display are consistent. pasted_text = pasted_text.replace('\r\n', '\n').replace('\r', '\n') + pasted_text = _strip_leaked_bracketed_paste_wrappers(pasted_text) if _should_auto_attach_clipboard_image_on_paste(pasted_text) and self._try_attach_clipboard_image(): event.app.invalidate() if pasted_text: @@ -9900,7 +9928,15 @@ class HermesCLI: still batch newlines. Alt+Enter only adds 1 newline per event so it never triggers this. """ - text = buf.text + text = _strip_leaked_bracketed_paste_wrappers(buf.text) + if text != buf.text: + cursor = min(buf.cursor_position, len(text)) + _paste_just_collapsed[0] = True + buf.text = text + buf.cursor_position = cursor + _prev_text_len[0] = len(text) + _prev_newline_count[0] = text.count('\n') + return chars_added = len(text) - _prev_text_len[0] _prev_text_len[0] = len(text) if _paste_just_collapsed[0] or self._skip_paste_collapse: @@ -10648,6 +10684,9 @@ class HermesCLI: submit_images = [] if isinstance(user_input, tuple): user_input, submit_images = user_input + + if isinstance(user_input, str): + user_input = _strip_leaked_bracketed_paste_wrappers(user_input) # Check for commands — but detect dragged/pasted file paths first. # See _detect_file_drop() for details. diff --git a/tests/cli/test_cli_bracketed_paste_sanitizer.py b/tests/cli/test_cli_bracketed_paste_sanitizer.py new file mode 100644 index 0000000000..79ecbe820f --- /dev/null +++ b/tests/cli/test_cli_bracketed_paste_sanitizer.py @@ -0,0 +1,49 @@ +"""Tests for defensive bracketed-paste wrapper stripping in the CLI.""" + +from cli import _strip_leaked_bracketed_paste_wrappers + + +class TestStripLeakedBracketedPasteWrappers: + def test_plain_text_unchanged(self): + text = "hello world" + assert _strip_leaked_bracketed_paste_wrappers(text) == text + + def test_strips_canonical_escape_wrappers(self): + text = "\x1b[200~hello\x1b[201~" + assert _strip_leaked_bracketed_paste_wrappers(text) == "hello" + + def test_strips_visible_caret_escape_wrappers(self): + text = "^[[200~hello^[[201~" + assert _strip_leaked_bracketed_paste_wrappers(text) == "hello" + + def test_strips_degraded_bracket_only_wrappers(self): + text = "[200~hello[201~" + assert _strip_leaked_bracketed_paste_wrappers(text) == "hello" + + def test_strips_degraded_bracket_only_wrappers_after_whitespace(self): + text = "prefix [200~hello[201~ suffix" + assert _strip_leaked_bracketed_paste_wrappers(text) == "prefix hello suffix" + + def test_strips_wrapper_fragments_at_boundaries(self): + text = "00~hello world01~" + assert _strip_leaked_bracketed_paste_wrappers(text) == "hello world" + + def test_strips_wrapper_fragments_after_whitespace(self): + text = "prefix 00~hello world01~ suffix" + assert _strip_leaked_bracketed_paste_wrappers(text) == "prefix hello world suffix" + + def test_does_not_strip_non_wrapper_00_tilde_in_normal_text(self): + text = "build00~tag should stay" + assert _strip_leaked_bracketed_paste_wrappers(text) == text + + def test_does_not_strip_non_wrapper_bracket_forms_in_normal_text(self): + text = "literal[200~tag and literal[201~tag should stay" + assert _strip_leaked_bracketed_paste_wrappers(text) == text + + def test_preserves_multiline_content_while_stripping_wrappers(self): + text = "^[[200~line 1\nline 2\nline 3^[[201~" + assert _strip_leaked_bracketed_paste_wrappers(text) == "line 1\nline 2\nline 3" + + def test_preserves_multiline_content_while_stripping_degraded_bracket_only_wrappers(self): + text = "[200~line 1\nline 2\nline 3[201~" + assert _strip_leaked_bracketed_paste_wrappers(text) == "line 1\nline 2\nline 3"