fix(gateway): pass encoding="utf-8" to read_text/write_text in update path (#37423)

This commit is contained in:
Rod Boev 2026-06-04 08:16:15 -04:00 committed by Teknium
parent cbb1457606
commit 5b76ce169b
9 changed files with 81 additions and 30 deletions

View file

@ -429,7 +429,7 @@ class DeliveryRouter:
lines.append("")
lines.append(content)
output_path.write_text("\n".join(lines))
output_path.write_text("\n".join(lines), encoding="utf-8")
return {
"path": str(output_path),
@ -442,7 +442,7 @@ class DeliveryRouter:
out_dir = get_hermes_home() / "cron" / "output"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{job_id}_{timestamp}.txt"
path.write_text(content)
path.write_text(content, encoding="utf-8")
return path
def _filter_silence_narration_enabled(self) -> bool:

View file

@ -1202,7 +1202,7 @@ class QQAdapter(BasePlatformAdapter):
home = get_hermes_home()
response_path = home / ".update_response"
tmp = response_path.with_suffix(".tmp")
tmp.write_text(answer)
tmp.write_text(answer, encoding="utf-8")
tmp.replace(response_path)
logger.info(
"QQ update prompt answered %r by %s",

View file

@ -3778,7 +3778,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
def _load_voice_modes(self) -> Dict[str, str]:
try:
data = json.loads(self._VOICE_MODE_PATH.read_text())
data = json.loads(self._VOICE_MODE_PATH.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
@ -3806,7 +3806,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
try:
self._VOICE_MODE_PATH.parent.mkdir(parents=True, exist_ok=True)
self._VOICE_MODE_PATH.write_text(
json.dumps(self._voice_mode, indent=2)
json.dumps(self._voice_mode, indent=2), encoding="utf-8"
)
except OSError as e:
logger.warning("Failed to save voice modes: %s", e)
@ -6956,7 +6956,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
path = _hermes_home / self._STUCK_LOOP_FILE
try:
counts = json.loads(path.read_text()) if path.exists() else {}
counts = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
except Exception:
counts = {}
@ -6986,7 +6986,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return 0
try:
counts = json.loads(path.read_text())
counts = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return 0
@ -7032,7 +7032,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if not path.exists():
return
try:
counts = json.loads(path.read_text())
counts = json.loads(path.read_text(encoding="utf-8"))
if session_key in counts:
del counts[session_key]
if counts:
@ -10774,7 +10774,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
prompt_path = _hermes_home / ".update_prompt.json"
try:
tmp = response_path.with_suffix(".tmp")
tmp.write_text(response_text)
tmp.write_text(response_text, encoding="utf-8")
tmp.replace(response_path)
prompt_path.unlink(missing_ok=True)
except OSError as e:
@ -10794,7 +10794,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
prompt_path = _hermes_home / ".update_prompt.json"
try:
tmp = response_path.with_suffix(".tmp")
tmp.write_text("")
tmp.write_text("", encoding="utf-8")
tmp.replace(response_path)
prompt_path.unlink(missing_ok=True)
logger.info(
@ -14743,7 +14743,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
self._booted_from_restart = False
return True
return False
data = json.loads(marker_path.read_text())
data = json.loads(marker_path.read_text(encoding="utf-8"))
except Exception:
return False
@ -16697,7 +16697,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
for path in (claimed_path, pending_path):
if path.exists():
try:
pending = json.loads(path.read_text())
pending = json.loads(path.read_text(encoding="utf-8"))
platform_str = pending.get("platform")
chat_id = pending.get("chat_id")
chat_type = pending.get("chat_type")
@ -16736,7 +16736,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return
await asyncio.sleep(poll_interval)
if (pending_path.exists() or claimed_path.exists()) and not exit_code_path.exists():
exit_code_path.write_text("124")
exit_code_path.write_text("124", encoding="utf-8")
await self._send_update_notification()
return
@ -16778,7 +16778,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Read any remaining output
if output_path.exists():
try:
content = output_path.read_text()
content = output_path.read_text(encoding="utf-8")
if len(content) > bytes_sent:
buffer += content[bytes_sent:]
bytes_sent = len(content)
@ -16788,7 +16788,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Send final status
try:
exit_code_raw = exit_code_path.read_text().strip() or "1"
exit_code_raw = exit_code_path.read_text(encoding="utf-8").strip() or "1"
exit_code = int(exit_code_raw)
if exit_code == 0:
await adapter.send(
@ -16817,7 +16817,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Check for new output
if output_path.exists():
try:
content = output_path.read_text()
content = output_path.read_text(encoding="utf-8")
if len(content) > bytes_sent:
buffer += content[bytes_sent:]
bytes_sent = len(content)
@ -16835,7 +16835,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if (prompt_path.exists() and session_key
and not self._update_prompt_pending.get(session_key)):
try:
prompt_data = json.loads(prompt_path.read_text())
prompt_data = json.loads(prompt_path.read_text(encoding="utf-8"))
prompt_text = prompt_data.get("prompt", "")
default = prompt_data.get("default", "")
if prompt_text:
@ -16883,7 +16883,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Timeout
if not exit_code_path.exists():
logger.warning("Update watcher timed out after %.0fs", timeout)
exit_code_path.write_text("124")
exit_code_path.write_text("124", encoding="utf-8")
await _flush_buffer()
try:
await adapter.send(
@ -16929,7 +16929,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
elif not claimed_path.exists():
return True
pending = json.loads(claimed_path.read_text())
pending = json.loads(claimed_path.read_text(encoding="utf-8"))
platform_str = pending.get("platform")
chat_id = pending.get("chat_id")
chat_type = pending.get("chat_type")
@ -16943,13 +16943,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
claimed_path.replace(pending_path)
return False
exit_code_raw = exit_code_path.read_text().strip() or "1"
exit_code_raw = exit_code_path.read_text(encoding="utf-8").strip() or "1"
exit_code = int(exit_code_raw)
# Read the captured update output
output = ""
if output_path.exists():
output = output_path.read_text()
output = output_path.read_text(encoding="utf-8")
# Resolve adapter
platform = Platform(platform_str)
@ -17024,7 +17024,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return None
try:
data = json.loads(notify_path.read_text())
data = json.loads(notify_path.read_text(encoding="utf-8"))
platform_str = data.get("platform")
chat_id = data.get("chat_id")
chat_type = data.get("chat_type")

View file

@ -4963,7 +4963,7 @@ class GatewaySlashCommandsMixin:
if event.message_id:
pending["message_id"] = event.message_id
_tmp_pending = pending_path.with_suffix(".tmp")
_tmp_pending.write_text(json.dumps(pending))
_tmp_pending.write_text(json.dumps(pending), encoding="utf-8")
_tmp_pending.replace(pending_path)
exit_code_path.unlink(missing_ok=True)

View file

@ -617,7 +617,7 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
return None
try:
raw = pid_path.read_text().strip()
raw = pid_path.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
# File was deleted between exists() and read_text(), permission
# flipped, or it holds non-UTF-8 / binary garbage.

View file

@ -2180,7 +2180,7 @@ class FeishuAdapter(BasePlatformAdapter):
def _write_update_prompt_response(answer: str) -> None:
response_path = get_hermes_home() / ".update_response"
tmp_path = response_path.with_suffix(".tmp")
tmp_path.write_text(answer)
tmp_path.write_text(answer, encoding="utf-8")
tmp_path.replace(response_path)
async def send_voice(

View file

@ -6352,7 +6352,7 @@ class TelegramAdapter(BasePlatformAdapter):
home = get_hermes_home()
response_path = home / ".update_response"
tmp = response_path.with_suffix(".tmp")
tmp.write_text(answer)
tmp.write_text(answer, encoding="utf-8")
tmp.replace(response_path)
logger.info("Telegram update prompt answered '%s' by user %s",
answer, getattr(query.from_user, "id", "unknown"))

View file

@ -167,7 +167,7 @@ def _kill_stale_bridge_by_pidfile(session_path: Path) -> None:
try:
# Format: line 1 = pid, optional line 2 = kernel start time. Legacy
# files written before the guard existed have only the pid.
lines = pid_file.read_text().split("\n")
lines = pid_file.read_text(encoding="utf-8").split("\n")
pid = int(lines[0].strip())
if len(lines) > 1 and lines[1].strip():
recorded_start = int(lines[1].strip())
@ -208,7 +208,7 @@ def _write_bridge_pidfile(session_path: Path, pid: int) -> None:
from gateway.status import get_process_start_time
start = get_process_start_time(pid)
text = str(pid) if start is None else "{}\n{}".format(pid, start)
(session_path / "bridge.pid").write_text(text)
(session_path / "bridge.pid").write_text(text, encoding="utf-8")
except OSError:
pass
@ -531,7 +531,9 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
_deps_fresh = False
if (bridge_dir / "node_modules").exists():
try:
_deps_fresh = (_dep_stamp.read_text().strip() == _pkg_hash) and bool(_pkg_hash)
_deps_fresh = (
_dep_stamp.read_text(encoding="utf-8").strip() == _pkg_hash
) and bool(_pkg_hash)
except OSError:
_deps_fresh = False
if not _deps_fresh:
@ -557,7 +559,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
print(f"[{self.name}] Dependencies installed")
if _pkg_hash:
try:
_dep_stamp.write_text(_pkg_hash)
_dep_stamp.write_text(_pkg_hash, encoding="utf-8")
except OSError:
pass # Stamp is an optimization; install still succeeded
except Exception as e:

View file

@ -0,0 +1,49 @@
"""Static guard: every ``read_text`` / ``write_text`` call under ``gateway/``
must pass an explicit ``encoding=`` keyword argument so non-UTF-8 Windows
locales don't corrupt file IPC. Mirrors the AST-based guard pattern in
``tests/tools/test_windows_compat.py``.
"""
import ast
import pathlib
import pytest
GATEWAY_DIR = pathlib.Path(__file__).resolve().parents[2] / "gateway"
METHODS = {"read_text", "write_text"}
SUPPRESSION = "# gateway-utf8: ok"
def _find_violations():
violations = []
for py_file in sorted(GATEWAY_DIR.rglob("*.py")):
source = py_file.read_text(encoding="utf-8")
source_lines = source.splitlines()
try:
tree = ast.parse(source, filename=str(py_file))
except SyntaxError:
continue
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if not isinstance(func, ast.Attribute):
continue
if func.attr not in METHODS:
continue
if any(kw.arg == "encoding" for kw in node.keywords):
continue
lineno = node.lineno
if lineno <= len(source_lines) and SUPPRESSION in source_lines[lineno - 1]:
continue
rel = py_file.relative_to(GATEWAY_DIR.parent)
violations.append(f"{rel}:{lineno}")
return violations
def test_all_read_write_text_pass_encoding():
violations = _find_violations()
assert not violations, (
"Bare read_text()/write_text() calls found (missing encoding= kwarg).\n"
"Add encoding=\"utf-8\" or suppress with '# gateway-utf8: ok':\n"
+ "\n".join(f" {v}" for v in violations)
)