From 2eaf1bbd315a40e5a959779a37d8e248f69e3776 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 25 May 2026 13:47:16 +0000 Subject: [PATCH] Studio: pass codex_bin to AppServerConfig so PATH-only codex installs work Reproduces with pip install openai-codex --no-deps (lightweight install that skips the pinned openai-codex-cli-bin runtime) or any host where the codex CLI is installed via npm i -g @openai/codex / Homebrew / manual download. Studio constructs AsyncCodex(config=AppServerConfig( env=...)) without codex_bin, so the SDK runs _installed_codex_path which 'from codex_cli_bin import bundled_codex_path' and raises FileNotFoundError: Unable to locate the pinned Codex runtime. Install the published SDK build with its openai-codex-cli-bin dependency, or set AppServerConfig.codex_bin explicitly. -- even though a perfectly good codex is on PATH and was the binary the availability probe already verified. Fix: resolve shutil.which("codex") and pass it as AppServerConfig(codex_bin=...). The PR's availability probe already returns that exact path in /api/codex/status.cli_path, so we are giving the SDK back the binary the user can see in the Connections form. Falls back to AppServerConfig(env=...) (no codex_bin) when the SDK build does not accept the kwarg yet, and falls back to PATH lookup returning None on hosts without codex on PATH (in which case the availability probe would have reported installed=false and Studio never gets here). Test fixture: also inject the fake module under openai_codex (the canonical name the production importer prefers) so the test does not silently exercise the real SDK on developer venvs that have pip install openai-codex already done. End-to-end verified live: pip install -e openai/codex sdk/python plus Studio with codex CLI on PATH yielded ROUND_TRIP_OK streaming for gpt-5.4-mini through the OpenAI-compat completions route. --- .../backend/core/inference/codex_provider.py | 66 ++++++++++++++++++- studio/backend/tests/test_codex_provider.py | 11 +++- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/studio/backend/core/inference/codex_provider.py b/studio/backend/core/inference/codex_provider.py index c2b07620de..149eb11b89 100644 --- a/studio/backend/core/inference/codex_provider.py +++ b/studio/backend/core/inference/codex_provider.py @@ -40,6 +40,7 @@ import asyncio import importlib import importlib.util import json +import shutil import sys import time from typing import Any, AsyncGenerator, Optional @@ -186,12 +187,52 @@ class _ScrubbedEnvAsyncCodex: self._restored_via_us.clear() +def _resolve_codex_bin() -> Optional[str]: + """Best-effort PATH lookup for the codex CLI used as ``codex_bin``. + + The upstream Python SDK normally locates its pinned codex binary + via the ``openai-codex-cli-bin`` runtime package, installed + automatically as a dependency of ``pip install openai-codex``. + Users who installed the SDK with ``--no-deps`` (lightweight + setups), users whose platform is not yet on the + ``openai-codex-cli-bin`` wheel matrix, and users whose codex CLI + was installed via ``npm i -g @openai/codex`` / Homebrew never + have the pinned runtime package on import path. Without an + explicit ``codex_bin`` the SDK then raises + ``FileNotFoundError("Unable to locate the pinned Codex runtime")`` + even though a perfectly good ``codex`` is on PATH and was the + binary Studio's availability probe already verified. + + Returning ``shutil.which("codex")`` here turns that hard failure + into a working session: Studio passes the resolved path through + ``AppServerConfig(codex_bin=...)`` and the SDK uses it directly. + Returning ``None`` keeps the pinned-runtime path intact when the + CLI is not on PATH (which only happens on hosts where the SDK is + importable but the CLI is missing -- ``codex_availability`` would + already report ``installed=false`` there, so callers never reach + this). + """ + try: + return shutil.which("codex") + except Exception as exc: + logger.warning( + "codex_provider.codex_bin_lookup_failed", + exc_type = type(exc).__name__, + error = str(exc), + ) + return None + + def _open_async_codex(async_codex_cls: Any) -> Any: """Construct an AsyncCodex whose spawned app-server cannot see Studio's secrets. - Preferred path: `AsyncCodex(config=AppServerConfig(env=...))` - which scopes the override to the spawned subprocess only. + Preferred path: `AsyncCodex(config=AppServerConfig(env=..., + codex_bin=...))` which scopes the env override to the spawned + subprocess only and explicitly pins the codex binary so the SDK + does not need its pinned ``openai-codex-cli-bin`` runtime to be + installed. + Fail-closed fallback: `_ScrubbedEnvAsyncCodex` swaps `os.environ` for the lifetime of the session so the SDK's internal `os.environ.copy()` spawn never sees HF_TOKEN / GH_TOKEN / @@ -203,8 +244,27 @@ def _open_async_codex(async_codex_cls: Any) -> Any: if sdk_mod is not None: app_server_config = getattr(sdk_mod, "AppServerConfig", None) if app_server_config is not None: + # Try the modern signature: AppServerConfig(env=..., codex_bin=...). + # codex_bin keeps PATH-installed codex working without the + # SDK's pinned openai-codex-cli-bin runtime package. We try + # the full signature first, then degrade gracefully if the + # installed SDK build does not accept codex_bin yet. + codex_bin = _resolve_codex_bin() + env_override = _codex_sdk_env_override() + if codex_bin is not None: + try: + return async_codex_cls( + config = app_server_config( + env = env_override, + codex_bin = codex_bin, + ), + ) + except TypeError: + # Older SDK build: codex_bin kwarg unknown. Fall + # through to env-only construction below. + pass return async_codex_cls( - config = app_server_config(env = _codex_sdk_env_override()), + config = app_server_config(env = env_override), ) except TypeError: # Older SDK: AppServerConfig may not accept the env kwarg yet. diff --git a/studio/backend/tests/test_codex_provider.py b/studio/backend/tests/test_codex_provider.py index 28d82e695b..112ed725a9 100644 --- a/studio/backend/tests/test_codex_provider.py +++ b/studio/backend/tests/test_codex_provider.py @@ -141,7 +141,16 @@ def _install_fake_codex_sdk(monkeypatch, async_codex_cls, *, with_safety_enums = workspace_write = "WORKSPACE_WRITE", danger_full_access = "DANGER_FULL_ACCESS", ) + # Inject the fake under BOTH module names the production importer + # checks. ``openai_codex`` is the canonical upstream name and is + # preferred by the lazy-import gate; ``codex_app_server`` is the + # legacy / Rust-crate alias. Hosts that have ``openai_codex`` + # actually installed (developer venvs, CI runners after the PR's + # `pip install openai-codex`) would otherwise bypass the fake and + # exercise the real SDK -- the same fake must be reachable under + # both names for the test to be deterministic. monkeypatch.setitem(sys.modules, "codex_app_server", fake_mod) + monkeypatch.setitem(sys.modules, "openai_codex", fake_mod) # importlib.util.find_spec walks finders, not sys.modules; patch # it directly so the lazy-import gate accepts the fake. import importlib.util as _iu @@ -149,7 +158,7 @@ def _install_fake_codex_sdk(monkeypatch, async_codex_cls, *, with_safety_enums = real_find_spec = _iu.find_spec def _shim(name: str, *args, **kwargs): - if name == "codex_app_server": + if name in ("codex_app_server", "openai_codex"): return types.SimpleNamespace() return real_find_spec(name, *args, **kwargs)