unsloth/studio/backend/core/inference/codex_provider.py
Daniel Han dee1b68b6d Studio: round 7b -- tighten device-login log filter + harden timeout kill
Two more P1 follow-ups from the round 7 reviewer pass:

1. Device-login log filter no longer leaks sensitive lines.

   The old `_safe_to_forward` used unanchored substring matches like
   `"logged in"`, so a line such as

     Not logged in: refresh_token=rt_LEAK auth.json=/home/u/.codex/auth.json

   slipped through the safe-vocabulary filter and was streamed to the
   browser. A malicious codex shim earlier on PATH can print that
   line trivially, defeating the "opaque output stays in backend
   logs" safety guarantee the route claimed.

   Round 7b fix: anchored regex set (must start with one of the
   known upstream phrases) plus an explicit blocklist for
   refresh_token / access_token / api_key / secret / auth.json / the
   codex config dir / "not logged in" / "not authenticated". A line
   that matches the blocklist is dropped regardless of which safe
   pattern would otherwise have accepted it. New tests reconstruct
   the regex set inline and assert both the leak cases drop and the
   clean upstream phrases pass.

2. `_run_cli` timeout cleanup no longer 500s on a kill race.

   `_run_cli` would call `proc.kill()` after `os.killpg(pid, SIGTERM)`
   reaped the process group. If the SIGTERM landed first, the
   subsequent `proc.kill()` raised `ProcessLookupError` and bubbled
   out of `_run_cli`, turning `/api/codex/status` into a 500 during
   a timeout race. The device-login cleanup at codex_provider.py
   already wraps the same destructive call in a try / except. Mirror
   that exception guard here so the two timeout paths behave the
   same.
2026-05-25 14:45:26 +00:00

1542 lines
62 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Codex SDK chat provider.
This module wraps the OpenAI Codex async Python SDK (``openai_codex``
canonical, ``codex_app_server`` legacy alias) so a chat request routed
at ``provider_type="codex"`` can dispatch through the user's local
Codex CLI. The contract back to the frontend is the standard OpenAI
Chat Completions SSE shape, exactly like every other entry in
``external_provider.py``.
The two interesting features here:
* ``_stream_codex_single`` -- translate Codex SDK events into
OpenAI-format chunks. We prefer ``thread.run_streaming`` when the
installed SDK exposes it; otherwise we fall back to
``await thread.run(...)`` and emit one big content chunk plus a
usage chunk at the end.
* ``_stream_codex_parallel`` -- the ``parallel_calls`` knob. When > 1,
spawn N async Codex tasks (capped at 20) via ``asyncio.gather`` and
emit per-tab ``_toolEvent`` markers so the frontend can render each
result in its own tab. A final ``codex_gather`` synthesis tab runs a
single Codex call that takes the N outputs and produces a unified
answer. Workers cancel cleanly when the SSE consumer disconnects so
cancelled fan-outs never leave zombie Codex calls running.
The SDK is imported lazily (inside the helpers that actually need it)
because the SDK may not be importable on the build host. The
availability probe in ``codex_availability.py`` is what the frontend
uses to gate the provider entirely; this module just refuses to run
if the import fails at request time.
"""
from __future__ import annotations
import asyncio
import importlib
import importlib.util
import json
import shutil
import sys
import time
from typing import Any, AsyncGenerator, Optional
from urllib.parse import urlparse
import structlog
logger = structlog.get_logger(__name__)
# Device-auth verification URLs the upstream codex CLI prints during
# `codex login --device-auth`. Only these hosts are forwarded to the
# browser as "Open verification page". A shimmed codex earlier on PATH
# can still print a phishing URL that matches the regex used to fish
# the verification URL out of stdout (`/device`, `/activate`,
# `/verify`), but Studio refuses to surface anything not on this list,
# so the malicious URL never reaches the user.
_ALLOWED_DEVICE_AUTH_HOSTS: frozenset[str] = frozenset(
{
"auth.openai.com",
"chatgpt.com",
}
)
def _safe_host(url: str) -> Optional[str]:
"""Return the lower-case host of ``url`` or None if it cannot be parsed."""
try:
return (urlparse(url).hostname or "").lower() or None
except Exception:
return None
def _is_allowed_device_url(url: str) -> bool:
"""Return True iff ``url`` looks like a real codex device-auth URL.
Requires https, a parseable URL, and a host on the allowlist above.
Codex login URLs are always https in the wild; downgrade to http
is a strong signal of a malicious shim, so we drop both at the
same gate.
"""
try:
parsed = urlparse(url)
except Exception:
return False
if parsed.scheme != "https":
return False
host = (parsed.hostname or "").lower()
return host in _ALLOWED_DEVICE_AUTH_HOSTS
# Hard cap on parallel Codex fan-out. Picked to match the upper bound
# in the request validator -- exceeding this risks the local Codex CLI
# rate-limiting itself or starving the loop.
MAX_PARALLEL_CALLS = 20
# Names the upstream Python SDK has shipped under. ``openai_codex`` is
# the canonical package at ``openai/codex/sdk/python``; ``codex_app_server``
# is kept as a forward-compat alias because the Rust crate uses that name.
# Order matters: first hit wins, so the canonical name is tried first.
_SDK_MODULE_NAMES: tuple[str, ...] = ("openai_codex", "codex_app_server")
class CodexUnavailableError(RuntimeError):
"""Raised when the Codex Python SDK is not importable at runtime.
The availability probe is supposed to hide the provider before any
request lands here, but we still raise a typed error so the route
layer can translate it into a 503 the user sees instead of an
opaque traceback.
"""
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}
_SCRUBBED_ENV_LOCK = asyncio.Lock()
_SCRUBBED_ENV_REFCOUNT: dict[str, int] = {}
# Saved originals shared across all wrappers. The first wrapper to scrub
# a key records its pre-scrub value here; later wrappers that pick up the
# same key while it is already absent from os.environ inherit the same
# saved value so the very last wrapper to release a key still restores
# the right thing. Kept module-level (not per-instance) because two
# concurrent wrappers must agree on what the original was even though
# only one of them actually saw it in os.environ.
_SCRUBBED_ENV_ORIGINALS: dict[str, str] = {}
class _ScrubbedEnvAsyncCodex:
"""Async-context wrapper that swaps `os.environ` for the lifetime
of a Codex SDK session.
Used as the fail-closed fallback when the SDK does not expose
`AppServerConfig(env=...)`. The SDK starts its app-server with
`env = os.environ.copy()`, so removing secrets from the parent
process env right before construction keeps them out of the child.
Concurrency model: a process-wide asyncio lock serialises the
enter/exit critical section, and a per-key refcount tracks how
many concurrent wrappers are currently "holding" the scrub. A
key is only restored when the last wrapper using it exits. This
fixes three issues:
1. Two concurrent fan-out workers used to race: wrapper A could
restore `HF_TOKEN` while wrapper B was still inside SDK
startup, letting B's spawned app-server inherit the secret.
The refcount keeps the key scrubbed for the full overlap
window.
2. If the SDK constructor raised before `__aenter__` returned,
Python never called `__aexit__`, so the deleted keys leaked
permanently. Construction now happens INSIDE the try/except
in `__aenter__`, and the scrub is rolled back on failure.
3. ``_codex_sdk_env_override()`` only enumerates keys currently
in ``os.environ``, so a wrapper B entering AFTER wrapper A
already scrubbed (say) ``HF_TOKEN`` would never see that key
in its overrides dict, never bump its refcount, and miss the
scrub for HF_TOKEN entirely. When A then exited it would
restore HF_TOKEN while B was still mid-session. Wrapper B
now also picks up every key currently refcounted by an
earlier wrapper (read under the lock) so the refcount
reflects the true set of holders for every scrubbed key.
"""
def __init__(self, async_codex_cls: Any):
self._async_codex_cls = async_codex_cls
self._inner: Any = None
# Keys this wrapper instance contributed to the refcount, so
# __aexit__ knows exactly which counters to decrement (avoids
# racing with concurrent wrappers that scrub a different set).
self._held_keys: list[str] = []
async def __aenter__(self) -> Any:
import os
async with _SCRUBBED_ENV_LOCK:
# Keys this session would scrub if it were entering first.
keys_to_scrub = set(_codex_sdk_env_override())
# Plus every key still held by an earlier wrapper -- without
# this we would miss keys that are already absent from
# os.environ but ARE still scrubbed and refcounted.
keys_to_scrub.update(
key for key, count in _SCRUBBED_ENV_REFCOUNT.items() if count > 0
)
for key in keys_to_scrub:
current = _SCRUBBED_ENV_REFCOUNT.get(key, 0)
if current == 0:
# First wrapper to scrub this key -- save the
# original. If the key is somehow not in os.environ
# right now (deleted between override-snapshot and
# here, or simply never set), skip it: nothing to
# scrub and nothing to restore.
if key not in os.environ:
continue
_SCRUBBED_ENV_ORIGINALS[key] = os.environ[key]
del os.environ[key]
_SCRUBBED_ENV_REFCOUNT[key] = current + 1
self._held_keys.append(key)
try:
self._inner = self._async_codex_cls()
return await self._inner.__aenter__()
except BaseException:
# Roll back the scrub if SDK construction / enter fails;
# otherwise the deleted env vars would leak permanently.
await self._release_held_keys()
raise
async def __aexit__(self, exc_type, exc, tb):
try:
if self._inner is not None:
return await self._inner.__aexit__(exc_type, exc, tb)
finally:
await self._release_held_keys()
async def _release_held_keys(self) -> None:
import os
async with _SCRUBBED_ENV_LOCK:
for key in self._held_keys:
current = _SCRUBBED_ENV_REFCOUNT.get(key, 0)
if current <= 0:
continue
next_count = current - 1
if next_count == 0:
# Last wrapper holding this key -- restore the
# original snapshot recorded when it was first
# scrubbed, regardless of which wrapper saved it.
_SCRUBBED_ENV_REFCOUNT.pop(key, None)
original = _SCRUBBED_ENV_ORIGINALS.pop(key, None)
if original is not None:
os.environ.setdefault(key, original)
else:
_SCRUBBED_ENV_REFCOUNT[key] = next_count
self._held_keys.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=...,
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 /
WANDB_API_KEY / etc. There is no code path that lets the SDK
inherit those secrets.
"""
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:
# 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 = env_override),
)
except TypeError:
# Older SDK: AppServerConfig may not accept the env kwarg yet.
# Fall through to the os.environ-swap wrapper.
pass
except Exception as exc:
logger.warning(
"codex_provider.env_scrub_config_failed",
exc_type = type(exc).__name__,
error = str(exc),
)
return _ScrubbedEnvAsyncCodex(async_codex_cls)
def _import_codex() -> Any:
"""Return the imported Codex SDK module or raise CodexUnavailableError.
Imported lazily so the rest of the backend keeps starting cleanly
on hosts that don't have the SDK installed. Probes ``openai_codex``
first (canonical upstream name), then ``codex_app_server`` (Rust-
crate-style alias) for forward compatibility. The frontend calls
``GET /api/codex/status`` first and hides the provider when no
name resolves, so this branch is reached only when (a) the user
explicitly forces the provider via a stale stored config or (b)
the install state changes between status probe and chat submit.
"""
for name in _SDK_MODULE_NAMES:
if importlib.util.find_spec(name) is not None:
return importlib.import_module(name)
raise CodexUnavailableError(
"Codex Python SDK is not installed on this host. "
"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."
)
def _stringify_content(content: Any) -> str:
"""Flatten an OpenAI-style content field into one plain-text block.
Multimodal entries (images, documents) are described inline rather
than embedded since the Codex SDK input shape is text-first.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for entry in content:
if not isinstance(entry, dict):
continue
if entry.get("type") == "text":
parts.append(str(entry.get("text") or ""))
elif entry.get("type") == "image_url":
url = (entry.get("image_url") or {}).get("url") or ""
parts.append(f"[image: {url[:80]}]" if url else "[image]")
elif entry.get("type") == "input_document":
name = entry.get("filename") or "document"
parts.append(f"[document: {name}]")
return "\n".join(p for p in parts if p)
return ""
def _last_user_prompt(messages: list[dict[str, Any]]) -> str:
"""Render the conversation as a single prompt for Codex.
Studio's chat history is a full OpenAI-style messages array. Codex
opens a fresh ``thread`` per chat-completion request (we have no
way to cache the SDK ``thread`` keyed on Studio's session id from
here -- the inference route is stateless), so we MUST serialise the
full transcript into the prompt or the model loses every prior
turn.
Layout:
User: <user_1>
Assistant: <assistant_1>
User: <user_2>
...
Assistant:
The trailing ``Assistant:`` cue tells Codex this is its turn. When
there is exactly one user message and no assistant history we drop
the cue and emit the plain text -- matches the historical single-
shot behaviour.
"""
# Filter to user / assistant only; system is handled separately by
# `_system_prompt` and passed to thread_start when supported.
convo: list[tuple[str, str]] = []
for msg in messages:
role = msg.get("role")
if role not in ("user", "assistant"):
continue
text = _stringify_content(msg.get("content"))
if text:
convo.append((role, text))
if not convo:
return ""
# Trivial case: a single user turn — pass it through unchanged so we
# don't perturb single-shot behaviour or test expectations.
if len(convo) == 1 and convo[0][0] == "user":
return convo[0][1]
# Multi-turn: render User:/Assistant: blocks then prompt the model.
lines: list[str] = []
for role, text in convo:
label = "User" if role == "user" else "Assistant"
lines.append(f"{label}: {text}")
# If the last turn is from the user (the common case), append an
# empty Assistant cue so Codex picks up from the right side.
if convo[-1][0] == "user":
lines.append("Assistant:")
return "\n\n".join(lines)
def _system_prompt(messages: list[dict[str, Any]]) -> str:
"""Concatenate all ``role=system`` messages.
Codex's ``thread_start`` accepts a system prompt for the lifetime
of the thread. We pass any leading system messages so the user's
Studio-side system prompt (chat presets) reaches the Codex side
intact.
"""
parts: list[str] = []
for msg in messages:
if msg.get("role") != "system":
continue
content = msg.get("content")
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list):
for entry in content:
if isinstance(entry, dict) and entry.get("type") == "text":
parts.append(str(entry.get("text") or ""))
return "\n\n".join(p for p in parts if p)
def _chunk_text(completion_id: str, text: str) -> str:
"""OpenAI Chat Completions content chunk."""
payload = {
"id": completion_id,
"object": "chat.completion.chunk",
"choices": [
{
"index": 0,
"delta": {"content": text},
"finish_reason": None,
}
],
}
return f"data: {json.dumps(payload)}"
def _chunk_stop(completion_id: str) -> str:
payload = {
"id": completion_id,
"object": "chat.completion.chunk",
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "stop",
}
],
}
return f"data: {json.dumps(payload)}"
def _chunk_tool_event(completion_id: str, event: dict[str, Any]) -> str:
"""Synthetic OpenAI-shaped chunk carrying an ``_toolEvent`` payload.
Studio's frontend chat-adapter already understands the
``_toolEvent`` envelope and renders tool cards on the fly. We piggy-
back on the same channel to ship Codex-specific tab markers
(``codex_tab_open`` / ``codex_tab_chunk`` / ``codex_gather``) so the
UI doesn't need a brand-new transport.
"""
payload = {
"id": completion_id,
"object": "chat.completion.chunk",
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": None,
}
],
"_toolEvent": event,
}
return f"data: {json.dumps(payload)}"
def _chunk_usage(
completion_id: str,
prompt_tokens: int,
completion_tokens: int,
) -> str:
payload = {
"id": completion_id,
"object": "chat.completion.chunk",
"choices": [],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
}
return f"data: {json.dumps(payload)}"
# Event types Codex emits that carry the assistant's natural-language
# answer (or its stream-time deltas). Other event types -- command
# execution, file edits, tool calls, plan steps -- have their own
# `delta` fields that the OpenAI Chat Completions surface must NOT
# render as visible assistant text or local stdout / paths would leak
# into the chat reply.
_ANSWER_EVENT_TYPES: frozenset[str] = frozenset(
{
"message.delta",
"message.completed",
"assistant.message.delta",
"assistant.message.completed",
"thread.message.delta",
"thread.message.completed",
"text_delta",
"completed",
# Some SDK revs use a bare "message" / "delta" wrapper without
# qualifying the role; we accept those too because the legacy
# tests rely on the shape.
"message",
"delta",
}
)
def _coerce_text(payload: Any) -> str:
"""Pull text out of a Codex streaming event or result.
Only events whose ``type`` is in `_ANSWER_EVENT_TYPES` (or have no
``type`` field at all, i.e. raw text containers) are translated to
visible text. Tool / command / plan deltas are dropped so local
stdout, file paths, or tool-call arguments never flow into the
Chat Completions content stream.
Both dict-shaped events (tests + some pre-release SDKs) AND
object-shaped events (the real upstream SDK's typed notification
classes) are gated -- if the payload exposes a `type` attribute
or key whose value is not in the answer-event allow-list, we
return the empty string regardless of whether `.delta` or `.text`
is present. Round 6 reviewer caught the object-shape gap: the
upstream SDK can emit `item/commandExecution/outputDelta`,
`item/fileChange/outputDelta`, etc. as typed objects, all of
which carry `.delta` strings containing local stdout, patches,
or tool arguments. Without the object-side filter those strings
would have flowed straight into visible assistant text.
"""
if payload is None:
return ""
if isinstance(payload, str):
return payload
if isinstance(payload, dict):
# If the dict carries a typed event tag, gate on it: only
# answer-bearing types contribute visible text. Untyped dicts
# (legacy / raw text wrappers) fall through to the field walk.
ev_type = payload.get("type")
if isinstance(ev_type, str) and ev_type not in _ANSWER_EVENT_TYPES:
return ""
for key in ("delta", "text", "content", "message", "final_response"):
if key in payload:
value = _coerce_text(payload[key])
if value:
return value
return ""
if isinstance(payload, list):
return "".join(_coerce_text(item) for item in payload)
# Object path: gate on `payload.type` if present, AND on the class
# name as a fallback (the upstream SDK uses class names like
# `AgentMessageDeltaNotification` / `CommandExecutionOutputDelta`
# so a denylist-by-substring catches typed payloads that lack a
# `type` attribute).
ev_type_obj = getattr(payload, "type", None)
if isinstance(ev_type_obj, str) and ev_type_obj not in _ANSWER_EVENT_TYPES:
return ""
cls_name = payload.__class__.__name__
# Allow only class names that contain "Message" or "Delta" without
# also containing a tool / command / plan / file marker.
cls_lower = cls_name.lower()
if any(
marker in cls_lower
for marker in (
"command",
"exec",
"file",
"patch",
"plan",
"tool",
"reason",
"stdout",
"stderr",
)
):
return ""
text_attr = getattr(payload, "text", None)
if isinstance(text_attr, str):
return text_attr
delta_attr = getattr(payload, "delta", None)
if isinstance(delta_attr, str):
return delta_attr
final_attr = getattr(payload, "final_response", None)
if isinstance(final_attr, str):
return final_attr
return ""
def _completed_agent_message_text(payload: Any) -> str:
"""Return the assistant text from an ``ItemCompletedNotification``.
The canonical openai_codex SDK sometimes finishes a turn without
emitting any ``message.delta`` events: the final answer arrives
only as ``ItemCompletedNotification(item=AgentMessage(text=...))``
at the end of the stream. Without recognising that shape,
``_stream_thread_run`` would loop through the stream, see no
``delta`` text, and return an empty Chat Completions response.
Returns the empty string for any other event shape so the caller
can ignore it. Matches by class name + structural shape so the
function works on both real upstream events and the dict / fake
shapes the tests use.
"""
if payload is None:
return ""
# Dict shape: tests + some pre-release SDK revs.
if isinstance(payload, dict):
if payload.get("type") not in (
"ItemCompletedNotification",
"item.completed",
"thread.item.completed",
):
return ""
item = payload.get("item")
# The upstream model wraps the item in a discriminated-union
# `root` field; some pre-release shapes drop the wrapper. Look
# both ways.
if isinstance(item, dict):
inner = item.get("root", item)
if not isinstance(inner, dict):
return ""
if inner.get("type") not in ("agentMessage", "agent_message"):
return ""
text = inner.get("text")
return text if isinstance(text, str) else ""
return ""
# Object shape: upstream events with `.item.root.text`.
if payload.__class__.__name__ not in (
"ItemCompletedNotification",
"ThreadItemCompletedNotification",
):
return ""
item = getattr(payload, "item", None)
item = getattr(item, "root", item)
if getattr(item, "type", None) not in ("agentMessage", "agent_message"):
return ""
text = getattr(item, "text", None)
return text if isinstance(text, str) else ""
async def _stream_thread_run(
thread: Any,
prompt: str,
) -> AsyncGenerator[str, None]:
"""Yield raw text chunks from a Codex thread.
Tries three SDK surfaces in order:
1. ``thread.turn(prompt).stream()`` -- the canonical streaming path
on the upstream ``openai_codex`` SDK (see
``openai/codex/sdk/python/src/openai_codex/api.py``: AsyncThread.turn
returns an AsyncTurnHandle whose ``.stream()`` yields events).
2. ``thread.run_streaming(prompt)`` -- a legacy helper exposed by
some earlier SDK pre-releases. Kept for forward-compat.
3. ``await thread.run(prompt)`` -- the always-supported buffered
path. Used when neither streaming helper resolves and as the
final fallback.
Cross-turn side-effect protection: once any chunk has been emitted
via a streaming helper, we never fall through to the buffered
``thread.run(prompt)`` path -- a partial-stream failure would
otherwise re-execute the same Codex turn and duplicate side
effects (file writes, shell commands, etc.). The buffered path
runs only when streaming helpers produced zero output.
Empty-delta protection: the canonical SDK can complete a turn
successfully without emitting any ``message.delta`` events --
the final text arrives only as an ``ItemCompletedNotification``
whose ``item`` is an ``agentMessage``. We collect those during the
stream loop and emit the last one if no deltas came through, so
Studio never returns an empty answer for a successful turn.
"""
emitted_any = False
# 1. Canonical: thread.turn(prompt).stream()
turn_factory = getattr(thread, "turn", None)
if turn_factory is not None:
agent_message_texts: list[str] = []
try:
turn_handle = turn_factory(prompt)
if asyncio.iscoroutine(turn_handle):
turn_handle = await turn_handle
stream_fn = getattr(turn_handle, "stream", None)
if stream_fn is not None:
stream_obj = stream_fn()
if asyncio.iscoroutine(stream_obj):
stream_obj = await stream_obj
async for event in stream_obj:
payload = getattr(event, "payload", event)
text = _coerce_text(payload)
if text:
emitted_any = True
yield text
else:
final_text = _completed_agent_message_text(payload)
if final_text:
agent_message_texts.append(final_text)
if not emitted_any and agent_message_texts:
# The stream completed cleanly but only via a final
# ItemCompletedNotification -- emit the last agent
# message text so the chat reply is not blank.
yield agent_message_texts[-1]
emitted_any = True
return
except Exception as exc:
logger.warning(
"codex_provider.turn_stream_failed_fallback",
exc_type = type(exc).__name__,
error = str(exc),
emitted_any = emitted_any,
)
if emitted_any:
# The Codex turn already ran far enough to emit text;
# do not re-execute via run() or run_streaming() -- the
# side-effects (commands / writes) would replay.
return
# 2. Legacy: thread.run_streaming(prompt)
run_streaming = getattr(thread, "run_streaming", None)
if run_streaming is not None:
try:
stream_obj = run_streaming(prompt)
if asyncio.iscoroutine(stream_obj):
stream_obj = await stream_obj
async for event in stream_obj:
text = _coerce_text(event)
if text:
emitted_any = True
yield text
return
except Exception as exc:
logger.warning(
"codex_provider.run_streaming_failed_fallback",
exc_type = type(exc).__name__,
error = str(exc),
emitted_any = emitted_any,
)
if emitted_any:
return
# 3. Buffered fallback: await the full TurnResult, emit one chunk.
# Only reached when no streaming helper emitted anything, so this
# is the first (and only) execution of the turn.
result = await thread.run(prompt)
text = _buffered_result_text(result)
if text:
yield text
def _buffered_result_text(result: Any) -> str:
"""Extract assistant text from a buffered ``TurnResult``.
The upstream SDK documents ``TurnResult.final_response`` as
nullable -- a turn that performs only tool work and completes
without a final assistant message will set it to ``None``. The
previous ``... or str(result)`` fallback then sent a Python
object repr (``TurnResult(...)``) into the chat, which surfaced
as visible garbage to the user. Returning the empty string for
that case lets the OpenAI-shape stream finish cleanly with no
extra content chunk -- the usage / stop / [DONE] frames still
fire, and the chat UI simply shows no assistant text rather
than a misleading object dump.
"""
text = _coerce_text(result)
if text:
return text
final = getattr(result, "final_response", None)
if isinstance(final, str) and final:
return final
return ""
def _safe_thread_safety_kwargs() -> dict[str, Any]:
"""Return the safe ``approval_mode`` + ``sandbox`` kwargs for thread_start.
The upstream ``openai_codex.AsyncCodex.thread_start`` defaults
``approval_mode`` to ``ApprovalMode.auto_review`` -- which the SDK
docs describe as "automatically execute tools when permission
escalations occur, without user intervention" -- and leaves
``sandbox`` as ``None``. Studio drives Codex from a server-side
chat request with no per-action UI, so leaving those at the
defaults would let a model decide on its own to run shell
commands, write files, or hit the network on the operator's
machine.
We pin both to the strictest values the SDK exposes:
* ``approval_mode = ApprovalMode.deny_all`` -- reject any tool /
command request rather than auto-approving it.
* ``sandbox = SandboxMode.read_only`` -- the policy that bans
file writes and disables network access.
Probes multiple locations: ``ApprovalMode`` is exported at the
top-level ``openai_codex`` package, but ``SandboxMode`` lives in
``openai_codex.generated.v2_all`` (re-exported into
``openai_codex.api``) and is NOT in the top-level __init__.
Round 6 reviewer caught this -- looking only at the top-level
module returned ``{}``, silently degrading to the auto_review
default on the canonical SDK install.
Returns an empty dict only when the installed SDK is so different
that neither path resolves -- the caller then issues a
structured warning and proceeds. Failing closed (refusing to
run) on a future SDK rev would brick users for no security gain
-- the auto_review default is upstream's choice, not a Studio
regression.
"""
sdk_mod = sys.modules.get("openai_codex") or sys.modules.get("codex_app_server")
if sdk_mod is None:
return {}
# ApprovalMode: top-level export on canonical SDK.
approval_mode_cls = getattr(sdk_mod, "ApprovalMode", None)
# SandboxMode: try top-level, then `.api`, then `.generated.v2_all`.
# We do not eagerly import these submodules because the SDK may
# not expose them and we do not want to crash the request on an
# ImportError. importlib.import_module gives us a typed failure.
sandbox_mode_cls = getattr(sdk_mod, "SandboxMode", None)
if sandbox_mode_cls is None:
for sub in ("api", "generated.v2_all"):
mod_name = getattr(sdk_mod, "__name__", "")
if not mod_name:
continue
try:
submod = importlib.import_module(f"{mod_name}.{sub}")
except Exception:
continue
sandbox_mode_cls = getattr(submod, "SandboxMode", None)
if sandbox_mode_cls is not None:
break
if approval_mode_cls is None or sandbox_mode_cls is None:
return {}
deny_all = getattr(approval_mode_cls, "deny_all", None)
read_only = getattr(sandbox_mode_cls, "read_only", None)
if deny_all is None or read_only is None:
return {}
return {"approval_mode": deny_all, "sandbox": read_only}
async def _start_thread_with_system(
codex: Any,
model: str,
system: str,
prompt: str,
) -> tuple[Any, str]:
"""Start a Codex thread carrying the system prompt and safe defaults.
Upstream ``openai_codex.AsyncCodex.thread_start`` accepts the system
prompt under the kwarg ``base_instructions``. Some pre-release / alias
SDK revisions historically used ``system`` instead. We try the
canonical kwarg first, then the legacy one, then drop both and
prepend the system text to the user prompt so the model still sees
it. The returned (thread, prompt) tuple lets the caller use the
possibly-rewritten prompt.
We always pin ``approval_mode`` to ``deny_all`` and ``sandbox`` to
``read_only`` when the SDK exposes them (see
``_safe_thread_safety_kwargs``) -- the upstream defaults would let
a model decide on its own to execute shell commands or write to
the operator's filesystem, which is not appropriate for a
server-side chat surface with no per-action approval UI. If the
installed SDK rev cannot expose those enums we fail closed by
default (raise ``CodexUnavailableError``) rather than silently
falling through to upstream's ``auto_review`` default. Power
users on a dev install can set ``UNSLOTH_CODEX_ALLOW_UNSAFE_DEFAULTS=1``
to override -- the variable name is deliberately long and explicit
so it does not creep into production environments by accident.
"""
import os as _os
safety_kwargs = _safe_thread_safety_kwargs()
if not safety_kwargs:
allow_unsafe = _os.environ.get(
"UNSLOTH_CODEX_ALLOW_UNSAFE_DEFAULTS", ""
).strip().lower() in ("1", "true", "yes", "on")
if not allow_unsafe:
# Fail closed: the user sees a clear 503 with a typed
# error rather than discovering after the fact that
# Codex ran with auto_review approvals.
raise CodexUnavailableError(
"Installed openai_codex SDK does not expose ApprovalMode "
"/ SandboxMode, so Studio cannot pin the safe deny_all / "
"read_only defaults required for a server-side chat "
"surface. Upgrade openai_codex to a build that exports "
"those enums, or set "
"UNSLOTH_CODEX_ALLOW_UNSAFE_DEFAULTS=1 to opt in to the "
"SDK's auto_review default on a trusted dev host."
)
logger.warning(
"codex_provider.safety_kwargs_unavailable_override",
note = (
"UNSLOTH_CODEX_ALLOW_UNSAFE_DEFAULTS is set; Codex "
"threads will use the SDK auto_review default with no "
"explicit sandbox. This should only be enabled on a "
"trusted dev host."
),
)
base_kwargs: dict[str, Any] = {"model": model, **safety_kwargs}
if not system:
thread = await codex.thread_start(**base_kwargs)
return thread, prompt
for kw_name in ("base_instructions", "system"):
try:
thread = await codex.thread_start(**base_kwargs, **{kw_name: system})
return thread, prompt
except TypeError:
continue
except Exception:
raise
# Last-resort fallback: inline the system text in the user prompt so
# the role intent reaches Codex even on an SDK with no kwarg for it.
thread = await codex.thread_start(**base_kwargs)
return thread, f"{system}\n\n{prompt}"
async def _stream_codex_single(
model: str,
system: str,
prompt: str,
completion_id: str,
) -> AsyncGenerator[str, None]:
"""Run one Codex turn and emit OpenAI-shaped SSE lines."""
sdk = _import_codex()
async_codex_cls = getattr(sdk, "AsyncCodex", None)
if async_codex_cls is None:
raise CodexUnavailableError(
"Codex SDK is installed but AsyncCodex is missing -- " "upgrade the SDK."
)
completion_text_chars = 0
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
# Upstream `openai_codex.AsyncCodex.thread_start` uses
# `base_instructions` for the system prompt (see
# openai/codex/sdk/python/src/openai_codex/api.py). Older / alias
# SDKs may use `system` instead. We try `base_instructions`
# first, then `system`, and finally fall through to inlining
# the system text in the user prompt if neither kwarg is
# accepted.
thread, prompt = await _start_thread_with_system(codex, model, system, prompt)
async for text in _stream_thread_run(thread, prompt):
completion_text_chars += len(text)
yield _chunk_text(completion_id, text)
# Estimate tokens crudely from char counts. The Codex SDK does not
# consistently expose a token breakdown; we surface a usage chunk
# purely so the frontend cost / context display has a non-zero
# value to render. ``int(chars / 4)`` is the standard rough-cut.
yield _chunk_usage(
completion_id,
prompt_tokens = max(1, len(prompt) // 4),
completion_tokens = max(0, completion_text_chars // 4),
)
yield _chunk_stop(completion_id)
async def stream_codex(
messages: list[dict[str, Any]],
model: str,
parallel_calls: int = 1,
) -> AsyncGenerator[str, None]:
"""Top-level entry point used by the inference route.
When ``parallel_calls == 1`` (the default), this delegates to the
single-turn helper. When > 1 (and <= ``MAX_PARALLEL_CALLS``), it
spawns N parallel Codex turns and emits one tab per result plus a
final synthesis tab. The fan-out runs every spawned turn against
the SAME user prompt -- the parallel knob is for sampling N
independent attempts at the same task, which is what the UI tab
strip surfaces.
"""
prompt = _last_user_prompt(messages)
system = _system_prompt(messages)
completion_id = f"chatcmpl-codex-{int(time.time() * 1000)}"
if not prompt:
yield _chunk_text(
completion_id,
"(no user prompt found -- send a message before invoking codex)",
)
yield _chunk_stop(completion_id)
return
clamped = max(1, min(int(parallel_calls or 1), MAX_PARALLEL_CALLS))
if clamped == 1:
async for line in _stream_codex_single(model, system, prompt, completion_id):
yield line
yield "data: [DONE]"
return
async for line in _stream_codex_parallel(
model = model,
system = system,
prompt = prompt,
n = clamped,
completion_id = completion_id,
):
yield line
yield "data: [DONE]"
async def _stream_codex_parallel(
*,
model: str,
system: str,
prompt: str,
n: int,
completion_id: str,
) -> AsyncGenerator[str, None]:
"""Fan out N Codex tasks, emit per-tab chunks, then synthesise.
Each tab id is a 1-based integer. We emit ``codex_tab_open`` up
front for every tab so the UI can paint the tab strip before any
response lands, then route streamed text per tab via
``codex_tab_chunk`` (one event per text delta). The final
``codex_gather`` event carries a synthesis from a separate
standalone Codex call so the user sees both the per-tab raw
outputs AND a unified merged answer.
Bounded concurrency: ``asyncio.gather`` is used with a fresh
``AsyncCodex`` instance per tab to keep the SDK from sharing a
single thread object across coroutines (the SDK is not documented
as concurrency-safe on a single ``Codex`` handle). N is clamped to
``MAX_PARALLEL_CALLS`` by the caller.
"""
queue: asyncio.Queue[Optional[str]] = asyncio.Queue()
# Pre-emit one tab_open per tab so the UI can paint the tabs
# immediately. The frontend creates the tabs lazily on first
# ``codex_tab_open`` event anyway; pre-emitting just gives us the
# familiar tabs-first / content-second render order.
for idx in range(1, n + 1):
yield _chunk_tool_event(
completion_id,
{
"type": "codex_tab_open",
"tab_id": idx,
"query": prompt,
"total_tabs": n,
},
)
async def _worker(tab_id: int) -> str:
"""Run one Codex turn, push every chunk into the queue, and
return the full accumulated text so the synthesis step can
consume it. Errors are surfaced as a ``codex_tab_error``
tool-event so the tab strip shows which lane failed without
aborting the whole fan-out.
"""
collected: list[str] = []
try:
sdk = _import_codex()
async_codex_cls = getattr(sdk, "AsyncCodex")
async with _open_async_codex(async_codex_cls) as codex:
thread, inner_prompt = await _start_thread_with_system(
codex, model, system, prompt
)
async for text in _stream_thread_run(thread, inner_prompt):
collected.append(text)
await queue.put(
_chunk_tool_event(
completion_id,
{
"type": "codex_tab_chunk",
"tab_id": tab_id,
"text": text,
},
)
)
except Exception as exc:
# CodeQL: never echo str(exc) in client-facing SSE events.
# Log full reason server-side; surface a generic message plus
# an exception_type discriminator so the UI can still group
# failures without leaking file paths / env vars from the
# SDK traceback. CodexUnavailableError is the one exception
# we DO surface verbatim because it's a user-actionable
# install hint with no sensitive content.
logger.warning(
"codex_provider.parallel_tab_failed",
tab_id = tab_id,
exc_type = type(exc).__name__,
error = str(exc),
)
public_error = (
str(exc)
if isinstance(exc, CodexUnavailableError)
else "Codex tab failed"
)
await queue.put(
_chunk_tool_event(
completion_id,
{
"type": "codex_tab_error",
"tab_id": tab_id,
"error": public_error,
"exception_type": type(exc).__name__,
},
)
)
finally:
await queue.put(
_chunk_tool_event(
completion_id,
{
"type": "codex_tab_close",
"tab_id": tab_id,
},
)
)
return "".join(collected)
workers = [asyncio.create_task(_worker(i + 1)) for i in range(n)]
# asyncio.gather returns a _GatheringFuture, not a coroutine, so it
# cannot be passed to create_task. Wrap the await in a small helper
# coroutine so we keep both (a) a handle for awaiting and (b) the
# ability to schedule the drain side-effect that unblocks the
# consumer queue. Stash the results into a list the drain finally
# block reads so per-tab outputs survive any single-worker errors.
per_tab_texts: list[str] = []
async def _await_workers() -> None:
results = await asyncio.gather(*workers, return_exceptions = True)
for r in results:
if isinstance(r, BaseException):
per_tab_texts.append("")
else:
per_tab_texts.append(r)
async def _drain_when_done() -> None:
try:
await _await_workers()
finally:
await queue.put(None)
drain_task = asyncio.create_task(_drain_when_done())
cancelled = False
try:
while True:
line = await queue.get()
if line is None:
break
yield line
except (asyncio.CancelledError, GeneratorExit):
cancelled = True
# Cancel every in-flight worker so the Codex SDK calls release
# their quota / sockets instead of running to completion against
# a disconnected client. Workers shield themselves in `_worker`'s
# own try/finally so we just signal cancellation and bail.
for w in workers:
if not w.done():
w.cancel()
drain_task.cancel()
# Best-effort gather so cancellation propagates and we don't
# leave coroutines hanging on the event loop.
try:
await asyncio.gather(*workers, drain_task, return_exceptions = True)
except Exception:
pass
raise
finally:
# If we exited normally, drain_task is already done (it put None
# on the queue right after gather). On the cancellation path we
# already gathered above, so this await is a fast no-op.
if not cancelled:
try:
await drain_task
except Exception as exc:
logger.warning(
"codex_provider.parallel_drain_failed",
error = str(exc),
)
synthesis_text = await _run_codex_synthesis(
model = model,
system = system,
prompt = prompt,
tab_outputs = per_tab_texts,
)
yield _chunk_tool_event(
completion_id,
{
"type": "codex_gather",
"summary": synthesis_text,
"tab_count": n,
},
)
# Also emit the synthesis as a visible content chunk so any client
# that ignores the tab tool-events (e.g. a curl user) still sees
# a final unified answer. The tabbed UI re-uses the same payload
# via ``codex_gather`` and hides it from the main content lane to
# avoid duplication.
if synthesis_text:
yield _chunk_text(completion_id, synthesis_text)
# Account for ALL Codex turns the fan-out spawned: N parallel
# workers each ran the same prompt (≈ N * prompt_tokens), and the
# synthesis turn re-sent the prompt plus every tab's output. Without
# this the cost / context display is off by the fan-out factor and
# users see a wildly inaccurate token count for the request.
total_tab_completion_chars = sum(len(t) for t in per_tab_texts)
synthesis_prompt_chars = sum(len(t) for t in per_tab_texts) + len(prompt)
yield _chunk_usage(
completion_id,
# n worker prompts (same prompt each) + synthesis prompt (which
# carries the prompt again plus every tab's output).
prompt_tokens = max(1, (n * len(prompt) + synthesis_prompt_chars) // 4),
# Sum of every worker's output plus the synthesis text.
completion_tokens = max(
0, (total_tab_completion_chars + len(synthesis_text)) // 4
),
)
yield _chunk_stop(completion_id)
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. 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 ""
joined = "\n\n".join(
f"=== Attempt {i + 1} ===\n{text.strip() or '(no output)'}"
for i, text in enumerate(tab_outputs)
)
synthesis_prompt = (
"You are given multiple independent attempts at the same task.\n"
"Your job: synthesise a single best response that takes the "
"strongest parts of each attempt, resolves disagreements, and "
"presents a clear unified answer.\n\n"
f"Original task:\n{prompt}\n\n"
f"Attempts:\n{joined}\n\n"
"Unified answer:"
)
try:
sdk = _import_codex()
async_codex_cls = getattr(sdk, "AsyncCodex")
async with _open_async_codex(async_codex_cls) as codex:
thread, synthesis_prompt = await _start_thread_with_system(
codex, model, system, synthesis_prompt
)
result = await thread.run(synthesis_prompt)
# Use the same buffered extraction as `_stream_thread_run` so a
# synthesis turn whose `final_response` is None returns an empty
# string instead of a `TurnResult(...)` Python object repr.
return _buffered_result_text(result)
except Exception as exc:
logger.warning("codex_provider.synthesis_failed", error = str(exc))
return ""
# ── Device-auth helper ──────────────────────────────────────────────
async def stream_codex_device_login() -> AsyncGenerator[dict[str, Any], None]:
"""Run ``codex login --device-auth`` and yield progress events.
Yields dicts (NOT SSE lines) that the route layer wraps in SSE.
First event is always ``{type: "device_url", url: "..."}`` once we
detect a verification URL in the CLI output. The one-time code is
emitted as ``{type: "device_code", code: "ABCD-EFGH"}`` so the UI
can show it next to the URL (upstream CLI prints both on separate
lines). Subsequent CLI stdout/stderr lines forward as
``{type: "log", line: ...}``. A final ``{type: "done", ok: bool}``
signals completion.
Subprocess lifecycle: started in its own process group via
``start_new_session=True`` (Unix) so cancellation can SIGTERM the
whole group and reach any child processes the codex CLI spawns.
On Windows a fallback uses ``CREATE_NEW_PROCESS_GROUP``. When the
SSE consumer disconnects, the generator's cleanup terminates the
process group within a 5s budget then SIGKILL's as a last resort,
so the CLI never lingers consuming a device-auth session.
URL handling: upstream ``codex login --device-auth`` prints the URL
wrapped in ANSI escape sequences (``\x1b[34m...\x1b[0m``). We strip
ANSI before regex matching so the URL emitted to the frontend is
clean and clickable.
"""
import os
import re
import signal
cli_path = "codex"
args = ["login", "--device-auth"]
# Detach the subprocess into a new process group on Unix so we can
# SIGTERM the whole group on cancel without sending it to ourselves.
# On Windows, ``creationflags=CREATE_NEW_PROCESS_GROUP`` (0x200) gives
# an equivalent isolation for ``proc.send_signal(signal.CTRL_BREAK_EVENT)``.
# Env is scrubbed to the codex safe-list (see codex_availability) so a
# shimmed `codex` on PATH does not inherit other provider secrets.
from core.inference.codex_availability import _codex_subprocess_env
spawn_kwargs: dict[str, Any] = {
"stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.STDOUT,
"env": _codex_subprocess_env(),
}
if os.name == "posix":
spawn_kwargs["start_new_session"] = True
elif os.name == "nt":
spawn_kwargs["creationflags"] = 0x00000200 # CREATE_NEW_PROCESS_GROUP
try:
proc = await asyncio.create_subprocess_exec(cli_path, *args, **spawn_kwargs)
except FileNotFoundError:
yield {"type": "error", "message": "codex CLI not found on PATH"}
yield {"type": "done", "ok": False}
return
except Exception as exc:
logger.warning("codex_provider.login_spawn_failed", error = str(exc))
yield {"type": "error", "message": "Failed to start codex CLI"}
yield {"type": "done", "ok": False}
return
# 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]")
# 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|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.
code_re = re.compile(r"\b([A-Z0-9]{4}-[A-Z0-9]{4})\b")
url_emitted = False
code_emitted = False
rc: int = -1
cancelled = False
# Allow-list of substrings the upstream `codex login --device-auth`
# command prints during the normal flow. Anything outside this list
# is treated as opaque and not forwarded to the browser, so a
# shimmed binary that prints auth JSON, refresh tokens, local
# config paths, or unexpected stderr cannot leak that content
# through Studio's authenticated SSE stream. The URL and code
# extracted above are emitted separately as `device_url` /
# `device_code` events and are not affected by this filter.
# Anchored regexes so the line must START with one of the upstream
# `codex login --device-auth` phrases. A substring match like the
# old "logged in" check is too loose: a malicious shim could print
# `Not logged in: refresh_token=rt_LEAK auth.json=/home/u/.codex/`
# and the substring `logged in` would let the line through, leaking
# auth artefacts into the browser. Start anchors plus a blocklist
# of known sensitive substrings close that hole.
safe_log_res: tuple[Any, ...] = (
re.compile(r"^welcome to codex\b", re.IGNORECASE),
re.compile(r"^initializing\b", re.IGNORECASE),
re.compile(r"^open (?:this|the verification)", re.IGNORECASE),
re.compile(r"^open:\s*https?://", re.IGNORECASE),
re.compile(r"^enter (?:this one-time code|the code)\b", re.IGNORECASE),
re.compile(r"^waiting\b", re.IGNORECASE),
re.compile(r"^successfully (?:logged|signed) in\b", re.IGNORECASE),
re.compile(r"^(?:logged|signed) in\b", re.IGNORECASE),
re.compile(r"^browser opened\b", re.IGNORECASE),
re.compile(r"^press ctrl", re.IGNORECASE),
)
# Strict blocklist: any of these substrings in the line means the
# log entry contains sensitive auth state, a path under the codex
# config dir, or an explicit "not logged in" failure -- none of
# which the user-facing SSE stream should mirror, regardless of
# whether some other prefix matched.
unsafe_log_re = re.compile(
r"\bnot\s+(?:logged|signed)\s+in\b|"
r"\bnot\s+authenticated\b|"
r"refresh[_-]?token|access[_-]?token|"
r"\bapi[_-]?key\b|\bsecret\b|"
r"\bauth\.json\b|"
r"/\.codex/|\\\.codex\\",
re.IGNORECASE,
)
def _safe_to_forward(text: str) -> bool:
if unsafe_log_re.search(text):
return False
return any(pattern.search(text) for pattern in safe_log_res)
try:
assert proc.stdout is not None
while True:
line_b = await proc.stdout.readline()
if not line_b:
break
raw = line_b.decode("utf-8", errors = "replace").rstrip()
line = ansi_re.sub("", raw)
if not url_emitted:
match = url_re.search(line)
if match:
candidate = match.group(0)
if _is_allowed_device_url(candidate):
yield {"type": "device_url", "url": candidate}
url_emitted = True
else:
# A shimmed codex on PATH could print a phishing
# URL that matches the regex but points at an
# attacker host (e.g. https://evil.example/activate
# ?code=ABCD). Drop it rather than surfacing
# "Open verification page" to a real user.
logger.warning(
"codex_provider.login_url_rejected",
host = (_safe_host(candidate) or "<unparsable>"),
)
if not code_emitted:
cm = code_re.search(line)
if cm:
yield {"type": "device_code", "code": cm.group(1)}
code_emitted = True
# Only forward lines from the known safe vocabulary; opaque
# output (file paths, tokens, JSON, error messages) stays in
# backend logs only.
if line and _safe_to_forward(line):
yield {"type": "log", "line": line}
except (asyncio.CancelledError, GeneratorExit):
cancelled = True
raise
finally:
# Tear the subprocess down even on cancellation. Unix: kill the
# whole process group; Windows: ``CTRL_BREAK_EVENT`` followed by
# ``terminate()``. Bounded wait so cleanup never deadlocks the
# SSE close path.
if proc.returncode is None:
try:
if os.name == "posix":
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
except ProcessLookupError:
pass
else:
try:
proc.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
except Exception:
proc.terminate()
except Exception as exc:
logger.warning(
"codex_provider.login_terminate_failed",
error = str(exc),
)
try:
await asyncio.wait_for(proc.wait(), timeout = 5.0)
except (asyncio.TimeoutError, asyncio.CancelledError):
try:
proc.kill()
await asyncio.wait_for(proc.wait(), timeout = 2.0)
except Exception:
pass
try:
rc = proc.returncode if proc.returncode is not None else -1
except Exception:
rc = -1
if cancelled:
return
yield {"type": "done", "ok": rc == 0, "return_code": rc}