diff --git a/swe_af/runtime/codex_harness_patch.py b/swe_af/runtime/codex_harness_patch.py index 96eaa3fb..79ddbf35 100644 --- a/swe_af/runtime/codex_harness_patch.py +++ b/swe_af/runtime/codex_harness_patch.py @@ -88,8 +88,16 @@ async def _run_codex_cli_with_stdin( env: dict[str, str] | None, cwd: str | None, ) -> tuple[str, str, int]: + # Windows: CreateProcess does no PATHEXT resolution, and npm installs the + # codex CLI only as a .cmd shim — spawning the bare name raises + # FileNotFoundError ([WinError 2]) on every call even though the shell + # finds it. resolve_cli_command resolves the shim's real path via + # shutil.which (a no-op on POSIX and for names that already carry a path). + from agentfield.harness._cli import resolve_cli_command + proc = await asyncio.create_subprocess_exec( - *cmd, + resolve_cli_command(cmd[0]), + *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, diff --git a/tests/test_codex_harness_patch.py b/tests/test_codex_harness_patch.py index 3919e977..405b69e2 100644 --- a/tests/test_codex_harness_patch.py +++ b/tests/test_codex_harness_patch.py @@ -108,3 +108,46 @@ def test_non_codex_prompt_suffix_keeps_agentfield_write_tool_default(tmp_path) - assert "Write tool" in suffix assert "Codex CLI" not in suffix + + +def test_run_codex_cli_spawns_via_resolved_command(monkeypatch) -> None: + """The spawn must route cmd[0] through the SDK's resolve_cli_command. + + On Windows, npm installs the codex CLI only as a .cmd shim, and + CreateProcess does no PATHEXT resolution — spawning the bare name fails + with FileNotFoundError ([WinError 2]) on every codex harness call. + resolve_cli_command resolves the shim's real path (no-op on POSIX). + """ + import asyncio + + import agentfield.harness._cli as sdk_cli + + from swe_af.runtime.codex_harness_patch import _run_codex_cli_with_stdin + + spawned: dict = {} + + class FakeProc: + returncode = 0 + + async def communicate(self, data: bytes) -> tuple[bytes, bytes]: + spawned["stdin"] = data + return b"", b"" + + async def fake_exec(program: str, *args: str, **kwargs: object) -> FakeProc: + spawned["program"] = program + spawned["args"] = args + return FakeProc() + + monkeypatch.setattr(sdk_cli, "resolve_cli_command", lambda name: f"resolved::{name}") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + + stdout, stderr, returncode = asyncio.run( + _run_codex_cli_with_stdin( + ["codex", "exec", "--json"], "prompt", env=None, cwd=None + ) + ) + + assert spawned["program"] == "resolved::codex" + assert spawned["args"][:2] == ("exec", "--json") + assert spawned["stdin"] == b"prompt" + assert (stdout, stderr, returncode) == ("", "", 0)