From 62d6937aa47645472e45b93176190633b78e18f1 Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Tue, 11 Aug 2026 00:01:15 +0800 Subject: [PATCH 1/6] feat(config): add Windows PowerShell version option --- astrbot/core/config/default.py | 11 +++++++++++ .../i18n/locales/en-US/features/config-metadata.json | 5 +++++ .../i18n/locales/ru-RU/features/config-metadata.json | 5 +++++ .../i18n/locales/zh-CN/features/config-metadata.json | 5 +++++ 4 files changed, 26 insertions(+) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index aa8c83c79d..6ed823d779 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -171,6 +171,7 @@ "add_cron_tools": True, }, "computer_use_runtime": "none", + "windows_shell": "powershell.exe", "computer_use_require_admin": True, "sandbox": { "booter": "shipyard_neo", @@ -3465,6 +3466,16 @@ "labels": ["无", "本地", "沙箱"], "hint": "选择 Computer Use 运行环境。", }, + "provider_settings.windows_shell": { + "description": "Windows PowerShell 版本", + "type": "string", + "options": ["powershell.exe", "pwsh.exe"], + "labels": ["Windows PowerShell 5.1", "PowerShell 7"], + "hint": "Windows 下本地运行时使用的 Shell。PowerShell 7 需自行安装。", + "condition": { + "provider_settings.computer_use_runtime": "local", + }, + }, "provider_settings.computer_use_require_admin": { "description": "需要 AstrBot 管理员权限", "type": "bool", diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index fdd430c63e..7c6cbf11c9 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -170,6 +170,11 @@ "description": "Computer Use Runtime", "hint": "Environment allowed for Agent usage. `local` means the local machine environment, `sandbox` means the sandbox environment, and `none` means no environment is allowed." }, + "windows_shell": { + "description": "Windows PowerShell Version", + "hint": "Shell used by the Windows local runtime. PowerShell 7 must be installed separately.", + "labels": ["Windows PowerShell 5.1", "PowerShell 7"] + }, "computer_use_require_admin": { "description": "Require AstrBot Admin Permission", "hint": "When enabled, AstrBot admin permission is required to use computer capabilities. Admins can be added in Platform Config. Use the /sid command to view admin IDs." diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 3cd0104ca1..ecffcbe4e3 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -170,6 +170,11 @@ "description": "Среда выполнения (Runtime)", "hint": "Среда, к которой разрешён доступ Agent: `local` — локальная среда компьютера, `sandbox` — изолированная песочница, `none` — запретить доступ к любым средам." }, + "windows_shell": { + "description": "Версия Windows PowerShell", + "hint": "Оболочка Windows Local runtime. PowerShell 7 нужно устанавливать отдельно.", + "labels": ["Windows PowerShell 5.1", "PowerShell 7"] + }, "computer_use_require_admin": { "description": "Требовать права администратора AstrBot", "hint": "Если включено, только администраторы смогут использовать возможности управления компьютером. Добавить администраторов можно в конфиге платформы." diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index e25cb8e0fb..18613400cc 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -172,6 +172,11 @@ "description": "运行环境", "hint": "允许 Agent 访问的环境。local 为本机环境,sandbox 为沙箱环境,none 为不允许任何环境。" }, + "windows_shell": { + "description": "Windows PowerShell 版本", + "hint": "Windows 下本地运行时使用的 Shell。PowerShell 7 需自行安装。", + "labels": ["Windows PowerShell 5.1", "PowerShell 7"] + }, "computer_use_require_admin": { "description": "需要 AstrBot 管理员权限", "hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。" From 3276c98e9795aaf5956ea39866d65b7b9984d240 Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Tue, 11 Aug 2026 00:30:42 +0800 Subject: [PATCH 2/6] feat(computer): honor Windows PowerShell version in local runtime --- astrbot/core/astr_main_agent.py | 41 +++++-- astrbot/core/computer/booters/local.py | 17 ++- astrbot/core/tools/computer_tools/shell.py | 2 + astrbot/core/tools/computer_tools/util.py | 9 ++ tests/test_local_shell_component.py | 119 +++++++++++++++++++++ tests/unit/test_astr_main_agent.py | 18 ++++ 6 files changed, 193 insertions(+), 13 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index e3bf74f598..503cb7ba20 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -428,7 +428,11 @@ async def _apply_workspace_extra_prompt( ) -def _apply_local_env_tools(req: ProviderRequest, plugin_context: Context) -> None: +def _apply_local_env_tools( + req: ProviderRequest, + plugin_context: Context, + windows_shell: str | None = None, +) -> None: if req.func_tool is None: req.func_tool = ToolSet() tool_mgr = plugin_context.get_llm_tool_manager() @@ -439,18 +443,29 @@ def _apply_local_env_tools(req: ProviderRequest, plugin_context: Context) -> Non req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileWriteTool)) req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileEditTool)) req.func_tool.add_tool(tool_mgr.get_builtin_tool(GrepTool)) - req.system_prompt = f"{req.system_prompt or ''}\n{_build_local_mode_prompt()}\n" + req.system_prompt = ( + f"{req.system_prompt or ''}\n{_build_local_mode_prompt(windows_shell)}\n" + ) -def _build_local_mode_prompt() -> str: +def _build_local_mode_prompt(windows_shell: str | None = None) -> str: system_name = platform.system() or "Unknown" - shell_hint = ( - "The runtime shell is Windows PowerShell 5.1 (powershell.exe). " - "Use Windows PowerShell 5.1-compatible syntax and cmdlets; do not use " - "PowerShell 7-only syntax or assume Unix commands like cat/ls/grep are available." - if system_name.lower() == "windows" - else "The runtime shell is Unix-like. Use POSIX-compatible shell commands." - ) + if system_name.lower() != "windows": + shell_hint = ( + "The runtime shell is Unix-like. Use POSIX-compatible shell commands." + ) + elif windows_shell and "pwsh" in windows_shell.lower(): + shell_hint = ( + "The runtime shell is PowerShell 7 (pwsh.exe). " + "Use PowerShell 7-compatible syntax and cmdlets; do not " + "assume Unix commands like cat/ls/grep are available." + ) + else: + shell_hint = ( + "The runtime shell is Windows PowerShell 5.1 (powershell.exe). " + "Use Windows PowerShell 5.1-compatible syntax and cmdlets; do not use " + "PowerShell 7-only syntax or assume Unix commands like cat/ls/grep are available." + ) return ( "You have access to the host local environment and can execute shell commands and Python code. " f"Current operating system: {system_name}. " @@ -1584,7 +1599,11 @@ async def build_main_agent( if config.computer_use_runtime == "sandbox": _apply_sandbox_tools(config, req, req.session_id) elif config.computer_use_runtime == "local": - _apply_local_env_tools(req, plugin_context) + _apply_local_env_tools( + req, + plugin_context, + config.provider_settings.get("windows_shell"), + ) agent_runner = AgentRunner() astr_agent_ctx = AstrAgentContext( diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index d2ab59f2e7..ac8efdc1aa 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -92,6 +92,17 @@ def _decode_shell_output(output: bytes | None) -> str: return _decode_bytes_with_fallback(output, preferred_encoding="utf-8") +def _resolve_windows_shell(windows_shell: str | None) -> str: + """Return the Windows PowerShell executable, failing when it is missing.""" + executable = windows_shell or "powershell.exe" + if os.name == "nt" and shutil.which(executable) is None: + raise RuntimeError( + f"The Windows PowerShell executable '{executable}' was not found on " + "PATH. Install it or reset the 'Windows PowerShell version' setting." + ) + return executable + + @dataclass class _LocalShellSession: """Runtime state for one managed local shell process.""" @@ -134,6 +145,7 @@ async def exec( timeout: int | None = 300, shell: bool = True, background: bool = False, + windows_shell: str | None = None, ) -> dict[str, Any]: if not _is_safe_command(command): raise PermissionError("Blocked unsafe shell command.") @@ -147,7 +159,7 @@ def _run() -> dict[str, Any]: popen_shell = shell if sys.platform == "win32" and shell: popen_command = [ - "powershell.exe", + _resolve_windows_shell(windows_shell), "-NoLogo", "-NoProfile", "-NonInteractive", @@ -223,6 +235,7 @@ async def exec_managed( timeout: int | None = None, yield_time_ms: int = 10_000, max_output_chars: int = 10_000, + windows_shell: str | None = None, ) -> dict[str, Any]: """Start a locally managed shell process and briefly wait for it. @@ -279,7 +292,7 @@ async def exec_managed( if sys.platform == "win32": process_factory = asyncio.create_subprocess_exec process_args = ( - "powershell.exe", + _resolve_windows_shell(windows_shell), "-NoLogo", "-NoProfile", "-NonInteractive", diff --git a/astrbot/core/tools/computer_tools/shell.py b/astrbot/core/tools/computer_tools/shell.py index 61e1f589ee..537728fcc3 100644 --- a/astrbot/core/tools/computer_tools/shell.py +++ b/astrbot/core/tools/computer_tools/shell.py @@ -19,6 +19,7 @@ from .util import ( check_admin_permission, is_local_runtime, + windows_shell_setting, workspace_root_for_context, ) @@ -134,6 +135,7 @@ async def call( env=env, timeout=timeout, yield_time_ms=0 if background else yield_time_ms, + windows_shell=windows_shell_setting(context), ) elapsed_seconds = monotonic() - started_at if result.get("session_closed") and result.get("status") in { diff --git a/astrbot/core/tools/computer_tools/util.py b/astrbot/core/tools/computer_tools/util.py index 9bb71298bd..06eac8e0b3 100644 --- a/astrbot/core/tools/computer_tools/util.py +++ b/astrbot/core/tools/computer_tools/util.py @@ -54,6 +54,15 @@ def is_local_runtime(context: ContextWrapper[AstrAgentContext]) -> bool: return runtime == "local" +def windows_shell_setting(context: ContextWrapper[AstrAgentContext]) -> str | None: + """Return the configured Windows PowerShell executable, if any.""" + cfg = context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ) + value = cfg.get("provider_settings", {}).get("windows_shell") + return str(value) if value else None + + def check_admin_permission( context: ContextWrapper[AstrAgentContext], operation_name: str ) -> str | None: diff --git a/tests/test_local_shell_component.py b/tests/test_local_shell_component.py index a81e620a6a..18ed1a6e36 100644 --- a/tests/test_local_shell_component.py +++ b/tests/test_local_shell_component.py @@ -75,6 +75,60 @@ def fake_run(*args, **kwargs): assert calls[0][1]["shell"] is False +def test_local_shell_component_uses_pwsh_when_configured(monkeypatch): + calls = [] + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + return _FakePopen(stdout=b"") + + monkeypatch.setattr(subprocess, "Popen", fake_run) + monkeypatch.setattr(local_booter.sys, "platform", "win32") + + result = asyncio.run( + LocalShellComponent().exec("Get-ChildItem", windows_shell="pwsh.exe") + ) + + assert result["exit_code"] == 0 + assert calls[0][0][0] == [ + "pwsh.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-ChildItem", + ] + assert calls[0][1]["shell"] is False + + +def test_resolve_windows_shell_defaults_to_powershell_51(): + assert local_booter._resolve_windows_shell(None) == "powershell.exe" + assert local_booter._resolve_windows_shell("powershell.exe") == "powershell.exe" + + +def test_resolve_windows_shell_returns_configured_pwsh(): + assert local_booter._resolve_windows_shell("pwsh.exe") == "pwsh.exe" + + +def test_resolve_windows_shell_raises_when_missing_on_windows(monkeypatch): + monkeypatch.setattr(local_booter.os, "name", "nt") + monkeypatch.setattr(local_booter.shutil, "which", lambda _cmd: None) + + with pytest.raises(RuntimeError, match="pwsh.exe"): + local_booter._resolve_windows_shell("pwsh.exe") + + +def test_resolve_windows_shell_skips_check_outside_windows(monkeypatch): + monkeypatch.setattr(local_booter.os, "name", "posix") + + def fail_if_called(_cmd): + raise AssertionError("shutil.which must not be called outside Windows") + + monkeypatch.setattr(local_booter.shutil, "which", fail_if_called) + + assert local_booter._resolve_windows_shell("pwsh.exe") == "pwsh.exe" + + def test_local_shell_component_keeps_platform_shell_outside_windows(monkeypatch): calls = [] @@ -156,6 +210,71 @@ async def fail_create_subprocess_shell(*_args, **_kwargs): assert "creationflags" in calls[0][1] +@pytest.mark.asyncio +async def test_managed_shell_uses_pwsh_when_configured(monkeypatch, tmp_path): + calls = [] + + class FakeStdout: + def __init__(self): + self.chunks = [b"done\n", b""] + + async def read(self, _limit): + return self.chunks.pop(0) + + class FakeProcess: + def __init__(self): + self.pid = 12345 + self.returncode = None + self.stdout = FakeStdout() + self.stdin = None + + async def wait(self): + self.returncode = 0 + return 0 + + async def fake_create_subprocess_exec(*args, **kwargs): + calls.append((args, kwargs)) + return FakeProcess() + + async def fail_create_subprocess_shell(*_args, **_kwargs): + raise AssertionError("Windows managed commands must not use cmd.exe.") + + monkeypatch.setattr(local_booter.sys, "platform", "win32") + monkeypatch.setattr( + local_booter.asyncio, + "create_subprocess_exec", + fake_create_subprocess_exec, + ) + monkeypatch.setattr( + local_booter.asyncio, + "create_subprocess_shell", + fail_create_subprocess_shell, + ) + + result = await LocalShellComponent().exec_managed( + "Get-ChildItem", + owner_id="owner-a", + creator_id="user-a", + creator_is_admin=False, + sandboxed=False, + cwd=str(tmp_path), + yield_time_ms=5_000, + windows_shell="pwsh.exe", + ) + + assert result["status"] == "completed" + assert result["stdout"] == "done\n" + assert calls[0][0] == ( + "pwsh.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-ChildItem", + ) + assert "creationflags" in calls[0][1] + + def test_local_shell_component_prefers_utf8_before_windows_locale( monkeypatch, ): diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 73aa4419dd..00935b32fc 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -178,6 +178,24 @@ def test_local_mode_prompt_uses_windows_powershell_51(): assert "cmd.exe" not in prompt +def test_local_mode_prompt_uses_pwsh_when_configured(): + with patch("astrbot.core.astr_main_agent.platform.system", return_value="Windows"): + prompt = ama._build_local_mode_prompt("pwsh.exe") + + assert "PowerShell 7 (pwsh.exe)" in prompt + assert "Windows PowerShell 5.1" not in prompt + assert "Unix-like" not in prompt + + +def test_local_mode_prompt_ignores_pwsh_on_non_windows(): + with patch("astrbot.core.astr_main_agent.platform.system", return_value="Linux"): + prompt = ama._build_local_mode_prompt("pwsh.exe") + + assert "Unix-like" in prompt + assert "POSIX-compatible" in prompt + assert "PowerShell" not in prompt + + def test_local_mode_prompt_keeps_posix_shell_guidance(): with patch("astrbot.core.astr_main_agent.platform.system", return_value="Linux"): prompt = ama._build_local_mode_prompt() From 204ffc6e619fa9eae0963907a2eea7a920a81f92 Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Tue, 11 Aug 2026 00:58:41 +0800 Subject: [PATCH 3/6] fix(computer): correct stale shell comment for configurable PowerShell --- astrbot/core/computer/booters/local.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index ac8efdc1aa..26199cf940 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -168,8 +168,8 @@ def _run() -> dict[str, Any]: ] popen_shell = False if background: - # Shell commands use PowerShell 5.1 on Windows and the platform - # shell elsewhere. Safety relies on `_is_safe_command()`. + # Shell commands use the configured PowerShell on Windows and the + # platform shell elsewhere. Safety relies on `_is_safe_command()`. proc = subprocess.Popen( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit popen_command, shell=popen_shell, @@ -179,8 +179,8 @@ def _run() -> dict[str, Any]: stderr=subprocess.DEVNULL, ) return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None} - # Shell commands use PowerShell 5.1 on Windows and the platform shell - # elsewhere. Safety relies on `_is_safe_command()`. + # Shell commands use the configured PowerShell on Windows and the + # platform shell elsewhere. Safety relies on `_is_safe_command()`. proc = subprocess.Popen( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit popen_command, shell=popen_shell, From 033344c3d27743873f6a7d1d12c0b176c61e1b5f Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Tue, 11 Aug 2026 01:22:46 +0800 Subject: [PATCH 4/6] refactor(computer): inline windows shell resolution per AGENTS.md --- astrbot/core/computer/booters/local.py | 29 +++++++++------- astrbot/core/tools/computer_tools/shell.py | 7 ++-- astrbot/core/tools/computer_tools/util.py | 9 ----- tests/test_local_shell_component.py | 40 ++++++++++++++-------- 4 files changed, 47 insertions(+), 38 deletions(-) diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index 26199cf940..1068402d4a 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -92,17 +92,6 @@ def _decode_shell_output(output: bytes | None) -> str: return _decode_bytes_with_fallback(output, preferred_encoding="utf-8") -def _resolve_windows_shell(windows_shell: str | None) -> str: - """Return the Windows PowerShell executable, failing when it is missing.""" - executable = windows_shell or "powershell.exe" - if os.name == "nt" and shutil.which(executable) is None: - raise RuntimeError( - f"The Windows PowerShell executable '{executable}' was not found on " - "PATH. Install it or reset the 'Windows PowerShell version' setting." - ) - return executable - - @dataclass class _LocalShellSession: """Runtime state for one managed local shell process.""" @@ -158,8 +147,15 @@ def _run() -> dict[str, Any]: popen_command: str | list[str] = command popen_shell = shell if sys.platform == "win32" and shell: + shell_executable = windows_shell or "powershell.exe" + if os.name == "nt" and shutil.which(shell_executable) is None: + raise RuntimeError( + f"The Windows PowerShell executable '{shell_executable}' " + "was not found on PATH. Install it or reset the " + "'Windows PowerShell version' setting." + ) popen_command = [ - _resolve_windows_shell(windows_shell), + shell_executable, "-NoLogo", "-NoProfile", "-NonInteractive", @@ -291,8 +287,15 @@ async def exec_managed( try: if sys.platform == "win32": process_factory = asyncio.create_subprocess_exec + shell_executable = windows_shell or "powershell.exe" + if os.name == "nt" and shutil.which(shell_executable) is None: + raise RuntimeError( + f"The Windows PowerShell executable '{shell_executable}' " + "was not found on PATH. Install it or reset the " + "'Windows PowerShell version' setting." + ) process_args = ( - _resolve_windows_shell(windows_shell), + shell_executable, "-NoLogo", "-NoProfile", "-NonInteractive", diff --git a/astrbot/core/tools/computer_tools/shell.py b/astrbot/core/tools/computer_tools/shell.py index 537728fcc3..b05ae10bbf 100644 --- a/astrbot/core/tools/computer_tools/shell.py +++ b/astrbot/core/tools/computer_tools/shell.py @@ -19,7 +19,6 @@ from .util import ( check_admin_permission, is_local_runtime, - windows_shell_setting, workspace_root_for_context, ) @@ -125,6 +124,10 @@ async def call( if not creator_id: return "Error executing command: sender identity is unavailable." started_at = monotonic() + cfg = context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ) + windows_shell = cfg.get("provider_settings", {}).get("windows_shell") result = await sb.shell.exec_managed( command, owner_id=context.context.event.unified_msg_origin, @@ -135,7 +138,7 @@ async def call( env=env, timeout=timeout, yield_time_ms=0 if background else yield_time_ms, - windows_shell=windows_shell_setting(context), + windows_shell=str(windows_shell) if windows_shell else None, ) elapsed_seconds = monotonic() - started_at if result.get("session_closed") and result.get("status") in { diff --git a/astrbot/core/tools/computer_tools/util.py b/astrbot/core/tools/computer_tools/util.py index 06eac8e0b3..9bb71298bd 100644 --- a/astrbot/core/tools/computer_tools/util.py +++ b/astrbot/core/tools/computer_tools/util.py @@ -54,15 +54,6 @@ def is_local_runtime(context: ContextWrapper[AstrAgentContext]) -> bool: return runtime == "local" -def windows_shell_setting(context: ContextWrapper[AstrAgentContext]) -> str | None: - """Return the configured Windows PowerShell executable, if any.""" - cfg = context.context.context.get_config( - umo=context.context.event.unified_msg_origin - ) - value = cfg.get("provider_settings", {}).get("windows_shell") - return str(value) if value else None - - def check_admin_permission( context: ContextWrapper[AstrAgentContext], operation_name: str ) -> str | None: diff --git a/tests/test_local_shell_component.py b/tests/test_local_shell_component.py index 18ed1a6e36..133f988ac4 100644 --- a/tests/test_local_shell_component.py +++ b/tests/test_local_shell_component.py @@ -101,32 +101,44 @@ def fake_run(*args, **kwargs): assert calls[0][1]["shell"] is False -def test_resolve_windows_shell_defaults_to_powershell_51(): - assert local_booter._resolve_windows_shell(None) == "powershell.exe" - assert local_booter._resolve_windows_shell("powershell.exe") == "powershell.exe" - - -def test_resolve_windows_shell_returns_configured_pwsh(): - assert local_booter._resolve_windows_shell("pwsh.exe") == "pwsh.exe" - - -def test_resolve_windows_shell_raises_when_missing_on_windows(monkeypatch): +def test_exec_raises_when_windows_shell_missing(monkeypatch): + monkeypatch.setattr(local_booter.sys, "platform", "win32") monkeypatch.setattr(local_booter.os, "name", "nt") monkeypatch.setattr(local_booter.shutil, "which", lambda _cmd: None) with pytest.raises(RuntimeError, match="pwsh.exe"): - local_booter._resolve_windows_shell("pwsh.exe") + asyncio.run( + LocalShellComponent().exec("Get-ChildItem", windows_shell="pwsh.exe") + ) -def test_resolve_windows_shell_skips_check_outside_windows(monkeypatch): - monkeypatch.setattr(local_booter.os, "name", "posix") +def test_exec_skips_windows_shell_check_outside_windows(monkeypatch): + calls = [] + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + return _FakePopen(stdout=b"") def fail_if_called(_cmd): raise AssertionError("shutil.which must not be called outside Windows") + monkeypatch.setattr(subprocess, "Popen", fake_run) + monkeypatch.setattr(local_booter.sys, "platform", "win32") monkeypatch.setattr(local_booter.shutil, "which", fail_if_called) - assert local_booter._resolve_windows_shell("pwsh.exe") == "pwsh.exe" + result = asyncio.run( + LocalShellComponent().exec("Get-ChildItem", windows_shell="pwsh.exe") + ) + + assert result["exit_code"] == 0 + assert calls[0][0][0] == [ + "pwsh.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-ChildItem", + ] def test_local_shell_component_keeps_platform_shell_outside_windows(monkeypatch): From 0ccef38afeeec8fd5641219a66bf0f5846e24482 Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Tue, 11 Aug 2026 01:54:28 +0800 Subject: [PATCH 5/6] test(computer): assert windows_shell in exec_managed call --- tests/unit/test_func_tool_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_func_tool_manager.py b/tests/unit/test_func_tool_manager.py index 111b853e07..1caad0acb1 100644 --- a/tests/unit/test_func_tool_manager.py +++ b/tests/unit/test_func_tool_manager.py @@ -150,6 +150,7 @@ async def fake_get_booter(context, session_id): env={}, timeout=None, yield_time_ms=250, + windows_shell=None, ) for status, exit_code, wall_time in ( ("completed", 0, "1.23"), From 51c12bd72c9469dd5d843f973b636cbee904ac14 Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Tue, 11 Aug 2026 16:10:58 +0800 Subject: [PATCH 6/6] test: make shell tests cross-platform Co-authored-by: Donoym --- tests/test_local_shell_component.py | 9 +++++---- tests/unit/test_astr_main_agent.py | 7 +++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/test_local_shell_component.py b/tests/test_local_shell_component.py index 133f988ac4..85f2ed0b17 100644 --- a/tests/test_local_shell_component.py +++ b/tests/test_local_shell_component.py @@ -124,6 +124,7 @@ def fail_if_called(_cmd): monkeypatch.setattr(subprocess, "Popen", fake_run) monkeypatch.setattr(local_booter.sys, "platform", "win32") + monkeypatch.setattr(local_booter.os, "name", "posix") monkeypatch.setattr(local_booter.shutil, "which", fail_if_called) result = asyncio.run( @@ -395,7 +396,7 @@ async def test_managed_shell_returns_completed_output_without_open_session(): ) assert result["status"] == "completed" - assert result["stdout"] == "hello\n" + assert result["stdout"].splitlines() == ["hello"] assert result["exit_code"] == 0 assert result["session_closed"] is True assert await shell.list_sessions( @@ -419,7 +420,7 @@ async def test_managed_shell_allows_creator_and_conversation_admin(): try: assert result["status"] == "running" - assert result["stdout"] == "ready\n" + assert result["stdout"].splitlines() == ["ready"] session_id = result["session_id"] assert ( await shell.list_sessions( @@ -594,7 +595,7 @@ async def test_managed_shell_accepts_stdin_and_polls_incremental_output(): output += completed["stdout"] assert completed["status"] == "completed" - assert output == "got:hello\n" + assert output.splitlines() == ["got:hello"] assert completed["session_closed"] is True finally: await shell.shutdown_sessions() @@ -656,7 +657,7 @@ async def test_managed_shell_keeps_completed_session_until_output_is_drained(): ) output += result["stdout"] - assert output == f"{'x' * 25000}\n" + assert output.splitlines() == ["x" * 25000] assert result["session_closed"] is True assert await shell.list_sessions( owner_id="owner-a", diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 00935b32fc..6ed5bed906 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -2,6 +2,7 @@ import datetime import os +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -1891,6 +1892,7 @@ async def test_build_main_agent_with_video_attachment( ): """Test building main agent with video attachments.""" module = ama + video_path = str(Path("/path/to/video.mp4")) mock_video = Video(file="file:///path/to/video.mp4") mock_event.message_obj.message = [mock_video] @@ -1918,7 +1920,7 @@ async def test_build_main_agent_with_video_attachment( assert result is not None assert [ part.text for part in result.provider_request.extra_user_content_parts - ] == ["[Video Attachment: name video.mp4, path /path/to/video.mp4]"] + ] == [f"[Video Attachment: name video.mp4, path {video_path}]"] @pytest.mark.asyncio async def test_build_main_agent_with_quoted_video_attachment( @@ -1926,6 +1928,7 @@ async def test_build_main_agent_with_quoted_video_attachment( ): """Test building main agent with quoted video attachments.""" module = ama + video_path = str(Path("/path/to/quoted-video.mp4")) mock_video = Video(file="file:///path/to/quoted-video.mp4") mock_reply = Reply( id="reply-1", @@ -1959,7 +1962,7 @@ async def test_build_main_agent_with_quoted_video_attachment( assert result is not None assert ( "[Video Attachment in quoted message: " - "name quoted-video.mp4, path /path/to/quoted-video.mp4]" + f"name quoted-video.mp4, path {video_path}]" ) in [part.text for part in result.provider_request.extra_user_content_parts] @pytest.mark.asyncio