Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 即失败

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
9 changes: 8 additions & 1 deletion emrg/__main__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
),
],
)
Expand Down
33 changes: 31 additions & 2 deletions emrg/tools/bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import locale
import logging
import os
import signal
Expand All@@ -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."""

Expand DownExpand Up@@ -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.
Expand Down
79 changes: 78 additions & 1 deletion tests/test_bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):
Expand DownExpand Up@@ -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"
Loading