diff --git a/Agent.md b/Agent.md index 816f771..821da08 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,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` (811) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (823) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (247: 45 daemon_client + 19 conn-manager + 22 app-commands + 121 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + 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/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index c6a43d6..1aac9b7 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -6,7 +6,9 @@ import locale import logging import os +import re import signal +import tempfile from emrg._win import win32_no_window_kwargs from emrg.server.git_utils import no_prompt_env @@ -18,6 +20,79 @@ MAX_OUTPUT_CHARS = 200_000 # Truncate large outputs (framing supports up to 16MB) +# Heredoc start: `cmd <<'EOF'` / `cmd <.*?)(?P<<-?)(?P['\"]?)(?P[A-Za-z_][A-Za-z0-9_.-]*)(?P=quote)\s*$", + re.MULTILINE, +) + + +def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]: + """Translate the first bash heredoc into a stdin redirect for cmd.exe. + + The bash tool's subprocess shell on Windows is cmd.exe (via COMSPEC), + which cannot parse ``cmd <<'EOF' ... EOF`` heredocs — commands documented + with heredoc syntax (e.g. ``browser-harness <<'PY' ... PY``) fail with + ``<< is not recognized`` / ``此时不应有 <<``, forcing agents into temp-file + workarounds. Rewriting the heredoc to ``cmd < tempfile`` feeds the same + bytes via stdin redirect, so Windows agents run the identical commands as + POSIX (host sessions 2026-08-14T21:26/21:36 observed the failure twice). + + Only the FIRST heredoc is translated (multiple heredocs in one command are + rare); an unterminated heredoc is left untouched so cmd.exe reports the + original error. ``<<-`` (tab-stripping) strips leading tabs from the body, + mirroring bash. Content is written literally (quoted ``<<'EOF'`` + semantics; unquoted heredocs containing ``$`` expansion keep literal + content — a documented approximation). Everything before the opener line + (e.g. ``cd /tmp\n`` in a multi-line command) is preserved verbatim in the + rewritten command, so the heredoc feeds the same stdin into the same + command line as POSIX (review #797 ❌). + + Returns ``(rewritten_cmd, temp_path)`` — the caller must unlink temp_path + after the subprocess finishes (normal or timeout path). + """ + m = _HEREDOC_START_RE.search(cmd) + if not m: + return cmd, None + head, op, name = m.group("head"), m.group("op"), m.group("name") + strip_tabs = op.endswith("-") + # Terminator: a line containing exactly the delimiter (optionally indented + # with tabs when the opener used `<<-`, mirroring bash tab-stripping). + term = re.compile(rf"(?m)^[ \t]*{re.escape(name)}\s*$" if strip_tabs + else rf"(?m)^{re.escape(name)}\s*$") + tm = term.search(cmd, m.end()) + if not tm: + return cmd, None # unterminated — let cmd.exe report it + body = cmd[m.end():tm.start()] + if body.startswith("\r\n"): + body = body[2:] # opener-line newline is not part of the body + elif body.startswith("\n"): + body = body[1:] + if strip_tabs: + body = "\n".join(line.lstrip("\t") for line in body.split("\n")) + # bash keeps the newline that precedes the terminator line as part of the + # body (a heredoc always ends with exactly one newline) — keep it verbatim. + tail = cmd[tm.end():].strip() + fd, path = tempfile.mkstemp(suffix=".heredoc", text=True) + try: + # newline="" keeps LF verbatim (no CRLF conversion of the body) + with os.fdopen(fd, "w", encoding="utf-8", newline="") as f: + f.write(body) + except Exception: + try: + os.unlink(path) + except OSError: + pass + raise + # Keep everything before the opener line (multi-line commands such as + # `cd /tmp\npython <<'PY'` must not lose their prefix — review #797 ❌). + rewritten = f"{cmd[:m.start()]}{head}< \"{path}\"" + if tail: + rewritten += f" {tail}" + return rewritten.rstrip(), path + def _decode_output(data: bytes, os_name: str | None = None) -> str: """Decode subprocess output bytes without corrupting non-UTF-8 text. @@ -65,7 +140,9 @@ def definition(self) -> ToolDefinition: "properties": { "command": { "type": "string", - "description": "The shell command to execute.", + "description": "The shell command to execute. Bash heredocs " + "(cmd <<'EOF' ... EOF) are supported on all platforms " + "— on Windows they are auto-translated to stdin redirects.", }, "timeout": { "type": "integer", @@ -90,6 +167,13 @@ async def execute(self, arguments: dict) -> ToolResult: logger.debug("bash: running %r (timeout=%ds)", cmd[:100], timeout) + # Windows: cmd.exe cannot parse bash heredocs — translate the first + # one to a stdin redirect (host sessions 2026-08-14T21:26/21:36 hit + # "此时不应有 <<" with browser-harness <<'PY'). POSIX unchanged. + temp_path = None + if os.name == "nt": + cmd, temp_path = _translate_windows_heredocs(cmd) + try: proc = await asyncio.create_subprocess_shell( cmd, @@ -125,6 +209,12 @@ async def execute(self, arguments: dict) -> ToolResult: content=f"Command timed out after {timeout}s: {cmd[:100]}", error=True, ) + finally: + if temp_path: + try: + os.unlink(temp_path) + except OSError: + pass out = _decode_output(stdout).rstrip() err = _decode_output(stderr).rstrip() diff --git a/tests/test_bash_tool.py b/tests/test_bash_tool.py index 94d563a..44fddbc 100644 --- a/tests/test_bash_tool.py +++ b/tests/test_bash_tool.py @@ -1,11 +1,12 @@ """Tests for the bash tool.""" import asyncio +import os import sys import pytest -from emrg.tools.bash_tool import BashTool, _decode_output +from emrg.tools.bash_tool import BashTool, _decode_output, _translate_windows_heredocs def _run(coro): @@ -168,3 +169,157 @@ def __init__(self, *args, **kwargs): main_mod._run_client() assert captured["kwargs"]["encoding"] == "utf-8" assert captured["kwargs"]["errors"] == "backslashreplace" + + +# ── Windows heredoc translation (host sessions 2026-08-14T21:26/21:36) ── +# cmd.exe (the bash tool's subprocess shell on Windows) cannot parse bash +# heredocs: `browser-harness <<'PY' ... PY` fails with "此时不应有 <<". The +# translation rewrites the first heredoc to `cmd < tempfile` so Windows agents +# run the identical commands as POSIX. All tests are pure-function (no +# subprocess) and run on every platform; a real cmd.exe integration test is +# gated to Windows. + +def test_heredoc_no_heredoc_unchanged(): + cmd, path = _translate_windows_heredocs("echo hello & dir") + assert cmd == "echo hello & dir" + assert path is None + + +def test_heredoc_quoted_delimiter(): + cmd = "browser-harness <<'PY'\nnew_tab(\"https://example.com\")\nprint(page_info())\nPY\n" + rewritten, path = _translate_windows_heredocs(cmd) + assert path is not None + assert rewritten.startswith("browser-harness < \"") + assert rewritten.endswith('"') + try: + with open(path, encoding="utf-8") as f: + # bash keeps the newline before the terminator line in the body + assert f.read() == 'new_tab("https://example.com")\nprint(page_info())\n' + finally: + os.unlink(path) + + +def test_heredoc_unquoted_delimiter(): + cmd = "cat < "alpha\nbeta") + assert f.read() == "alpha\nbeta\n" + finally: + os.unlink(path) + + +def test_heredoc_unterminated_left_untouched(): + cmd = "browser-harness <<'PY'\nprint('never closed')\n" + rewritten, path = _translate_windows_heredocs(cmd) + assert rewritten == cmd + assert path is None + + +def test_heredoc_tail_preserved(): + cmd = "cat <