From 5c3476795aaeba9fc3d939fc22bf208c06419854 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sat, 3 Oct 2026 22:02:31 +0000 Subject: [PATCH] fix(cli): force UTF-8 stdout/stderr on Windows so Rich output never raises Windows hands a redirected or legacy console stream the ANSI code page (cp1252), which cannot encode Rich's panels or the model's text: one check mark in a finding ended a headless run with UnicodeEncodeError, and the error handler raised again rendering its own panel. Reconfigure both streams to UTF-8 at startup on win32, and open the Go TUI's os.devnull sink as UTF-8 so logging into it stops producing --- Logging error --- reports under the same code page. --- strix/interface/main.py | 22 ++++++++++-- strix/interface/tui/runtime.py | 14 ++++++-- tests/test_utf8_streams.py | 65 ++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 tests/test_utf8_streams.py diff --git a/strix/interface/main.py b/strix/interface/main.py index b86ed141..a7ab2dc5 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -415,12 +415,30 @@ def _bootstrap_scan(args: argparse.Namespace) -> None: telemetry_start(args) -def main() -> None: - configure_dependency_logging() +def _force_utf8_streams() -> None: + """Give stdout/stderr a UTF-8 encoding, whatever the console code page. + Windows hands a redirected or legacy console stream the ANSI code page + (cp1252 and friends), which cannot encode Rich's output or the model's + text: one ``\u2713`` in a finding ends a headless run with + ``UnicodeEncodeError``. Streams without ``reconfigure`` (test captures, + replaced file objects) are left alone. + """ + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + continue + with contextlib.suppress(ValueError, OSError): + reconfigure(encoding="utf-8") + + +def main() -> None: if sys.platform == "win32": + _force_utf8_streams() asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + configure_dependency_logging() + if len(sys.argv) == 2 and sys.argv[1] in ("-h", "--help"): try: parse_arguments() diff --git a/strix/interface/tui/runtime.py b/strix/interface/tui/runtime.py index 64c77a7b..de3a4b73 100644 --- a/strix/interface/tui/runtime.py +++ b/strix/interface/tui/runtime.py @@ -11,7 +11,7 @@ import shutil import sys from copy import deepcopy from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TextIO from strix.config import load_settings, persist_current from strix.core.agents import AgentCoordinator @@ -58,6 +58,16 @@ class GoTuiPreActivationError(RuntimeError): """A sidecar failure raised before the Go TUI activates.""" +def _open_output_sink() -> TextIO: + """A UTF-8 ``os.devnull`` handle for Python's stdout/stderr while the Go TUI owns the terminal. + + UTF-8 is explicit because the locale encoding (cp1252 on Windows) cannot take + the model's text, and a logging handler that fails to write there floods the + log with ``--- Logging error ---`` reports. + """ + return Path(os.devnull).open("a", buffering=1, encoding="utf-8") + + class GoTuiRuntime: def __init__(self, args: argparse.Namespace) -> None: self.args = args @@ -433,7 +443,7 @@ class GoTuiRuntime: # only the Python-level bindings change. original_stdout = sys.stdout original_stderr = sys.stderr - output_sink = Path(os.devnull).open("a", buffering=1) # noqa: SIM115 + output_sink = _open_output_sink() sys.stdout = output_sink sys.stderr = output_sink backend_socket: socket.socket | None = None diff --git a/tests/test_utf8_streams.py b/tests/test_utf8_streams.py new file mode 100644 index 00000000..ebe42b6d --- /dev/null +++ b/tests/test_utf8_streams.py @@ -0,0 +1,65 @@ +"""Windows gives redirected stdout/stderr the ANSI code page; Strix forces UTF-8 (STR-742).""" + +from __future__ import annotations + +import io +import sys + +import pytest +from rich.console import Console + +from strix.interface.main import _force_utf8_streams +from strix.interface.tui.runtime import _open_output_sink + + +UNICODE_SAMPLE = "Reflected XSS in / \u2713 \u2192 \U0001f50d \u00b7 done" + + +def _cp1252_stream() -> tuple[io.TextIOWrapper, io.BytesIO]: + raw = io.BytesIO() + return io.TextIOWrapper(raw, encoding="cp1252", line_buffering=True), raw + + +def test_rich_output_raises_on_a_cp1252_stream(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "stdout", _cp1252_stream()[0]) + monkeypatch.setattr(sys, "stderr", _cp1252_stream()[0]) + + with pytest.raises(UnicodeEncodeError): + Console().print(UNICODE_SAMPLE) + + +def test_force_utf8_streams_lets_rich_print_unicode(monkeypatch: pytest.MonkeyPatch) -> None: + stdout, stdout_bytes = _cp1252_stream() + stderr, stderr_bytes = _cp1252_stream() + monkeypatch.setattr(sys, "stdout", stdout) + monkeypatch.setattr(sys, "stderr", stderr) + + _force_utf8_streams() + + assert stdout.encoding == "utf-8" + assert stderr.encoding == "utf-8" + Console().print(UNICODE_SAMPLE) + Console(stderr=True).print(UNICODE_SAMPLE) + stdout.flush() + stderr.flush() + assert UNICODE_SAMPLE in stdout_bytes.getvalue().decode("utf-8") + assert UNICODE_SAMPLE in stderr_bytes.getvalue().decode("utf-8") + + +def test_force_utf8_streams_skips_streams_without_reconfigure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + stdout = io.StringIO() + monkeypatch.setattr(sys, "stdout", stdout) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + _force_utf8_streams() + + sys.stdout.write(UNICODE_SAMPLE) + assert stdout.getvalue() == UNICODE_SAMPLE + + +def test_tui_output_sink_accepts_unicode() -> None: + with _open_output_sink() as sink: + assert sink.encoding == "utf-8" + sink.write(UNICODE_SAMPLE + "\n")