fix(shell): configure PowerShell UTF-8 output

This commit is contained in:
chengyongru
2026-07-14 13:50:02 +08:00
committed by chengyongru
parent 9cdf17f5d5
commit 61afbffc89
3 changed files with 78 additions and 40 deletions
+9 -14
View File
@@ -65,17 +65,6 @@ def _reap_pid(pid: int) -> None:
logger.debug("_reap_pid({}): {}", pid, exc)
def _decode_process_output(data: bytes) -> str:
if not data:
return ""
if _IS_WINDOWS and b"\x00" in data[:200]:
try:
return data.decode("utf-16")
except UnicodeDecodeError:
pass
return data.decode("utf-8", errors="replace")
# Policy note appended to recoverable workspace-boundary guard errors.
_WORKSPACE_BOUNDARY_NOTE = (
"\n\nNote: this is a hard policy boundary, not a transient failure. "
@@ -348,10 +337,10 @@ class ExecTool(Tool):
output_parts = []
if stdout:
output_parts.append(_decode_process_output(stdout))
output_parts.append(stdout.decode("utf-8", errors="replace"))
if stderr:
stderr_text = _decode_process_output(stderr)
stderr_text = stderr.decode("utf-8", errors="replace")
if stderr_text.strip():
output_parts.append(f"STDERR:\n{stderr_text}")
@@ -546,7 +535,13 @@ class ExecTool(Tool):
env=cmd_env,
)
command = ExecTool._normalize_powershell_command(command)
command = f"{command}\nif ($LASTEXITCODE -ne $null) {{ exit $LASTEXITCODE }}"
command = (
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
"$OutputEncoding = [Console]::OutputEncoding\n"
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n"
f"{command}\n"
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
)
return await asyncio.create_subprocess_exec(
program, "-NoProfile", "-NonInteractive", "-Command", command,
stdin=stdin,
+13 -2
View File
@@ -152,7 +152,12 @@ async def test_exec_tool_uses_scope_project_as_default_cwd(tmp_path: Path) -> No
)
token = bind_workspace_scope(scope)
try:
result = await tool.execute(command="printf ok > scoped-marker.txt")
result = await tool.execute(
command=(
'python -c "from pathlib import Path; '
"Path('scoped-marker.txt').write_text('ok')\""
)
)
finally:
reset_workspace_scope(token)
@@ -174,7 +179,13 @@ async def test_exec_full_scope_allows_explicit_cwd_outside_project(tmp_path: Pat
)
token = bind_workspace_scope(scope)
try:
result = await tool.execute(command="printf ok > outside-marker.txt", working_dir=str(outside))
result = await tool.execute(
command=(
'python -c "from pathlib import Path; '
"Path('outside-marker.txt').write_text('ok')\""
),
working_dir=str(outside),
)
finally:
reset_workspace_scope(token)
+56 -24
View File
@@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from nanobot.agent.tools.exec_session import ExecSessionManager
from nanobot.agent.tools.shell import ExecTool
_WINDOWS_ENV_KEYS = {
@@ -212,6 +213,22 @@ class TestSpawnWindows:
assert "cmd /c exit 7" in command
assert "if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }" in command
@pytest.mark.asyncio
async def test_powershell_configures_utf8_output(self):
"""PowerShell should emit UTF-8 for captured output and redirections."""
env = {"PATH": ""}
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
):
mock_exec.return_value = AsyncMock()
await ExecTool._spawn("Write-Output 'café 你好'", r"C:\work", env)
command = mock_exec.call_args[0][-1]
assert "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)" in command
assert "$OutputEncoding = [Console]::OutputEncoding" in command
assert "$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'" in command
@pytest.mark.asyncio
async def test_powershell_invokes_quoted_windows_executable_path(self):
"""PowerShell needs & before quoted executable paths with arguments."""
@@ -225,7 +242,7 @@ class TestSpawnWindows:
await ExecTool._spawn(command, r"C:\work", env)
powershell_command = mock_exec.call_args[0][-1]
assert powershell_command.startswith("& " + command)
assert f"\n& {command}\n" in powershell_command
@pytest.mark.asyncio
async def test_prefers_pwsh_when_available(self):
@@ -465,29 +482,6 @@ class TestExecuteEndToEnd:
assert "hello world" in result
assert "Exit code: 0" in result
@pytest.mark.asyncio
async def test_windows_decodes_utf16_output(self):
"""PowerShell output may arrive as UTF-16LE on Windows."""
mock_proc = AsyncMock()
mock_proc.communicate.return_value = (
"ok café\r\n".encode("utf-16-le"),
"warn λ\r\n".encode("utf-16-le"),
)
mock_proc.returncode = 0
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch.object(ExecTool, "_spawn", return_value=mock_proc),
patch.object(ExecTool, "_guard_command", return_value=None),
):
tool = ExecTool()
result = await tool.execute(command="echo ok")
assert "ok café" in result
assert "warn λ" in result
assert "\x00" not in result
assert "Exit code: 0" in result
@pytest.mark.asyncio
async def test_unix_full_path(self):
"""Full execute() flow on Unix: env, spawn, output formatting."""
@@ -759,3 +753,41 @@ class TestWindowsRealExec:
result = await ExecTool(timeout=10).execute(command="cmd /c exit 7")
assert "Exit code: 7" in result
@pytest.mark.asyncio
async def test_windows_powershell_output_is_utf8(self):
result = await ExecTool(timeout=10).execute(
command=(
"Write-Output 'café λ 你好'; "
"[Console]::Error.WriteLine('warn λ 你好')"
),
shell="powershell",
)
assert "café λ 你好" in result
assert "warn λ 你好" in result
assert "\x00" not in result
@pytest.mark.asyncio
async def test_windows_powershell_redirection_avoids_utf16(self, tmp_path):
result = await ExecTool(working_dir=str(tmp_path), timeout=10).execute(
command="Write-Output 'café λ 你好' > marker.txt",
shell="powershell",
)
data = (tmp_path / "marker.txt").read_bytes()
assert "Exit code: 0" in result
assert b"\x00" not in data
assert data.decode("utf-8-sig").strip() == "café λ 你好"
@pytest.mark.asyncio
async def test_windows_powershell_session_output_is_utf8(self):
manager = ExecSessionManager()
result = await ExecTool(timeout=10, session_manager=manager).execute(
command="Write-Output 'café λ 你好'",
shell="powershell",
yield_time_ms=1000,
)
assert "café λ 你好" in result
assert "\x00" not in result