Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 54 additions & 11 deletions python/packages/tools/agent_framework_tools/shell/_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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.

Expand Down
46 changes: 46 additions & 0 deletions python/packages/tools/tests/test_shell_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading