fix(cli): keep hermes_cli import off os.environ; run the import-purity test on Windows

Importing hermes_cli on a host whose stdout is not UTF-8 (a cp1252 pipe on
Windows, a latin-1 locale on a Pi) set PYTHONUTF8/PYTHONIOENCODING in
os.environ as a side effect. Every library importer -- gateway.relay, the
compute host, agent.auxiliary_client -- then leaked that into each child it
spawned, which is what
test_library_imports_of_dual_use_entry_modules_stay_side_effect_free
catches on Windows (changed == [PYTHONIOENCODING, PYTHONUTF8]).

The package import now only repairs its own streams and records whether it
had to. hermes_cli.main.main() turns that record into the child-process
hint, so `hermes` still steers its Python children to UTF-8 on such a host.
On Windows the entry-point bootstrap and configure_windows_stdio() already
export both variables; the tool subprocess env builders set them for
children independently.

The test was unmarked, so the OS lanes (`-m "platforms and not
integration"`) deselected it and Windows never ran it. Mark it
platforms("any"): list_os_marked_tests.py lists the file for every lane and
the -m expression now selects the test.
This commit is contained in:
ethernet
2026-09-24 14:34:01 -04:00
parent 9b99d6b91e
commit a021b61bfc
3 changed files with 17 additions and 9 deletions

View File

@@ -1,6 +1,5 @@
"""Hermes CLI - Unified command-line interface for Hermes Agent.""" """Hermes CLI - Unified command-line interface for Hermes Agent."""
import os
import sys import sys
__release_date__ = "2026.9.24" __release_date__ = "2026.9.24"
@@ -40,8 +39,8 @@ def __getattr__(name: str) -> str:
return str(read_install_stamp(repo_root()).get("baseVersion") or "0.0.0") return str(read_install_stamp(repo_root()).get("baseVersion") or "0.0.0")
def _ensure_utf8(): def _ensure_utf8() -> bool:
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes. """Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes; True when a stream was repaired.
The CLI prints box-drawing characters and the ☤ glyph in the setup wizard, doctor, and status The CLI prints box-drawing characters and the ☤ glyph in the setup wizard, doctor, and status
banners; under a non-UTF-8 codec that raises before the command can even start (e.g. banners; under a non-UTF-8 codec that raises before the command can even start (e.g.
@@ -68,11 +67,10 @@ def _ensure_utf8():
repaired = True repaired = True
except (AttributeError, OSError, ValueError): except (AttributeError, OSError, ValueError):
pass pass
# Only nudge child processes toward UTF-8 when a non-UTF-8 locale was actually detected; on a return repaired
# healthy UTF-8 host children inherit it from the locale already.
if repaired:
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
_ensure_utf8() # Import repairs only this process's streams. Gateway, compute host, and test code import this
# package as a library; rewriting their os.environ would leak into every child they spawn, so the
# child-process UTF-8 hint is applied by the CLI entry point (hermes_cli.main.main) instead.
_stdio_repaired = _ensure_utf8()

View File

@@ -3545,6 +3545,12 @@ def main():
configure_windows_stdio() configure_windows_stdio()
except Exception: except Exception:
pass pass
# A non-UTF-8 locale that the package import had to repair would crash Python children the
# same way. Only on that host, so a healthy UTF-8 locale keeps its children untouched.
from hermes_cli import _stdio_repaired
if _stdio_repaired:
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# One TLS authority: trust the OS store before any outbound call resolves a # One TLS authority: trust the OS store before any outbound call resolves a
# CA bundle (agent/ssl_verify.py). Never raises; False just means OpenSSL's paths. # CA bundle (agent/ssl_verify.py). Never raises; False just means OpenSSL's paths.

View File

@@ -228,6 +228,10 @@ else:
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "bootstrap-before-app" assert result.stdout.strip() == "bootstrap-before-app"
# "any": the OS lanes select only platforms-marked tests, and Windows is where hermes_cli's
# stdio repair fires on a cp1252 pipe.
@pytest.mark.platforms("any")
def test_library_imports_of_dual_use_entry_modules_stay_side_effect_free(tmp_path): def test_library_imports_of_dual_use_entry_modules_stay_side_effect_free(tmp_path):
# The codex hermes-tools MCP server and the compute host are ``python -m`` entry points # The codex hermes-tools MCP server and the compute host are ``python -m`` entry points
# that agent/gateway code and tests also import; there the bootstrap exported TMPDIR and # that agent/gateway code and tests also import; there the bootstrap exported TMPDIR and