diff --git a/studio/backend/core/inference/codex_availability.py b/studio/backend/core/inference/codex_availability.py
index 5b6f6c18be..f663513e41 100644
--- a/studio/backend/core/inference/codex_availability.py
+++ b/studio/backend/core/inference/codex_availability.py
@@ -226,8 +226,12 @@ async def _detect_logged_in() -> bool:
# Negative prefixes win, regardless of rc. We anchor on word
# boundaries so "not logged in" / "not authenticated" both match
# without being fooled by the substring "logged in" inside them.
+ # Covers the variants seen across CLI releases and locales.
negative = re.compile(
- r"\b(not logged in|not authenticated|please log in|run\s+`?codex login`?)\b"
+ r"\b(not\s+(?:logged|signed)\s+in|"
+ r"not\s+authenticated|"
+ r"please\s+(?:log|sign)\s+in|"
+ r"run\s+`?codex\s+login`?)\b"
)
if negative.search(combined):
return False
diff --git a/studio/backend/core/inference/codex_provider.py b/studio/backend/core/inference/codex_provider.py
index 903e76aec7..cf2811e4b0 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 sys
import time
from typing import Any, AsyncGenerator, Optional
@@ -71,6 +72,60 @@ class CodexUnavailableError(RuntimeError):
"""
+def _codex_sdk_env_override() -> dict[str, str]:
+ """Return an env update dict that scrubs sensitive vars from the
+ codex app-server subprocess env.
+
+ The upstream openai_codex SDK's `AppServerConfig.env` is merged on
+ top of `os.environ.copy()` (see openai/codex/sdk/python/src/openai_codex/client.py),
+ so providing an empty-string mapping for every non-safe key
+ effectively overrides them in the spawn env. Combined with
+ `_codex_subprocess_env()` (used for direct CLI calls) this gives
+ parity between the CLI and SDK code paths: neither sees HF_TOKEN,
+ GH_TOKEN, WANDB_API_KEY, ANTHROPIC_API_KEY, or any other secret
+ that lives in the Studio parent environment.
+ """
+ import os
+
+ from core.inference.codex_availability import _SAFE_CODEX_ENV_KEYS
+
+ safe = set(_SAFE_CODEX_ENV_KEYS)
+ return {key: "" for key in os.environ if key not in safe}
+
+
+def _open_async_codex(async_codex_cls: Any) -> Any:
+ """Construct an AsyncCodex with a scrubbed env config.
+
+ Tries `AsyncCodex(config=AppServerConfig(env=...))` first; falls
+ back to the bare `AsyncCodex()` form when either the SDK does not
+ expose AppServerConfig or its signature does not accept the env
+ kwarg. The fallback is a soft degradation: the app-server will see
+ the full Studio env, but every other Codex hardening still
+ applies.
+ """
+ try:
+ sdk_mod = sys.modules.get("openai_codex") or sys.modules.get(
+ "codex_app_server"
+ )
+ if sdk_mod is not None:
+ app_server_config = getattr(sdk_mod, "AppServerConfig", None)
+ if app_server_config is not None:
+ return async_codex_cls(
+ config = app_server_config(env = _codex_sdk_env_override()),
+ )
+ except TypeError:
+ # Older SDK: AppServerConfig may not accept the env kwarg yet.
+ # Fall through to the bare constructor.
+ pass
+ except Exception as exc:
+ logger.warning(
+ "codex_provider.env_scrub_config_failed",
+ exc_type = type(exc).__name__,
+ error = str(exc),
+ )
+ return async_codex_cls()
+
+
def _import_codex() -> Any:
"""Return the imported Codex SDK module or raise CodexUnavailableError.
@@ -88,9 +143,9 @@ def _import_codex() -> Any:
return importlib.import_module(name)
raise CodexUnavailableError(
"Codex Python SDK is not installed on this host. "
- "Install with `pip install openai-codex-app-server-sdk` "
- "(import name `openai_codex`, legacy alias `codex_app_server`), "
- "or use a different provider."
+ "Install with `pip install openai-codex` (canonical upstream "
+ "name, imports as `openai_codex`; legacy alias `codex_app_server` "
+ "is also accepted), or use a different provider."
)
@@ -407,7 +462,7 @@ async def _stream_codex_single(
completion_text_chars = 0
- async with async_codex_cls() as codex:
+ async with _open_async_codex(async_codex_cls) as codex:
# ``thread_start`` accepts a model id; system prompts are
# passed when supported by the SDK rev (older revs ignore the
# extra kwarg). Be tolerant about kwargs that may not exist.
@@ -540,7 +595,7 @@ async def _stream_codex_parallel(
try:
sdk = _import_codex()
async_codex_cls = getattr(sdk, "AsyncCodex")
- async with async_codex_cls() as codex:
+ async with _open_async_codex(async_codex_cls) as codex:
thread_kwargs: dict[str, Any] = {"model": model}
if system:
thread_kwargs["system"] = system
@@ -670,6 +725,7 @@ async def _stream_codex_parallel(
synthesis_text = await _run_codex_synthesis(
model = model,
+ system = system,
prompt = prompt,
tab_outputs = per_tab_texts,
)
@@ -702,13 +758,16 @@ async def _stream_codex_parallel(
async def _run_codex_synthesis(
*,
model: str,
+ system: str,
prompt: str,
tab_outputs: list[str],
) -> str:
"""Run one extra Codex call that consumes the N per-tab outputs and
returns a unified synthesis. Returns the empty string on failure --
the caller already surfaced the per-tab outputs so an empty
- synthesis is recoverable.
+ synthesis is recoverable. The Studio system prompt is forwarded to
+ the synthesis thread so style/role instructions like "Always answer
+ in Spanish" survive the fan-out.
"""
if not tab_outputs:
return ""
@@ -728,11 +787,18 @@ async def _run_codex_synthesis(
try:
sdk = _import_codex()
async_codex_cls = getattr(sdk, "AsyncCodex")
- async with async_codex_cls() as codex:
+ async with _open_async_codex(async_codex_cls) as codex:
+ thread_kwargs: dict[str, Any] = {"model": model}
+ if system:
+ thread_kwargs["system"] = system
try:
- thread = await codex.thread_start(model = model)
+ thread = await codex.thread_start(**thread_kwargs)
except TypeError:
- thread = await codex.thread_start()
+ # Older SDK rev: no `system` kwarg. Fall back to model-only
+ # and prepend the system prompt to the synthesis text.
+ thread = await codex.thread_start(model = model)
+ if system:
+ synthesis_prompt = f"{system}\n\n{synthesis_prompt}"
result = await thread.run(synthesis_prompt)
return (
_coerce_text(result) or getattr(result, "final_response", "") or str(result)
@@ -809,11 +875,13 @@ async def stream_codex_device_login() -> AsyncGenerator[dict[str, Any], None]:
# Strip ANSI control sequences (the upstream login command wraps the
# URL and code in `\x1b[34m...\x1b[0m`) before pattern matching.
ansi_re = re.compile(r"\x1b\[[0-9;]*[mGKHF]")
- # Anchor on the upstream URL shape: ``.../codex/device`` (optionally
- # with a query string). The pattern accepts any host because some
- # builds redirect via a staging host.
+ # Accept any plausible device-auth URL the CLI prints. Upstream has
+ # used `.../codex/device`, `chatgpt.com/activate`, and
+ # `auth.openai.com/device`; rather than guess we look for any
+ # https URL whose path mentions `device`, `activate`, or `verify`.
url_re = re.compile(
- r"https?://[^\s\x1b]+?/codex/device(?:\?[^\s\x1b]*)?", re.IGNORECASE
+ r"https?://[^\s\x1b]+/(?:codex/)?(?:device|activate|verify)\b[^\s\x1b]*",
+ re.IGNORECASE,
)
# One-time-code format from upstream device_code_auth.rs: 4 chars,
# dash, 4 chars. Pattern is tolerant of any uppercase alphanum.
diff --git a/studio/backend/core/inference/providers.py b/studio/backend/core/inference/providers.py
index 4d758f1520..f18b9c177d 100644
--- a/studio/backend/core/inference/providers.py
+++ b/studio/backend/core/inference/providers.py
@@ -307,10 +307,10 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
"hidden": True,
"notes": (
"Dispatches chat turns through the local Codex CLI via "
- "the OpenAI Codex Python SDK (pip install "
- "`openai-codex-app-server-sdk`, import `openai_codex`, legacy "
- "alias `codex_app_server`). Surfaced only when the CLI and "
- "SDK are both installed; sign in with `codex login`."
+ "the OpenAI Codex Python SDK (pip install `openai-codex`, "
+ "imports as `openai_codex`; legacy alias `codex_app_server` "
+ "is accepted). Surfaced only when the CLI and SDK are both "
+ "installed; sign in with `codex login`."
),
},
"openrouter": {
diff --git a/studio/backend/tests/test_codex_provider.py b/studio/backend/tests/test_codex_provider.py
index e5212d7b5c..57272d5ca3 100644
--- a/studio/backend/tests/test_codex_provider.py
+++ b/studio/backend/tests/test_codex_provider.py
@@ -37,6 +37,15 @@ _backend = os.path.join(os.path.dirname(__file__), "..")
if _backend not in sys.path:
sys.path.insert(0, _backend)
+# Resolved relative to this file so the source-inspection tests work in any
+# checkout location (CI, dev machines, the review worker, etc.).
+_BACKEND_DIR = os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))
+
+
+def _backend_file(rel: str) -> str:
+ """Return an absolute path inside the backend tree, regardless of cwd."""
+ return os.path.join(_BACKEND_DIR, rel)
+
# ── Helpers ─────────────────────────────────────────────────────────
@@ -529,10 +538,7 @@ class TestCodexHardenedRegressions:
def test_login_status_uses_login_subcommand(self):
"""Upstream is `codex login status`, NOT `codex auth status`."""
- src = (
- "/mnt/disks/unslothai/ubuntu/workspace_11/unsloth_pr5724/"
- "studio/backend/core/inference/codex_availability.py"
- )
+ src = _backend_file("core/inference/codex_availability.py")
text = open(src).read()
assert (
'"auth", "status"' not in text
@@ -540,10 +546,7 @@ class TestCodexHardenedRegressions:
assert '"login", "status"' in text
def test_device_login_uses_login_subcommand(self):
- src = (
- "/mnt/disks/unslothai/ubuntu/workspace_11/unsloth_pr5724/"
- "studio/backend/core/inference/codex_provider.py"
- )
+ src = _backend_file("core/inference/codex_provider.py")
text = open(src).read()
assert (
'"auth", "login", "--device-auth"' not in text
@@ -621,10 +624,7 @@ class TestCodexHardenedRegressions:
"""SSE error frame must NOT echo str(exc) verbatim (CodeQL)."""
import re
- src = (
- "/mnt/disks/unslothai/ubuntu/workspace_11/unsloth_pr5724/"
- "studio/backend/routes/inference.py"
- )
+ src = _backend_file("routes/inference.py")
text = open(src).read()
bad = re.findall(r'f["\']Codex error:\s*\{exc\}["\']', text)
assert not bad, f"raw exception in SSE: {bad}"
@@ -633,10 +633,7 @@ class TestCodexHardenedRegressions:
"""codex.py SSE stream wrapping must also not leak str(exc)."""
import re
- src = (
- "/mnt/disks/unslothai/ubuntu/workspace_11/unsloth_pr5724/"
- "studio/backend/routes/codex.py"
- )
+ src = _backend_file("routes/codex.py")
text = open(src).read()
for line in text.splitlines():
ls = line.strip()
@@ -750,6 +747,167 @@ class TestCodexHardenedRegressions:
assert "partial output" in body
assert "REPLAYED" not in body
+ def test_not_signed_in_wording_also_handled(self):
+ """`Not signed in` (alternative localisation) must also be
+ treated as logged-out, not as positive match.
+ """
+ import asyncio
+
+ from core.inference import codex_availability as av
+
+ async def _fake_run_cli(args, **kw):
+ return (0, "Not signed in.", "")
+
+ orig = av._run_cli
+ av._run_cli = _fake_run_cli # type: ignore[assignment]
+ try:
+ assert asyncio.run(av._detect_logged_in()) is False
+ finally:
+ av._run_cli = orig # type: ignore[assignment]
+
+ def test_device_url_accepts_generic_verification_url(self):
+ """The login parser must accept upstream's chatgpt.com/activate
+ URL as well as the canonical /codex/device shape.
+ """
+ import re
+
+ src = _backend_file("core/inference/codex_provider.py")
+ text = open(src).read()
+ # Find the url_re pattern literal and compile it.
+ m = re.search(r"url_re\s*=\s*re\.compile\(\s*\n?\s*r\"([^\"]+)\"", text)
+ assert m, "url_re definition not found"
+ pattern = re.compile(m.group(1), re.IGNORECASE)
+ # Upstream device URLs we expect to match.
+ for u in (
+ "https://auth.openai.com/codex/device",
+ "https://chatgpt.com/activate",
+ "https://auth.openai.com/device/verify?code=ABCD",
+ ):
+ assert pattern.search(u), f"device URL regex missed: {u}"
+
+ def test_synthesis_call_forwards_system_prompt(self, monkeypatch):
+ """`_run_codex_synthesis` must pass the system prompt so a
+ fan-out style instruction ("Always answer in Spanish") survives
+ the unification step.
+ """
+ seen_kwargs: list[dict] = []
+ seen_prompts: list[str] = []
+
+ class _SynThread:
+ async def run(self, prompt):
+ seen_prompts.append(prompt)
+ return "synth ok"
+
+ def turn(self, prompt):
+ # Force buffered path via no `stream` attr.
+ class _T:
+ pass
+
+ return _T()
+
+ class _Async:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *a):
+ return False
+
+ async def thread_start(self, **kw):
+ seen_kwargs.append(kw)
+ return _SynThread()
+
+ _install_fake_codex_sdk(monkeypatch, _Async)
+ from core.inference.codex_provider import _run_codex_synthesis
+
+ out = asyncio.run(
+ _run_codex_synthesis(
+ model = "gpt-5.5",
+ system = "Always answer in Spanish.",
+ prompt = "What is the capital of France?",
+ tab_outputs = ["Paris", "Paris."],
+ )
+ )
+ # Either the kwargs carried the system prompt or it was
+ # prepended to the synthesis prompt as a fallback.
+ system_seen = (
+ any("Spanish" in (kw.get("system") or "") for kw in seen_kwargs)
+ or any("Always answer in Spanish" in p for p in seen_prompts)
+ )
+ assert system_seen, (
+ f"system prompt dropped in synthesis. kwargs={seen_kwargs} "
+ f"prompts={seen_prompts}"
+ )
+ # And the synthesis still returned the model's text.
+ assert "synth" in out.lower()
+
+ def test_sdk_env_scrubbed_via_appserverconfig(self, monkeypatch):
+ """The SDK construction path must wire AppServerConfig(env=...)
+ when the SDK exposes it, so HF_TOKEN / GH_TOKEN are not leaked
+ to the codex app-server subprocess.
+ """
+ monkeypatch.setenv("HF_TOKEN", "should_be_scrubbed")
+ monkeypatch.setenv("GH_TOKEN", "should_be_scrubbed")
+ monkeypatch.setenv("OPENAI_API_KEY", "ok_for_codex")
+
+ seen_configs: list[Any] = []
+
+ class _FakeAppServerConfig:
+ def __init__(self, env=None, **kw):
+ self.env = env or {}
+
+ class _Async:
+ def __init__(self, config=None):
+ seen_configs.append(config)
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *a):
+ return False
+
+ async def thread_start(self, **kw):
+ return _FakeThread(chunks=["ok"])
+
+ # Inject a fake openai_codex module exposing AppServerConfig.
+ import importlib.util as _iu
+ import types as _types
+
+ fake_mod = _types.ModuleType("openai_codex")
+ fake_mod.AsyncCodex = _Async # type: ignore[attr-defined]
+ fake_mod.AppServerConfig = _FakeAppServerConfig # type: ignore[attr-defined]
+ monkeypatch.setitem(sys.modules, "openai_codex", fake_mod)
+ real_find_spec = _iu.find_spec
+ monkeypatch.setattr(
+ "importlib.util.find_spec",
+ lambda n, *a, **kw: (
+ _types.SimpleNamespace()
+ if n in ("openai_codex", "codex_app_server")
+ else real_find_spec(n, *a, **kw)
+ ),
+ )
+
+ from core.inference.codex_provider import stream_codex
+
+ asyncio.run(
+ _consume_first(
+ stream_codex(
+ messages=[{"role": "user", "content": "hi"}],
+ model="gpt-5.5",
+ parallel_calls=1,
+ )
+ )
+ )
+ assert seen_configs, "AsyncCodex was never instantiated"
+ cfg = seen_configs[0]
+ assert cfg is not None, "AppServerConfig was not passed to AsyncCodex"
+ assert "HF_TOKEN" in cfg.env and cfg.env["HF_TOKEN"] == "", (
+ "HF_TOKEN not overridden to empty in SDK env"
+ )
+ assert "GH_TOKEN" in cfg.env and cfg.env["GH_TOKEN"] == ""
+ # Safe-listed keys must NOT appear in the override dict (so the
+ # SDK keeps their os.environ values intact).
+ assert "OPENAI_API_KEY" not in cfg.env
+
def test_thread_turn_stream_path_taken(self, monkeypatch):
"""The canonical openai_codex API uses thread.turn(prompt).stream();
the provider must prefer that over the legacy run_streaming hook.
diff --git a/studio/frontend/src/features/chat/api/codex-api.ts b/studio/frontend/src/features/chat/api/codex-api.ts
index 6652bf9391..50d71cc009 100644
--- a/studio/frontend/src/features/chat/api/codex-api.ts
+++ b/studio/frontend/src/features/chat/api/codex-api.ts
@@ -31,8 +31,12 @@ export interface CodexStatus {
}
export interface CodexLoginEvent {
- type: "device_url" | "log" | "error" | "done";
+ // `device_code` is the one-time code the verification page asks for
+ // (separate from the URL); the backend extracts it from the CLI
+ // stdout via a dedicated regex and emits it as a structured event.
+ type: "device_url" | "device_code" | "log" | "error" | "done";
url?: string;
+ code?: string;
line?: string;
message?: string;
ok?: boolean;
diff --git a/studio/frontend/src/features/chat/components/codex-login-button.tsx b/studio/frontend/src/features/chat/components/codex-login-button.tsx
index 3458a99c9e..deaacb8a71 100644
--- a/studio/frontend/src/features/chat/components/codex-login-button.tsx
+++ b/studio/frontend/src/features/chat/components/codex-login-button.tsx
@@ -37,6 +37,7 @@ export function CodexLoginButton({ onLoggedIn }: Props) {
const [error, setError] = useState
+ One-time code:{" "}
+ {deviceCode}
+
{error}
)}