From 5921d667855880b0aa2083a50f001748aed52f3e Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sat, 30 May 2026 11:55:12 -0500 Subject: [PATCH] fix(cli): stop OSC 11 bg probe from trapping users in a stray editor (#35441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Over SSH the OSC 11 background-color query round-trip routinely exceeds the 100ms read budget, so _query_osc11_background() gives up and the late reply lands after prompt_toolkit has grabbed the tty. prompt_toolkit then injects the OSC payload as typed text and reads its BEL terminator (\x07 = Ctrl+G) as a keystroke — Ctrl+G is the open-external-editor binding, dropping the user into vi with garbage and no obvious way out. - Skip the OSC 11 probe on remote sessions (SSH_CONNECTION/CLIENT/TTY); fall back to COLORFGBG / env hints / the dark default. - Restore the tty with TCSAFLUSH instead of TCSANOW so any partial/late reply is scrubbed from the input buffer before pt reads it. --- cli.py | 13 ++++++++++++- tests/cli/test_cli_light_mode.py | 25 ++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index b22e26333..baf033920 100644 --- a/cli.py +++ b/cli.py @@ -1542,9 +1542,17 @@ def _query_osc11_background() -> str | None: Most modern terminals reply with \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\ within a few ms. We wait up to 100ms total before giving up. Returns "#RRGGBB" or None on timeout / non-tty. + + Skipped over SSH: the round-trip routinely exceeds our 100ms budget, so a + late reply lands after prompt_toolkit has grabbed the tty — its payload + leaks in as typed text and the BEL terminator reads as Ctrl+G (open + editor), trapping the user in a stray editor. Remote sessions fall back to + COLORFGBG / env hints / the dark default instead. """ if not sys.stdin.isatty() or not sys.stdout.isatty(): return None + if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")): + return None try: import termios import tty @@ -1592,8 +1600,11 @@ def _query_osc11_background() -> str | None: r, g, b = norm(m.group(1)), norm(m.group(2)), norm(m.group(3)) return f"#{r:02X}{g:02X}{b:02X}" finally: + # TCSAFLUSH discards any unread input as it restores the original + # attributes — scrubs a slow/partial OSC 11 reply out of the tty + # buffer before prompt_toolkit can read it as keystrokes. try: - termios.tcsetattr(fd, termios.TCSANOW, old) + termios.tcsetattr(fd, termios.TCSAFLUSH, old) except Exception: pass diff --git a/tests/cli/test_cli_light_mode.py b/tests/cli/test_cli_light_mode.py index c1df160e6..1a8d51ae6 100644 --- a/tests/cli/test_cli_light_mode.py +++ b/tests/cli/test_cli_light_mode.py @@ -75,6 +75,27 @@ class TestLightModeDetection: assert cli_mod._detect_light_mode() is True +class TestOsc11Probe: + """The OSC 11 background probe must never run where its reply can leak + into prompt_toolkit's input (a late BEL-terminated reply reads as Ctrl+G + = open-editor, trapping the user in a stray editor). Guard the cases we + refuse to probe in. + """ + + @pytest.mark.parametrize("var", ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")) + def test_skips_over_ssh(self, cli_mod, monkeypatch, var): + monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr(cli_mod.sys.stdout, "isatty", lambda: True, raising=False) + for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"): + monkeypatch.delenv(v, raising=False) + monkeypatch.setenv(var, "1.2.3.4 5555 22") + assert cli_mod._query_osc11_background() is None + + def test_skips_when_not_a_tty(self, cli_mod, monkeypatch): + monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: False, raising=False) + assert cli_mod._query_osc11_background() is None + + class TestLightModeRemap: def test_remap_no_op_in_dark_mode(self, cli_mod, monkeypatch): monkeypatch.setenv("HERMES_LIGHT", "0") @@ -133,7 +154,9 @@ class TestSkinConfigHook: after = SkinConfig.get_color assert before is after - def test_skin_color_remaps_through_wrapper_in_light_mode(self, cli_mod, monkeypatch): + def test_skin_color_remaps_through_wrapper_in_light_mode( + self, cli_mod, monkeypatch + ): from hermes_cli.skin_engine import SkinConfig cli_mod._LIGHT_MODE_CACHE = True