"""Codex app-server JSON-RPC client. Newline-delimited JSON-RPC 2.0 over stdio to ``codex app-server`` (codex 0.125+): ``initialize`` handshake, then ``thread/start`` + ``turn/start`` with streaming ``item/*`` notifications until ``turn/completed``. Wire-level speaker only — projection, approvals and transcript handling live in sibling modules. Opt-in runtime gated behind ``model.openai_runtime == "codex_app_server"``. """ from __future__ import annotations import json import os import queue import re import subprocess import threading import time from dataclasses import dataclass, field from typing import Any, Optional from tools.environments.local import hermes_subprocess_env MIN_CODEX_VERSION = (0, 125, 0) @dataclass class CodexAppServerError(RuntimeError): """Raised on JSON-RPC errors from the app-server.""" code: int message: str data: Optional[Any] = None def __str__(self) -> str: # pragma: no cover - trivial return f"codex app-server error {self.code}: {self.message}" @dataclass class _Pending: queue: queue.Queue method: str sent_at: float = field(default_factory=time.time) class CodexAppServerClient: """Minimal synchronous JSON-RPC 2.0 client for ``codex app-server`` over stdio. The caller drives request/response pairs; one reader thread routes replies to pending queues and notifications / server requests to bounded queues; another captures stderr. Intentionally NOT async: AIAgent.run_conversation() is synchronous and cancellation goes through ``turn/interrupt``. """ def __init__( self, codex_bin: str = "codex", codex_home: Optional[str] = None, extra_args: Optional[list[str]] = None, env: Optional[dict[str, str]] = None, ) -> None: self._codex_bin = codex_bin # codex needs LLM provider creds (inherit_credentials=True) but must not # receive Tier-1 Hermes secrets (gateway/GitHub/infra tokens) — #29157. spawn_env = hermes_subprocess_env(inherit_credentials=True) if env: spawn_env.update(env) if codex_home: spawn_env["CODEX_HOME"] = codex_home app_server_args = list(extra_args or []) # Kanban workers must write handoff/status to the board DB outside the # workspace: keep the sandbox on, add the Kanban root as writable. if spawn_env.get("HERMES_KANBAN_TASK"): kanban_db = spawn_env.get("HERMES_KANBAN_DB") default_root = os.path.join(spawn_env.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "kanban") kanban_root = os.path.dirname(kanban_db) if kanban_db else spawn_env.get("HERMES_KANBAN_ROOT", default_root) app_server_args.extend([ "-c", 'sandbox_mode="workspace-write"', "-c", f'sandbox_workspace_write.writable_roots=["{kanban_root}"]', "-c", "sandbox_workspace_write.network_access=false", ]) cmd = [codex_bin, "app-server"] + app_server_args # Codex emits tracing to stderr; default WARN keeps it quiet for users. spawn_env.setdefault("RUST_LOG", "warn") # Hide the console flash on Windows (#56747); stdio pipes stay intact. from hermes_cli._subprocess_compat import windows_hide_flags self._proc = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0, env=spawn_env, creationflags=windows_hide_flags(), ) self._next_id = 1 self._pending: dict[int, _Pending] = {} self._pending_lock = threading.Lock() self._notifications: queue.Queue = queue.Queue() self._server_requests: queue.Queue = queue.Queue() self._stderr_lines: list[str] = [] self._stderr_lock = threading.Lock() self._closed = False self._initialized = False self._reader = threading.Thread(target=self._read_stdout, daemon=True) self._stderr_reader = threading.Thread(target=self._read_stderr, daemon=True) self._reader.start() self._stderr_reader.start() def initialize( self, client_name: str = "hermes", client_title: str = "Hermes Agent", client_version: str = "0.1", capabilities: Optional[dict] = None, timeout: float = 10.0, ) -> dict: """Send ``initialize`` + ``initialized``; return the server's InitializeResponse.""" if self._initialized: raise RuntimeError("already initialized") params = { "clientInfo": {"name": client_name, "title": client_title, "version": client_version}, "capabilities": capabilities or {}, } result = self.request("initialize", params, timeout=timeout) self.notify("initialized") self._initialized = True return result def close(self, timeout: float = 3.0) -> None: """Close stdin and wait for the subprocess to exit, escalating to kill.""" if self._closed: return self._closed = True try: if self._proc.stdin and not self._proc.stdin.closed: self._proc.stdin.close() except Exception: pass try: self._proc.terminate() self._proc.wait(timeout=timeout) except subprocess.TimeoutExpired: try: self._proc.kill() self._proc.wait(timeout=1.0) except Exception: pass def __enter__(self) -> "CodexAppServerClient": return self def __exit__(self, *exc: Any) -> None: self.close() def request(self, method: str, params: Optional[dict] = None, timeout: float = 30.0) -> dict: """Send a request and block for ``result``; raise CodexAppServerError on ``error``.""" rid = self._take_id() q: queue.Queue = queue.Queue(maxsize=1) with self._pending_lock: self._pending[rid] = _Pending(queue=q, method=method) self._send({"id": rid, "method": method, "params": params or {}}) try: msg = q.get(timeout=timeout) except queue.Empty: with self._pending_lock: self._pending.pop(rid, None) raise TimeoutError(f"codex app-server method {method!r} timed out after {timeout}s") if "error" in msg: err = msg["error"] raise CodexAppServerError(code=err.get("code", -1), message=err.get("message", ""), data=err.get("data")) return msg.get("result", {}) def notify(self, method: str, params: Optional[dict] = None) -> None: """Send a JSON-RPC notification (no id, no response expected).""" self._send({"method": method, "params": params or {}}) def respond(self, request_id: Any, result: dict) -> None: """Reply to a server-initiated request (e.g. approval prompts).""" self._send({"id": request_id, "result": result}) def respond_error(self, request_id: Any, code: int, message: str, data: Optional[Any] = None) -> None: """Reply to a server-initiated request with an error.""" err: dict[str, Any] = {"code": code, "message": message} if data is not None: err["data"] = data self._send({"id": request_id, "error": err}) @staticmethod def _take(q: queue.Queue, timeout: float) -> Optional[dict]: try: if timeout <= 0: return q.get_nowait() return q.get(timeout=timeout) except queue.Empty: return None def take_notification(self, timeout: float = 0.0) -> Optional[dict]: """Pop the next streaming notification, or None on timeout (0 = non-blocking).""" return self._take(self._notifications, timeout) def take_server_request(self, timeout: float = 0.0) -> Optional[dict]: """Pop the next server-initiated request (e.g. exec/applyPatch approval).""" return self._take(self._server_requests, timeout) def stderr_tail(self, n: int = 20) -> list[str]: """Return last n lines of codex's stderr (for error reports).""" with self._stderr_lock: return list(self._stderr_lines[-n:]) def is_alive(self) -> bool: return self._proc.poll() is None def _take_id(self) -> int: rid = self._next_id self._next_id += 1 return rid def _send(self, obj: dict) -> None: if self._closed: raise RuntimeError("codex app-server client is closed") if self._proc.stdin is None: raise RuntimeError("codex app-server stdin not available") try: self._proc.stdin.write((json.dumps(obj) + "\n").encode("utf-8")) self._proc.stdin.flush() except (BrokenPipeError, ValueError) as exc: raise RuntimeError(f"codex app-server stdin closed unexpectedly: {exc}") from exc def _append_stderr(self, line: str) -> None: with self._stderr_lock: self._stderr_lines.append(line) if len(self._stderr_lines) > 500: # bound memory self._stderr_lines = self._stderr_lines[-500:] def _read_stdout(self) -> None: if self._proc.stdout is None: return try: for line in iter(self._proc.stdout.readline, b""): line = line.strip() if not line: continue try: msg = json.loads(line) except json.JSONDecodeError: # Non-JSON stdout is unexpected; surface it via the stderr buffer. self._append_stderr(f" {line[:200]!r}") continue self._dispatch(msg) except Exception as exc: self._append_stderr(f" {exc}") def _dispatch(self, msg: dict) -> None: if "id" in msg and ("result" in msg or "error" in msg): # reply with self._pending_lock: pending = self._pending.pop(msg["id"], None) if pending is not None: try: pending.queue.put_nowait(msg) except queue.Full: # pragma: no cover - defensive pass elif "method" in msg: # server-initiated request (has id) or notification (self._server_requests if "id" in msg else self._notifications).put(msg) def _read_stderr(self) -> None: if self._proc.stderr is None: return try: for line in iter(self._proc.stderr.readline, b""): self._append_stderr(line.decode("utf-8", "replace").rstrip()) except Exception: # pragma: no cover pass def parse_codex_version(output: str) -> Optional[tuple[int, int, int]]: """Parse ``codex --version`` output ("codex-cli 0.130.0 ...") into (major, minor, patch).""" match = re.search(r"(\d+)\.(\d+)\.(\d+)", output or "") return tuple(int(g) for g in match.groups()) if match else None def check_codex_binary( codex_bin: str = "codex", min_version: tuple[int, int, int] = MIN_CODEX_VERSION ) -> tuple[bool, str]: """Verify codex CLI is installed and meets minimum version. Returns (ok, message).""" try: proc = subprocess.run( [codex_bin, "--version"], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=10, stdin=subprocess.DEVNULL, ) except FileNotFoundError: return False, f"codex CLI not found at {codex_bin!r}. Install with: npm i -g @openai/codex" except subprocess.TimeoutExpired: return False, "codex --version timed out" if proc.returncode != 0: return False, f"codex --version exited {proc.returncode}: {proc.stderr.strip()}" version = parse_codex_version(proc.stdout) if version is None: return False, f"could not parse codex version from: {proc.stdout!r}" have, need = ".".join(map(str, version)), ".".join(map(str, min_version)) if version < min_version: return False, f"codex {have} is older than required {need}. Run: npm i -g @openai/codex" return True, have