Files
小东 cb6b8ed6e1 feat: harden company resume, external sessions, and Office UI i18n
Improve suspend/resume identity continuity and external broker session handling, add Office UI internationalization with rebuilt assets, and expand company org configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 09:20:40 +08:00

1023 lines
40 KiB
Python

"""Codex CLI adapter."""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import shutil
import uuid
from typing import Any
from loguru import logger
from opc.core.models import AgentStatus, ApprovalDecision, Task, TaskResult, TaskStatus
from opc.layer3_agent.adapters.base import (
ExternalAgentAdapter,
ExternalAgentStdinPolicy,
ExternalApprovalRequest,
)
class CodexAdapter(ExternalAgentAdapter):
"""Invokes the OpenAI Codex CLI."""
agent_type = "codex"
default_command = "codex"
_COMMAND_OUTPUT_LIMIT = 2000
_MIRRORED_USER_CONFIG_FILES = ("auth.json", "config.toml")
_PARENT_CODEX_RUNTIME_ENV_VARS = {
"CODEX_INTERNAL_ORIGINATOR_OVERRIDE",
"CODEX_SANDBOX_NETWORK_DISABLED",
"CODEX_THREAD_ID",
}
_RUNTIME_SHELL_ENV_KEYS = {"PYTHONPATH"}
_RUNTIME_SHELL_ENV_PREFIXES = ("OPC_",)
_PROMPT_SENTINEL = "-"
_TTY_EOF = b"\x04"
_INTERACTIVE_ARGV_PROMPT_MAX_BYTES = 16 * 1024
def __init__(self, config=None) -> None:
super().__init__(config=config)
self._process: asyncio.subprocess.Process | None = None
self._input_fds: dict[int, int] = {}
async def is_available(self) -> bool:
return self.resolve_binary() is not None
async def get_status(self) -> AgentStatus:
if self._process and self._process.returncode is None:
return AgentStatus.RUNNING
return AgentStatus.IDLE
def supports_interactive(self) -> bool:
return True
def supports_session_resume(self) -> bool:
return True
def agent_isolation_home_slug(self) -> str:
# Point spawned codex at ``<opc_home>/agent_homes/codex/``. The
# broker installs the ``opc-collab`` skill under
# ``skills/opc-collab/`` there; codex exec's native skill
# discovery picks it up. The user's personal ``~/.codex/`` is
# not inherited, so codex invoked directly by the user stays
# separated from the OpenOPC collaboration surface.
return "codex"
def agent_home_env_vars(self, home: str) -> dict[str, str]:
return {"CODEX_HOME": home}
def post_install_agent_home(self, home: str) -> None:
# Mirror the user's key Codex config files into the isolated
# CODEX_HOME so the spawned process uses the same login and model
# provider settings as the CLI the user runs directly. Prefer
# symlinks so rotations are tracked; fall back to copying on
# Windows/filesystems where symlink creation is blocked.
from pathlib import Path
user_home = Path.home() / ".codex"
target_home = Path(home)
target_home.mkdir(parents=True, exist_ok=True)
for file_name in self._MIRRORED_USER_CONFIG_FILES:
self._mirror_user_config_file(user_home / file_name, target_home / file_name)
@staticmethod
def _mirror_user_config_file(source: Path, target: Path) -> None:
if not source.exists() or not source.is_file():
return
target.parent.mkdir(parents=True, exist_ok=True)
try:
if target.is_symlink() or target.exists():
if target.is_symlink() and target.resolve() == source.resolve():
return
target.unlink()
target.symlink_to(source)
except (OSError, NotImplementedError):
try:
if not target.exists() or target.read_bytes() != source.read_bytes():
target.write_bytes(source.read_bytes())
except OSError as exc:
logger.warning(
"Unable to mirror Codex config file {} into isolated home: {}",
source.name,
exc,
)
def build_process_env(self, extra_env: dict[str, str] | None = None) -> dict[str, str] | None:
env = {
str(key): str(value)
for key, value in os.environ.items()
if not self._is_parent_codex_runtime_env(str(key))
}
if extra_env:
env.update({str(k): str(v) for k, v in extra_env.items()})
return env
@classmethod
def _is_parent_codex_runtime_env(cls, key: str) -> bool:
normalized = key.upper()
return normalized in cls._PARENT_CODEX_RUNTIME_ENV_VARS
@classmethod
def _runtime_shell_environment_config_args(
cls,
extra_env: dict[str, str] | None,
) -> tuple[list[str], list[str]]:
"""Forward only OpenOPC's runtime contract into Codex shell tools.
Codex can intentionally run with ``shell_environment_policy.inherit=core``.
The broker process still receives ``OPC_*`` variables, but commands launched
by Codex do not unless they are added to the policy's ``set`` table.
"""
forwarded: dict[str, str] = {}
for raw_key, raw_value in dict(extra_env or {}).items():
key = str(raw_key).strip()
if not key:
continue
if key not in cls._RUNTIME_SHELL_ENV_KEYS and not key.startswith(
cls._RUNTIME_SHELL_ENV_PREFIXES
):
continue
value = str(raw_value)
if key == "OPC_ALLOWED_COLLAB_TOOLS":
try:
parsed = json.loads(value)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, list):
value = ",".join(
str(item).strip() for item in parsed if str(item).strip()
)
forwarded[key] = value
args: list[str] = []
for key in sorted(forwarded):
value = forwarded[key]
if "'''" in value:
encoded = json.dumps(value, ensure_ascii=False)
else:
encoded = f"'''{value}'''"
args.extend(["-c", f"shell_environment_policy.set.{key}={encoded}"])
return args, sorted(forwarded)
@classmethod
def _inject_runtime_shell_environment(
cls,
cmd: list[str],
extra_env: dict[str, str] | None,
launch_metadata: dict[str, Any] | None = None,
) -> list[str]:
config_args, forwarded_keys = cls._runtime_shell_environment_config_args(extra_env)
if not config_args or not cmd:
return list(cmd)
insert_at = 2 if len(cmd) > 1 and str(cmd[1]).strip() == "exec" else 1
launch_cmd = [*cmd[:insert_at], *config_args, *cmd[insert_at:]]
if isinstance(launch_metadata, dict):
launch_metadata["runtime_shell_environment_keys"] = forwarded_keys
return launch_cmd
def build_workspace_args(self, workspace_path: str | None = None) -> list[str]:
args: list[str] = []
if workspace_path:
args.extend(["-C", workspace_path, "--add-dir", workspace_path])
return args
def _extra_writable_roots(self, task: Task | None) -> list[str]:
# Surface to Codex's workspace-write sandbox every path outside
# the main workspace that OpenOPC expects the agent (or a shell
# it spawns) to be able to write to:
#
# * ``comms_workspace_root`` — sibling of the deliverable
# folder; prompts tell the agent to write into
# ``.opc-comms/...`` there.
# * ``.opc/memory`` — durable global/project Markdown memory.
# * the OPC home directory — the ``opc-collab`` CLI (shelled
# out from inside the sandbox) writes to ``<opc_home>/
# projects/<project>/tasks.db``. Without this entry, SQLite
# returns SQLITE_READONLY because Codex mounts the DB path
# read-only. (This manifested as "runtime database opened
# as read-only" failures on every collab tool call.)
if task is None:
return []
roots: list[str] = []
seen: set[str] = set()
def _add(path: str) -> None:
if not path:
return
normalized = str(path).strip()
if not normalized or normalized in seen:
return
seen.add(normalized)
roots.append(normalized)
workspace = str((task.metadata or {}).get("target_output_dir") or "").strip()
_add_except_workspace = lambda path: _add(path) if path and path != workspace else None # noqa: E731
comms_root = str((task.metadata or {}).get("comms_workspace_root") or "").strip()
_add_except_workspace(comms_root)
# Compute the OPC home lazily so failures here (e.g. a test
# environment without ``opc.core.config`` fully bootable) never
# take down the adapter — the agent will still launch, just
# without the DB writable path, and the broker will surface the
# SQLITE_READONLY error if it matters.
try:
from opc.core.config import get_opc_home
opc_home_path = get_opc_home()
memory_root = str(opc_home_path / "memory")
opc_home = str(opc_home_path)
except Exception:
memory_root = ""
opc_home = ""
_add_except_workspace(memory_root)
_add_except_workspace(opc_home)
return roots
def _build_extra_dir_args(self, task: Task | None) -> list[str]:
extra: list[str] = []
for root in self._extra_writable_roots(task):
extra.extend(["--add-dir", root])
return extra
def _build_writable_roots_config_args(self, task: Task | None) -> list[str]:
roots = self._extra_writable_roots(task)
if not roots:
return []
return [
"-c",
f"sandbox_workspace_write.writable_roots={json.dumps(roots, ensure_ascii=False)}",
]
def _build_stdin_prompt_metadata(self, task: Task) -> dict[str, object]:
prompt = self.build_task_prompt(task)
return {
"prompt_transport": "stdin",
"prompt_bytes": len(prompt.encode("utf-8")),
}
def _build_argv_prompt_metadata(self, prompt: str) -> dict[str, object]:
return {
"prompt_transport": "argv",
"prompt_bytes": len(prompt.encode("utf-8")),
}
def _interactive_prompt_transport(self, prompt: str) -> str:
prompt_bytes = len(prompt.encode("utf-8"))
if prompt_bytes <= self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES and self._windows_multiline_argv_is_unsafe(prompt):
return "stdin"
return "argv" if prompt_bytes <= self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES else "stdin"
def _windows_multiline_argv_is_unsafe(self, prompt: str) -> bool:
if os.name != "nt":
return False
if "\n" not in prompt and "\r" not in prompt:
return False
command = self.configured_command()
resolved = shutil.which(command) or command
suffix = os.path.splitext(str(resolved or ""))[1].lower()
return suffix in {"", ".cmd", ".bat", ".ps1", ".com", ".exe"}
@staticmethod
def _redact_prompt_arg(cmd: list[str], prompt: str) -> list[str]:
redacted = list(cmd)
if redacted:
redacted[-1] = f"<prompt:{len(prompt.encode('utf-8'))}-bytes>"
return redacted
def _prompt_text_from_task(self, task: Task | None) -> str:
if task is None:
return ""
return self.build_task_prompt(task)
@classmethod
def _tty_prompt_payload(cls, prompt: str) -> bytes:
payload = prompt.encode("utf-8")
if payload and not payload.endswith(b"\n"):
payload += b"\n"
return payload + cls._TTY_EOF
async def _seed_tty_prompt(self, proc: asyncio.subprocess.Process, prompt: str) -> None:
input_fd = self._input_fds.get(proc.pid)
if input_fd is None:
return
await asyncio.to_thread(
self._write_input_bytes,
input_fd,
self._tty_prompt_payload(prompt),
)
@staticmethod
async def _seed_pipe_prompt(
proc: asyncio.subprocess.Process,
prompt: str,
) -> bool:
"""Write *prompt* to the subprocess stdin pipe and close it.
Returns ``True`` when the full payload was delivered, ``False``
if the pipe broke before delivery completed (the child may have
exited early or rejected the data).
"""
writer = getattr(proc, "stdin", None)
if writer is None:
return False
payload = prompt.encode("utf-8")
delivered = True
try:
writer.write(payload)
await writer.drain()
except (BrokenPipeError, ConnectionResetError, OSError) as exc:
logger.warning(
"Pipe prompt delivery failed ({} bytes planned): {}",
len(payload),
exc,
)
delivered = False
writer.close()
wait_closed = getattr(writer, "wait_closed", None)
if callable(wait_closed):
try:
await wait_closed()
except (BrokenPipeError, ConnectionResetError, OSError):
delivered = False
return delivered
def build_invocation(
self,
task: Task,
workspace_path: str | None = None,
) -> tuple[list[str], dict[str, object]]:
session_id = str(self.config.session_id or "").strip()
if str(self.config.session_mode or "").strip().lower() == "resume" and session_id:
cmd = [
self.configured_command(),
"exec",
"resume",
"--skip-git-repo-check",
*self._build_resume_approval_args(),
*self._build_resume_model_args(),
session_id,
self._PROMPT_SENTINEL,
]
else:
cmd = [
self.configured_command(),
"exec",
*self.build_workspace_args(workspace_path),
*self._build_extra_dir_args(task),
*self._build_writable_roots_config_args(task),
"--skip-git-repo-check",
*self._build_approval_args(),
*self.build_common_args(),
self._PROMPT_SENTINEL,
]
metadata = self.build_invocation_metadata(cmd)
metadata.update(self._build_stdin_prompt_metadata(task))
self._record_stdin_policy_metadata(
metadata,
self.stdin_policy_for_process(cmd, metadata),
)
if (
metadata.get("stdin_policy") == "pipe_open"
and self._uses_interactive_input_channel(cmd)
and self._supports_pty_input_channel()
):
metadata["interactive_input_channel"] = "pty"
return cmd, metadata
def build_interactive_invocation(
self,
task: Task,
workspace_path: str | None = None,
) -> tuple[list[str], dict[str, object]]:
session_id = str(self.config.session_id or "").strip()
prompt = self._prompt_text_from_task(task)
prompt_transport = self._interactive_prompt_transport(prompt)
prompt_arg = prompt if prompt_transport == "argv" else self._PROMPT_SENTINEL
if str(self.config.session_mode or "").strip().lower() == "resume" and session_id:
cmd = [
self.configured_command(),
"exec",
"resume",
"--skip-git-repo-check",
"--json",
*self._build_resume_approval_args(),
*self._build_resume_model_args(),
session_id,
prompt_arg,
]
else:
cmd = [
self.configured_command(),
"exec",
*self.build_workspace_args(workspace_path),
*self._build_extra_dir_args(task),
*self._build_writable_roots_config_args(task),
"--skip-git-repo-check",
"--json",
*self._build_approval_args(),
*self.build_common_args(),
prompt_arg,
]
# Small interactive prompts stay on argv because Codex CLI 0.130+
# can exit before a PTY-backed `exec --json -` prompt is seeded. Large
# prompts must not use argv: macOS/Linux command-line limits turn the
# final-delivery assessment into an `Argument list too long` failure.
if prompt_transport == "argv":
metadata = self.build_invocation_metadata(self._redact_prompt_arg(cmd, prompt))
metadata.update(self._build_argv_prompt_metadata(prompt))
else:
metadata = self.build_invocation_metadata(cmd)
metadata.update(self._build_stdin_prompt_metadata(task))
metadata["stdin_prompt_channel"] = "pipe"
metadata["prompt_transport_reason"] = (
"prompt_too_large_for_argv"
if len(prompt.encode("utf-8")) > self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES
else "windows_multiline_argv_unsafe"
)
metadata["interactive_input_limitation"] = (
"initial prompt is delivered through stdin; live approval replies "
"may be unavailable after stdin closes"
)
self._record_stdin_policy_metadata(
metadata,
self.stdin_policy_for_process(cmd, metadata),
)
return cmd, metadata
def extract_resume_session_id(self, output: str) -> str:
for line in output.splitlines():
event = self._parse_runtime_event(line)
if not event:
continue
if str(event.get("type") or "").strip() != "thread.started":
continue
thread_id = str(event.get("thread_id") or "").strip()
if thread_id:
return thread_id
return super().extract_resume_session_id(output)
@classmethod
def _parse_runtime_event(cls, text: str) -> dict[str, Any] | None:
envelope = cls._parse_json_line(text)
if not isinstance(envelope, dict):
return None
event = envelope.get("msg") if isinstance(envelope.get("msg"), dict) else envelope
if not isinstance(event, dict):
return None
event_type = str(event.get("type") or "").strip()
return event if event_type else None
@staticmethod
def _trim_text(text: str, *, limit: int) -> str:
stripped = str(text or "").strip()
if len(stripped) <= limit:
return stripped
return stripped[: limit - 1].rstrip() + "…"
@classmethod
def _command_summary(cls, item: dict[str, Any]) -> str:
command = cls.normalize_shell_command(item.get("command"))
if not command:
command = str(item.get("command") or "").strip()
if not command:
return "command execution"
return cls._trim_text(command.replace("\n", " "), limit=120)
@classmethod
def _command_detail(cls, item: dict[str, Any], *, include_output: bool) -> str:
parts: list[str] = []
command = cls.normalize_shell_command(item.get("command"))
if command:
parts.append(f"$ {command}")
raw_command = str(item.get("command") or "").strip()
if not command and raw_command:
parts.append(f"$ {raw_command}")
if include_output:
output = str(item.get("aggregated_output") or "").strip()
if output:
parts.append(cls._trim_text(output, limit=cls._COMMAND_OUTPUT_LIMIT))
status = str(item.get("status") or "").strip()
exit_code = item.get("exit_code")
status_bits: list[str] = []
if status:
status_bits.append(f"status={status}")
if exit_code is not None:
status_bits.append(f"exit_code={exit_code}")
if status_bits:
parts.append(", ".join(status_bits))
return "\n\n".join(part for part in parts if part)
@classmethod
def normalize_transcript_text(cls, output: str) -> str:
last_completed_agent_message = ""
last_agent_message = ""
for line in output.splitlines():
event = cls._parse_runtime_event(line)
if not event:
continue
if str(event.get("type") or "").strip() not in {"item.started", "item.completed"}:
continue
item = event.get("item") if isinstance(event.get("item"), dict) else None
if not isinstance(item, dict) or str(item.get("type") or "").strip() != "agent_message":
continue
text = str(item.get("text") or "").strip()
if not text:
continue
last_agent_message = text
if str(event.get("type") or "").strip() == "item.completed":
last_completed_agent_message = text
return last_completed_agent_message or last_agent_message or output
def normalize_result_output(self, output: str) -> str:
return self.normalize_transcript_text(output)
def format_progress_update(self, text: str, stream_name: str) -> str | None:
event = self._parse_runtime_event(text)
if not event:
return super().format_progress_update(text, stream_name)
event_type = str(event.get("type") or "").strip()
if event_type in {"exec_approval_request", "apply_patch_approval_request"}:
return None
if event_type not in {"item.started", "item.completed"}:
return None
item = event.get("item") if isinstance(event.get("item"), dict) else None
if not isinstance(item, dict):
return None
item_type = str(item.get("type") or "").strip()
if item_type == "agent_message" and event_type == "item.completed":
message = self._trim_text(str(item.get("text") or ""), limit=2400)
if not message:
return None
return f"[External:{self.agent_type}:thinking] {message}"
if item_type == "command_execution":
detail = self._command_detail(item, include_output=event_type == "item.completed")
if not detail:
detail = self._command_summary(item)
return f"[External:{self.agent_type}:tool] {detail}"
return None
def detect_runtime_failure(
self,
text: str,
stream_name: str,
metadata: dict[str, Any] | None = None,
) -> str | None:
_ = stream_name
if "Reading additional input from stdin..." not in str(text):
return None
policy = str((metadata or {}).get("stdin_policy") or "").strip()
prompt_transport = str((metadata or {}).get("prompt_transport") or "").strip().lower()
if prompt_transport == "stdin" or policy == "pipe_prompt_then_close":
return None
return (
"Codex entered supplemental stdin intake mode (`Reading additional input from stdin...`) "
"instead of executing the delegated task prompt."
)
def _uses_interactive_input_channel(self, cmd: list[str]) -> bool:
return "--json" in cmd
def stdin_policy_for_process(
self,
cmd: list[str],
metadata: dict[str, Any] | None = None,
) -> ExternalAgentStdinPolicy:
prompt_transport = str((metadata or {}).get("prompt_transport") or "").strip().lower()
if prompt_transport == "stdin" or (cmd and str(cmd[-1]).strip() == self._PROMPT_SENTINEL):
return "pipe_prompt_then_close"
if self._uses_interactive_input_channel(cmd):
return "pipe_open" if self._supports_pty_input_channel() else "inherit"
return super().stdin_policy_for_process(cmd, metadata)
@staticmethod
def _supports_pty_input_channel() -> bool:
return callable(getattr(os, "openpty", None))
def _build_resume_model_args(self) -> list[str]:
if not self.config.model:
return []
return [self.config.model_flag or "--model", self.config.model]
async def start_process(
self,
cmd: list[str],
workspace_path: str,
extra_env: dict[str, str] | None = None,
task: Task | None = None,
launch_metadata: dict[str, Any] | None = None,
) -> asyncio.subprocess.Process:
prompt_transport = str((launch_metadata or {}).get("prompt_transport") or "").strip().lower()
prompt = self._prompt_text_from_task(task) if prompt_transport == "stdin" else ""
env = self.build_process_env(extra_env)
runtime_cmd = self._inject_runtime_shell_environment(
cmd,
extra_env,
launch_metadata=launch_metadata,
)
launch_cmd = self._resolve_launch_command(
runtime_cmd,
extra_env=extra_env,
launch_metadata=launch_metadata,
)
stdin_prompt_channel = str((launch_metadata or {}).get("stdin_prompt_channel") or "").strip().lower()
stdin_policy = self.stdin_policy_for_process(launch_cmd, launch_metadata)
if not self._uses_interactive_input_channel(launch_cmd):
proc = await super().start_process(
launch_cmd,
workspace_path,
extra_env=extra_env,
task=task,
launch_metadata=launch_metadata,
)
if prompt_transport == "stdin":
delivered = await self._seed_pipe_prompt(proc, prompt)
if not delivered:
logger.warning(
"Stdin prompt delivery to {} (pid={}) may be incomplete; "
"the process might fail with an encoding error",
self.agent_type,
proc.pid,
)
return proc
if prompt_transport == "stdin" and stdin_prompt_channel == "pipe":
if isinstance(launch_metadata, dict):
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
proc = await super().start_process(
launch_cmd,
workspace_path,
extra_env=extra_env,
task=task,
launch_metadata=launch_metadata,
)
delivered = await self._seed_pipe_prompt(proc, prompt)
if not delivered:
if isinstance(launch_metadata, dict):
launch_metadata["prompt_delivery_failed"] = True
logger.warning(
"Large stdin prompt delivery to {} (pid={}) may be incomplete",
self.agent_type,
proc.pid,
)
return proc
if not self._supports_pty_input_channel():
if prompt_transport == "stdin":
if isinstance(launch_metadata, dict):
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
launch_metadata["interactive_input_limitation"] = (
"initial stdin prompt is delivered through a pipe on PTY-less platforms; "
"live approval replies require a PTY-capable platform"
)
logger.info(
"PTY-backed Codex input is unavailable on this platform; using stdin prompt "
"pipe delivery. Live approval replies require a PTY-capable platform."
)
proc = await super().start_process(
launch_cmd,
workspace_path,
extra_env=extra_env,
task=task,
launch_metadata=launch_metadata,
)
delivered = await self._seed_pipe_prompt(proc, prompt)
if not delivered:
logger.warning(
"Stdin prompt delivery to {} (pid={}) may be incomplete; "
"the process might fail with an encoding error",
self.agent_type,
proc.pid,
)
return proc
if isinstance(launch_metadata, dict):
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
launch_metadata["interactive_input_limitation"] = (
"stdin is inherited for argv prompt delivery on PTY-less platforms; "
"live approval replies require a PTY-capable platform"
)
logger.info(
"PTY-backed Codex input is unavailable on this platform; using argv prompt "
"delivery with inherited stdin. Live approval replies require a PTY-capable platform."
)
proc = await asyncio.create_subprocess_exec(
*launch_cmd,
stdin=None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace_path,
env=env,
**self._subprocess_group_kwargs(),
)
return proc
if isinstance(launch_metadata, dict):
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
launch_metadata["interactive_input_channel"] = "pty"
master_fd, slave_fd = os.openpty()
try:
proc = await asyncio.create_subprocess_exec(
*launch_cmd,
stdin=slave_fd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace_path,
env=env,
**self._subprocess_group_kwargs(),
)
except Exception:
os.close(master_fd)
os.close(slave_fd)
raise
finally:
with contextlib.suppress(OSError):
os.close(slave_fd)
self._input_fds[proc.pid] = master_fd
if prompt_transport == "stdin":
try:
await self._seed_tty_prompt(proc, prompt)
except OSError:
self._input_fds.pop(proc.pid, None)
with contextlib.suppress(OSError):
os.close(master_fd)
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(Exception):
await proc.wait()
raise
return proc
async def send_process_input(
self,
proc: asyncio.subprocess.Process,
text: str,
) -> bool:
if not text:
return True
input_fd = self._input_fds.get(proc.pid)
if input_fd is None:
return await super().send_process_input(proc, text)
payload = text.encode("utf-8")
try:
await asyncio.to_thread(self._write_input_bytes, input_fd, payload)
except OSError:
return False
return True
async def cleanup_process(self, proc: asyncio.subprocess.Process) -> None:
input_fd = self._input_fds.pop(proc.pid, None)
if input_fd is not None:
with contextlib.suppress(OSError):
os.close(input_fd)
await super().cleanup_process(proc)
@staticmethod
def _write_input_bytes(input_fd: int, payload: bytes) -> None:
written = 0
while written < len(payload):
written += os.write(input_fd, payload[written:])
def parse_approval_request(
self,
text: str,
stream_name: str,
) -> ExternalApprovalRequest | None:
envelope = self._parse_json_line(text)
if not isinstance(envelope, dict):
return None
event = envelope.get("msg") if isinstance(envelope.get("msg"), dict) else envelope
if not isinstance(event, dict):
return None
event_type = str(event.get("type") or "").strip()
common_metadata = {
"stream": stream_name,
"provider_event_type": event_type,
"approval_id": str(event.get("call_id") or event.get("id") or ""),
"turn_id": str(event.get("turn_id") or ""),
"raw_event": event,
}
if event_type == "exec_approval_request":
command = self.normalize_shell_command(event.get("command"))
arguments: dict[str, object] = {}
if command:
arguments["command"] = command
cwd = str(event.get("cwd") or "").strip()
if cwd:
arguments["working_directory"] = cwd
prompt_text = str(event.get("reason") or "").strip()
if not prompt_text and command:
prompt_text = f"Allow Codex to run `{command}`?"
metadata = {
**common_metadata,
"cwd": cwd,
"command": command,
"network_approval_context": event.get("network_approval_context"),
"additional_permissions": event.get("additional_permissions"),
"proposed_execpolicy_amendment": event.get("proposed_execpolicy_amendment"),
"proposed_network_policy_amendments": event.get("proposed_network_policy_amendments"),
"available_decisions": event.get("available_decisions"),
}
return ExternalApprovalRequest(
approval_scope="tool",
action_name="shell_exec",
prompt_text=prompt_text,
arguments=arguments,
metadata=metadata,
raw_text=text,
)
if event_type == "apply_patch_approval_request":
changes = event.get("changes")
paths = sorted(str(path) for path in changes.keys()) if isinstance(changes, dict) else []
grant_root = str(event.get("grant_root") or "").strip()
arguments: dict[str, object] = {}
if grant_root:
arguments["path"] = grant_root
elif len(paths) == 1:
arguments["path"] = paths[0]
elif paths:
arguments["target"] = paths[0]
prompt_text = str(event.get("reason") or "").strip()
if not prompt_text:
prompt_text = "Allow Codex to apply file changes?"
metadata = {
**common_metadata,
"grant_root": grant_root,
"paths": paths,
"available_decisions": event.get("available_decisions"),
}
return ExternalApprovalRequest(
approval_scope="tool",
action_name="file_edit",
prompt_text=prompt_text,
arguments=arguments,
metadata=metadata,
raw_text=text,
)
# Codex interactive runs are launched with `--json`, so real approval
# prompts arrive as structured approval_request events. Falling back to
# the generic parser here causes ordinary command logs or sandbox error
# text to be misclassified as approval prompts.
return None
def format_approval_response(
self,
request: ExternalApprovalRequest,
approved: bool,
decision: ApprovalDecision,
) -> str:
event_type = str(request.metadata.get("provider_event_type") or "").strip()
if event_type not in {"exec_approval_request", "apply_patch_approval_request"}:
return super().format_approval_response(request, approved, decision)
approval_id = str(request.metadata.get("approval_id") or "").strip()
if not approval_id:
return super().format_approval_response(request, approved, decision)
op = {
"type": "exec_approval" if event_type == "exec_approval_request" else "patch_approval",
"id": approval_id,
"decision": self._review_decision_payload(request, approved, decision),
}
turn_id = str(request.metadata.get("turn_id") or "").strip()
if event_type == "exec_approval_request" and turn_id:
op["turn_id"] = turn_id
return json.dumps({"id": str(uuid.uuid4()), "op": op}, ensure_ascii=False) + "\n"
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
if not await self.is_available():
return TaskResult(status=TaskStatus.FAILED, content="Codex CLI not found")
cmd, metadata = self.build_invocation(task, workspace_path=workspace_path)
logger.info(f"Codex executing: {task.title}")
try:
self._process = await self.start_process(
cmd,
workspace_path,
task=task,
launch_metadata=metadata,
)
stdout, stderr = await asyncio.wait_for(
self._process.communicate(), timeout=600
)
output = stdout.decode("utf-8", errors="replace")
errors = stderr.decode("utf-8", errors="replace")
if self._process.returncode == 0:
return TaskResult(status=TaskStatus.DONE, content=output, artifacts=metadata)
else:
return TaskResult(
status=TaskStatus.FAILED,
content=f"Codex exited with code {self._process.returncode}\n{errors}\n{output}",
artifacts=metadata,
)
except asyncio.TimeoutError:
if self._process:
self._process.kill()
return TaskResult(status=TaskStatus.FAILED, content="Codex timed out", artifacts=metadata)
except Exception as e:
return TaskResult(status=TaskStatus.FAILED, content=f"Codex error: {e}", artifacts=metadata)
finally:
self._process = None
async def cancel(self, task_id: str) -> bool:
if self._process and self._process.returncode is None:
self._process.kill()
return True
return False
def _build_approval_args(self) -> list[str]:
common_args = self.build_common_args()
# Skip injection if the user has manually configured sandbox / approval
# flags via `extra_args` so we don't trample explicit overrides.
conflict_markers = {
"--dangerously-bypass-approvals-and-sandbox",
"--full-auto",
"-s",
"--sandbox",
}
for arg in common_args:
if arg in conflict_markers:
return []
if arg.startswith("--sandbox="):
return []
mode = str(self.config.approval_mode or "auto").strip().lower()
if mode == "user-settings":
return []
if mode == "full-auto":
return ["--dangerously-bypass-approvals-and-sandbox"]
return ["--sandbox", "danger-full-access"]
def _build_resume_approval_args(self) -> list[str]:
common_args = self.build_common_args()
if any(arg in {"--dangerously-bypass-approvals-and-sandbox", "--full-auto"} for arg in common_args):
return []
mode = str(self.config.approval_mode or "auto").strip().lower()
if mode == "user-settings":
return []
if mode == "full-auto":
return ["--dangerously-bypass-approvals-and-sandbox"]
# `codex exec resume` does not accept the `--sandbox` flag that plain
# `codex exec` supports, but it does accept config overrides.
return ["-c", 'sandbox_mode="danger-full-access"']
def _review_decision_payload(
self,
request: ExternalApprovalRequest,
approved: bool,
decision: ApprovalDecision,
) -> str | dict[str, object]:
if not approved:
return "denied"
human_reply = str((decision.metadata or {}).get("human_reply") or "").strip().lower()
if human_reply not in {"always_project", "always_global"}:
return "approved"
event_type = str(request.metadata.get("provider_event_type") or "").strip()
if event_type == "exec_approval_request":
proposed_execpolicy = request.metadata.get("proposed_execpolicy_amendment")
if isinstance(proposed_execpolicy, dict):
return {
"approved_execpolicy_amendment": {
"proposed_execpolicy_amendment": proposed_execpolicy,
}
}
amendments = request.metadata.get("proposed_network_policy_amendments")
if isinstance(amendments, list):
for amendment in amendments:
if isinstance(amendment, dict) and str(amendment.get("action") or "").lower() == "allow":
return {"network_policy_amendment": {"network_policy_amendment": amendment}}
if request.metadata.get("network_approval_context") or request.metadata.get("additional_permissions"):
return "approved_for_session"
if event_type == "apply_patch_approval_request" and request.metadata.get("grant_root"):
return "approved_for_session"
return "approved"