hermes-agent/tests/tui_gateway/test_subprocess_encoding.py

132 lines
6.1 KiB
Python

"""Regression tests for UTF-8 encoding hardening in tui_gateway/server.py (#53137).
On Windows with a non-UTF-8 system locale (e.g. GBK on Chinese Windows),
text-mode subprocess reads defaulted to the locale encoding. When a child
process emitted bytes invalid in that locale, an unhandled UnicodeDecodeError
crashed the reader thread / gateway thread.
#53137 added encoding="utf-8", errors="replace" to every text-mode subprocess
call in tui_gateway/server.py. These tests assert that the kwargs survive so
the crash class cannot silently regress.
# Test pattern adapted from @devorun's PR #52700 (salvage convention).
"""
from __future__ import annotations
import subprocess
from unittest.mock import MagicMock, patch
import pytest
import tui_gateway.server as server
# ── helpers ──────────────────────────────────────────────────────────────
def _make_completed_process() -> MagicMock:
"""A CompletedProcess-like mock with str stdout/stderr (text=True contract)."""
cp = MagicMock()
cp.stdout = ""
cp.stderr = ""
cp.returncode = 0
return cp
# ── _SlashWorker.Popen path ──────────────────────────────────────────────
def test_slash_worker_popen_uses_utf8_replace():
"""The slash-worker subprocess.Popen must pass encoding="utf-8" and
errors="replace" so invalid bytes in child stdout/stderr don't raise
UnicodeDecodeError inside the drain threads (#53137).
"""
with patch.dict("sys.modules", {
"hermes_constants": MagicMock(
get_hermes_home=MagicMock(return_value="/tmp/hermes_test")
),
}):
with patch("subprocess.Popen") as mock_popen:
mock_popen.return_value.stdout = MagicMock()
mock_popen.return_value.stderr = MagicMock()
from tui_gateway.server import _SlashWorker
_SlashWorker(
session_key="test_key",
model="test-model",
)
assert mock_popen.called, "Popen was not invoked"
kwargs = mock_popen.call_args[1]
assert kwargs.get("encoding") == "utf-8", (
f"slash-worker Popen must set encoding='utf-8' (got {kwargs.get('encoding')!r})"
)
assert kwargs.get("errors") == "replace", (
f"slash-worker Popen must set errors='replace' (got {kwargs.get('errors')!r})"
)
# ── cli.exec handler ─────────────────────────────────────────────────────
def test_cli_exec_uses_utf8_replace():
"""The cli.exec RPC handler runs `python -m hermes_cli.main` via
subprocess.run; it must pass encoding="utf-8" and errors="replace"
(#53137)."""
handler = server._methods["cli.exec"]
with patch("subprocess.run", return_value=_make_completed_process()) as mock_run:
# Non-interactive argv that passes _cli_exec_blocked.
resp = handler(1, {"argv": ["--version"]})
assert mock_run.called, "subprocess.run was not invoked"
kwargs = mock_run.call_args[1]
assert kwargs.get("encoding") == "utf-8", (
f"cli.exec subprocess.run must set encoding='utf-8' (got {kwargs.get('encoding')!r})"
)
assert kwargs.get("errors") == "replace", (
f"cli.exec subprocess.run must set errors='replace' (got {kwargs.get('errors')!r})"
)
# ── shell.exec handler ───────────────────────────────────────────────────
def test_shell_exec_uses_utf8_replace():
"""The shell.exec RPC handler runs an arbitrary shell command via
subprocess.run; it must pass encoding="utf-8" and errors="replace"
(#53137)."""
handler = server._methods["shell.exec"]
with patch("subprocess.run", return_value=_make_completed_process()) as mock_run:
# A harmless, non-dangerous command that passes the approval gate.
with patch("tools.approval.detect_hardline_command", return_value=(False, "")), \
patch("tools.approval.detect_dangerous_command", return_value=(False, None, "")):
resp = handler(1, {"command": "echo hello"})
assert mock_run.called, "subprocess.run was not invoked"
kwargs = mock_run.call_args[1]
assert kwargs.get("encoding") == "utf-8", (
f"shell.exec subprocess.run must set encoding='utf-8' (got {kwargs.get('encoding')!r})"
)
assert kwargs.get("errors") == "replace", (
f"shell.exec subprocess.run must set errors='replace' (got {kwargs.get('errors')!r})"
)
# ── quick-command exec path (via command.dispatch) ───────────────────────
def test_quick_command_exec_uses_utf8_replace():
"""A quick_command of type 'exec' is dispatched via command.dispatch;
the underlying subprocess.run must pass encoding="utf-8" and
errors="replace" (#53137)."""
handler = server._methods["command.dispatch"]
fake_cp = _make_completed_process()
with patch("subprocess.run", return_value=fake_cp) as mock_run, \
patch("tui_gateway.server._load_cfg", return_value={
"quick_commands": {"runcmd": {"type": "exec", "command": "echo hi"}}
}), \
patch("tools.environments.local._sanitize_subprocess_env", return_value={"PATH": "/usr/bin"}):
resp = handler(1, {"name": "runcmd", "arg": "", "session_id": ""})
assert mock_run.called, "subprocess.run was not invoked for quick-command exec"
kwargs = mock_run.call_args[1]
assert kwargs.get("encoding") == "utf-8", (
f"quick-command exec subprocess.run must set encoding='utf-8' (got {kwargs.get('encoding')!r})"
)
assert kwargs.get("errors") == "replace", (
f"quick-command exec subprocess.run must set errors='replace' (got {kwargs.get('errors')!r})"
)