diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 5c001257..1fd00511 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -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, diff --git a/tests/agent/test_workspace_scope.py b/tests/agent/test_workspace_scope.py index 2da0374b..9730671e 100644 --- a/tests/agent/test_workspace_scope.py +++ b/tests/agent/test_workspace_scope.py @@ -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) diff --git a/tests/tools/test_exec_platform.py b/tests/tools/test_exec_platform.py index 21f9196f..c47440ab 100644 --- a/tests/tools/test_exec_platform.py +++ b/tests/tools/test_exec_platform.py @@ -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