diff --git a/hermes_bootstrap.py b/hermes_bootstrap.py index d57a9f4c7b..b740702af5 100644 --- a/hermes_bootstrap.py +++ b/hermes_bootstrap.py @@ -1,4 +1,4 @@ -"""Process bootstrap for Hermes entry points: Windows UTF-8 stdio, import-path +"""Process bootstrap for Hermes entry points: Windows UTF-8 stdio and ANSI console, import-path hardening, durable lazy-install target, and dual-stack (Happy Eyeballs) connects. Windows binds stdio to the console code page (cp1252), so ``print("café")`` raises @@ -253,6 +253,47 @@ def apply_windows_utf8_bootstrap() -> bool: return True +_ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + + +def enable_windows_vt(streams=None) -> bool: + """Opt the console behind stdout/stderr in to ANSI escape processing. + + ``hermes_cli.colors`` and the skins emit raw SGR codes whenever stdout is a TTY. A + conhost console (PowerShell 5.1, cmd.exe, the installer's ``hermes setup``) prints + them as ``←[35m`` until the output handle has ENABLE_VIRTUAL_TERMINAL_PROCESSING, and + shells hand native children a console with it off. The mode belongs to the console + buffer, so setting it here also covers the relaunched child. Handles that are not a + console (pipes, files, NUL, a windowless pythonw) fail GetConsoleMode and are left + alone. A console that refuses VT (pre-Windows 10) gets NO_COLOR instead, which + ``should_use_color`` and rich honour, so it shows plain text rather than garbage. + Returns False only in that fallback case. + """ + if not _IS_WINDOWS: + return True + import ctypes + import msvcrt + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + enabled = True + for stream in (sys.stdout, sys.stderr) if streams is None else streams: + try: + handle = msvcrt.get_osfhandle(stream.fileno()) + except (AttributeError, OSError, ValueError): + continue # no fd (None under pythonw, StringIO in embedders) + mode = wintypes.DWORD() + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + continue + if mode.value & _ENABLE_VIRTUAL_TERMINAL_PROCESSING: + continue + if not kernel32.SetConsoleMode(handle, mode.value | _ENABLE_VIRTUAL_TERMINAL_PROCESSING): + enabled = False + if not enabled: + os.environ.setdefault("NO_COLOR", "1") + return enabled + + def suppress_platform_ver_console() -> None: """Stub ``platform._syscmd_ver`` on Windows — decode-crash + console-flash guard. @@ -432,6 +473,7 @@ def export_scratch_tmp_env() -> None: # the very top of their module, before importing anything else. The # import side effect does the right thing. apply_windows_utf8_bootstrap() +enable_windows_vt() suppress_platform_ver_console() install_never_free_environ() diff --git a/tests/test_hermes_bootstrap.py b/tests/test_hermes_bootstrap.py index 1018184560..8aaa79271b 100644 --- a/tests/test_hermes_bootstrap.py +++ b/tests/test_hermes_bootstrap.py @@ -395,6 +395,49 @@ class TestHardenImportPath: +class TestEnableWindowsVt: + """Hermes prints raw SGR codes; a conhost console renders them only with VT on.""" + + @pytest.mark.platforms("windows") + def test_turns_vt_on_for_a_console_that_has_it_off(self): + # A fresh console via CREATE_NEW_CONSOLE is the installer's situation: + # a real conhost whose output handle starts without VT processing. + script = textwrap.dedent(""" + import ctypes, msvcrt, sys + from ctypes import wintypes + sys.path.insert(0, sys.argv[1]) + import hermes_bootstrap + kernel32 = ctypes.WinDLL("kernel32") + handle = msvcrt.get_osfhandle(sys.stdout.fileno()) + mode = wintypes.DWORD() + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + sys.exit(4) + kernel32.SetConsoleMode(handle, mode.value & ~0x0004) + ok = hermes_bootstrap.enable_windows_vt() + kernel32.GetConsoleMode(handle, ctypes.byref(mode)) + sys.exit(0 if ok and mode.value & 0x0004 else 3) + """).strip() + root = str(Path(__file__).resolve().parents[1]) + result = subprocess.run( + [sys.executable, "-c", script, root], + creationflags=subprocess.CREATE_NEW_CONSOLE, + timeout=120, + ) + if result.returncode == 4: + pytest.skip("this session cannot create a console") + assert result.returncode == 0 + + @pytest.mark.platforms("windows") + def test_leaves_non_console_handles_and_colour_alone(self, tmp_path, monkeypatch): + # Redirected output must neither fail nor flip Hermes to NO_COLOR. + monkeypatch.delenv("NO_COLOR", raising=False) + import hermes_bootstrap + + with open(tmp_path / "out.txt", "w", encoding="utf-8") as stream: + assert hermes_bootstrap.enable_windows_vt([stream]) is True + assert "NO_COLOR" not in os.environ + + class TestSuppressPlatformVerConsole: """suppress_platform_ver_console: stub applied on Windows, no-op on POSIX."""