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 number Diff line number Diff line change
Expand Up @@ -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 路径不受影响)
Expand Down
92 changes: 91 additions & 1 deletion emrg/tools/bash_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +20,79 @@

MAX_OUTPUT_CHARS = 200_000 # Truncate large outputs (framing supports up to 16MB)

# Heredoc start: `cmd <<'EOF'` / `cmd <<EOF` / `cmd <<-EOF` (quote optional,
# matched symmetrically via backreference). MULTILINE so ^/$ bound the first
# command line, not the whole command string. cmd.exe cannot parse this.
_HEREDOC_START_RE = re.compile(
r"^(?P<head>.*?)(?P<op><<-?)(?P<quote>['\"]?)(?P<name>[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.
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
157 changes: 156 additions & 1 deletion tests/test_bash_tool.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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 <<EOF\nline1\nline2\nEOF\n"
rewritten, path = _translate_windows_heredocs(cmd)
assert path is not None
assert rewritten.startswith("cat < \"")
try:
with open(path, encoding="utf-8") as f:
assert f.read() == "line1\nline2\n"
finally:
os.unlink(path)


def test_heredoc_tab_stripping():
cmd = "cat <<-EOF\n\talpha\n\t\tbeta\nEOF\n"
rewritten, path = _translate_windows_heredocs(cmd)
assert path is not None
try:
with open(path, encoding="utf-8") as f:
# bash <<- strips ALL leading tabs from each body line (verified
# empirically: "alpha\n\t\tbeta" -> "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 <<EOF\nbody\nEOF\n& echo after\n"
rewritten, path = _translate_windows_heredocs(cmd)
assert path is not None
assert rewritten.endswith('" & echo after')
try:
with open(path, encoding="utf-8") as f:
assert f.read() == "body\n"
finally:
os.unlink(path)


def test_heredoc_body_word_does_not_terminate():
cmd = "cat <<EOF\nEOF is just a word here\nreal EOF\nEOF\n"
rewritten, path = _translate_windows_heredocs(cmd)
assert path is not None
try:
with open(path, encoding="utf-8") as f:
assert f.read() == "EOF is just a word here\nreal EOF\n"
finally:
os.unlink(path)


def test_heredoc_empty_body():
cmd = "cat <<EOF\nEOF\n"
rewritten, path = _translate_windows_heredocs(cmd)
assert path is not None
try:
with open(path, encoding="utf-8") as f:
# verified empirically: bash delivers 0 bytes for an empty heredoc
assert f.read() == ""
finally:
os.unlink(path)


def test_heredoc_multiline_prefix_preserved_opener_line2():
"""Multi-line commands keep everything before the opener line (review #797 ❌).

`echo a\necho b <<'PY'` previously rewrote to `echo b < temp`, silently
dropping `echo a`. The identical POSIX command shape must be preserved.
"""
cmd = "echo a\necho b <<'PY'\nbody\nPY\n"
rewritten, path = _translate_windows_heredocs(cmd)
assert path is not None
assert rewritten.startswith("echo a\necho b < \"")
try:
with open(path, encoding="utf-8") as f:
assert f.read() == "body\n"
finally:
os.unlink(path)


def test_heredoc_multiline_prefix_preserved_opener_line3():
"""Opener on line 3 — both preceding lines survive the rewrite."""
cmd = "echo a\necho b\necho c <<'PY'\nbody\nPY\n"
rewritten, path = _translate_windows_heredocs(cmd)
assert path is not None
assert rewritten.startswith("echo a\necho b\necho c < \"")
try:
with open(path, encoding="utf-8") as f:
assert f.read() == "body\n"
finally:
os.unlink(path)


def test_heredoc_cd_prefix_workdir_pattern():
"""The plausible agent pattern `cd /tmp\\npython <<'PY'` keeps its cd."""
cmd = "cd /tmp\npython -X utf8 <<'PY'\nprint('ok')\nPY\n"
rewritten, path = _translate_windows_heredocs(cmd)
assert path is not None
assert rewritten.startswith("cd /tmp\npython -X utf8 < \"")
try:
with open(path, encoding="utf-8") as f:
assert f.read() == "print('ok')\n"
finally:
os.unlink(path)


@pytest.mark.skipif(sys.platform != "win32", reason="cmd.exe heredoc translation is Windows-only")
def test_bash_heredoc_integration_windows():
"""On Windows, `python <<'PY'` executes via the temp-file stdin redirect."""
tool = BashTool()
result = _run(tool.execute({
"command": (
f"\"{sys.executable}\" -X utf8 <<'PY'\n"
"import sys\n"
"print('HEREDOC_OK')\n"
"PY\n"
),
}))
assert not result.error
assert "HEREDOC_OK" in result.content
Loading