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.
This commit is contained in:
Ahmed Allam 2026-10-03 22:02:31 +00:00 • committed by Ahmed Allam
parent 45b775dcd5
commit 5c3476795a
3 changed files with 97 additions and 4 deletions

View file

@ -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()

View file

@ -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

View file

@ -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")