diff --git a/Agent.md b/Agent.md index c51a06ee..68c621b6 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (591) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (597) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (93: 22 daemon_client + 22 app-commands + 24 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/README.cn.md b/README.cn.md index e2612f76..e489e81a 100644 --- a/README.cn.md +++ b/README.cn.md @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。 git clone https://github.com/argszero/emrg.git cd emrg uv sync # 安装依赖 -uv run pytest tests/ -v # 跑测试(当前 591 项) +uv run pytest tests/ -v # 跑测试(当前 597 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 diff --git a/README.md b/README.md index b4d5eab4..c7cf7e0d 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 591 items) +uv run pytest tests/ -v # run tests (currently 597 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/__main__.py b/emrg/__main__.py index b8a589ae..dd6b6753 100644 --- a/emrg/__main__.py +++ b/emrg/__main__.py @@ -285,7 +285,14 @@ def _run_client(init_auto_evolve: bool = False) -> None: datefmt="%H:%M:%S", handlers=[ RotatingFileHandler( - str(log_path), maxBytes=10 * 1024 * 1024, backupCount=3 + str(log_path), maxBytes=10 * 1024 * 1024, backupCount=3, + # encoding="utf-8" — symmetric with the daemon's #556 fix: + # the default locale code page (GBK on zh-CN Windows) cannot + # encode U+FFFD and logging.emit would crash with "--- Logging + # error ---", polluting the shared TUI terminal. errors= + # "backslashreplace" is defense-in-depth: logging must never + # crash on exotic characters (rant 2026-08-08T09:35:30). + encoding="utf-8", errors="backslashreplace", ), ], ) diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index c3fa99ca..1f387b01 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import locale import logging import os import signal @@ -17,6 +18,34 @@ MAX_OUTPUT_CHARS = 200_000 # Truncate large outputs (framing supports up to 16MB) +def _decode_output(data: bytes, os_name: str | None = None) -> str: + """Decode subprocess output bytes without corrupting non-UTF-8 text. + + POSIX: subprocess output is UTF-8 — unchanged behavior. + + Windows: cmd.exe/dir/echo output uses the console locale code page + (GBK/cp936 on zh-CN), while git/gh emit UTF-8. Both must decode + correctly, so we try the locale encoding **strictly** first and fall + back to UTF-8 (also strict), then to UTF-8 with replacement as a last + resort. A non-strict first attempt would silently mojibake UTF-8 + output and never reach the fallback (rant 2026-08-08T09:35:30 — + U+FFFD garbage from decoding GBK bytes as UTF-8). + + ``os_name`` is injectable for tests (defaults to ``os.name``). + """ + if not data: + return "" + name = os_name or os.name + if name == "nt": + enc = locale.getpreferredencoding(False) or "utf-8" + for candidate in (enc, "utf-8"): + try: + return data.decode(candidate) # strict + except (LookupError, UnicodeDecodeError): + continue + return data.decode("utf-8", errors="replace") + + class BashTool(ToolExecutor): """Execute shell commands via asyncio subprocess.""" @@ -92,8 +121,8 @@ async def execute(self, arguments: dict) -> ToolResult: error=True, ) - out = stdout.decode("utf-8", errors="replace").rstrip() - err = stderr.decode("utf-8", errors="replace").rstrip() + out = _decode_output(stdout).rstrip() + err = _decode_output(stderr).rstrip() # Smart truncation: keep stderr intact (errors are critical), # truncate stdout with head+tail when output exceeds limit. diff --git a/tests/test_bash_tool.py b/tests/test_bash_tool.py index 46bd18da..94d563a7 100644 --- a/tests/test_bash_tool.py +++ b/tests/test_bash_tool.py @@ -5,7 +5,7 @@ import pytest -from emrg.tools.bash_tool import BashTool +from emrg.tools.bash_tool import BashTool, _decode_output def _run(coro): @@ -91,3 +91,80 @@ def test_bash_child_env_overridable_by_command(): })) assert not result.error assert result.content.strip().endswith("1") + + +# ── _decode_output (rant 2026-08-08T09:35:30, Windows GBK output) ── +# On zh-CN Windows, cmd.exe/dir/echo emit GBK/cp936 bytes while git/gh emit +# UTF-8. Decoding GBK bytes as UTF-8 produces U+FFFD garbage (and logging +# that to a GBK-code-page handler crashes logging). _decode_output tries the +# locale code page strictly first, then UTF-8 — both must decode correctly. + +def test_decode_output_gbk_bytes_on_windows(monkeypatch): + """GBK bytes (zh-CN cmd output) decode correctly on a simulated nt path.""" + # The test machine's locale is UTF-8 — force the zh-CN code page. + monkeypatch.setattr("emrg.tools.bash_tool.locale.getpreferredencoding", + lambda default=False: "gbk") + assert _decode_output("中文文件名.txt".encode("gbk"), os_name="nt") == "中文文件名.txt" + + +def test_decode_output_utf8_bytes_on_windows(monkeypatch): + """UTF-8 bytes (git/gh output) decode correctly on a simulated nt path.""" + monkeypatch.setattr("emrg.tools.bash_tool.locale.getpreferredencoding", + lambda default=False: "gbk") + assert _decode_output("中文".encode("utf-8"), os_name="nt") == "中文" + + +def test_decode_output_posix_unchanged(): + """POSIX path keeps the original UTF-8-with-replacement behavior.""" + assert _decode_output("hello".encode("utf-8"), os_name="posix") == "hello" + # Invalid UTF-8 on POSIX still degrades to replacement (never raises). + assert "\ufffd" in _decode_output(b"\xff\xfe\x00", os_name="posix") + + +def test_decode_output_empty_and_none(): + """Empty/None bytes yield '' without errors.""" + assert _decode_output(b"") == "" + assert _decode_output(None) == "" + + +def test_decode_output_fallback_chain_no_mojibake(monkeypatch): + """A UTF-8 string must not be silently mojibake'd by the GBK first try. + + If the first decode were non-strict (errors='replace'), UTF-8 bytes would + decode as GBK to garbage and never reach the UTF-8 fallback — the + regression this test pins. + """ + monkeypatch.setattr("emrg.tools.bash_tool.locale.getpreferredencoding", + lambda default=False: "gbk") + payload = "git log 输出中文".encode("utf-8") + assert _decode_output(payload, os_name="nt") == "git log 输出中文" + + +def test_client_log_handler_uses_utf8_encoding(): + """_run_client's RotatingFileHandler must be UTF-8 (rant #556 symmetry). + + Daemon side got encoding='utf-8' in #556; the client side was missed, + crashing on zh-CN Windows when a log line contains U+FFFD. Assert the + handler receives encoding='utf-8' and errors='backslashreplace'. + """ + import logging.handlers as lh_mod + from unittest.mock import patch + + captured = {} + + class _SpyHandler(lh_mod.RotatingFileHandler): + def __init__(self, *args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + # Avoid actually opening a file + raise RuntimeError("stop") + + import emrg.__main__ as main_mod + + # _run_client does `from logging.handlers import RotatingFileHandler` + # at call time — patch the source module, not emrg.__main__. + with patch.object(lh_mod, "RotatingFileHandler", _SpyHandler): + with pytest.raises(RuntimeError, match="stop"): + main_mod._run_client() + assert captured["kwargs"]["encoding"] == "utf-8" + assert captured["kwargs"]["errors"] == "backslashreplace"