From 5b76ce169bc79dc077dd0b1aa70b126ef3c2cb30 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 4 Jun 2026 08:16:15 -0400 Subject: [PATCH] fix(gateway): pass encoding="utf-8" to read_text/write_text in update path (#37423) --- gateway/delivery.py | 4 +- gateway/platforms/qqbot/adapter.py | 2 +- gateway/run.py | 38 ++++++++-------- gateway/slash_commands.py | 2 +- gateway/status.py | 2 +- plugins/platforms/feishu/adapter.py | 2 +- plugins/platforms/telegram/adapter.py | 2 +- plugins/platforms/whatsapp/adapter.py | 10 +++-- tests/gateway/test_gateway_utf8_encoding.py | 49 +++++++++++++++++++++ 9 files changed, 81 insertions(+), 30 deletions(-) create mode 100644 tests/gateway/test_gateway_utf8_encoding.py diff --git a/gateway/delivery.py b/gateway/delivery.py index 3672a7c49e3a..fa43db6d0f92 100644 --- a/gateway/delivery.py +++ b/gateway/delivery.py @@ -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: diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index cb9a64e3bd48..2816326efb3f 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -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", diff --git a/gateway/run.py b/gateway/run.py index 72f2b2626ada..5f9219bcd643 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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") diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index e70cc25d72d3..58c365ddb3e7 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -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) diff --git a/gateway/status.py b/gateway/status.py index d2d3c1509188..365518e26e90 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -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. diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 93571de06199..e1df2a05e5e5 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -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( diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 77eb7dcb42a4..60e2d7bb9ee1 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -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")) diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 8736274a76cc..8d654197e04a 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -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: diff --git a/tests/gateway/test_gateway_utf8_encoding.py b/tests/gateway/test_gateway_utf8_encoding.py new file mode 100644 index 000000000000..691311888dee --- /dev/null +++ b/tests/gateway/test_gateway_utf8_encoding.py @@ -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) + )