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
32 changes: 27 additions & 5 deletions switchyard/cli/launchers/codex_cli_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -40,17 +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:
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


Expand Down Expand Up @@ -97,6 +105,16 @@ 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()
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)
env["NO_PROXY"] = ",".join(no_proxy)
env["no_proxy"] = env["NO_PROXY"]
if not use_openai_auth:
env["OPENAI_API_KEY"] = "switchyard"
return env
Expand Down Expand Up @@ -203,10 +221,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,
Expand Down
2 changes: 1 addition & 1 deletion switchyard/cli/launchers/codex_model_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
179 changes: 179 additions & 0 deletions tests/test_codex_windows_launcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# 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_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,
) -> 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.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"] == (
"uppercase.example,shared.example,lowercase.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