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
9 changes: 6 additions & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ jobs:
- name: Documentation links resolve
run: python3 installers/checks/check_docs.py

- name: Codex MCP config merge is safe and compatible
run: python3 installers/checks/check_codex_config.py

- name: Installer dry-run for every profile
run: |
for p in webui harness skills; do
Expand Down Expand Up @@ -100,12 +103,12 @@ print('TOML parser available')

printf 'this is [not valid toml\n' > /tmp/bad.toml
before=$(md5sum /tmp/bad.toml | cut -d' ' -f1)
python3 installers/lib/codex_config.py /tmp/bad.toml http://localhost:8000/mcp >/dev/null 2>&1
python3 installers/lib/codex_config.py /tmp/bad.toml http://localhost:8000/mcp /usr/bin/python3 >/dev/null 2>&1
[ $? -eq 0 ] && { echo "::error::accepted invalid TOML"; fail=1; }
[ "$(md5sum /tmp/bad.toml | cut -d' ' -f1)" = "$before" ] || { echo "::error::modified invalid TOML"; fail=1; }

printf '[mcp_servers.dataflow]\ncommand = "python"\n' > /tmp/stdio.toml
python3 installers/lib/codex_config.py /tmp/stdio.toml http://localhost:8000/mcp >/dev/null 2>&1
python3 installers/lib/codex_config.py /tmp/stdio.toml http://localhost:8000/mcp /usr/bin/python3 >/dev/null 2>&1
[ $? -eq 4 ] || { echo "::error::did not refuse a command/stdio TOML entry"; fail=1; }

printf '{"mcpServers":{"dataflow":{"command":"python"}}}\n' > /tmp/stdio.json
Expand All @@ -118,7 +121,7 @@ print('TOML parser available')

# A quoted table name must be updated in place, not duplicated.
printf '[mcp_servers."dataflow"]\nurl = "http://localhost:8000/mcp"\n' > /tmp/quoted.toml
out=$(python3 installers/lib/codex_config.py /tmp/quoted.toml http://localhost:8000/mcp 2>/dev/null)
out=$(python3 installers/lib/codex_config.py /tmp/quoted.toml http://localhost:8000/mcp /usr/bin/python3 2>/dev/null)
n=$(printf '%s\n' "$out" | grep -c 'mcp_servers')
[ "$n" -eq 1 ] || { echo "::error::quoted table produced $n dataflow tables"; fail=1; }

Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ check: skills
@$(PYTHON) installers/checks/check_mcp_whitelist.py
@$(PYTHON) installers/checks/check_profiles.py
@$(PYTHON) installers/checks/check_docs.py
@$(PYTHON) installers/checks/check_codex_config.py

checks: check
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
# Codex (API key or `codex login` OAuth)
npm install --global @openai/codex
codex login # OAuth, or export OPENAI_API_KEY=sk-...
./install.sh configure-agent --agent codex
./install.sh configure-agent --agent codex --scope user

# Cursor: install the IDE, open this repository, then:
./install.sh configure-agent --agent cursor
Expand All @@ -167,6 +167,10 @@ Agent configuration is intentionally a separate step from installation. The
installer does not write API keys. See [Agent setup](docs/agents/SETUP.md) for
the exact authorization boundaries and verification steps.

For Codex, the configurator writes a stdio bridge backed by the WebUI Python
environment's `mcp-proxy`. This is required because the bundled DataFlow MCP
endpoint uses legacy SSE while current Codex URL entries expect streamable HTTP.

## Installing never writes agent configuration

Installing and configuring an agent are two separate commands, on purpose:
Expand Down
142 changes: 129 additions & 13 deletions backend/app/services/agents/codex_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
2. **System prompt delivery** — ``codex exec`` does not accept a system
prompt flag in headless mode. We prepend the harness rules to the user
message, same approach as Cursor.
3. **Tool approval** — ``--sandbox workspace-write`` grants full auto-approve.
3. **Tool approval** — ``--approve-for-me`` reviews headless tool calls
automatically and applies Codex's workspace-write sandbox. Current Codex
releases reject combining this flag with an explicit ``--sandbox`` value.
4. **Session resume** — ``codex exec`` itself does not accept ``--resume``;
the top-level ``codex resume`` subcommand is for interactive sessions.
We treat each turn as stateless and rely on Codex's own conversation
Expand All @@ -26,6 +28,7 @@

import asyncio
import json
from collections import deque
from typing import AsyncGenerator, Optional

from app.core.logger_setup import get_logger
Expand All @@ -44,6 +47,34 @@ def _format_user_message(system_prompt: str, message: str) -> str:
)


def _build_exec_command(
cli_path: str,
webui_root: str,
system_prompt: str,
message: str,
) -> list[str]:
"""Build a command compatible with current headless Codex CLI releases."""
return [
cli_path,
"exec",
"--json",
"--approve-for-me",
"--cd",
webui_root,
_format_user_message(system_prompt, message),
]


def _format_process_error(returncode: int, stderr: str) -> str:
"""Turn a failed Codex invocation into a useful WebUI error."""
detail = "\n".join(line for line in stderr.splitlines() if line.strip())
if len(detail) > 1200:
detail = detail[-1200:]
if detail:
return f"Codex exited with status {returncode}: {detail}"
return f"Codex exited with status {returncode} without an error message."


class CodexAdapter(AgentAdapter):
kind = "codex"

Expand Down Expand Up @@ -73,13 +104,12 @@ async def chat_stream(
import os

# Codex's `exec` does not resume; session_id is ignored.
cmd = [
self.cli_path, "exec",
"--json",
"--sandbox", "workspace-write",
"--cwd", str(self.webui_root),
_format_user_message(self.system_prompt, message),
]
cmd = _build_exec_command(
self.cli_path,
str(self.webui_root),
self.system_prompt,
message,
)

# Forward auth-related env vars. Codex config.toml may reference
# CODEX_API_KEY, OPENAI_API_KEY, or OPENAI_BASE_URL depending on
Expand All @@ -96,6 +126,17 @@ async def chat_stream(
)
self._process = process
emitted_session = False
emitted_error = False
stderr_lines: deque[str] = deque(maxlen=40)

async def drain_stderr() -> None:
assert process.stderr is not None
async for raw in process.stderr:
line = raw.decode("utf-8", errors="replace").rstrip()
if line:
stderr_lines.append(line)

stderr_task = asyncio.create_task(drain_stderr())

try:
assert process.stdout is not None
Expand All @@ -108,21 +149,32 @@ async def chat_stream(
except json.JSONDecodeError:
continue

# Codex emits a session-id at the top of the stream when
# available. Tolerate either a top-level field or a typed
# `session_started` event.
# Current Codex emits ``thread.started`` with ``thread_id``;
# older builds used session_id in one of two shapes.
if not emitted_session:
sid = (
chunk.get("session_id")
chunk.get("thread_id")
or chunk.get("session_id")
or (chunk.get("msg") or {}).get("session_id")
)
if sid:
emitted_session = True
yield {"type": "session", "session_id": sid}

async for evt in self._translate(chunk):
if evt.get("type") == "error":
emitted_error = True
yield evt

returncode = await process.wait()
await stderr_task
if returncode != 0 and not emitted_error:
yield {
"type": "error",
"message": _format_process_error(
returncode, "\n".join(stderr_lines)
),
}
yield {"type": "done"}
except asyncio.CancelledError:
yield {"type": "done"}
Expand All @@ -137,10 +189,74 @@ async def chat_stream(
process.kill()
except Exception:
pass
if not stderr_task.done():
stderr_task.cancel()
self._process = None

async def _translate(self, chunk: dict) -> AsyncGenerator[NormalizedEvent, None]:
# Codex events are typed via msg.type per the docs.
# Codex 0.149+ uses top-level lifecycle events with an ``item`` payload.
ctype = chunk.get("type", "")
item = chunk.get("item") if isinstance(chunk.get("item"), dict) else {}
item_type = item.get("type", "")

if ctype == "item.completed" and item_type == "agent_message":
text = item.get("text", "")
if text:
yield {"type": "text_chunk", "content": str(text)}
return

if ctype == "item.started" and item_type == "mcp_tool_call":
server = item.get("server", "")
tool = item.get("tool", "")
name = f"mcp__{server}__{tool}" if server and tool else tool
yield {
"type": "tool_call_start",
"tool_use_id": item.get("id", ""),
"name": name,
"input_preview": truncate_preview(item.get("arguments") or {}),
}
return

if ctype == "item.completed" and item_type == "mcp_tool_call":
error = item.get("error")
yield {
"type": "tool_call_end",
"tool_use_id": item.get("id", ""),
"is_error": bool(error),
"output_preview": truncate_preview(error or item.get("result") or ""),
}
return

if ctype == "item.started" and item_type == "command_execution":
yield {
"type": "tool_call_start",
"tool_use_id": item.get("id", ""),
"name": "command_execution",
"input_preview": truncate_preview(item.get("command") or ""),
}
return

if ctype == "item.completed" and item_type == "command_execution":
exit_code = item.get("exit_code")
yield {
"type": "tool_call_end",
"tool_use_id": item.get("id", ""),
"is_error": exit_code not in (None, 0),
"output_preview": truncate_preview(item.get("aggregated_output") or ""),
}
return

if ctype in ("turn.failed", "error"):
error = chunk.get("error")
if isinstance(error, dict):
message = error.get("message") or str(error)
else:
message = chunk.get("message") or str(error or chunk)
yield {"type": "error", "message": message}
return

# Older Codex events were typed via msg.type. Keep accepting them so
# the WebUI does not become tied to one narrow CLI release.
msg = chunk.get("msg") if isinstance(chunk.get("msg"), dict) else chunk
mtype = msg.get("type", "")

Expand Down
3 changes: 3 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ fastapi[standard]
# MCP 2.x made description keyword-only, which raises a TypeError at startup.
fastapi-mcp==0.4.0
mcp>=1.12,<2
# Codex supports streamable HTTP, while fastapi-mcp 0.4.0 exposes legacy SSE.
# Run the remote SSE endpoint through a local stdio bridge for Codex.
mcp-proxy==0.12.0
uvicorn[standard]
pydantic_settings
pandas
Expand Down
Loading