From 341268c634ec239600f1a929a13b02890938c03d Mon Sep 17 00:00:00 2001 From: ninglinLiu Date: Fri, 30 Oct 2026 10:52:36 +0800 Subject: [PATCH 1/4] fix(tools): keep Windows backslashes in LocalShellTool shell paths Fixes #9819 resolve_shell used shlex.split on string overrides, which treats \\ as an escape on every platform and turns C:\\Windows\\...\\powershell.exe into a mangled path. On win32, split without escape processing and only honour double quotes so unquoted Windows paths (and AGENT_FRAMEWORK_SHELL) work. --- .../agent_framework_tools/shell/_resolve.py | 39 +++++++++++++++---- .../tools/tests/test_shell_resolve.py | 27 +++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/python/packages/tools/agent_framework_tools/shell/_resolve.py b/python/packages/tools/agent_framework_tools/shell/_resolve.py index 724bbd98041..6043d3f1263 100644 --- a/python/packages/tools/agent_framework_tools/shell/_resolve.py +++ b/python/packages/tools/agent_framework_tools/shell/_resolve.py @@ -14,13 +14,40 @@ _ENV_OVERRIDE = "AGENT_FRAMEWORK_SHELL" +def _split_shell_override(value: str) -> list[str]: + """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``. Use non-POSIX splitting that keeps backslashes and + only honours double quotes (aligned with ``CommandLineToArgvW``). + """ + import shlex + + if sys.platform != "win32": + return shlex.split(value) + + lexer = shlex.shlex(value, posix=False) + lexer.whitespace_split = True + lexer.commenters = "" + lexer.quotes = '"' + parts: list[str] = [] + for token in lexer: + if len(token) >= 2 and token[0] == '"' and token[-1] == '"': + parts.append(token[1:-1]) + else: + parts.append(token) + return parts + + 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 +66,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 +77,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) diff --git a/python/packages/tools/tests/test_shell_resolve.py b/python/packages/tools/tests/test_shell_resolve.py index 845a982e1bd..ef8a76c46c5 100644 --- a/python/packages/tools/tests/test_shell_resolve.py +++ b/python/packages/tools/tests/test_shell_resolve.py @@ -93,3 +93,30 @@ 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" From ccd0285d60ca37be94df64cc2d1ff11ccce5f661 Mon Sep 17 00:00:00 2001 From: ninglinLiu Date: Fri, 30 Oct 2026 16:41:35 +0800 Subject: [PATCH 2/4] fix(tools): use raw docstring for Windows path backslashes Ruff escape-sequence-in-docstring failed Package Checks after the win32 shell-path fix; mark the docstring raw so ``\\`` examples are valid. --- python/packages/tools/agent_framework_tools/shell/_resolve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/tools/agent_framework_tools/shell/_resolve.py b/python/packages/tools/agent_framework_tools/shell/_resolve.py index 6043d3f1263..b37e2ccbe7a 100644 --- a/python/packages/tools/agent_framework_tools/shell/_resolve.py +++ b/python/packages/tools/agent_framework_tools/shell/_resolve.py @@ -15,7 +15,7 @@ def _split_shell_override(value: str) -> list[str]: - """Split a shell override string into argv. + 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 From 6c99d30bc6f7518dd91e4a9d373634a3b7be4126 Mon Sep 17 00:00:00 2001 From: ninglinLiu Date: Fri, 30 Oct 2026 17:13:45 +0800 Subject: [PATCH 3/4] fix(tools): detect PowerShell from win32-style paths on POSIX os.path.basename does not split on backslashes on Linux, so win32 path overrides in unit tests (and any cross-platform callers) were treated as non-PowerShell and got -c instead of -Command. Normalize separators before basename. --- .../tools/agent_framework_tools/shell/_resolve.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/python/packages/tools/agent_framework_tools/shell/_resolve.py b/python/packages/tools/agent_framework_tools/shell/_resolve.py index b37e2ccbe7a..f8a352c069e 100644 --- a/python/packages/tools/agent_framework_tools/shell/_resolve.py +++ b/python/packages/tools/agent_framework_tools/shell/_resolve.py @@ -105,13 +105,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. From 251551e872bf6db49d5a211fc30b04a4b77779ef Mon Sep 17 00:00:00 2001 From: ninglinLiu Date: Sat, 31 Oct 2026 02:10:22 +0800 Subject: [PATCH 4/4] fix(tools): parse Windows shell overrides like CommandLineToArgvW Adjacent quoted/unquoted path fragments and --flag="value with spaces" must stay one argv element; shlex(posix=False) was splitting them. --- .../agent_framework_tools/shell/_resolve.py | 44 +++++++++++++------ .../tools/tests/test_shell_resolve.py | 19 ++++++++ 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/python/packages/tools/agent_framework_tools/shell/_resolve.py b/python/packages/tools/agent_framework_tools/shell/_resolve.py index f8a352c069e..df0bc591285 100644 --- a/python/packages/tools/agent_framework_tools/shell/_resolve.py +++ b/python/packages/tools/agent_framework_tools/shell/_resolve.py @@ -18,27 +18,43 @@ 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``. Use non-POSIX splitting that keeps backslashes and - only honours double quotes (aligned with ``CommandLineToArgvW``). + ``\`` 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) - lexer = shlex.shlex(value, posix=False) - lexer.whitespace_split = True - lexer.commenters = "" - lexer.quotes = '"' - parts: list[str] = [] - for token in lexer: - if len(token) >= 2 and token[0] == '"' and token[-1] == '"': - parts.append(token[1:-1]) + 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: - parts.append(token) - return parts + 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]: diff --git a/python/packages/tools/tests/test_shell_resolve.py b/python/packages/tools/tests/test_shell_resolve.py index ef8a76c46c5..18c5c07e34a 100644 --- a/python/packages/tools/tests/test_shell_resolve.py +++ b/python/packages/tools/tests/test_shell_resolve.py @@ -120,3 +120,22 @@ def test_win32_quoted_shell_override_with_spaces(monkeypatch: pytest.MonkeyPatch 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"