From 675a702e505913508fd1adab32210f711f734280 Mon Sep 17 00:00:00 2001 From: wellorbetter <1419919418@qq.com> Date: Sat, 15 Aug 2026 19:10:16 +0800 Subject: [PATCH 1/2] fix(cli): support Codex launcher on native Windows Signed-off-by: wellorbetter <1419919418@qq.com> --- .../cli/launchers/codex_cli_launcher.py | 21 ++- .../cli/launchers/codex_model_catalog.py | 2 +- tests/test_codex_windows_launcher.py | 148 ++++++++++++++++++ 3 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 tests/test_codex_windows_launcher.py diff --git a/switchyard/cli/launchers/codex_cli_launcher.py b/switchyard/cli/launchers/codex_cli_launcher.py index d3b1aced7..f34ab50ac 100644 --- a/switchyard/cli/launchers/codex_cli_launcher.py +++ b/switchyard/cli/launchers/codex_cli_launcher.py @@ -30,7 +30,6 @@ from switchyard.cli.launchers.native_server import NativeServer from switchyard.cli.launchers.proxy_health_monitor import ProxyHealthMonitor from switchyard.cli.launchers.session_summary import print_session_summary -from switchyard.cli.launchers.shell_tui import ShellTUI logger = logging.getLogger(__name__) @@ -44,6 +43,11 @@ def _find_codex_binary() -> str | None: """Locate the ``codex`` executable.""" path_hit = shutil.which("codex") if path_hit: + if os.name == "nt": + # npm places a POSIX shim beside the Windows-launchable .cmd shim. + cmd_shim = Path(f"{path_hit}.cmd") + if cmd_shim.is_file(): + return str(cmd_shim) return path_hit for candidate in ( Path.home() / ".npm-global" / "bin" / "codex", @@ -97,6 +101,13 @@ def _provider_overrides( def _codex_env(use_openai_auth: bool = False) -> dict[str, str]: """Return the environment required by the transient provider.""" env = os.environ.copy() + existing_no_proxy = env.get("NO_PROXY") or env.get("no_proxy", "") + no_proxy = [item.strip() for item in existing_no_proxy.split(",") if item.strip()] + for loopback_host in ("127.0.0.1", "localhost"): + if loopback_host not in no_proxy: + no_proxy.append(loopback_host) + env["NO_PROXY"] = ",".join(no_proxy) + env["no_proxy"] = env["NO_PROXY"] if not use_openai_auth: env["OPENAI_API_KEY"] = "switchyard" return env @@ -203,10 +214,14 @@ def _run_codex_with_switchyard( routes=[display_model], default_route=display_model, ) - if stdin_is_tty(): + if stdin_is_tty() and os.name != "nt": banner_pause() - if stdin_is_tty(): + if stdin_is_tty() and os.name != "nt": + # ShellTUI depends on Unix-only pty/fcntl modules. Import it lazily so + # native Windows can still launch Codex with the inherited console. + from switchyard.cli.launchers.shell_tui import ShellTUI + footer = LiveStatsFooter( stats, display_model, diff --git a/switchyard/cli/launchers/codex_model_catalog.py b/switchyard/cli/launchers/codex_model_catalog.py index 12e5acd71..08a2640bd 100644 --- a/switchyard/cli/launchers/codex_model_catalog.py +++ b/switchyard/cli/launchers/codex_model_catalog.py @@ -79,7 +79,7 @@ def _load_codex_model_template(codex_bin: str) -> dict[str, Any]: raw = subprocess.check_output( [codex_bin, "debug", "models", "--bundled"], stderr=subprocess.DEVNULL, - text=True, + encoding="utf-8", timeout=_CODEX_CATALOG_LOAD_TIMEOUT_S, ) catalog = json.loads(raw) diff --git a/tests/test_codex_windows_launcher.py b/tests/test_codex_windows_launcher.py new file mode 100644 index 000000000..743ae50eb --- /dev/null +++ b/tests/test_codex_windows_launcher.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Windows compatibility tests for the Codex launcher.""" + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from switchyard.cli.launchers import codex_cli_launcher, codex_model_catalog + + +def test_windows_finder_prefers_a_launchable_codex_shim( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + calls: list[str] = [] + codex_shim = tmp_path / "codex" + cmd_shim = tmp_path / "codex.cmd" + codex_shim.write_text("#!/bin/sh\n") + cmd_shim.write_text("@node codex.js\n") + + def fake_which(executable: str) -> str | None: + calls.append(executable) + return str(codex_shim) + + monkeypatch.setattr(codex_cli_launcher, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr(codex_cli_launcher.shutil, "which", fake_which) + + assert codex_cli_launcher._find_codex_binary() == str(cmd_shim) + assert calls == ["codex"] + + +def test_posix_finder_keeps_the_existing_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + def fake_which(executable: str) -> str | None: + calls.append(executable) + return "/usr/local/bin/codex" + + monkeypatch.setattr(codex_cli_launcher, "os", SimpleNamespace(name="posix")) + monkeypatch.setattr(codex_cli_launcher.shutil, "which", fake_which) + + assert codex_cli_launcher._find_codex_binary() == "/usr/local/bin/codex" + assert calls == ["codex"] + + +def test_windows_tty_uses_the_plain_supervisor( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + captured: dict[str, object] = {} + + class FakeServer: + port = 4321 + stats = object() + + def caller_auth_kind(self, model: str) -> None: + captured["model"] = model + + def close(self) -> None: + captured["closed"] = True + + server = FakeServer() + monkeypatch.setattr( + codex_cli_launcher, + "os", + SimpleNamespace(name="nt", environ={}), + ) + monkeypatch.setattr(codex_cli_launcher, "_find_codex_binary", lambda: "codex.cmd") + monkeypatch.setattr(codex_cli_launcher, "_start_native_server", lambda config: server) + monkeypatch.setattr(codex_cli_launcher, "_wait_ready", lambda port: True) + monkeypatch.setattr(codex_cli_launcher, "stdin_is_tty", lambda: True) + monkeypatch.setattr(codex_cli_launcher, "silence_launch_loggers", lambda **kwargs: None) + monkeypatch.setattr( + codex_cli_launcher, + "configure_debug_file_logging", + lambda **kwargs: tmp_path / "switchyard.log", + ) + monkeypatch.setattr(codex_cli_launcher, "print_ready_banner", lambda **kwargs: None) + monkeypatch.setattr(codex_cli_launcher, "print_session_summary", lambda stats: None) + monkeypatch.setattr( + codex_cli_launcher, + "_write_codex_model_catalog", + lambda codex_bin, catalog: None, + ) + monkeypatch.setattr( + codex_cli_launcher, + "_remove_codex_model_catalog", + lambda path: captured.setdefault("removed", path), + ) + + def fake_supervise(command: list[str], env: dict[str, str]) -> int: + captured["command"] = command + captured["env"] = env + return 0 + + monkeypatch.setattr(codex_cli_launcher, "_supervise_codex", fake_supervise) + + result = codex_cli_launcher._run_codex_with_switchyard( + tmp_path / "routes.toml", + "windows-route", + [], + [], + ) + + assert result == 0 + assert captured["command"][0] == "codex.cmd" # type: ignore[index] + assert captured["model"] == "windows-route" + assert captured["closed"] is True + assert captured["removed"] is None + + +def test_codex_env_bypasses_proxies_for_loopback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.setenv("no_proxy", "internal.example") + + env = codex_cli_launcher._codex_env() + + assert env["NO_PROXY"] == "internal.example,127.0.0.1,localhost" + assert env["no_proxy"] == env["NO_PROXY"] + + +def test_codex_catalog_is_decoded_as_utf8(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + def fake_check_output(command: list[str], **kwargs: object) -> str: + captured["command"] = command + captured.update(kwargs) + return json.dumps({"models": [{"slug": "gpt-5.4"}]}) + + monkeypatch.setattr( + codex_model_catalog.subprocess, + "check_output", + fake_check_output, + ) + + template = codex_model_catalog._load_codex_model_template("codex") + + assert template["slug"] == "gpt-5.4" + assert captured["encoding"] == "utf-8" + assert "text" not in captured From 755e7b4267cb2a9a5f99dcb12d3c5ae31b6bf8b0 Mon Sep 17 00:00:00 2001 From: wellorbetter <1419919418@qq.com> Date: Sat, 15 Aug 2026 19:22:41 +0800 Subject: [PATCH 2/2] fix(cli): handle Codex launcher fallback edge cases Signed-off-by: wellorbetter <1419919418@qq.com> --- .../cli/launchers/codex_cli_launcher.py | 25 ++++++++----- tests/test_codex_windows_launcher.py | 37 +++++++++++++++++-- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/switchyard/cli/launchers/codex_cli_launcher.py b/switchyard/cli/launchers/codex_cli_launcher.py index f34ab50ac..d9ad06cf7 100644 --- a/switchyard/cli/launchers/codex_cli_launcher.py +++ b/switchyard/cli/launchers/codex_cli_launcher.py @@ -39,22 +39,26 @@ _PROVIDER_ID = "switchyard" +def _launchable_codex_path(path: str | Path) -> str: + """Prefer npm's launchable Windows shim when it is adjacent to ``path``.""" + if os.name == "nt": + cmd_shim = Path(f"{path}.cmd") + if cmd_shim.is_file(): + return str(cmd_shim) + return str(path) + + def _find_codex_binary() -> str | None: """Locate the ``codex`` executable.""" path_hit = shutil.which("codex") if path_hit: - if os.name == "nt": - # npm places a POSIX shim beside the Windows-launchable .cmd shim. - cmd_shim = Path(f"{path_hit}.cmd") - if cmd_shim.is_file(): - return str(cmd_shim) - return path_hit + return _launchable_codex_path(path_hit) for candidate in ( Path.home() / ".npm-global" / "bin" / "codex", Path.home() / ".local" / "bin" / "codex", ): if candidate.is_file() and os.access(candidate, os.X_OK): - return str(candidate) + return _launchable_codex_path(candidate) return None @@ -101,8 +105,11 @@ def _provider_overrides( def _codex_env(use_openai_auth: bool = False) -> dict[str, str]: """Return the environment required by the transient provider.""" env = os.environ.copy() - existing_no_proxy = env.get("NO_PROXY") or env.get("no_proxy", "") - no_proxy = [item.strip() for item in existing_no_proxy.split(",") if item.strip()] + no_proxy: list[str] = [] + for value in (env.get("NO_PROXY", ""), env.get("no_proxy", "")): + for item in (part.strip() for part in value.split(",")): + if item and item not in no_proxy: + no_proxy.append(item) for loopback_host in ("127.0.0.1", "localhost"): if loopback_host not in no_proxy: no_proxy.append(loopback_host) diff --git a/tests/test_codex_windows_launcher.py b/tests/test_codex_windows_launcher.py index 743ae50eb..88d630a21 100644 --- a/tests/test_codex_windows_launcher.py +++ b/tests/test_codex_windows_launcher.py @@ -49,6 +49,27 @@ def fake_which(executable: str) -> str | None: assert calls == ["codex"] +def test_windows_finder_prefers_cmd_for_fallback_candidate( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + codex_shim = tmp_path / ".npm-global" / "bin" / "codex" + cmd_shim = Path(f"{codex_shim}.cmd") + codex_shim.parent.mkdir(parents=True) + codex_shim.write_text("#!/bin/sh\n") + cmd_shim.write_text("@node codex.js\n") + + monkeypatch.setattr(codex_cli_launcher.Path, "home", lambda: tmp_path) + monkeypatch.setattr( + codex_cli_launcher, + "os", + SimpleNamespace(name="nt", access=lambda path, mode: True, X_OK=1), + ) + monkeypatch.setattr(codex_cli_launcher.shutil, "which", lambda executable: None) + + assert codex_cli_launcher._find_codex_binary() == str(cmd_shim) + + def test_windows_tty_uses_the_plain_supervisor( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -118,12 +139,22 @@ def fake_supervise(command: list[str], env: dict[str, str]) -> int: def test_codex_env_bypasses_proxies_for_loopback( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.delenv("NO_PROXY", raising=False) - monkeypatch.setenv("no_proxy", "internal.example") + monkeypatch.setattr( + codex_cli_launcher, + "os", + SimpleNamespace( + environ={ + "NO_PROXY": "uppercase.example,shared.example", + "no_proxy": "lowercase.example,shared.example", + } + ), + ) env = codex_cli_launcher._codex_env() - assert env["NO_PROXY"] == "internal.example,127.0.0.1,localhost" + assert env["NO_PROXY"] == ( + "uppercase.example,shared.example,lowercase.example,127.0.0.1,localhost" + ) assert env["no_proxy"] == env["NO_PROXY"]