diff --git a/python/packages/tools/agent_framework_tools/shell/_resolve.py b/python/packages/tools/agent_framework_tools/shell/_resolve.py index 724bbd98041..df0bc591285 100644 --- a/python/packages/tools/agent_framework_tools/shell/_resolve.py +++ b/python/packages/tools/agent_framework_tools/shell/_resolve.py @@ -14,13 +14,56 @@ _ENV_OVERRIDE = "AGENT_FRAMEWORK_SHELL" +def _split_shell_override(value: str) -> list[str]: + r"""Split a shell override string into argv. + + On POSIX, ``shlex.split`` is correct. On Windows, ``shlex.split`` treats + ``\`` as an escape and drops backslashes from unquoted paths such as + ``C:\Windows\...\powershell.exe``, which then fails with + ``FileNotFoundError``. Parse with CommandLineToArgvW-like rules: keep + backslashes literal, honour double quotes, and concatenate adjacent + quoted and unquoted fragments (so ``"C:/Program Files"/PowerShell/7/pwsh.exe`` + stays one argv element). + """ + import shlex + + if sys.platform != "win32": + return shlex.split(value) + + args: list[str] = [] + buf: list[str] = [] + in_quotes = False + i = 0 + n = len(value) + while i < n: + c = value[i] + if in_quotes: + if c == '"': + in_quotes = False + else: + buf.append(c) + else: + if c == '"': + in_quotes = True + elif c in " ": + if buf: + args.append("".join(buf)) + buf = [] + else: + buf.append(c) + i += 1 + if buf: + args.append("".join(buf)) + return args + + def resolve_shell(shell: str | Sequence[str] | None, *, interactive: bool) -> list[str]: """Resolve the shell invocation argv. Priority: - 1. Explicit ``shell`` argument (string is split via shlex rules; sequence - is used verbatim). + 1. Explicit ``shell`` argument (string is split with platform-aware rules; + sequence is used verbatim). 2. ``AGENT_FRAMEWORK_SHELL`` environment variable. 3. Platform default. @@ -39,9 +82,7 @@ def resolve_shell(shell: str | Sequence[str] | None, *, interactive: bool) -> li """ if shell is not None: if isinstance(shell, str): - import shlex - - parts = shlex.split(shell) + parts = _split_shell_override(shell) if not parts: raise ShellExecutionError("shell override must not be empty") else: @@ -52,9 +93,7 @@ def resolve_shell(shell: str | Sequence[str] | None, *, interactive: bool) -> li env_override = os.environ.get(_ENV_OVERRIDE) if env_override: - import shlex - - parts = shlex.split(env_override) + parts = _split_shell_override(env_override) if parts: return parts if interactive else _ensure_command_flag(parts) @@ -82,13 +121,17 @@ def resolve_shell(shell: str | Sequence[str] | None, *, interactive: bool) -> li def is_powershell(argv: Sequence[str]) -> bool: - """Return True when ``argv[0]`` appears to be PowerShell.""" + """Return True when ``argv[0]`` appears to be PowerShell. + + Normalize backslashes to ``/`` before ``basename`` so win32-style + paths still detect correctly when this helper runs on POSIX (unit + tests monkeypatch ``sys.platform`` to ``win32`` on Linux CI). + """ if not argv: return False - name = os.path.basename(argv[0]).lower() + name = os.path.basename(argv[0].replace("\\", "/")).lower() return name in {"pwsh", "pwsh.exe", "powershell", "powershell.exe"} - def _ensure_command_flag(argv: list[str]) -> list[str]: """Append the right ``-c`` / ``-Command`` flag for stateless argv. diff --git a/python/packages/tools/tests/test_shell_resolve.py b/python/packages/tools/tests/test_shell_resolve.py index 845a982e1bd..18c5c07e34a 100644 --- a/python/packages/tools/tests/test_shell_resolve.py +++ b/python/packages/tools/tests/test_shell_resolve.py @@ -93,3 +93,49 @@ def test_stateless_preserves_existing_pwsh_command_flag() -> None: def test_interactive_does_not_append_command_flag() -> None: argv = resolve_shell("/bin/bash --noprofile", interactive=True) assert "-c" not in argv + + +def test_win32_shell_override_keeps_backslashes(monkeypatch: pytest.MonkeyPatch) -> None: + import agent_framework_tools.shell._resolve as resolve_module + + monkeypatch.setattr(resolve_module.sys, "platform", "win32") + path = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" + assert resolve_shell(path, interactive=False) == [path, "-Command"] + + +def test_win32_env_shell_override_keeps_backslashes(monkeypatch: pytest.MonkeyPatch) -> None: + import agent_framework_tools.shell._resolve as resolve_module + + monkeypatch.setattr(resolve_module.sys, "platform", "win32") + path = r"C:\msys64\usr\bin\bash.exe" + monkeypatch.setenv("AGENT_FRAMEWORK_SHELL", path) + assert resolve_shell(None, interactive=False) == [path, "-c"] + + +def test_win32_quoted_shell_override_with_spaces(monkeypatch: pytest.MonkeyPatch) -> None: + import agent_framework_tools.shell._resolve as resolve_module + + monkeypatch.setattr(resolve_module.sys, "platform", "win32") + argv = resolve_shell(r'"C:\Program Files\PowerShell\7\pwsh.exe" -NoProfile', interactive=False) + assert argv[0] == r"C:\Program Files\PowerShell\7\pwsh.exe" + assert "-NoProfile" in argv + assert argv[-1] == "-Command" + + +def test_win32_adjacent_quoted_unquoted_path(monkeypatch: pytest.MonkeyPatch) -> None: + import agent_framework_tools.shell._resolve as resolve_module + + monkeypatch.setattr(resolve_module.sys, "platform", "win32") + argv = resolve_shell('"C:/Program Files"/PowerShell/7/pwsh.exe -NoProfile', interactive=False) + assert argv[0] == "C:/Program Files/PowerShell/7/pwsh.exe" + assert "-NoProfile" in argv + assert argv[-1] == "-Command" + + +def test_win32_equals_quoted_value_stays_one_arg(monkeypatch: pytest.MonkeyPatch) -> None: + import agent_framework_tools.shell._resolve as resolve_module + + monkeypatch.setattr(resolve_module.sys, "platform", "win32") + argv = resolve_shell(r'C:\msys64\usr\bin\bash.exe --rcfile="C:/Program Files/bash.rc"', interactive=True) + assert argv[0] == r"C:\msys64\usr\bin\bash.exe" + assert argv[1] == "--rcfile=C:/Program Files/bash.rc"