Files
2026-07-13 13:12:00 +08:00

1752 lines
73 KiB
Python

"""Codex app-server process and JSON-RPC client for native TUI sessions."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import re
import shlex
import sys
import tempfile
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
import websockets
if TYPE_CHECKING:
from omnigent.onboarding.provider_config import ProviderEntry
from omnigent.codex_native_bridge import write_policy_hook_config
from omnigent.codex_native_process_registry import (
CodexNativeProcessOwnerLock,
acquire_codex_native_process_owner_lock,
codex_native_session_tag_cmdline_arg,
reconcile_codex_native_process_registry,
register_codex_native_process,
unregister_codex_native_process,
)
from omnigent.inner import _proc
from omnigent.inner.codex_executor import (
_clean_codex_env,
_codex_cli_version,
_codex_home_config_source_from_env,
_databricks_codex_auth_command,
_databricks_codex_base_url,
_databricks_codex_config_overrides,
_find_codex_cli,
_populate_codex_home_config,
_provider_codex_config_overrides,
)
from omnigent.inner.databricks_executor import _read_databrickscfg, _read_databrickscfg_host
_logger = logging.getLogger(__name__)
CodexMessage = dict[str, Any]
CodexParams = dict[str, Any]
_CONNECT_RETRY_DELAY_SECONDS = 0.05
_CONNECT_TIMEOUT_SECONDS = 10.0
_STDERR_CHUNK_LIMIT = 65536
_DATABRICKS_CODEX_DEFAULT_MODEL = "databricks-gpt-5-5"
_UDS_WEBSOCKET_HANDSHAKE_URI = "ws://localhost/rpc"
_MAX_WEBSOCKET_MESSAGE_SIZE_BYTES = 128 << 20
# hooks.json filename written into the private CODEX_HOME registering the
# Omnigent policy hook. Codex discovers it as a ``user``-layer hook
# source on every config load (see codex hooks ``discover_handlers``).
_CODEX_HOOKS_FILE = "hooks.json"
# Module the codex policy command hook runs. Also the marker used to
# identify *our* hooks in ``hooks/list`` output so the trust step never
# auto-trusts unrelated hooks that the user's symlinked ``config.toml``
# might declare.
_POLICY_HOOK_MODULE = "omnigent.codex_native_hook"
# Codex-side timeout for the policy hook subprocess. A TOOL_CALL/LLM_REQUEST
# ASK is now resolved server-side: ``POST /policies/evaluate`` parks the gate
# (URL-based elicitation) until a human answers or the deciding policy's
# ``ask_timeout`` elapses, and the hook's own request budget
# (``_EVALUATE_POLICY_TIMEOUT_S``) is a day to match. Codex must wait at least
# as long, or it kills the hook mid-park and the tool runs before the verdict
# arrives — exactly the bug that let sub-agent tool calls slip past the cost
# gate. Held at a day so the server-side ``ask_timeout`` is the single cap,
# mirroring claude-native's ``PermissionRequest`` hook (``timeout: 86400``).
_POLICY_HOOK_TIMEOUT_SECONDS = 86400
# Hook trust statuses that allow a hook to execute (see codex
# ``hook_trust_status``). Anything else means the hook is silently
# skipped — which for a policy gate is a fail-open we must reject.
_TRUSTED_HOOK_STATUSES = frozenset({"trusted", "managed"})
# Minimum codex CLI version whose ``hooks/list`` returns the
# ``currentHash`` / ``trustStatus`` fields the trust handshake needs.
# Below this codex never exposes those fields, so the policy hook can
# never be trusted (it just stays ``untrusted`` and is silently skipped)
# — we detect the old version up front and skip registration with a loud
# warning rather than crash startup on an un-trustable hook.
_MIN_POLICY_HOOK_CODEX_VERSION = (0, 129, 0)
def _format_codex_version(version: tuple[int, int, int] | None) -> str:
"""
Render a parsed codex version tuple for log / error messages.
:param version: Version tuple, e.g. ``(0, 129, 0)``, or ``None`` when
the version could not be determined.
:returns: A dotted string, e.g. ``"0.129.0"``, or ``"unknown"`` for
``None``.
"""
if version is None:
return "unknown"
return ".".join(str(part) for part in version)
def _toml_table_header_name(line: str) -> str | None:
"""
Return the TOML table name declared by *line*, if any.
This intentionally recognizes only normal table headers because the
injected Codex MCP server config is a normal table. Array tables are
left untouched.
:param line: One config line, e.g.
``"[mcp_servers.omnigent] # generated\n"``.
:returns: The table name, e.g. ``"mcp_servers.omnigent"``, or
``None`` when *line* is not a normal table header.
"""
stripped = line.strip()
if not stripped.startswith("["):
return None
if stripped.startswith("[["):
return None
end = stripped.find("]")
if end < 0:
return None
suffix = stripped[end + 1 :].strip()
if suffix and not suffix.startswith("#"):
return None
return stripped[1:end].strip()
def _remove_toml_table(text: str, table_name: str) -> str:
"""
Remove one TOML table and its subtables from a config document.
Used for generated private Codex config before appending the
Omnigent MCP server table. This avoids accumulating duplicate
``[mcp_servers.omnigent]`` sections across terminal relaunches.
:param text: TOML document text.
:param table_name: Table name to remove, e.g.
``"mcp_servers.omnigent"``.
:returns: TOML text with the target table block removed.
"""
kept: list[str] = []
skipping = False
for line in text.splitlines(keepends=True):
header = _toml_table_header_name(line)
if header is not None:
skipping = header == table_name or header.startswith(f"{table_name}.")
if not skipping:
kept.append(line)
return "".join(kept).rstrip()
def _codex_mcp_server_config_section(
bridge_dir: Path,
python_executable: str | None = None,
) -> str:
"""
Build the generated Codex MCP server TOML section.
:param bridge_dir: Bridge directory containing ``bridge.json`` and
``tool_relay.json``.
:param python_executable: Python executable for serve-mcp, e.g.
``"/path/to/.venv/bin/python"``. ``None`` uses
:data:`sys.executable`.
:returns: TOML text for ``[mcp_servers.omnigent]``.
"""
python = python_executable or sys.executable
args = [
"-I",
"-m",
"omnigent.claude_native_bridge",
"serve-mcp",
"--bridge-dir",
str(bridge_dir),
]
args_toml = ", ".join(json.dumps(a) for a in args)
return f"[mcp_servers.omnigent]\ncommand = {json.dumps(python)}\nargs = [{args_toml}]\n"
def _pin_codex_config_model(codex_home: Path, model: str) -> None:
"""
Write *model* as the top-level ``model`` key in the session config.toml.
The per-session ``config.toml`` starts as a copy of the user's shared
one, so its ``model`` line is whatever the user last ran — NOT this
session's launch model. The forwarder mirrors that file into
``model_override`` and the cost gate's hook reads it, so without this
seed a per-dispatch model override is silently misreported (live-caught:
a child launched on ``databricks-gpt-5-4-mini`` was mirrored back as the
shared file's stale ``gpt-5.5``). An in-TUI ``/model`` later overwrites
the same line, so user switches still win.
:param codex_home: Private per-session ``CODEX_HOME`` directory.
:param model: Validated model id to pin.
"""
config_path = codex_home / "config.toml"
# Same symlink-materialization dance as the MCP injection: never edit
# the user's real config.toml through the link.
if config_path.is_symlink():
target = config_path.resolve()
config_path.unlink()
if target.is_file():
import shutil
shutil.copy2(target, config_path)
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
pin_line = f"model = {json.dumps(model)}"
lines = existing.splitlines()
replaced = False
for i, line in enumerate(lines):
if line.startswith("["):
break
if re.match(r"^model\s*=", line):
lines[i] = pin_line
replaced = True
break
if not replaced:
lines.insert(0, pin_line)
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _inject_mcp_server_config(
codex_home: Path,
bridge_dir: Path,
python_executable: str | None = None,
) -> None:
"""
Upsert Omnigent MCP server config into ``config.toml``.
Writes a ``[mcp_servers.omnigent]`` section that points Codex
at the ``serve-mcp`` subprocess. This supplements the ``-c``
overrides (which ``codex app-server`` may not honor) by writing
directly to the config file. The write is idempotent so terminal
relaunches do not corrupt TOML with duplicate table headers.
:param codex_home: Private per-session CODEX_HOME directory.
:param bridge_dir: Bridge directory containing ``bridge.json``
and ``tool_relay.json``.
:param python_executable: Python executable for serve-mcp.
``None`` uses :data:`sys.executable`.
:returns: None.
"""
config_path = codex_home / "config.toml"
# Materialize the symlink from _populate_codex_home_config before
# editing so the user's real config.toml stays untouched.
if config_path.is_symlink():
target = config_path.resolve()
config_path.unlink()
if target.is_file():
import shutil
shutil.copy2(target, config_path)
if config_path.exists():
existing = config_path.read_text(encoding="utf-8")
else:
existing = ""
updated = _remove_toml_table(existing, "mcp_servers.omnigent")
section = _codex_mcp_server_config_section(bridge_dir, python_executable)
rendered = f"{updated}\n\n{section}" if updated else section
config_path.write_text(rendered, encoding="utf-8")
class CodexAppServerClient:
"""JSON-RPC client for a Codex app-server.
Connects via Unix socket (``socket_path``) or TCP websocket
(``ws_url``). Exactly one must be provided.
:param socket_path: App-server Unix socket path, e.g.
``"/tmp/codex.sock"``.
:param ws_url: App-server WebSocket URL, e.g.
``"ws://127.0.0.1:9876"``.
:param client_name: App-server client name for the initialize
handshake, e.g. ``"omnigent-codex-native"``.
"""
def __init__(
self,
socket_path: Path | None = None,
*,
ws_url: str | None = None,
client_name: str = "omnigent",
) -> None:
if socket_path is None and ws_url is None:
raise ValueError("CodexAppServerClient requires socket_path or ws_url")
self._socket_path = socket_path
self._ws_url = ws_url
self._client_name = client_name
self._ws: Any | None = None
self._reader_task: asyncio.Task[None] | None = None
self._pending_requests: dict[int, asyncio.Future[CodexMessage]] = {}
self._events: asyncio.Queue[CodexMessage] = asyncio.Queue()
self._next_id = 1
async def connect(self) -> None:
"""
Connect to the app-server and run the initialize handshake.
:returns: None.
"""
if self._ws_url is not None:
self._ws = await websockets.connect(
self._ws_url,
max_size=_MAX_WEBSOCKET_MESSAGE_SIZE_BYTES,
compression=None,
)
else:
self._ws = await websockets.unix_connect(
path=str(self._socket_path),
uri=_UDS_WEBSOCKET_HANDSHAKE_URI,
max_size=_MAX_WEBSOCKET_MESSAGE_SIZE_BYTES,
compression=None,
)
self._reader_task = asyncio.create_task(
self._reader_loop(),
name="codex-native-app-server-reader",
)
await self.request(
"initialize",
{
"clientInfo": {
"name": self._client_name,
"version": "0.1",
},
"capabilities": {
"experimentalApi": True,
},
},
)
await self.notify("initialized")
async def close(self) -> None:
"""
Close the app-server client connection.
:returns: None.
"""
if self._reader_task is not None:
self._reader_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._reader_task
for future in self._pending_requests.values():
if not future.done():
future.cancel()
self._pending_requests.clear()
if self._ws is not None:
await self._ws.close()
self._ws = None
self._reader_task = None
async def request(self, method: str, params: CodexParams) -> CodexMessage:
"""
Send one JSON-RPC request and wait for its response.
:param method: App-server method, e.g. ``"thread/start"``.
:param params: JSON-serializable method parameters.
:returns: Decoded response envelope.
:raises RuntimeError: If the app-server returns an error.
"""
if self._ws is None:
raise RuntimeError("Codex app-server client is not connected")
request_id = self._next_id
self._next_id += 1
loop = asyncio.get_running_loop()
future: asyncio.Future[CodexMessage] = loop.create_future()
self._pending_requests[request_id] = future
await self._ws.send(
json.dumps(
{
"id": request_id,
"method": method,
"params": params,
}
)
)
response = await future
error = response.get("error")
if error:
raise RuntimeError(str(error))
return response
async def notify(self, method: str, params: CodexParams | None = None) -> None:
"""
Send one JSON-RPC notification.
:param method: App-server notification method, e.g.
``"initialized"``.
:param params: Optional JSON-serializable notification params.
:returns: None.
"""
if self._ws is None:
raise RuntimeError("Codex app-server client is not connected")
message: CodexMessage = {"method": method}
if params is not None:
message["params"] = params
await self._ws.send(json.dumps(message))
async def respond(self, request_id: int | str, result: CodexParams) -> None:
"""
Send one JSON-RPC result for an app-server request.
Codex app-server can send server-to-client requests on the
same websocket, such as ``mcpServer/elicitation/request``.
The forwarder handles those requests through Omnigent and replies
with this method using Codex's original request id.
:param request_id: JSON-RPC id from the Codex request, e.g.
``7`` or ``"7"``.
:param result: JSON-serializable result payload for that
request.
:returns: None.
:raises RuntimeError: If the app-server client is not
connected.
"""
if self._ws is None:
raise RuntimeError("Codex app-server client is not connected")
await self._ws.send(json.dumps({"id": request_id, "result": result}))
async def iter_events(self) -> AsyncIterator[CodexMessage]:
"""
Yield app-server notifications until the connection closes.
:returns: Async iterator of notification envelopes.
"""
while True:
yield await self._events.get()
async def _reader_loop(self) -> None:
"""
Read messages from the websocket and route responses/events.
:returns: None.
"""
assert self._ws is not None
async for raw in self._ws:
if not isinstance(raw, str):
continue
message = json.loads(raw)
if (
"id" in message
and "method" not in message
and ("result" in message or "error" in message)
):
future = self._pending_requests.pop(int(message["id"]), None)
if future is not None and not future.done():
future.set_result(message)
continue
await self._events.put(message)
@dataclass
class CodexNativeAppServer:
"""
Running native Codex app-server subprocess.
:param codex_path: Executable path for the Codex CLI, e.g.
``"/usr/local/bin/codex"``.
:param socket_path: Codex app-server Unix socket path.
:param codex_home: Private per-session ``CODEX_HOME`` path.
:param env: Environment for the app-server subprocess.
:param config_overrides: Codex ``-c`` config override values.
:param cwd: Working directory for the app-server process.
:param bridge_dir: Native Codex bridge directory, e.g.
``Path("~/.omnigent/codex-native/<hash>")``. The policy hook
subprocess is pointed at it via ``--bridge-dir`` and reads the
session id + Omnigent coordinates from it.
:param ap_server_url: Omnigent server base URL the policy hook POSTs tool
calls to, e.g. ``"http://127.0.0.1:8787"``. ``None`` registers
and trusts the hook but writes no Omnigent coordinates, so the hook
no-ops (no enforcement) until coordinates exist.
:param ap_auth_headers: Outbound auth headers for the policy hook's
Omnigent requests, e.g. ``{"Authorization": "Bearer <token>"}``.
:param python_executable: Python executable the policy hook command
runs, e.g. ``"/path/to/.venv/bin/python"``. ``None`` uses
:data:`sys.executable`.
:param policy_hook_disabled_reason: Runtime field set by
:meth:`start`: ``None`` when the tool-call policy hook is active
(registered + trusted), or a human-readable reason string when it
is NOT enforced for this session — codex too old, or the trust
handshake failed. Hook problems are non-fatal (fail-open): the
session still starts and this reason is surfaced as a web-UI
notice rather than blocking session creation. Not a constructor
input — defaults ``None`` until ``start``.
:param pinned_model: Session-pinned model id written into the
per-session ``config.toml`` at start, or ``None``. Keeps the
forwarder's config.toml model mirror (and the cost gate's hook
read) consistent with what the session was launched to run.
:param policy_notice_pending: One-shot flag: ``True`` once a degrade
reason is recorded, until the runner's terminal-ensure handler
surfaces it to Omnigent (which posts a single durable banner). Prevents
re-posting the same notice on every subsequent ensure. Not a
constructor input.
"""
codex_path: str
socket_path: Path
codex_home: Path
env: dict[str, str]
config_overrides: list[str]
cwd: Path
bridge_dir: Path
ap_server_url: str | None = None
ap_auth_headers: dict[str, str] | None = None
python_executable: str | None = None
listen_url: str | None = None
proc: asyncio.subprocess.Process | None = None
stderr_task: asyncio.Task[None] | None = None
recent_stderr: list[str] | None = None
policy_hook_disabled_reason: str | None = None
policy_notice_pending: bool = False
pinned_model: str | None = None
process_registry_tag: str | None = None
process_owner_lock: CodexNativeProcessOwnerLock | None = None
async def start(self) -> None:
"""
Start the Codex app-server and wait for the socket.
:returns: None.
"""
self.codex_home.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(self.codex_home, 0o700)
if self.listen_url is None or self.listen_url.startswith("unix://"):
with contextlib.suppress(FileNotFoundError):
self.socket_path.unlink()
_populate_codex_home_config(
self.codex_home,
_codex_home_config_source_from_env(),
)
# Write the MCP server config into config.toml so the app-server
# discovers it at config load. The -c overrides may not be honored
# by `codex app-server`, so we write directly to the file.
_inject_mcp_server_config(self.codex_home, self.bridge_dir, self.python_executable)
if self.pinned_model:
_pin_codex_config_model(self.codex_home, self.pinned_model)
# Native policy enforcement needs codex's hook-trust protocol
# (``currentHash`` / ``trustStatus`` in ``hooks/list``), added in
# codex 0.129. Below that the hook can never be trusted, so
# registering it would only fail at the trust gate. Detect the
# version up front; below the minimum we skip registration and
# degrade to "no enforcement" with a surfaced reason. A version we
# cannot parse (``None``) is treated as supported so a flaky probe
# never silently disables enforcement — a genuine trust failure is
# then caught below.
codex_version = await _codex_cli_version(self.codex_path)
if codex_version is not None and codex_version < _MIN_POLICY_HOOK_CODEX_VERSION:
self._disable_policy_hook(
f"Codex CLI {_format_codex_version(codex_version)} is older than "
f"{_format_codex_version(_MIN_POLICY_HOOK_CODEX_VERSION)}; upgrade "
"codex to enforce tool-call policies."
)
else:
# Register the Omnigent policy hook in this private CODEX_HOME
# *before* launching the app-server so codex discovers it at
# config load. The Omnigent coordinates the hook subprocess
# needs go in the bridge dir's policy_hook.json; without
# ap_server_url the hook is still registered + trusted but
# no-ops.
_write_codex_policy_hooks_file(
self.codex_home, self.bridge_dir, self.python_executable
)
if self.ap_server_url:
write_policy_hook_config(
self.bridge_dir,
ap_server_url=self.ap_server_url,
ap_auth_headers=self.ap_auth_headers or {},
)
reconcile_codex_native_process_registry()
resolved_listen = self.listen_url or f"unix://{self.socket_path}"
self.process_registry_tag = f"codex-native-{uuid.uuid4().hex}"
tagged_argv0 = (
f"{Path(self.codex_path).name} "
f"{codex_native_session_tag_cmdline_arg(self.process_registry_tag)}"
)
argv = [
tagged_argv0,
"app-server",
"--listen",
resolved_listen,
]
for override in self.config_overrides:
argv.extend(["-c", override])
proc_env = {**self.env, "CODEX_HOME": str(self.codex_home)}
self.process_owner_lock = acquire_codex_native_process_owner_lock()
try:
self.proc = await asyncio.create_subprocess_exec(
*argv,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
env=proc_env,
cwd=str(self.cwd),
executable=self.codex_path,
**_proc.spawn_kwargs(),
)
except BaseException:
if self.process_owner_lock is not None:
self.process_owner_lock.close()
self.process_owner_lock = None
raise
if self.process_owner_lock is not None:
register_codex_native_process(
pid=self.proc.pid,
pgid=_process_group_id(self.proc),
session_tag=self.process_registry_tag,
owner_lock_path=self.process_owner_lock.path,
)
self.recent_stderr = []
self.stderr_task = asyncio.create_task(
self._stderr_loop(),
name="codex-native-app-server-stderr",
)
# Readiness failure (the app-server never came up) is fatal and
# tears down the subprocess so it is not orphaned. Policy-hook
# trust, by contrast, is best-effort: a trust failure degrades the
# session to "no enforcement" with a surfaced reason rather than
# blocking session creation (fail-open). ``BaseException`` on the
# outer guard so a cancellation mid-trust still tears down.
try:
await self._wait_until_ready()
if self.policy_hook_disabled_reason is None:
try:
await self._trust_policy_hooks()
except Exception as exc: # noqa: BLE001 - degrade, never block startup
self._disable_policy_hook(f"Codex policy hook could not be trusted: {exc}")
except BaseException:
await self.close()
raise
async def _trust_policy_hooks(self) -> None:
"""
Mark the registered Omnigent policy hook as trusted.
A freshly-written non-managed hook is ``untrusted`` and codex
silently skips untrusted hooks — for a policy gate that is a
fail-open. This connects a transient app-server client and runs
the same ``hooks/list`` → ``config/batchWrite`` trust flow codex's
own TUI uses, then verifies the hook is trusted. ``--listen`` may
be a unix socket (local CLI) or a loopback websocket (host
runner); both transports are handled.
:returns: None.
:raises RuntimeError: If the policy hook is missing from the
discovered set or remains untrusted after the trust write —
either condition means enforcement would silently not run.
The message is augmented with codex's captured configuration
error (see :meth:`_codex_config_error_hint`) when present.
"""
if self.listen_url and self.listen_url.startswith("ws://"):
client = CodexAppServerClient(
ws_url=self.listen_url,
client_name="omnigent-policy-trust",
)
else:
client = CodexAppServerClient(
self.socket_path,
client_name="omnigent-policy-trust",
)
await client.connect()
try:
await trust_native_policy_hooks(client, cwd=str(self.cwd))
except RuntimeError as exc:
raise RuntimeError(f"{exc}{self._codex_config_error_hint()}") from exc
finally:
await client.close()
def _codex_config_error_hint(self) -> str:
"""
Return a hint drawn from codex stderr when policy-hook trust fails.
Codex logs ``Invalid configuration; using defaults`` when the
per-session ``config.toml`` is not valid TOML, then loads zero
hooks — the most common cause of a "not discovered" failure.
Surfacing the captured stderr line (which the trust handshake
otherwise never sees) turns an opaque failure into a
self-diagnosing error pointing at the offending config file.
:returns: A space-prefixed hint string, or ``""`` when codex
reported no configuration problem.
"""
config_errors = [
line for line in (self.recent_stderr or []) if "Invalid configuration" in line
]
if not config_errors:
return ""
joined = " | ".join(config_errors[-3:])
return (
f" Codex reported a configuration problem (the per-session config "
f"{self.codex_home / 'config.toml'} is likely invalid TOML, so codex "
f"loaded no hooks): {joined}"
)
def _disable_policy_hook(self, reason: str) -> None:
"""
Record that tool-call policy enforcement is inactive (fail-open).
Single entry point used by :meth:`start` for both degrade paths
(codex too old, trust handshake failed). Stores the reason, flags
a one-shot web-UI notice for the runner's ensure handler to
surface, and logs (see :meth:`_warn_policy_hook_disabled`).
:param reason: Human-readable cause, e.g. ``"Codex CLI 0.128.0 is
older than 0.129.0; upgrade codex to enforce tool-call
policies."``.
:returns: None.
"""
self.policy_hook_disabled_reason = reason
self.policy_notice_pending = True
self._warn_policy_hook_disabled()
def _warn_policy_hook_disabled(self) -> None:
"""
Log that tool-call policy enforcement is inactive for this session.
Called by :meth:`start` when it degrades the session to "no
enforcement" — either codex is too old to trust the hook, or the
trust handshake failed. The reason is in
:attr:`policy_hook_disabled_reason`. When Omnigent coordinates are
present (``ap_server_url`` set) enforcement was intended, so this
is a loud ``warning``; otherwise nothing would have been enforced
anyway and it is an ``info``.
:returns: None.
"""
message = "Native tool-call policy enforcement is NOT active for this session: %s"
if self.ap_server_url:
_logger.warning(message, self.policy_hook_disabled_reason)
else:
_logger.info(message, self.policy_hook_disabled_reason)
async def close(self) -> None:
"""
Stop the app-server subprocess.
:returns: None.
"""
if self.proc is not None and self.proc.returncode is None:
_terminate_process_tree(self.proc)
try:
await asyncio.wait_for(self.proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
_kill_process_tree(self.proc)
await self.proc.wait()
if self.process_registry_tag is not None:
unregister_codex_native_process(self.process_registry_tag)
if self.process_owner_lock is not None:
self.process_owner_lock.close()
if self.stderr_task is not None:
self.stderr_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self.stderr_task
self.proc = None
self.stderr_task = None
self.process_registry_tag = None
self.process_owner_lock = None
async def _wait_until_ready(self) -> None:
"""
Wait until the app-server socket accepts an initialized
client.
:returns: None.
:raises RuntimeError: If the app-server exits or never
becomes ready before the timeout.
"""
deadline = asyncio.get_running_loop().time() + _CONNECT_TIMEOUT_SECONDS
last_error: Exception | None = None
while asyncio.get_running_loop().time() < deadline:
if self.proc is not None and self.proc.returncode is not None:
detail = " | ".join((self.recent_stderr or [])[-5:])
raise RuntimeError(f"Codex app-server exited early: {detail}")
try:
if self.listen_url and self.listen_url.startswith("ws://"):
client = CodexAppServerClient(
ws_url=self.listen_url,
client_name="omnigent-probe",
)
else:
client = CodexAppServerClient(
self.socket_path,
client_name="omnigent-probe",
)
await client.connect()
await client.close()
return
except Exception as exc: # noqa: BLE001 - readiness retry boundary
last_error = exc
await asyncio.sleep(_CONNECT_RETRY_DELAY_SECONDS)
detail = " | ".join((self.recent_stderr or [])[-5:])
raise RuntimeError(
f"Timed out waiting for Codex app-server socket {self.socket_path}: "
f"{last_error}; stderr={detail}"
)
async def _stderr_loop(self) -> None:
"""
Capture recent app-server stderr for diagnostics.
:returns: None.
"""
assert self.proc is not None and self.proc.stderr is not None
while True:
line = await self.proc.stderr.readline()
if not line:
return
text = line.decode("utf-8", errors="replace").rstrip()
if len(text) >= _STDERR_CHUNK_LIMIT:
text = f"{text[:_STDERR_CHUNK_LIMIT]}...[truncated]"
if self.recent_stderr is not None:
self.recent_stderr.append(text)
if len(self.recent_stderr) > 20:
self.recent_stderr.pop(0)
_logger.debug("codex-native app-server stderr: %s", text)
def _codex_policy_hook_command(bridge_dir: Path, python_executable: str | None) -> str:
"""
Build the shell command codex runs for the policy hook.
:param bridge_dir: Native Codex bridge directory passed to the hook
via ``--bridge-dir``.
:param python_executable: Python executable to run, e.g.
``"/path/to/python"``. ``None`` uses :data:`sys.executable`.
:returns: A shell-escaped command string, e.g.
``"/path/python -m omnigent.codex_native_hook evaluate-policy
--bridge-dir /home/u/.omnigent/codex-native/abc"``.
"""
python = python_executable or sys.executable
return shlex.join(
[python, "-m", _POLICY_HOOK_MODULE, "evaluate-policy", "--bridge-dir", str(bridge_dir)]
)
def _codex_policy_hooks_settings(
bridge_dir: Path, python_executable: str | None
) -> dict[str, Any]:
"""
Build the ``hooks.json`` payload registering the policy hook.
Registers one catch-all (no ``matcher``) command hook on
``PreToolUse`` (blocks before execution), ``PostToolUse`` (warns
after), and ``UserPromptSubmit`` (blocks a user prompt before the
model sees it — the request-phase gate for native sessions, since
the server-level ``_evaluate_input_policy`` skips native message
events). ``mcp__*`` tools are filtered out inside the hook itself,
not by a matcher, so the relay path remains the single MCP
enforcement point.
:param bridge_dir: Native Codex bridge directory.
:param python_executable: Python executable for the hook command.
:returns: A ``hooks.json``-shaped dict.
"""
hook = {
"type": "command",
"command": _codex_policy_hook_command(bridge_dir, python_executable),
"timeout": _POLICY_HOOK_TIMEOUT_SECONDS,
}
return {
"hooks": {
"PreToolUse": [{"hooks": [hook]}],
"PostToolUse": [{"hooks": [hook]}],
"UserPromptSubmit": [{"hooks": [hook]}],
}
}
def _write_codex_policy_hooks_file(
codex_home: Path, bridge_dir: Path, python_executable: str | None
) -> None:
"""
Write ``hooks.json`` into the private CODEX_HOME (atomically).
:param codex_home: Private per-session ``CODEX_HOME`` directory.
:param bridge_dir: Native Codex bridge directory for the hook command.
:param python_executable: Python executable for the hook command.
:returns: None.
"""
codex_home.mkdir(mode=0o700, parents=True, exist_ok=True)
path = codex_home / _CODEX_HOOKS_FILE
payload = _codex_policy_hooks_settings(bridge_dir, python_executable)
fd, tmp_name = tempfile.mkstemp(prefix=f"{_CODEX_HOOKS_FILE}.", dir=str(codex_home))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, sort_keys=True)
handle.write("\n")
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
def _our_policy_hooks_from_list(listed: dict[str, Any], cwd: str) -> list[dict[str, Any]]:
"""
Extract *our* policy hooks for *cwd* from a ``hooks/list`` response.
Filters to hooks whose command references :data:`_POLICY_HOOK_MODULE`
so the trust step never touches hooks the user's symlinked
``config.toml`` might declare.
:param listed: Parsed ``hooks/list`` response envelope, with
``result.data`` a list of ``{cwd, hooks: [...]}`` entries.
:param cwd: The cwd whose hook set to read, e.g.
``"/home/user/repo"``.
:returns: The matching Omnigent hook metadata dicts (possibly
empty), each with ``key``, ``currentHash``, ``trustStatus``.
"""
result = listed.get("result", listed)
data = result.get("data", []) if isinstance(result, dict) else []
for entry in data:
if isinstance(entry, dict) and entry.get("cwd") == cwd:
hooks = entry.get("hooks", [])
return [
h
for h in hooks
if isinstance(h, dict) and _POLICY_HOOK_MODULE in str(h.get("command", ""))
]
return []
def _hooks_list_diagnostics(listed: dict[str, Any], cwd: str) -> str:
"""
Summarize a ``hooks/list`` response for a discovery-failure error.
Turns an opaque "hook not discovered" into a self-diagnosing message
by reporting what codex actually returned. Distinguishes the common
causes:
- **zero entries / zero hooks** — codex loaded no hooks at all,
typically because the per-session ``config.toml`` is invalid TOML
and codex fell back to defaults;
- **cwd mismatch** — entries came back but none for the queried
*cwd*;
- **module mismatch** — an entry matched but no hook command
references :data:`_POLICY_HOOK_MODULE` (e.g. a stale / renamed
module from an out-of-date install).
:param listed: Parsed ``hooks/list`` response envelope.
:param cwd: The cwd that was queried, e.g. ``"/home/user/repo"``.
:returns: A one-line diagnostic, e.g.
``"hooks/list returned no hooks (codex loaded none — likely an "
"invalid per-session config.toml)"``.
"""
result = listed.get("result", listed)
data = result.get("data", []) if isinstance(result, dict) else []
entries = [e for e in data if isinstance(e, dict)]
if not entries or all(not e.get("hooks") for e in entries):
return (
"hooks/list returned no hooks (codex loaded none — likely an "
"invalid per-session config.toml, so codex fell back to defaults)"
)
matched_cwd = any(e.get("cwd") == cwd for e in entries)
parts: list[str] = []
for entry in entries:
hooks = entry.get("hooks", []) or []
ours = sum(
1
for h in hooks
if isinstance(h, dict) and _POLICY_HOOK_MODULE in str(h.get("command", ""))
)
parts.append(f"cwd={entry.get('cwd')!r}: {len(hooks)} hook(s), {ours} ours")
prefix = "" if matched_cwd else f"no entry matched queried cwd {cwd!r}; "
return f"hooks/list returned [{prefix}{'; '.join(parts)}]"
def _untrusted_hook_detail(hooks: list[dict[str, Any]]) -> str:
"""
Render untrusted hook metadata for a trust-failure error.
Surfaces codex's own per-hook ``trustStatus`` / ``statusMessage`` /
``isManaged`` (which the trust handshake otherwise discards) so the
error explains *why* a hook could not be trusted — e.g. a managed
requirement rejecting a user hook, or an old codex that omits
``trustStatus`` entirely.
:param hooks: Untrusted Omnigent hook metadata dicts from
``hooks/list``.
:returns: A semicolon-joined per-hook detail string.
"""
return "; ".join(
f"{h.get('key')!r} trustStatus={h.get('trustStatus')!r} "
f"isManaged={h.get('isManaged')!r} statusMessage={h.get('statusMessage')!r}"
for h in hooks
)
async def trust_native_policy_hooks(client: CodexAppServerClient, *, cwd: str) -> None:
"""
Trust the Omnigent policy hook so codex actually runs it.
Runs the same flow codex's TUI uses for hook trust: ``hooks/list``
to read each hook's content hash, then ``config/batchWrite`` (with
``reloadUserConfig`` so loaded threads hot-reload) writing
``hooks.state.<key>.trusted_hash = currentHash``. Re-lists and
verifies every Omnigent hook ended ``trusted``/``managed``.
:param client: A connected Codex app-server client.
:param cwd: The session cwd the hooks are scoped to, e.g.
``"/home/user/repo"``.
:returns: None.
:raises RuntimeError: If no Omnigent hook is discovered (it was
not registered or not loaded) or if a hook remains untrusted
after the trust write. Either is a silent fail-open for a policy
gate, so it must fail loud.
"""
listed = await client.request("hooks/list", {"cwds": [cwd]})
ours = _our_policy_hooks_from_list(listed, cwd)
if not ours:
raise RuntimeError(
f"Omnigent policy hook was not discovered for cwd {cwd!r}; "
"tool-call policy enforcement would silently not run. "
f"{_hooks_list_diagnostics(listed, cwd)}."
)
untrusted = [h for h in ours if h.get("trustStatus") not in _TRUSTED_HOOK_STATUSES]
if not untrusted:
return
trust_value = {
str(h["key"]): {"trusted_hash": h["currentHash"]}
for h in untrusted
if h.get("key") and h.get("currentHash")
}
await client.request(
"config/batchWrite",
{
"edits": [
{
"keyPath": "hooks.state",
"mergeStrategy": "upsert",
"value": trust_value,
}
],
"reloadUserConfig": True,
},
)
relisted = await client.request("hooks/list", {"cwds": [cwd]})
still_untrusted = [
h
for h in _our_policy_hooks_from_list(relisted, cwd)
if h.get("trustStatus") not in _TRUSTED_HOOK_STATUSES
]
if still_untrusted:
missing_protocol = any(
h.get("currentHash") is None or h.get("trustStatus") is None for h in still_untrusted
)
minimum = _format_codex_version(_MIN_POLICY_HOOK_CODEX_VERSION)
hint = (
" The hooks/list metadata is missing currentHash/trustStatus, so the "
f"codex CLI is older than {minimum} (upgrade codex)."
if missing_protocol
else ""
)
raise RuntimeError(
"Omnigent policy hook still untrusted after config/batchWrite; "
"tool-call policy enforcement would not run. Untrusted hooks: "
f"{_untrusted_hook_detail(still_untrusted)}.{hint}"
)
def build_codex_native_server(
*,
socket_path: Path,
codex_home: Path,
cwd: Path,
model: str | None,
profile: str | None,
bridge_dir: Path,
ap_server_url: str | None = None,
ap_auth_headers: dict[str, str] | None = None,
python_executable: str | None = None,
codex_path: str | None = None,
extra_config_overrides: list[str] | None = None,
bypass_sandbox: bool = False,
) -> CodexNativeAppServer:
"""
Build a configured native Codex app-server process wrapper.
:param socket_path: Unix socket path for the app-server.
:param codex_home: Private per-session ``CODEX_HOME`` path.
:param cwd: Working directory for Codex, e.g. the user's repo.
:param model: Optional Codex model id, e.g. ``"gpt-5.4-mini"``.
:param profile: Optional Databricks CLI profile, e.g.
``"<your-profile>"``.
:param bridge_dir: Native Codex bridge directory; the policy hook is
pointed at it and reads the session id + Omnigent coordinates from it.
:param ap_server_url: Omnigent server base URL the policy hook POSTs tool
calls to, e.g. ``"http://127.0.0.1:8787"``. ``None`` registers
the hook but writes no Omnigent coordinates (hook no-ops).
:param ap_auth_headers: Outbound auth headers for the policy hook's
Omnigent requests, e.g. ``{"Authorization": "Bearer <token>"}``.
:param python_executable: Python executable the policy hook command
runs. ``None`` uses :data:`sys.executable`.
:param codex_path: Optional executable override. ``None`` searches
``PATH``.
:param extra_config_overrides: Additional ``-c`` config overrides
appended after Databricks routing overrides, e.g. MCP server
registration for the Omnigent tool relay.
:param bypass_sandbox: When ``True``, append config overrides that put
the app-server's threads into the full-bypass stance
(``approval_policy="never"`` + ``sandbox_mode="danger-full-access"``)
so the chat/forwarder seam matches the ``--remote`` TUI launched
with ``--dangerously-bypass-approvals-and-sandbox``. DANGEROUS:
disables both approval prompts and the command sandbox; gated
behind an explicit, typed-confirmation opt-in in the web UI.
Default ``False``. See issue #657.
:returns: Configured app-server process wrapper.
:raises ImportError: If no Codex CLI is available.
:raises OSError: If Databricks routing was requested but no
credentials can be resolved.
"""
resolved_codex = codex_path or _find_codex_cli()
if not resolved_codex:
raise ImportError("Native Codex requires the 'codex' CLI on PATH.")
env = _clean_codex_env()
config_overrides: list[str] = []
if profile is not None:
creds = _read_databrickscfg(profile)
host = creds.host if creds is not None else _read_databrickscfg_host(profile)
if not host:
raise OSError(
f"Native Codex with Databricks profile {profile!r} (from your "
"provider config) requires a matching ~/.databrickscfg section "
"with a host visible to the runner process."
)
host = host.rstrip("/")
config_overrides.extend(
_databricks_codex_config_overrides(
model=model or _DATABRICKS_CODEX_DEFAULT_MODEL,
base_url=_databricks_codex_base_url(host),
auth_command=_databricks_codex_auth_command(host, profile),
)
)
env["DATABRICKS_HOST"] = host
if extra_config_overrides:
config_overrides.extend(extra_config_overrides)
if bypass_sandbox:
# Mirror the --remote TUI's --dangerously-bypass-approvals-and-sandbox
# on the app-server threads: never prompt for approval, and run
# commands with no command sandbox. Emitted last so it wins over any
# earlier approval/sandbox override.
config_overrides.extend(
[
'approval_policy="never"',
'sandbox_mode="danger-full-access"',
]
)
return CodexNativeAppServer(
codex_path=resolved_codex,
socket_path=socket_path,
codex_home=codex_home,
env=env,
config_overrides=config_overrides,
cwd=cwd,
bridge_dir=bridge_dir,
ap_server_url=ap_server_url,
ap_auth_headers=ap_auth_headers,
python_executable=python_executable,
pinned_model=model,
)
@dataclass(frozen=True)
class NativeCodexLaunch:
"""How a native Codex terminal should be launched, across all offerings.
Resolved by :func:`resolve_native_codex_launch` so a native Codex
session honors ``configure harnesses`` like the in-process codex harness.
:param config_overrides: Codex ``-c`` overrides that route through a
generic provider (``model_provider`` + base_url + auth + wire);
empty for the Databricks-profile and CLI-login paths.
:param model: Model id to pin, or ``None`` to keep Codex's default.
:param profile: Databricks profile for the ucode path, or ``None`` (a
generic provider routes via *config_overrides*; CLI login uses
neither).
"""
config_overrides: list[str]
model: str | None
profile: str | None
def codex_session_meta_model_provider(launch: NativeCodexLaunch) -> str:
"""Return the provider id a launch routes through, for rollout synthesis.
Synthesized rollouts (fork carry-history, cross-machine cold resume) must
name the model provider in ``session_meta``: codex >= 0.133 backfills
rollouts written before app-server start into its thread-store sqlite,
and ``thread/resume`` of a backfilled row whose provider is empty or
unresolvable fails config load (``Model provider `` not found``),
silently dropping the carried history. The correct value is whatever
provider the launch itself routes through:
- a ``model_provider`` ``-c`` override (cli-config / key / gateway /
local providers) pins it explicitly — the override value is a TOML
basic string, which is also valid JSON;
- a Databricks profile launch carries no override here; the provider
table is generated at app-server start under the fixed
``omnigent_databricks`` id (see ``_databricks_codex_config_overrides``);
- otherwise the launch defers to Codex's own login, the built-in
``openai`` provider.
:param launch: Resolved native-Codex launch, e.g. one returned by
:func:`resolve_native_codex_launch`.
:returns: Provider id for ``session_meta.model_provider``, e.g.
``"omnigent_databricks"``.
"""
prefix = "model_provider="
for override in launch.config_overrides:
if override.startswith(prefix):
return json.loads(override.removeprefix(prefix))
if launch.profile is not None:
return "omnigent_databricks"
return "openai"
def _codex_provider_launch(entry: ProviderEntry, model: str | None) -> NativeCodexLaunch | None:
"""Build a native-Codex launch that routes through a single provider entry.
Mirrors the in-process codex harness routing for the ``openai`` surface:
- a ``databricks`` entry routes via its ucode profile (the Databricks
branch of :func:`build_native_codex_app` turns the profile into config
overrides), so the launch carries ``profile`` and empty overrides;
- a ``cli-config`` entry routes via a single ``model_provider`` ``-c``
override pinning the custom provider its ``~/.codex/config.toml``
defines (the provider table + credential live in that file, which the
native server bridges into the session ``CODEX_HOME``);
- a ``key`` / ``gateway`` / ``local`` entry routes via a generated
``model_provider`` ``-c`` override (base_url + bearer-token auth command
+ wire protocol).
Returns ``None`` when *entry* cannot route Codex on its own — a
``subscription`` entry (which defers to Codex's own stored login), a
provider that does not serve the ``openai`` surface, or a key/gateway/local
entry with no credential or whose secret reference does not resolve in this
process. A ``None`` lets the caller fall through to another provider rather
than crash at terminal launch or strand the user at Codex's login screen.
:param entry: The provider entry to route through, e.g. a ``key`` entry for
``openai`` or a ``databricks`` entry.
:param model: An explicit/session model override that wins over the
provider's default model, e.g. ``"gpt-5.5"``; ``None`` keeps the
provider's default.
:returns: A routable :class:`NativeCodexLaunch`, or ``None`` when *entry*
cannot route without Codex's own login.
"""
from omnigent.errors import OmnigentError
from omnigent.onboarding.provider_config import (
CLI_CONFIG_KIND,
DATABRICKS_KIND,
GATEWAY_KIND,
KEY_KIND,
LOCAL_KIND,
OPENAI_FAMILY,
)
if entry.kind == DATABRICKS_KIND:
return NativeCodexLaunch(config_overrides=[], model=model, profile=entry.profile)
if entry.kind == CLI_CONFIG_KIND:
# Pin the config.toml-defined provider by name; its table (and
# credential) ride along via the bridged config.toml. json.dumps
# yields a valid TOML basic string for the -c override value.
return NativeCodexLaunch(
config_overrides=[f"model_provider={json.dumps(entry.model_provider)}"],
model=model,
profile=None,
)
if entry.kind not in (KEY_KIND, GATEWAY_KIND, LOCAL_KIND):
return None
try:
family = entry.family(OPENAI_FAMILY)
except OmnigentError:
# The credential reference (``env:...`` / ``keychain:...``) does not
# resolve in this process — treat the provider as unroutable so the
# caller can try another instead of crashing at terminal launch.
return None
if family is None:
return None
if family.auth_command:
auth_command = family.auth_command
elif family.api_key:
auth_command = f"printf %s {shlex.quote(family.api_key)}"
else:
# Serves openai but carries no usable credential.
return None
pinned = model or family.default_model
overrides = _provider_codex_config_overrides(
model=pinned,
base_url=family.base_url,
auth_command=auth_command,
wire_api=family.wire_api or "responses",
)
return NativeCodexLaunch(config_overrides=overrides, model=pinned, profile=None)
def _first_routable_codex_provider(
config: dict[str, object], *, exclude: str, model: str | None
) -> NativeCodexLaunch | None:
"""Find a provider other than *exclude* that can route a native Codex launch.
Used when the resolved default is a ``subscription`` entry but Codex has no
usable stored login: rather than strand the user at Codex's login screen,
route through the first *other* provider serving the ``openai`` surface that
can produce a launch (a real key/gateway/local credential or a Databricks
profile). Explicit providers are tried before ambient detections — the
user's config is authoritative — and ambient detections (e.g. a real
``OPENAI_API_KEY`` in the environment) are honored only as a fallback.
This does **not** mutate the persisted default: the dead subscription
remains the configured default, so if the user re-logs into Codex it
resumes being used. This is a runtime safety net, not a default change.
:param config: The explicit parsed config mapping (``providers:`` block);
ambient detections are merged in read-only.
:param exclude: The provider name to skip — the dead subscription default,
e.g. ``"codex"``.
:param model: An explicit/session model override, or ``None``.
:returns: The first routable :class:`NativeCodexLaunch`, or ``None`` when no
other provider can route Codex.
"""
from omnigent.onboarding.detected import effective_config_with_detected
from omnigent.onboarding.provider_config import (
OPENAI_FAMILY,
load_providers,
provider_families,
)
explicit_providers = load_providers(config)
merged_providers = load_providers(effective_config_with_detected(config))
# Explicit first (authoritative), then ambient-only detections.
ordered = list(explicit_providers.items()) + [
(name, entry) for name, entry in merged_providers.items() if name not in explicit_providers
]
for name, candidate in ordered:
if name == exclude:
continue
if OPENAI_FAMILY not in provider_families(candidate):
continue
launch = _codex_provider_launch(candidate, model)
if launch is not None:
_logger.warning(
"native-codex: subscription default %r has no usable Codex login; "
"routing through provider %r instead.",
exclude,
name,
)
return launch
return None
def _resolve_subscription_launch(
entry: ProviderEntry, model: str | None, explicit: dict[str, object]
) -> NativeCodexLaunch:
"""Resolve a native-Codex launch when the Codex default is a ``subscription``.
A subscription defers to Codex's own stored login — correct only when Codex
is actually logged in. An empty / logged-out ``auth.json`` would otherwise
route to Codex's login screen even though the user configured a real
credential, so when Codex is not logged in this falls through to the first
other configured provider that can route (runtime only — the persisted
default is untouched). With no usable login and nothing to fall through to,
Codex's own login is the correct outcome (the user must re-authenticate).
:param entry: The resolved ``subscription`` provider entry (the Codex
default), e.g. a ``codex`` CLI-login entry.
:param model: An explicit/session model override, or ``None``.
:param explicit: The explicit parsed config mapping (``providers:`` block),
used for the fall-through search over other configured/detected
providers.
:returns: The resolved :class:`NativeCodexLaunch`.
"""
from omnigent.onboarding.ambient import codex_auth_has_credential
# Pin codex's built-in ``openai`` provider: the bridged config.toml may
# set a custom default ``model_provider`` (e.g. isaac's Databricks AI
# Gateway), which would silently hijack a Subscription selection. A
# no-op when the user's config sets no custom default.
subscription_overrides = ['model_provider="openai"']
# Resolve against the same CODEX_HOME the native server bridges from
# (``_populate_codex_home_config``) so this "is Codex logged in?" check reads
# the exact auth.json the launched Codex process will use.
real_codex_home = _codex_home_config_source_from_env()
if codex_auth_has_credential(real_codex_home / "auth.json"):
_logger.info(
"native-codex routing: Codex CLI login (subscription provider %r; Codex is logged in)",
entry.name,
)
return NativeCodexLaunch(
config_overrides=subscription_overrides, model=model, profile=None
)
fallback = _first_routable_codex_provider(explicit, exclude=entry.name, model=model)
if fallback is not None:
return fallback
_logger.info(
"native-codex routing: Codex CLI login (subscription provider %r has no usable "
"Codex login and no alternative provider is configured)",
entry.name,
)
return NativeCodexLaunch(config_overrides=subscription_overrides, model=model, profile=None)
def resolve_native_codex_launch(*, model: str | None) -> NativeCodexLaunch:
"""Resolve the native Codex launch config across all offerings.
Mirrors the in-process codex harness routing precedence
(:func:`omnigent.runtime.workflow._resolve_provider_for_build`) for the
``openai`` surface, so ``omnigent codex`` and a host-spawned native
Codex session route through ``omnigent setup``:
1. an explicit per-family default provider →
- ``key`` / ``gateway`` / ``local`` → provider ``-c`` overrides
(base_url + token + wire), ``profile=None``;
- ``databricks`` → the ucode profile path (its profile);
- ``subscription`` → the Codex CLI's own stored login (no overrides)
**when Codex is actually logged in**; otherwise (empty / logged-out
``auth.json``) fall through to the first other configured provider
that can route, so a real credential is not shadowed by a dead
subscription default;
2. else a global Databricks ``auth:`` block → ucode;
3. else an ambient-detected provider (first run without configure);
4. else the codex CLI's own login.
Credentials are controlled exclusively by ``omnigent setup``
provider config (or the legacy global ``auth:`` block) — there is
no CLI/env profile override.
:param model: An explicit/session model override that wins over the
provider's default model, or ``None``.
:returns: The resolved :class:`NativeCodexLaunch`.
"""
from omnigent.onboarding.detected import (
codex_config_provider_dismissed,
effective_config_with_detected,
)
from omnigent.onboarding.provider_config import (
SUBSCRIPTION_KIND,
default_provider_for_harness,
load_config,
)
from omnigent.runtime.workflow import _load_global_auth
from omnigent.spec.types import DatabricksAuth
explicit = load_config()
# When the launch ends up on codex's own login with NO provider routing,
# the bridged config.toml's custom default model_provider would still
# apply — including one the user explicitly Removed (dismissed). Pin
# codex's built-in provider in that case so the dismissal holds at run
# time. An undetectable/undismissed custom provider keeps its routing.
no_provider_overrides = (
['model_provider="openai"'] if codex_config_provider_dismissed(explicit) else []
)
entry = default_provider_for_harness(explicit, "codex")
if entry is None:
# No explicit provider default: global auth wins over ambient
# (parity with _resolve_provider_for_build).
global_auth = _load_global_auth()
if isinstance(global_auth, DatabricksAuth):
return NativeCodexLaunch(config_overrides=[], model=model, profile=global_auth.profile)
if global_auth is not None:
return NativeCodexLaunch(config_overrides=[], model=model, profile=None)
entry = default_provider_for_harness(effective_config_with_detected(explicit), "codex")
if entry is None:
_logger.info(
"native-codex routing: Codex CLI login (no provider configured for the Codex "
"harness, no Databricks profile). Run `omnigent setup --no-internal-beta` to route "
"through a provider."
)
return NativeCodexLaunch(config_overrides=no_provider_overrides, model=model, profile=None)
if entry.kind == SUBSCRIPTION_KIND:
return _resolve_subscription_launch(entry, model, explicit)
launch = _codex_provider_launch(entry, model)
if launch is not None:
if launch.profile is not None:
_logger.info("native-codex routing: Databricks ucode profile %r", launch.profile)
else:
_logger.info("native-codex routing: provider %r (model=%s)", entry.name, launch.model)
return launch
# Default provider can't route on its own (no openai surface / no usable
# credential / unresolvable secret) → Codex's own login.
_logger.warning(
"native-codex: provider %r is the Codex default but has no usable openai "
"credential — falling back to Codex's own login.",
entry.name,
)
return NativeCodexLaunch(config_overrides=no_provider_overrides, model=model, profile=None)
def client_for_transport(
transport: str,
*,
client_name: str = "omnigent",
) -> CodexAppServerClient:
"""
Build an app-server client for a persisted transport string.
The native Codex app-server is reachable over either a loopback
WebSocket (``"ws://IP:PORT"`` / ``"wss://..."``) or a Unix socket
path (legacy ``"/path/app-server.sock"``). Both the host-spawned
runner and the local CLI now listen on ``ws://`` — the only
transport Codex CLI ``app-server`` accepts since it dropped
``unix://`` support — and persist whichever was used as the bridge
state's ``socket_path``. Every connect site (executor steering /
interrupt / run_turn, forwarder, initial turn) routes through this
one rule so a ``ws://`` transport is never mistakenly wrapped in
``Path(...)`` and dialed as a (nonexistent) Unix socket.
:param transport: App-server transport from bridge state, e.g.
``"ws://127.0.0.1:9876"`` or
``"/home/u/.omnigent/codex-native/x/app-server.sock"``.
:param client_name: App-server initialize-handshake client name,
e.g. ``"omnigent-codex-native"``.
:returns: A client configured for the transport (not yet connected).
"""
if transport.startswith(("ws://", "wss://")):
return CodexAppServerClient(ws_url=transport, client_name=client_name)
return CodexAppServerClient(Path(transport), client_name=client_name)
async def preload_codex_thread_for_resume(transport: str, thread_id: str) -> None:
"""
Load an existing Codex thread into a freshly started app-server.
A rollout JSONL on disk is not enough for immediate web-message
injection: a new app-server can reject ``turn/start`` with
``thread not found`` until some client has resumed the thread. This
helper runs the lightweight ``thread/resume`` call before bridge
state is exposed to the web-message executor.
:param transport: App-server transport, e.g. ``"ws://127.0.0.1:9876"``
or ``"/tmp/app-server.sock"``.
:param thread_id: Codex thread id to load, e.g.
``"019e96aa-0be2-7343-8d3b-6f914d60936b"``.
:returns: None.
:raises RuntimeError: If the app-server rejects the resume.
"""
client = client_for_transport(
transport,
client_name="omnigent-codex-native-preload",
)
await client.connect()
try:
await client.request(
"thread/resume",
{"threadId": thread_id, "excludeTurns": True},
)
finally:
await client.close()
def codex_terminal_env(app_server: CodexNativeAppServer) -> dict[str, str]:
"""
Build terminal env overrides for the native Codex TUI.
:param app_server: Running app-server wrapper.
:returns: Environment variables for the terminal process.
"""
return {
key: value
for key, value in {**app_server.env, "CODEX_HOME": str(app_server.codex_home)}.items()
if key in {"CODEX_HOME", "DATABRICKS_HOST", "DATABRICKS_CODEX_TOKEN"}
or key.startswith(("OPENAI_", "HTTP_", "HTTPS_", "NO_PROXY", "ALL_PROXY"))
}
# Codex's full-bypass flag. Disables BOTH the approval prompts and the
# command sandbox in one switch. Verified against codex-cli 0.140.0-alpha.2:
# it is mutually exclusive with the approval flag only — passing
# ``--ask-for-approval`` (or its ``-a`` alias, in any spelling) alongside it
# aborts at startup with "cannot be used with
# --dangerously-bypass-approvals-and-sandbox". ``--sandbox`` / ``-s`` do NOT
# conflict (the bypass already implies ``danger-full-access``), so leaving
# them in is harmless. We strip BOTH anyway when bypass is on — the approval
# flag because it MUST go, the sandbox flag for hygiene so the launched arg
# list reflects a single coherent stance.
_CODEX_BYPASS_SANDBOX_FLAG = "--dangerously-bypass-approvals-and-sandbox"
# Granular approval/sandbox flags to drop when bypass is on. The "Full
# access" / "Read only" approval presets emit the long ``--flag value`` form
# (see web CODEX_NATIVE_APPROVAL_MODES), but ``terminal_launch_args`` is
# client-supplied (validated only for count/length), so the short aliases
# (``-a`` / ``-s``) are included too: ``-a`` triggers the same startup abort
# as ``--ask-for-approval`` and must never reach codex. Each is matched in
# both the space-separated (``-a never``) and joined (``-a=never``) spellings
# by :func:`_strip_approval_sandbox_flags`.
_CODEX_APPROVAL_SANDBOX_FLAGS = frozenset({"--sandbox", "-s", "--ask-for-approval", "-a"})
def _strip_approval_sandbox_flags(codex_args: tuple[str, ...]) -> list[str]:
"""
Drop granular approval/sandbox flags (and values) when bypass is on.
Removes every flag in :data:`_CODEX_APPROVAL_SANDBOX_FLAGS` —
``--ask-for-approval`` / ``-a`` (which codex *rejects* alongside the
bypass flag) and ``--sandbox`` / ``-s`` (harmless, dropped for hygiene).
Both CLI spellings of each are handled:
- ``--sandbox=read-only`` (single ``--flag=value`` token) is dropped
whole.
- ``--sandbox read-only`` (separate flag + value) drops the flag and
its following value — but ONLY when that next token is actually a
value (it does not itself start with ``-``). A following
``--something`` is a separate flag, not this flag's value, so it is
left in place (e.g. ``("--sandbox", "--model", "gpt")`` keeps
``"--model", "gpt"``). A trailing flag at end-of-list is dropped
cleanly with no value to consume.
Any already-present bypass flag is also dropped so the caller can
re-add a single canonical copy. Unrelated args (model, config
overrides, ...) pass through untouched.
:param codex_args: Raw Codex CLI args, e.g.
``("--sandbox", "read-only", "--model", "gpt-5.4-mini")``.
:returns: ``codex_args`` with the conflicting flags removed, e.g.
``["--model", "gpt-5.4-mini"]``.
"""
cleaned: list[str] = []
i = 0
n = len(codex_args)
while i < n:
arg = codex_args[i]
if arg in _CODEX_APPROVAL_SANDBOX_FLAGS:
# ``--flag value``: drop the flag, and consume the NEXT token as
# its value ONLY when that token is a real value — it exists and
# does not itself start with ``-`` (a leading ``-`` marks a
# separate flag, e.g. ``("--sandbox", "--model", "gpt")`` keeps
# ``--model``; a trailing flag at end-of-list consumes nothing).
if i + 1 < n and not codex_args[i + 1].startswith("-"):
i += 2
else:
i += 1
continue
if any(arg.startswith(f"{flag}=") for flag in _CODEX_APPROVAL_SANDBOX_FLAGS):
# ``--flag=value`` single token: drop it whole, consume nothing.
i += 1
continue
if arg == _CODEX_BYPASS_SANDBOX_FLAG:
# Drop any pre-existing bypass flag; a single canonical copy is
# re-added by the caller so it is never duplicated.
i += 1
continue
cleaned.append(arg)
i += 1
return cleaned
def build_codex_remote_args(
*,
codex_args: tuple[str, ...],
thread_id: str | None,
remote_url: str,
config_overrides: tuple[str, ...] = (),
bypass_sandbox: bool = False,
) -> list[str]:
"""
Build Codex CLI args for an app-server-backed TUI session.
The TUI attaches to an already-running Codex app-server over its
``--remote`` transport so the terminal, the chat forwarder, and the
web-UI message bridge all drive the same thread. The transport is
passed verbatim so callers can attach over either a Unix socket
(``"unix://PATH"``, the local CLI path) or a loopback TCP websocket
(``"ws://IP:PORT"``, the host-spawned runner path — see
:class:`CodexNativeAppServer` ``listen_url``).
The ``config_overrides`` are the same ``-c key=value`` provider/model
overrides the app-server is launched with. The ``--remote`` TUI is a
*separate* process that loads its own config from ``CODEX_HOME`` and
does NOT inherit the app-server's ``-c`` flags; without them it falls
back to the built-in OpenAI provider, whose ``requires_openai_auth``
is ``true``, so the TUI renders the first-run "Sign in with ChatGPT"
onboarding screen and never creates a thread. On a host-spawned
(web-UI-driven) session there is nobody at the terminal to dismiss
that screen, so ``wait_for_thread_started`` times out and the session
hangs in ``running`` with no response. Passing the provider overrides
through makes the TUI resolve the Omnigent provider
(``requires_openai_auth = false``), skip onboarding, and start the
thread immediately. Codex global ``-c`` flags must precede the
``resume`` subcommand, so they are emitted first.
:param codex_args: Raw Codex CLI args that precede the attach flags,
e.g. ``("--model", "gpt-5.4-mini")``. Empty when the thread's own
settings already cover everything.
:param thread_id: Codex thread id to resume, e.g. ``"thread_abc123"``.
``None`` starts a fresh remote Codex TUI thread instead of
resuming an existing one.
:param remote_url: App-server endpoint the TUI attaches to, e.g.
``"unix:///home/user/.omnigent/codex-native/x/app-server.sock"``
or ``"ws://127.0.0.1:9876"``.
:param config_overrides: Codex ``-c`` config override values to apply
to the TUI, e.g.
``('model="databricks-gpt-5-5"', 'model_provider="omnigent_databricks"')``.
Each is emitted as a ``-c <value>`` global flag. Empty for a
plain Codex-login launch that needs no provider routing.
:param bypass_sandbox: When ``True``, emit a single
``--dangerously-bypass-approvals-and-sandbox`` flag and strip any
conflicting ``--sandbox`` / ``--ask-for-approval`` pairs from
*codex_args* (codex aborts at startup if the bypass flag is
combined with either). DANGEROUS: this disables both the approval
prompts and the command sandbox; it is gated behind an explicit,
typed-confirmation opt-in in the web UI. Default ``False`` keeps
the granular flags untouched. See issue #657.
:returns: Codex argv tail after the executable.
"""
override_args: list[str] = []
for override in config_overrides:
override_args.extend(["-c", override])
if bypass_sandbox:
# Strip the conflicting granular flags, then prepend one canonical
# bypass flag (a global flag, so it precedes any ``resume``).
passthrough = [_CODEX_BYPASS_SANDBOX_FLAG, *_strip_approval_sandbox_flags(codex_args)]
else:
passthrough = list(codex_args)
if thread_id is None:
return [*override_args, *passthrough, "--remote", remote_url]
return [*override_args, *passthrough, "resume", "--remote", remote_url, thread_id]
def _terminate_process_tree(process: asyncio.subprocess.Process) -> None:
"""
Send SIGTERM to a subprocess process group when possible.
:param process: Subprocess handle to terminate.
:returns: None.
"""
_proc.terminate_tree(process)
def _process_group_id(process: asyncio.subprocess.Process) -> int:
"""
Return the child process group id used for crash-safe reaping.
:param process: Subprocess handle.
:returns: Process group id, falling back to pid on non-POSIX hosts.
"""
if os.name == "posix":
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
return os.getpgid(process.pid)
return process.pid
def _kill_process_tree(process: asyncio.subprocess.Process) -> None:
"""
Send SIGKILL to a subprocess process group when possible.
:param process: Subprocess handle to kill.
:returns: None.
"""
_proc.kill_tree(process)