fix(shell): configure PowerShell UTF-8 output
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user