Files
Genarrative/rust/crates/agent-codex/src/lib.rs
T
kdletters 202279c6d9 新增独立 Agent Runtime Rust 工作区
新增 Core、Engine、Runtime、SQLite、Provider、MCP、Skill、Codex、CLI 与 DAG crate

补齐 OpenAI endpoint 配置、Provider 实例/协议路由和统一工具权限边界

加入持久化、lease、checkpoint、reconciliation、审批恢复与消息历史回归

加入独立 workspace CI、依赖边界、能力集和 Fake Agent 测试脚本

同步建设计划、TODO、架构、测试与验收文档
2026-09-06 17:44:54 +08:00

9144 lines
348 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Codex CLI/App Server 的外部适配器。
//!
//! 这个 crate 只负责进程/协议边界和中立事件映射,不嵌入 `codex-core`,也不
//! 持有 Host 的会话、权限或持久化真相。调用方应把返回的外部 ID、状态和
//! `side_effect_unknown` 交给自己的 Runtime/Store 记录。
use std::collections::{HashMap, VecDeque};
use std::fs::File;
use std::io::{self, BufRead, BufReader, Read, Write};
use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use agent_runtime_core::{
BackendRequest, BackendResult, ExternalBackend, ExternalError, ExternalErrorKind, Message,
RuntimeEvent, RuntimeEventKind, ToolCall, ToolResult,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use thiserror::Error;
/// 针对本地审计过的 Codex CLI `0.152.1` 的窄版本适配器。
///
/// 该模块只在调用方明确选择这个发行版时使用;`CodexAppServerProtocol::V2`
/// 仍然保留为不绑定发行版的中立客户端。
pub mod codex_0_152_1;
const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
const DEFAULT_JSON_RPC_FRAME_BYTES: usize = 1024 * 1024;
const DEFAULT_MAX_PENDING_NOTIFICATIONS: usize = 64;
const PROCESS_READER_CHANNEL_CHUNKS: usize = 32;
const PROCESS_READER_CHUNK_BYTES: usize = 8192;
const PROCESS_WRITER_CHANNEL_REQUESTS: usize = 8;
const PROCESS_EXIT_GRACE: Duration = Duration::from_millis(100);
const JSON_RPC_ROUTER_JOIN_GRACE: Duration = Duration::from_millis(100);
const JSON_RPC_VERSION: &str = "2.0";
/// 本适配器自己的中立 JSONL 版本。它不是对任何具体 Codex 发行版 wire
/// schema 的兼容声明;真实 Codex 版本应在更上层做转换。
pub const APP_SERVER_PROTOCOL_VERSION: u16 = 1;
fn default_app_server_protocol_version() -> u16 {
APP_SERVER_PROTOCOL_VERSION
}
#[derive(Debug, Error)]
pub enum CodexError {
#[error("Codex 配置无效: {0}")]
InvalidConfig(String),
#[error("Codex 进程启动失败")]
Spawn,
#[error("Codex 进程超时")]
Timeout,
#[error("Codex 进程被中断")]
Interrupted,
#[error("Codex 输出超过限制")]
OutputTooLarge,
#[error("Codex 输出不是有效 JSON")]
InvalidOutput,
#[error("Codex 进程退出码异常: {0}")]
Exit(i32),
#[error("Codex 进程被信号终止: {0}")]
Signal(i32),
#[error("Codex 进程适配器已终止")]
ProcessTerminated,
#[error("Codex 协议错误: {0}")]
Protocol(String),
}
impl CodexError {
/// 将尚未明确进入外部进程的错误映射成 Core 的外部错误类别。
///
/// 这个映射适合调用方在 dispatch 前做配置/启动检查。请求已经写入
/// app-server 后,应使用 [`Self::external_error_kind_after_dispatch`]
/// 或 [`Self::external_error_kind_for_cli_failure`],避免把协议/超时
/// 错误误当成可以安全重放的输入错误。
pub fn external_error_kind(&self) -> ExternalErrorKind {
match self {
Self::InvalidConfig(_) | Self::Protocol(_) => ExternalErrorKind::InvalidInput,
// InvalidOutput 只会在 child 已启动后产生(stdout 读取/解析阶段)。
// 即使进程以 0 退出,也无法证明外部动作没有发生;交给
// Runtime 的 reconciliation gate,禁止按普通输入错误重放。
Self::InvalidOutput => ExternalErrorKind::UnknownSideEffect,
Self::Timeout | Self::Interrupted => ExternalErrorKind::UnknownSideEffect,
// 进程已经启动后才发现输出过大,无法证明远端动作没有发生;
// 交给 Runtime 的 reconciliation gate,而不是当成可安全重试。
Self::OutputTooLarge | Self::Exit(_) | Self::Signal(_) | Self::ProcessTerminated => {
ExternalErrorKind::UnknownSideEffect
}
Self::Spawn => ExternalErrorKind::Unavailable,
}
}
/// Map an error observed after an App Server request has been dispatched.
///
/// `Protocol` is intentionally classified differently here than in the
/// generic/configuration mapping above. A malformed frame, an ID mismatch,
/// or a closed connection can happen after the remote process accepted the
/// request; treating that as `InvalidInput` would allow a caller to replay
/// an operation whose side effect is not known. Configuration validation is
/// still safe to report as invalid input, and a process that could not be
/// spawned remains unavailable.
pub fn external_error_kind_after_dispatch(&self) -> ExternalErrorKind {
match self {
Self::InvalidConfig(_) => ExternalErrorKind::InvalidInput,
Self::Spawn => ExternalErrorKind::Unavailable,
Self::Protocol(_)
| Self::Timeout
| Self::Interrupted
| Self::InvalidOutput
| Self::OutputTooLarge
| Self::Exit(_)
| Self::Signal(_)
| Self::ProcessTerminated => ExternalErrorKind::UnknownSideEffect,
}
}
/// Map an error returned by a real process operation after the backend has
/// reserved the client and entered its dispatch path.
///
/// Unlike the in-memory channel, a process-side server-request handler can
/// return `InvalidConfig` after the outbound frame was already written (for
/// example, a local approval policy may reject a server request). That
/// variant must not be treated as safe-to-retry input: the remote process
/// may already have performed the requested side effect. The process
/// backend therefore keeps every post-reservation error in the
/// reconciliation lane.
pub fn external_error_kind_after_process_dispatch(&self) -> ExternalErrorKind {
ExternalErrorKind::UnknownSideEffect
}
/// Map errors from the one-shot CLI supervisor. `invoke_cli_supervised`
/// performs its serialization/config checks before spawning; after spawn it
/// returns process/output/timeout variants that conservatively stay in the
/// unknown-side-effect lane. Protocol here denotes supervisor state (for
/// example a poisoned cancellation table), not user request syntax.
pub fn external_error_kind_for_cli_failure(&self) -> ExternalErrorKind {
match self {
Self::InvalidConfig(_) => ExternalErrorKind::InvalidInput,
Self::Spawn => ExternalErrorKind::Unavailable,
Self::Protocol(_)
| Self::Timeout
| Self::Interrupted
| Self::InvalidOutput
| Self::OutputTooLarge
| Self::Exit(_)
| Self::Signal(_)
| Self::ProcessTerminated => ExternalErrorKind::UnknownSideEffect,
}
}
}
/// Process-level lifecycle reason emitted by the stdio supervisor.
///
/// This is intentionally separate from [`CodexSessionLifecycleStatus`]: a
/// process may terminate while the remote turn result is still unknown.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CodexProcessLifecycleReason {
NaturalExit,
ReaderEof,
ReaderError,
ExplicitTerminate,
Cancel,
Timeout,
Drop,
}
impl CodexProcessLifecycleReason {
pub const fn as_str(self) -> &'static str {
match self {
Self::NaturalExit => "natural_exit",
Self::ReaderEof => "reader_eof",
Self::ReaderError => "reader_error",
Self::ExplicitTerminate => "explicit_terminate",
Self::Cancel => "cancel",
Self::Timeout => "timeout",
Self::Drop => "drop",
}
}
}
/// A one-shot observation emitted after the process has been reaped and the
/// reader/writer workers have been joined.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CodexProcessLifecycleEvent {
pub reason: CodexProcessLifecycleReason,
pub exit_code: Option<i32>,
}
/// Optional process-level observation sink. Implementations should persist or
/// forward the event quickly and must not synchronously re-enter the process.
pub trait CodexProcessLifecycleSink: Send + Sync {
fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError>;
}
/// Codex CLI 一次性节点的启动配置。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct CodexCliConfig {
pub program: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(
default = "default_timeout_ms",
alias = "timeout_ms",
deserialize_with = "deserialize_nonzero_timeout_ms"
)]
pub timeout_ms: u64,
#[serde(
default = "default_max_output_bytes",
alias = "max_output_bytes",
deserialize_with = "deserialize_nonzero_max_output_bytes"
)]
pub max_output_bytes: usize,
/// 允许的参数前缀;空集合表示只允许无参数启动,避免把任意 shell
/// 片段从上层配置直接传给 Codex。
#[serde(default, alias = "allowed_arg_prefixes")]
pub allowed_arg_prefixes: Vec<String>,
}
fn default_timeout_ms() -> u64 {
DEFAULT_TIMEOUT.as_millis() as u64
}
fn default_max_output_bytes() -> usize {
DEFAULT_MAX_OUTPUT_BYTES
}
/// 配置文件里的 `0` 不能被当成“尽快超时”或“关闭上限”。
///
/// 这里在 serde 边界先拒绝零值;运行时构造出的公开 struct 仍会在
/// [`CodexCliBackend::new`] / `invoke_cli` 的校验中再次拒绝,避免调用方
/// 通过字段直写绕过配置解析。
fn deserialize_nonzero_timeout_ms<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = u64::deserialize(deserializer)?;
if value == 0 {
return Err(serde::de::Error::custom("timeout_ms 必须大于 0"));
}
Ok(value)
}
fn deserialize_nonzero_max_output_bytes<'de, D>(deserializer: D) -> Result<usize, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = usize::deserialize(deserializer)?;
if value == 0 {
return Err(serde::de::Error::custom("max_output_bytes 必须大于 0"));
}
Ok(value)
}
impl CodexCliConfig {
pub fn try_new(program: impl Into<String>) -> Result<Self, CodexError> {
let program = program.into();
if program.trim().is_empty() || contains_control(&program) {
return Err(CodexError::InvalidConfig(
"program 不能为空或含控制字符".to_owned(),
));
}
Ok(Self {
program,
args: Vec::new(),
timeout_ms: default_timeout_ms(),
max_output_bytes: default_max_output_bytes(),
allowed_arg_prefixes: Vec::new(),
})
}
pub fn with_args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.args = args.into_iter().map(Into::into).collect();
self
}
pub fn with_allowed_arg_prefixes(
mut self,
prefixes: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.allowed_arg_prefixes = prefixes.into_iter().map(Into::into).collect();
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, CodexError> {
let millis = timeout.as_millis();
if millis == 0 {
return Err(CodexError::InvalidConfig("timeout 必须大于 0".to_owned()));
}
self.timeout_ms = u64::try_from(millis)
.map_err(|_| CodexError::InvalidConfig("timeout 超出 u64 毫秒范围".to_owned()))?;
Ok(self)
}
pub fn with_max_output_bytes(mut self, max: usize) -> Result<Self, CodexError> {
if max == 0 {
return Err(CodexError::InvalidConfig(
"max_output_bytes 必须大于 0".to_owned(),
));
}
self.max_output_bytes = max;
Ok(self)
}
fn validate_args(&self) -> Result<(), CodexError> {
validate_program_and_args(&self.program, &self.args, &self.allowed_arg_prefixes)?;
if self.timeout_ms == 0 {
return Err(CodexError::InvalidConfig("timeout 必须大于 0".to_owned()));
}
if self.max_output_bytes == 0 {
return Err(CodexError::InvalidConfig(
"max_output_bytes 必须大于 0".to_owned(),
));
}
Ok(())
}
fn timeout(&self) -> Duration {
// `validate_args` rejects zero before a process can be spawned. Do not
// normalize an invalid value here, so a missed validation cannot turn
// into an unbounded/surprising execution window.
Duration::from_millis(self.timeout_ms)
}
}
fn validate_program_and_args(
program: &str,
args: &[String],
allowed_arg_prefixes: &[String],
) -> Result<(), CodexError> {
if program.trim().is_empty() || contains_control(program) {
return Err(CodexError::InvalidConfig(
"Codex program 不能为空或含控制字符".to_owned(),
));
}
if allowed_arg_prefixes
.iter()
.any(|prefix| prefix.is_empty() || contains_control(prefix))
{
return Err(CodexError::InvalidConfig(
"Codex 参数白名单前缀不能为空或含控制字符".to_owned(),
));
}
for arg in args {
if contains_control(arg) {
return Err(CodexError::InvalidConfig("Codex 参数含控制字符".to_owned()));
}
if contains_sensitive_arg(arg) {
return Err(CodexError::InvalidConfig(
"禁止在 Codex argv 中传递密钥".to_owned(),
));
}
if !allowed_arg_prefixes
.iter()
.any(|prefix| arg.starts_with(prefix))
{
return Err(CodexError::InvalidConfig(format!(
"Codex 参数不在白名单中: {}",
redact_arg(arg)
)));
}
}
Ok(())
}
// argv 会被进程表和诊断工具直接暴露;只要参数形状明确表示凭据,就在
// 白名单检查前拒绝。这里按 flag 名称的 ASCII 形式匹配,避免把普通的
// `--tokenizer` 一类参数误判为密钥,也不尝试解析 shell 语法。
const SENSITIVE_ARG_KEYS: &[&str] = &[
"accesskey",
"accesstoken",
"apikey",
"apitoken",
"authorization",
"authtoken",
"bearertoken",
"clientsecret",
"cookie",
"credential",
"credentials",
"idtoken",
"oauth2token",
"oauthtoken",
"password",
"passwd",
"privatekey",
"refreshtoken",
"secret",
"secretkey",
"sessiontoken",
"token",
];
fn contains_sensitive_arg(arg: &str) -> bool {
let lower = arg.to_ascii_lowercase();
// 保留历史上拒绝的写法,并覆盖大小写变化;Bearer 值可能没有显式
// `Authorization:` 键,因此单独识别其值前缀。
if lower.contains("api_key")
|| lower.contains("api-key")
|| lower == "bearer"
|| lower.starts_with("bearer ")
|| lower.starts_with("bearer=")
{
return true;
}
if is_sensitive_arg_key(arg) {
return true;
}
// 常见 header 形式会把真实键放在 `--header=<value>` 的值中;只检查值
// 的键和 Bearer 前缀,不对任意普通文本做 token 子串匹配。
if let Some((_, value)) = split_arg_assignment(arg) {
if is_sensitive_arg_key(value) {
return true;
}
let value = value.trim_start().to_ascii_lowercase();
if value == "bearer" || value.starts_with("bearer ") || value.starts_with("bearer=") {
return true;
}
}
// 也覆盖没有 `=`/`:` 的空格分隔形式(例如 `--token secret`)。
arg.split_whitespace()
.next()
.is_some_and(is_sensitive_arg_key)
}
fn split_arg_assignment(value: &str) -> Option<(&str, &str)> {
value.split_once('=').or_else(|| value.split_once(':'))
}
fn is_sensitive_arg_key(value: &str) -> bool {
let key = split_arg_assignment(value)
.map_or(value, |(key, _)| key)
.split_whitespace()
.next()
.unwrap_or_default();
let normalized: String = key
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect();
SENSITIVE_ARG_KEYS.contains(&normalized.to_ascii_lowercase().as_str())
}
fn contains_control(value: &str) -> bool {
value.chars().any(char::is_control)
}
fn redact_arg(value: &str) -> String {
format!("<redacted {} chars>", value.chars().count())
}
/// 真实 App Server 子进程的显式启动配置。
///
/// `program` 与每一项 `args` 会逐项传给 [`Command`];这里不解析 shell 字符串,
/// 也不从环境变量拼接隐藏参数。`allowed_arg_prefixes` 保留与一次性 CLI 相同的
/// 最小白名单,调用方若确实要启动本地 fixture,可显式允许 `-c` 等参数。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct CodexAppServerProcessConfig {
pub program: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(
default = "default_timeout_ms",
alias = "timeout_ms",
deserialize_with = "deserialize_nonzero_timeout_ms"
)]
pub timeout_ms: u64,
#[serde(
default = "default_json_rpc_frame_bytes",
alias = "max_frame_bytes",
deserialize_with = "deserialize_nonzero_max_frame_bytes"
)]
pub max_frame_bytes: usize,
#[serde(default, alias = "allowed_arg_prefixes")]
pub allowed_arg_prefixes: Vec<String>,
}
fn default_json_rpc_frame_bytes() -> usize {
DEFAULT_JSON_RPC_FRAME_BYTES
}
fn deserialize_nonzero_max_frame_bytes<'de, D>(deserializer: D) -> Result<usize, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = usize::deserialize(deserializer)?;
if value == 0 {
return Err(serde::de::Error::custom("max_frame_bytes 必须大于 0"));
}
Ok(value)
}
impl CodexAppServerProcessConfig {
pub fn try_new(program: impl Into<String>) -> Result<Self, CodexError> {
let program = program.into();
validate_program_and_args(&program, &[], &[])?;
Ok(Self {
program,
args: Vec::new(),
timeout_ms: default_timeout_ms(),
max_frame_bytes: default_json_rpc_frame_bytes(),
allowed_arg_prefixes: Vec::new(),
})
}
pub fn with_args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.args = args.into_iter().map(Into::into).collect();
self
}
pub fn with_allowed_arg_prefixes(
mut self,
prefixes: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.allowed_arg_prefixes = prefixes.into_iter().map(Into::into).collect();
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, CodexError> {
let millis = timeout.as_millis();
if millis == 0 {
return Err(CodexError::InvalidConfig("timeout 必须大于 0".to_owned()));
}
self.timeout_ms = u64::try_from(millis)
.map_err(|_| CodexError::InvalidConfig("timeout 超出 u64 毫秒范围".to_owned()))?;
Ok(self)
}
pub fn with_max_frame_bytes(mut self, max: usize) -> Result<Self, CodexError> {
if max == 0 {
return Err(CodexError::InvalidConfig(
"max_frame_bytes 必须大于 0".to_owned(),
));
}
self.max_frame_bytes = max;
Ok(self)
}
fn validate(&self) -> Result<(), CodexError> {
validate_program_and_args(&self.program, &self.args, &self.allowed_arg_prefixes)?;
if self.max_frame_bytes == 0 {
return Err(CodexError::InvalidConfig(
"max_frame_bytes 必须大于 0".to_owned(),
));
}
if self.timeout_ms == 0 {
return Err(CodexError::InvalidConfig("timeout 必须大于 0".to_owned()));
}
Ok(())
}
fn timeout(&self) -> Duration {
// `validate` rejects zero before a child is spawned. Keep the raw
// value here instead of silently converting an invalid config to 1 ms.
Duration::from_millis(self.timeout_ms)
}
}
/// CLI 适配器返回的最小外部进程结果。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct CodexCliResult {
pub external_id: String,
pub status: CodexProcessStatus,
pub output: Value,
pub exit_code: i32,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum CodexProcessStatus {
Completed,
Failed,
TimedOut,
Interrupted,
}
static NEXT_PROCESS_ID: AtomicU64 = AtomicU64::new(1);
static NEXT_APP_SERVER_BACKEND_ID: AtomicU64 = AtomicU64::new(1);
static NEXT_BACKEND_NONCE: AtomicU64 = AtomicU64::new(1);
static NEXT_PROCESS_OPERATION_ID: AtomicU64 = AtomicU64::new(1);
/// 生成一个不会只依赖进程内计数器的 backend 实例 nonce。
///
/// 结果会进入持久化的 `external_id`:优先混入操作系统熵源,fallback
/// 同时包含时间戳、PID 和本进程序号。这样应用重启后不会因为计数器重新
/// 从 1 开始就复用上一轮的外部调用 ID;它不是认证凭据,也不要求可逆。
fn new_backend_nonce() -> String {
let sequence = NEXT_BACKEND_NONCE.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let pid = u128::from(std::process::id());
let mut entropy = [0_u8; 16];
let random = if let Ok(mut source) = File::open("/dev/urandom")
&& source.read_exact(&mut entropy).is_ok()
{
u128::from_le_bytes(entropy)
} else {
// 这条 fallback 只承担唯一性,不声称提供密码学随机性。
timestamp ^ pid.rotate_left(37) ^ u128::from(sequence).rotate_left(71)
};
format!("{random:032x}-{timestamp:x}-{pid:x}-{sequence:x}")
}
/// 以 stdin JSON、stdout JSON 的一次性 Codex CLI 后端。
#[derive(Clone, Debug)]
pub struct CodexCliBackend {
config: CodexCliConfig,
/// Shared by clones of one logical backend; the nonce prevents durable
/// external IDs from restarting at the same value after process restart.
instance_nonce: String,
/// request_id -> cancel flag。flag 放在共享表中而不是只保存在 invoke
/// 栈上,使宿主可以从另一个线程调用 `ExternalBackend::cancel`。
cancel_flags: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
}
impl CodexCliBackend {
pub fn new(config: CodexCliConfig) -> Result<Self, CodexError> {
config.validate_args()?;
Ok(Self {
config,
instance_nonce: new_backend_nonce(),
cancel_flags: Arc::new(Mutex::new(HashMap::new())),
})
}
pub fn config(&self) -> &CodexCliConfig {
&self.config
}
pub fn invoke_cli(&self, request: &BackendRequest) -> Result<CodexCliResult, CodexError> {
self.config.validate_args()?;
let request_id = request.request_id().to_owned();
let cancel_flag = Arc::new(AtomicBool::new(false));
{
let mut active = self
.cancel_flags
.lock()
.map_err(|_| CodexError::Protocol("Codex supervisor 状态锁已损坏".to_owned()))?;
if active.contains_key(&request_id) {
// 同一个 request_id 同时执行时无法安全判断 cancel 应该作用于
// 哪个 child;直接拒绝比误杀另一个请求更可控。
return Err(CodexError::InvalidConfig(format!(
"Codex request_id 已在执行: {}",
redact_arg(&request_id)
)));
}
active.insert(request_id.clone(), cancel_flag.clone());
}
let result = self.invoke_cli_supervised(request, &cancel_flag);
if let Ok(mut active) = self.cancel_flags.lock() {
active.remove(&request_id);
}
result
}
/// 请求一个正在运行的 CLI 进程尽快退出。未知 ID 视为幂等成功,便于
/// Host 在重试/恢复路径中重复发送取消;真正的 child 由 invoke 线程
/// 在下一个监督 tick 中 kill 并 wait。
pub fn cancel_cli(&self, request_id: &str) -> Result<(), CodexError> {
if request_id.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"Codex cancel request_id 不能为空".to_owned(),
));
}
let flag = self
.cancel_flags
.lock()
.map_err(|_| CodexError::Protocol("Codex supervisor 状态锁已损坏".to_owned()))?
.get(request_id)
.cloned();
if let Some(flag) = flag {
flag.store(true, Ordering::Release);
}
Ok(())
}
fn invoke_cli_supervised(
&self,
request: &BackendRequest,
cancel_flag: &AtomicBool,
) -> Result<CodexCliResult, CodexError> {
let external_id = format!(
"codex-cli-{}-{}",
self.instance_nonce,
NEXT_PROCESS_ID.fetch_add(1, Ordering::Relaxed)
);
// Encode before spawning so a local serialization failure remains a
// dispatch-free InvalidConfig rather than being confused with a child
// that may already have observed the request.
let payload = serde_json::to_vec(&request.payload())
.map_err(|_| CodexError::InvalidConfig("Codex 请求 JSON 编码失败".to_owned()))?;
let mut command = Command::new(&self.config.program);
command
.args(&self.config.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
configure_process_group(&mut command);
let mut child = command.spawn().map_err(|_| CodexError::Spawn)?;
if let Some(stdin) = child.stdin.as_mut()
&& stdin.write_all(&payload).is_err()
{
terminate_child(&mut child);
// The child was already started and may have consumed part of the
// request before the pipe failed; callers must reconcile instead
// of treating this as a safe, dispatch-free spawn failure.
return Err(CodexError::ProcessTerminated);
}
// 关闭 stdin,让一次性脚本知道请求已经结束。
drop(child.stdin.take());
// stdout 必须在进程运行期间持续排空;等 child 退出后再读取会在
// 大输出超过 pipe 缓冲区时形成互相等待。线程只保留有界字节数,
// 不改变外部进程的生命周期真相。
let stdout = match child.stdout.take() {
Some(stdout) => stdout,
None => {
terminate_child(&mut child);
return Err(CodexError::ProcessTerminated);
}
};
let max_output_bytes = self.config.max_output_bytes;
let reader = std::thread::spawn(move || {
let mut bytes = Vec::new();
let mut stdout = stdout;
let mut chunk = [0_u8; 8192];
loop {
let read = stdout
.read(&mut chunk)
.map_err(|_| CodexError::InvalidOutput)?;
if read == 0 {
break;
}
// 即使超过限制也继续排空 pipe,避免让外部进程卡在写入上。
if bytes.len() <= max_output_bytes {
let keep = read.min(max_output_bytes.saturating_add(1) - bytes.len());
bytes.extend_from_slice(&chunk[..keep]);
}
}
Ok(bytes)
});
let started = Instant::now();
loop {
if cancel_flag.load(Ordering::Acquire) {
terminate_child(&mut child);
let _ = reader.join();
return Err(CodexError::Interrupted);
}
let status = match child.try_wait() {
Ok(status) => status,
Err(_) => {
terminate_child(&mut child);
let _ = reader.join();
// try_wait failed after dispatch; the remote process may
// have run the request even though its exit status is no
// longer observable.
return Err(CodexError::ProcessTerminated);
}
};
if let Some(status) = status {
let bytes = reader.join().map_err(|_| CodexError::InvalidOutput)??;
if bytes.len() > self.config.max_output_bytes {
return Err(CodexError::OutputTooLarge);
}
let exit_code = process_exit_code(&status);
let process_status = if status.success() {
CodexProcessStatus::Completed
} else {
CodexProcessStatus::Failed
};
let output = serde_json::from_slice::<Value>(&bytes).map_err(|_| {
if status.success() {
CodexError::InvalidOutput
} else {
// 非零退出优先保留“进程已执行但结果未知”的语义,
// 不让损坏的 stdout 把它误分类成普通输入错误。
process_exit_error(&status)
}
})?;
return Ok(CodexCliResult {
external_id,
status: process_status,
output,
exit_code,
});
}
if started.elapsed() >= self.config.timeout() {
terminate_child(&mut child);
let _ = reader.join();
return Err(CodexError::Timeout);
}
std::thread::sleep(Duration::from_millis(5));
}
}
}
#[cfg(unix)]
fn configure_process_group(command: &mut Command) {
use std::os::unix::process::CommandExt;
// 把 child 设为新 process group leader;超时/取消时连同它启动的
// shell/子进程一起终止,避免 stdout 管道被孤儿进程长期占住。
command.process_group(0);
}
#[cfg(not(unix))]
fn configure_process_group(_command: &mut Command) {}
fn terminate_child(child: &mut Child) -> Option<ExitStatus> {
#[cfg(unix)]
{
// std::process::Child::kill 只覆盖直接 child;固定路径调用系统
// kill 发送给负 PID 代表的 process group。失败时仍回退到直接 kill。
let group_killed = terminate_process_group(child.id());
if !group_killed {
let _ = child.kill();
}
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
child.wait().ok()
}
#[cfg(unix)]
unsafe extern "C" {
fn kill(pid: std::os::raw::c_int, signal: std::os::raw::c_int) -> std::os::raw::c_int;
}
#[cfg(unix)]
fn terminate_process_group(pid: u32) -> bool {
let Ok(pid) = std::os::raw::c_int::try_from(pid) else {
return false;
};
// 直接调用 kill(2) 避免每次 cancel/Drop 再创建一个 `/bin/kill` helper
// 负 PID 表示目标 child 的 process groupSIGKILL 后由调用方 wait/reap。
// SAFETY: pid 来自本进程刚 spawn 的 child,转换后取负值只用于 process
// groupsignal 使用平台稳定的 SIGKILL 数值,调用不持有 Rust 引用。
unsafe { kill(-pid, 9) == 0 }
}
fn process_exit_error(status: &ExitStatus) -> CodexError {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
return CodexError::Signal(signal);
}
}
CodexError::Exit(status.code().unwrap_or(-1))
}
fn process_exit_code(status: &ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
// 负值遵循常见 supervisor 约定,保留“被 signal 终止”的信息,
// 同时不把它误当作一个正常的 shell exit code。
return -signal;
}
}
-1
}
impl ExternalBackend for CodexCliBackend {
fn invoke(&self, request: &BackendRequest) -> Result<BackendResult, ExternalError> {
let result = self.invoke_cli(request).map_err(|error| {
ExternalError::new(
error.external_error_kind_for_cli_failure(),
error.to_string(),
)
})?;
if result.status != CodexProcessStatus::Completed {
// 非零退出说明 Codex 进程确实被启动过,但 stdout/退出状态不
// 能证明内部副作用是否完成;禁止把它包装成成功 BackendResult。
return Err(ExternalError::new(
ExternalErrorKind::UnknownSideEffect,
format!(
"Codex CLI 未正常完成: status={:?} exit_code={}",
result.status, result.exit_code
),
));
}
BackendResult::try_new(request.request_id(), result.output)
// The process has completed by this point. A local result contract
// failure cannot prove that the external operation was harmless.
.map_err(|error| {
ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string())
})
.and_then(|value| {
value.with_external_id(result.external_id).map_err(|error| {
ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string())
})
})
}
fn cancel(&self, request_id: &str) -> Result<(), ExternalError> {
self.cancel_cli(request_id)
.map_err(|error| ExternalError::new(error.external_error_kind(), error.to_string()))
}
}
/// App Server 的中立请求/事件/结果 DTO;不暴露 Codex 内部上下文结构。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct NodeRequest {
pub request_id: String,
pub operation: String,
pub payload: Value,
}
static NEXT_NODE_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
impl NodeRequest {
/// 构造一个带显式 ID 的中立请求,并在进入 wire 层前做最小边界校验。
pub fn try_new(
request_id: impl Into<String>,
operation: impl Into<String>,
payload: Value,
) -> Result<Self, CodexError> {
let request_id = request_id.into();
let operation = operation.into();
if request_id.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"App Server request_id 不能为空".to_owned(),
));
}
if operation.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"App Server operation 不能为空".to_owned(),
));
}
Ok(Self {
request_id,
operation,
payload,
})
}
/// 当上层没有自己的 ID 时生成本适配器范围内唯一的 ID。ID 仍只是
/// 一次调用的关联键,不代表 Codex 服务端的持久化会话。
pub fn with_generated_id(
operation: impl Into<String>,
payload: Value,
) -> Result<Self, CodexError> {
Self::try_new(
format!(
"codex-node-{}",
NEXT_NODE_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
),
operation,
payload,
)
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct NodeEvent {
pub request_id: String,
pub event_type: String,
pub payload: Value,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct NodeResult {
pub request_id: String,
pub output: Value,
#[serde(default)]
pub side_effect_unknown: bool,
}
/// App Server 连接由宿主注入,方便用本地 fixture 测试 request ID、事件流和中断。
pub trait AppServerChannel: Send {
/// 启动/握手边界。默认实现让内存 fixture 保持轻量;真实 wire 实现应
/// 校验自己的协议版本和 session,再允许 request/event/result 流动。
fn initialize(&mut self, _session_id: &str) -> Result<(), CodexError> {
Ok(())
}
fn send(&mut self, request: NodeRequest) -> Result<NodeResult, CodexError>;
/// 长连接实现可逐条转发事件;默认通道仍可只返回最终结果,保持最小
/// fixture/同步调用兼容。事件只是观察值,不直接修改 Host 状态。
fn send_with_events(
&mut self,
request: NodeRequest,
events: &mut dyn FnMut(NodeEvent),
) -> Result<NodeResult, CodexError> {
let result = self.send(request)?;
let _ = events;
Ok(result)
}
fn interrupt(&mut self, _request_id: &str) -> Result<(), CodexError> {
Ok(())
}
}
type AppServerInterruptHook = dyn Fn(&str) -> Result<(), CodexError> + Send + Sync;
pub struct CodexAppServerBackend<C> {
channel: std::sync::Mutex<C>,
session_id: String,
/// 可选的带外中断路径。普通 channel interrupt 与 `send` 共用同一把锁,
/// 同步请求等待响应时无法取得进展;调用方若有独立控制传输,可显式启用
/// 这个 hook。hook 执行时不会获取 `channel` 锁。
interrupt_hook: Option<Arc<AppServerInterruptHook>>,
}
impl<C> std::fmt::Debug for CodexAppServerBackend<C> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CodexAppServerBackend")
.field("session_id", &self.session_id)
.field("interrupt_hook", &self.interrupt_hook.is_some())
.finish_non_exhaustive()
}
}
impl<C: AppServerChannel> CodexAppServerBackend<C> {
pub fn new(channel: C, session_id: impl Into<String>) -> Result<Self, CodexError> {
let session_id = session_id.into();
if session_id.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"App Server session_id 不能为空".to_owned(),
));
}
Ok(Self {
channel: std::sync::Mutex::new(channel),
session_id,
interrupt_hook: None,
})
}
/// 显式安装带外中断路径。
///
/// 回调必须使用与 [`Self::new`] 传入的 `AppServerChannel` 独立的传输。
/// 它会在回退到 channel 锁路径前调用,因此 `invoke_node` 等待响应时也能
/// 发送中断;未显式启用时,原有 channel interrupt 行为保持不变。
pub fn with_interrupt_hook<F>(mut self, hook: F) -> Self
where
F: Fn(&str) -> Result<(), CodexError> + Send + Sync + 'static,
{
self.interrupt_hook = Some(Arc::new(hook));
self
}
/// 创建并立即执行一次中立协议握手。保留 [`Self::new`] 的惰性行为,
/// 使只发送 interrupt 或使用旧 fixture 的调用方仍可自行控制读写时机。
pub fn new_initialized(channel: C, session_id: impl Into<String>) -> Result<Self, CodexError> {
let backend = Self::new(channel, session_id)?;
// 这里不能从 backend 再取出 channel;先用一次锁调用初始化,仍保持
// 与正常 invoke 相同的共享状态边界。
backend.initialize()?;
Ok(backend)
}
pub fn session_id(&self) -> &str {
&self.session_id
}
/// 暴露可注入的启动边界,供断线重连或宿主显式恢复时重新握手。
pub fn initialize(&self) -> Result<(), CodexError> {
self.channel
.lock()
.map_err(|_| CodexError::Protocol("App Server channel 锁已损坏".to_owned()))?
.initialize(&self.session_id)
}
pub fn invoke_node(&self, request: NodeRequest) -> Result<NodeResult, CodexError> {
validate_node_request(&request)?;
let expected_request_id = request.request_id.clone();
let result = self
.channel
.lock()
.map_err(|_| CodexError::Protocol("App Server channel 锁已损坏".to_owned()))?
.send(request)?;
validate_node_result_request_id(&expected_request_id, result)
}
pub fn invoke_node_with_events(
&self,
request: NodeRequest,
events: &mut dyn FnMut(NodeEvent),
) -> Result<NodeResult, CodexError> {
validate_node_request(&request)?;
let expected_request_id = request.request_id.clone();
let result = self
.channel
.lock()
.map_err(|_| CodexError::Protocol("App Server channel 锁已损坏".to_owned()))?
.send_with_events(request, events)?;
validate_node_result_request_id(&expected_request_id, result)
}
/// 直接把 App Server 的中立 request/event/result 流接到 Core 事件映射器。
///
/// 这是一个显式 bridge:它先发出 `NodeRequest` 对应的审计事件,再把
/// channel 逐条产生的 `NodeEvent` 和最终 `NodeResult` 交给同一个
/// [`NodeRuntimeEventMapper`]。回调收到的 `RuntimeEvent` 仍需由调用方
/// 写入 RuntimeStore/reducer;本方法不隐式修改 Host 或创建第二套会话。
/// 如果某条事件无法映射,当前 channel 调用会先结束,随后返回协议错误,
/// 不会把未知厂商事件静默丢弃。
pub fn invoke_node_with_runtime_events(
&self,
request: NodeRequest,
mapper: &mut NodeRuntimeEventMapper,
sink: &mut dyn FnMut(RuntimeEvent),
) -> Result<NodeResult, CodexError> {
let request_event = mapper.map_request(&request)?;
sink(request_event);
let mut mapping_error = None;
let result = self.invoke_node_with_events(request, &mut |event| {
if mapping_error.is_some() {
return;
}
match mapper.map_event(&event) {
Ok(runtime_event) => sink(runtime_event),
Err(error) => mapping_error = Some(error),
}
})?;
if let Some(error) = mapping_error {
return Err(error);
}
let result_event = mapper.map_result(&result)?;
sink(result_event);
Ok(result)
}
pub fn interrupt(&self, request_id: &str) -> Result<(), CodexError> {
if let Some(hook) = self.interrupt_hook.as_ref() {
// 这里不获取 `channel` 锁:正常 invoke 路径可能在等待服务端响应时
// 持有该锁。
return hook(request_id);
}
self.channel
.lock()
.map_err(|_| CodexError::Protocol("App Server channel 锁已损坏".to_owned()))?
.interrupt(request_id)
}
}
fn validate_node_request(request: &NodeRequest) -> Result<(), CodexError> {
if request.request_id.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"App Server request_id 不能为空".to_owned(),
));
}
if request.operation.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"App Server operation 不能为空".to_owned(),
));
}
Ok(())
}
fn validate_node_result_request_id(
expected_request_id: &str,
result: NodeResult,
) -> Result<NodeResult, CodexError> {
if result.request_id != expected_request_id {
return Err(CodexError::Protocol(format!(
"App Server request_id 不匹配: expected={expected_request_id} actual={}",
result.request_id
)));
}
Ok(result)
}
impl<C: AppServerChannel + 'static> ExternalBackend for CodexAppServerBackend<C> {
fn invoke(&self, request: &BackendRequest) -> Result<BackendResult, ExternalError> {
let node = NodeRequest {
request_id: request.request_id().to_owned(),
operation: request.operation().to_owned(),
payload: request.payload().clone(),
};
let result = self
.invoke_node(node)
// `invoke_node` validates before writing, but every channel error
// after that boundary is conservatively treated as an unknown
// side effect. Host can persist the request ID and reconcile it
// instead of silently replaying a possibly accepted operation.
.map_err(|error| {
ExternalError::new(
error.external_error_kind_after_dispatch(),
error.to_string(),
)
})?;
BackendResult::try_new(request.request_id(), result.output)
// A response that violates the Core contract still proves that
// the external request was processed; do not turn it into a safe
// retry just because local DTO validation failed.
.map_err(|error| {
ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string())
})
.and_then(|value| {
value
.with_external_id(format!("{}:{}", self.session_id, result.request_id))
.map_err(|error| {
ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string())
})
})
.map(|value| value.with_unknown_side_effect(result.side_effect_unknown))
}
fn cancel(&self, request_id: &str) -> Result<(), ExternalError> {
self.interrupt(request_id)
// The interrupt is sent for an already active external request;
// protocol/transport failures therefore cannot be treated as a
// harmless configuration error or safely retried operation.
.map_err(|error| {
ExternalError::new(
error.external_error_kind_after_dispatch(),
error.to_string(),
)
})
}
}
/// 将一个 App Server 事件转换成稳定的外部审计字段。
pub fn node_event_json(event: &NodeEvent) -> Value {
json!({
"requestId": &event.request_id,
"eventType": &event.event_type,
"payload": &event.payload,
})
}
/// 将中立 App Server DTO 映射为 Core `RuntimeEvent`。
///
/// `RuntimeEvent` 没有通用的 external-request/opaque kind,因此普通
/// `NodeRequest` 会被记录为一条带有完整 JSON envelope 的 developer message。
/// 这只是受信的适配器审计文本,不伪造用户消息,也不会替调用方推进
/// `RuntimeSnapshot`。当 operation 明确表示 tool call 时,才使用
/// `ToolCallRequested`,避免把任意 Node payload 当成工具调用。
///
/// Mapper 自己只维护事件游标和 request correlation;调用方仍必须把返回事件
/// 交给 Core reducer。这样状态转换(例如完成前必须处于 Running)仍由 Core
/// 统一校验,而不是在 Codex 适配器中复制一套状态机。
#[derive(Clone, Debug)]
pub struct NodeRuntimeEventMapper {
runtime_id: String,
run_id: String,
last_revision: u64,
occurred_at_ms: u64,
active_request_id: Option<String>,
reconciliation_pending: bool,
terminal_result: bool,
}
impl NodeRuntimeEventMapper {
/// `current_revision` 应是调用方快照中已经提交的 runtime revision;返回
/// 的第一条事件会使用其后的连续 revision。ID 在这里按 Core 的稳定标识
/// 规则校验,避免先生成一个 reducer 必然拒绝的事件。
pub fn try_new(
runtime_id: impl Into<String>,
run_id: impl Into<String>,
current_revision: u64,
occurred_at_ms: u64,
) -> Result<Self, CodexError> {
let runtime_id = runtime_id.into();
let run_id = run_id.into();
let next_revision = current_revision.checked_add(1).ok_or_else(|| {
CodexError::InvalidConfig("runtime event revision 已达到上限".to_owned())
})?;
// RuntimeEvent::new 与 Core reducer 使用同一套 runtime/run ID 校验;
// 这里构造并丢弃一个最小事件,避免在适配器中复制校验规则。
RuntimeEvent::new(
runtime_id.clone(),
next_revision,
occurred_at_ms,
RuntimeEventKind::RunStarted,
Some(run_id.clone()),
json!({}),
)
.map_err(|error| CodexError::InvalidConfig(format!("runtime/run 标识无效: {error}")))?;
Ok(Self {
runtime_id,
run_id,
last_revision: current_revision,
occurred_at_ms,
active_request_id: None,
reconciliation_pending: false,
terminal_result: false,
})
}
pub fn runtime_id(&self) -> &str {
&self.runtime_id
}
pub fn run_id(&self) -> &str {
&self.run_id
}
/// 返回最后一条已生成事件的 revision。尚未生成事件时这是构造器传入的
/// snapshot revision,而不是一个虚构的 revision 0。
pub fn revision(&self) -> u64 {
self.last_revision
}
pub fn request_id(&self) -> Option<&str> {
self.active_request_id.as_deref()
}
/// 映射一个 Node 请求。普通 operation 以 neutral audit message 记录;
/// `tool_call`/`tool` 等显式 operation 才解析为 Core tool call。
pub fn map_request(&mut self, request: &NodeRequest) -> Result<RuntimeEvent, CodexError> {
validate_node_request(request)?;
self.ensure_can_accept_request(&request.request_id)?;
let (kind, detail) = if is_tool_request_operation(&request.operation) {
let call = node_tool_call(&request.payload)?;
(
RuntimeEventKind::ToolCallRequested,
serde_json::to_value(call).map_err(|error| {
CodexError::Protocol(format!("tool call 编码失败: {error}"))
})?,
)
} else {
let audit = json!({
"source": "codex-node",
"kind": "request",
"requestId": &request.request_id,
"operation": &request.operation,
"payload": &request.payload,
});
let audit_text = serde_json::to_string(&audit).map_err(|error| {
CodexError::Protocol(format!("NodeRequest 审计文本编码失败: {error}"))
})?;
let message = Message::developer(format!("[codex node request audit] {audit_text}"))
.map_err(|error| {
CodexError::Protocol(format!("NodeRequest 审计消息无效: {error}"))
})?;
(
RuntimeEventKind::MessageAppended,
serde_json::to_value(message).map_err(|error| {
CodexError::Protocol(format!("NodeRequest 消息编码失败: {error}"))
})?,
)
};
let event = self.push_event(kind, detail)?;
self.active_request_id = Some(request.request_id.clone());
Ok(event)
}
/// 映射一个与当前 request 关联的中立事件。这里只接受有明确 Core
/// 语义的白名单;未知 event type 返回 Protocol,而不是静默丢弃。
pub fn map_event(&mut self, event: &NodeEvent) -> Result<RuntimeEvent, CodexError> {
self.ensure_request_matches(&event.request_id)?;
let event_type = normalize_node_event_type(&event.event_type);
self.ensure_event_allowed_while_pending(&event_type)?;
let (kind, detail) = match event_type.as_str() {
"delta" | "message" | "message_delta" | "output_text" | "text" => {
let text = node_event_text(&event.payload)?;
let message = Message::assistant(text).map_err(|error| {
CodexError::Protocol(format!("NodeEvent 文本无效: {error}"))
})?;
(
RuntimeEventKind::MessageAppended,
serde_json::to_value(message).map_err(|error| {
CodexError::Protocol(format!("NodeEvent 消息编码失败: {error}"))
})?,
)
}
"tool_call" | "tool_call_requested" | "tool_use" => {
let call = node_tool_call(&event.payload)?;
(
RuntimeEventKind::ToolCallRequested,
serde_json::to_value(call).map_err(|error| {
CodexError::Protocol(format!("NodeEvent tool call 编码失败: {error}"))
})?,
)
}
"tool_started" | "tool_call_started" => {
(RuntimeEventKind::ToolCallStarted, node_event_json(event))
}
"tool_result"
| "tool_call_completed"
| "tool_completed"
| "tool_call_failed"
| "tool_failed" => {
let failed_by_type = event_type.ends_with("failed");
let (result, failed) = node_tool_result(&event.payload, failed_by_type)?;
(
if failed {
RuntimeEventKind::ToolCallFailed
} else {
RuntimeEventKind::ToolCallCompleted
},
serde_json::to_value(result).map_err(|error| {
CodexError::Protocol(format!("NodeEvent tool result 编码失败: {error}"))
})?,
)
}
"reconciliation_required" | "unknown_side_effect" => (
RuntimeEventKind::ReconciliationRequired,
node_event_json(event),
),
"reconciled" | "run_reconciled" => {
let mut detail = node_event_json(event);
if let Some(object) = detail.as_object_mut() {
// Core requires both flags before reopening a reconciliation gate.
object.insert("reconciled".to_owned(), Value::Bool(true));
object.insert("external_pending".to_owned(), Value::Bool(false));
}
(RuntimeEventKind::RunReconciled, detail)
}
"started" | "run_started" => (RuntimeEventKind::RunStarted, node_event_json(event)),
"paused" | "run_paused" => (RuntimeEventKind::RunPaused, node_event_json(event)),
"resumed" | "run_resumed" => (RuntimeEventKind::RunResumed, node_event_json(event)),
"approval_requested" => (RuntimeEventKind::ApprovalRequested, node_event_json(event)),
"approval_resolved" => (RuntimeEventKind::ApprovalResolved, node_event_json(event)),
"cancelled" | "canceled" | "run_cancelled" => {
(RuntimeEventKind::RunCancelled, node_event_json(event))
}
"completed" | "run_completed" => {
let output = event_output(&event.payload);
(
RuntimeEventKind::RunCompleted,
json!({
"summary": output_summary(&output),
"requestId": &event.request_id,
"output": output,
"eventType": &event.event_type,
}),
)
}
"failed" | "run_failed" => {
let error = node_error_text(&event.payload);
(
RuntimeEventKind::RunFailed,
json!({
"error": error,
"requestId": &event.request_id,
"eventType": &event.event_type,
"payload": &event.payload,
}),
)
}
_ => {
return Err(CodexError::Protocol(format!(
"不支持的 NodeEvent event_type: {}",
event.event_type
)));
}
};
let terminal = matches!(
kind,
RuntimeEventKind::RunCompleted
| RuntimeEventKind::RunFailed
| RuntimeEventKind::RunCancelled
);
let opens_reconciliation = kind == RuntimeEventKind::ReconciliationRequired;
let closes_reconciliation = kind == RuntimeEventKind::RunReconciled;
let runtime_event = self.push_event(kind, detail)?;
self.terminal_result |= terminal;
if opens_reconciliation {
self.reconciliation_pending = true;
} else if closes_reconciliation {
self.reconciliation_pending = false;
}
Ok(runtime_event)
}
/// 映射最终 Node 结果。完整 `output` 会保留在 event detail 中,即使
/// Core reducer 只从 `summary` 字段更新 `RunSnapshot.final_text`。
/// `side_effect_unknown` 结果先进入 reconciliation gate,不能被当成完成。
pub fn map_result(&mut self, result: &NodeResult) -> Result<RuntimeEvent, CodexError> {
self.ensure_request_matches(&result.request_id)?;
if self.reconciliation_pending {
return Err(CodexError::Protocol(
"reconciliation 尚未明确收口,不能提交 NodeResult".to_owned(),
));
}
if self.terminal_result {
return Err(CodexError::Protocol(
"NodeResult 已经在当前 mapper 中收口".to_owned(),
));
}
let output = result.output.clone();
let detail = if result.side_effect_unknown {
json!({
"requestId": &result.request_id,
"output": output,
"sideEffectUnknown": true,
"external_pending": true,
})
} else {
json!({
"requestId": &result.request_id,
"output": output,
"sideEffectUnknown": false,
"summary": output_summary(&result.output),
})
};
let kind = if result.side_effect_unknown {
RuntimeEventKind::ReconciliationRequired
} else {
RuntimeEventKind::RunCompleted
};
let event = self.push_event(kind, detail)?;
if result.side_effect_unknown {
self.reconciliation_pending = true;
} else {
self.terminal_result = true;
}
Ok(event)
}
fn ensure_can_accept_request(&self, request_id: &str) -> Result<(), CodexError> {
if request_id.trim().is_empty() {
return Err(CodexError::Protocol(
"NodeRequest request_id 不能为空".to_owned(),
));
}
if self.active_request_id.is_some() {
return Err(CodexError::Protocol(
"当前 mapper 已绑定一个 NodeRequest".to_owned(),
));
}
if self.terminal_result {
return Err(CodexError::Protocol(
"当前 mapper 已经收口,不能重新绑定 NodeRequest".to_owned(),
));
}
Ok(())
}
fn ensure_request_matches(&self, request_id: &str) -> Result<(), CodexError> {
let Some(expected) = self.active_request_id.as_deref() else {
return Err(CodexError::Protocol(
"NodeEvent/NodeResult 在 NodeRequest 之前到达".to_owned(),
));
};
if request_id.trim().is_empty() || request_id != expected {
return Err(CodexError::Protocol(format!(
"Node request_id 不匹配: expected={expected} actual={request_id}"
)));
}
if self.terminal_result {
return Err(CodexError::Protocol(
"当前 mapper 已经收口,不能继续接收 Node 事件".to_owned(),
));
}
Ok(())
}
fn ensure_event_allowed_while_pending(&self, event_type: &str) -> Result<(), CodexError> {
if !self.reconciliation_pending {
return Ok(());
}
if matches!(
event_type,
"reconciled"
| "run_reconciled"
| "cancelled"
| "canceled"
| "run_cancelled"
| "failed"
| "run_failed"
) {
Ok(())
} else {
Err(CodexError::Protocol(
"reconciliation gate 未收口,不能继续映射 NodeEvent".to_owned(),
))
}
}
fn push_event(
&mut self,
kind: RuntimeEventKind,
detail: Value,
) -> Result<RuntimeEvent, CodexError> {
let revision = self
.last_revision
.checked_add(1)
.ok_or_else(|| CodexError::Protocol("runtime event revision 溢出".to_owned()))?;
let event = RuntimeEvent::new(
self.runtime_id.clone(),
revision,
self.occurred_at_ms,
kind,
Some(self.run_id.clone()),
detail,
)
.map_err(|error| CodexError::Protocol(format!("RuntimeEvent 构造失败: {error}")))?;
// Keep these checks explicit: if a future refactor changes the constructor
// arguments, a mismatched event must fail here instead of reaching storage.
if event.runtime_id() != self.runtime_id
|| event.run_id() != Some(self.run_id.as_str())
|| event.revision() != revision
{
return Err(CodexError::Protocol(
"生成的 RuntimeEvent identity/revision 不匹配".to_owned(),
));
}
self.last_revision = revision;
Ok(event)
}
}
fn normalize_node_event_type(event_type: &str) -> String {
let normalized = event_type
.trim()
.to_ascii_lowercase()
.replace(['-', '.', ' '], "_");
// Codex app-server notifications use slash-qualified method names. Keep
// the mapping explicit so audited lifecycle events enter the neutral
// mapper while unknown vendor methods still fail closed below.
match normalized.as_str() {
"item/agentmessage/delta" => "delta".to_owned(),
"turn/completed" => "completed".to_owned(),
"turn/failed" => "failed".to_owned(),
"turn/cancelled" | "turn/canceled" => "cancelled".to_owned(),
"item/tool/call" => "tool_call".to_owned(),
"item/tool/result" => "tool_result".to_owned(),
other => other.to_owned(),
}
}
fn is_tool_request_operation(operation: &str) -> bool {
matches!(
normalize_node_event_type(operation).as_str(),
"tool" | "tool_call" | "tool_call_requested" | "tool_use"
)
}
fn node_payload_field<'a>(payload: &'a Value, names: &[&str]) -> Option<&'a Value> {
let object = payload.as_object()?;
names.iter().find_map(|name| object.get(*name))
}
fn node_string_field(payload: &Value, names: &[&str], field: &str) -> Result<String, CodexError> {
node_payload_field(payload, names)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| CodexError::Protocol(format!("Node payload 缺少有效 {field}")))
}
fn node_tool_call(payload: &Value) -> Result<ToolCall, CodexError> {
let payload = node_payload_field(payload, &["call", "toolCall"]).unwrap_or(payload);
let id = node_string_field(
payload,
&["callId", "call_id", "id", "toolCallId", "tool_call_id"],
"callId",
)?;
let name = node_string_field(payload, &["name", "tool", "toolName", "tool_name"], "name")?;
let arguments = match node_payload_field(payload, &["arguments", "args", "input"]) {
None => json!({}),
Some(Value::String(text)) => serde_json::from_str(text).map_err(|error| {
CodexError::Protocol(format!("Node tool arguments 不是 JSON object: {error}"))
})?,
Some(value) => value.clone(),
};
ToolCall::try_new(id, name, arguments)
.map_err(|error| CodexError::Protocol(format!("Node tool call 无效: {error}")))
}
fn node_tool_result(
payload: &Value,
failed_by_type: bool,
) -> Result<(ToolResult, bool), CodexError> {
let call_id = node_string_field(
payload,
&["callId", "call_id", "toolCallId", "tool_call_id", "id"],
"callId",
)?;
let output = node_payload_field(payload, &["output", "result", "value"])
.cloned()
.unwrap_or_else(|| payload.clone());
let failed = node_payload_field(payload, &["isError", "is_error", "failed"])
.and_then(Value::as_bool)
.unwrap_or(failed_by_type);
let result = if failed {
ToolResult::failure(call_id, output)
} else {
ToolResult::success(call_id, output)
}
.map_err(|error| CodexError::Protocol(format!("Node tool result 无效: {error}")))?;
Ok((result, failed))
}
fn node_event_text(payload: &Value) -> Result<String, CodexError> {
match payload {
Value::String(text) if !text.trim().is_empty() => Ok(text.clone()),
Value::Object(object) => ["text", "delta", "output"]
.iter()
.find_map(|name| object.get(*name).and_then(Value::as_str))
.filter(|text| !text.trim().is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| {
CodexError::Protocol("NodeEvent 文本 payload 缺少 text/delta/output".to_owned())
}),
_ => Err(CodexError::Protocol(
"NodeEvent 文本 payload 必须是非空字符串或对象".to_owned(),
)),
}
}
fn event_output(payload: &Value) -> Value {
node_payload_field(payload, &["output", "result", "value"])
.cloned()
.unwrap_or_else(|| payload.clone())
}
fn output_summary(output: &Value) -> String {
match output {
Value::String(text) if !text.is_empty() => text.clone(),
_ => output.to_string(),
}
}
fn node_error_text(payload: &Value) -> String {
node_payload_field(payload, &["error", "message", "reason"])
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
payload
.as_str()
.filter(|text| !text.trim().is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| "Codex node failed".to_owned())
}
/// 一个不绑定具体 Codex 版本的 JSONL App Server 通道。
///
/// Codex 的进程启动参数和内部 wire schema 会随发行版变化,因此这里故意只
/// 约定本仓库的中立 fixture 格式:每行一个带 `kind` 的 JSON frame`event`
/// 和 `result` 必须带回原始 `requestId`。真正的 Codex 进程适配器可以把它的
/// wire frame 转换到这个接口,而不会把厂商字段泄漏到 Core 或 Host。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
// `protocolVersion` 由 `AppServerWireFrame` 校验;这里允许读取该 envelope
// 中的额外字段,以兼容仍直接反序列化中立 DTO 的旧调用方。
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum AppServerFrame {
Initialize {
#[serde(rename = "sessionId")]
session_id: String,
},
Ready {
#[serde(rename = "sessionId")]
session_id: String,
},
Request {
#[serde(rename = "requestId")]
request_id: String,
operation: String,
payload: Value,
},
Event {
#[serde(rename = "requestId")]
request_id: String,
#[serde(rename = "eventType")]
event_type: String,
payload: Value,
},
Result {
#[serde(rename = "requestId")]
request_id: String,
output: Value,
#[serde(default)]
#[serde(rename = "sideEffectUnknown")]
side_effect_unknown: bool,
},
Error {
#[serde(rename = "requestId")]
request_id: String,
message: String,
},
Interrupt {
#[serde(rename = "requestId")]
request_id: String,
},
}
/// JSONL 上实际传输的版本化 frame。`AppServerFrame` 保持为中立 DTO,避免
/// 把协议版本字段散落到 Host/Core;只有这个 wire enum 会携带版本。
///
/// 这里的版本是本仓库 fixture 协议版本,不声称与任意具体 Codex 版本兼容。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
pub enum AppServerWireFrame {
Initialize {
#[serde(
rename = "protocolVersion",
default = "default_app_server_protocol_version"
)]
protocol_version: u16,
#[serde(rename = "sessionId")]
session_id: String,
},
Ready {
#[serde(
rename = "protocolVersion",
default = "default_app_server_protocol_version"
)]
protocol_version: u16,
#[serde(rename = "sessionId")]
session_id: String,
},
Request {
#[serde(
rename = "protocolVersion",
default = "default_app_server_protocol_version"
)]
protocol_version: u16,
#[serde(rename = "requestId")]
request_id: String,
operation: String,
payload: Value,
},
Event {
#[serde(
rename = "protocolVersion",
default = "default_app_server_protocol_version"
)]
protocol_version: u16,
#[serde(rename = "requestId")]
request_id: String,
#[serde(rename = "eventType")]
event_type: String,
payload: Value,
},
Result {
#[serde(
rename = "protocolVersion",
default = "default_app_server_protocol_version"
)]
protocol_version: u16,
#[serde(rename = "requestId")]
request_id: String,
output: Value,
#[serde(default)]
#[serde(rename = "sideEffectUnknown")]
side_effect_unknown: bool,
},
Error {
#[serde(
rename = "protocolVersion",
default = "default_app_server_protocol_version"
)]
protocol_version: u16,
#[serde(rename = "requestId")]
request_id: String,
message: String,
},
Interrupt {
#[serde(
rename = "protocolVersion",
default = "default_app_server_protocol_version"
)]
protocol_version: u16,
#[serde(rename = "requestId")]
request_id: String,
},
}
impl AppServerWireFrame {
fn from_frame(frame: AppServerFrame) -> Self {
let version = APP_SERVER_PROTOCOL_VERSION;
match frame {
AppServerFrame::Initialize { session_id } => Self::Initialize {
protocol_version: version,
session_id,
},
AppServerFrame::Ready { session_id } => Self::Ready {
protocol_version: version,
session_id,
},
AppServerFrame::Request {
request_id,
operation,
payload,
} => Self::Request {
protocol_version: version,
request_id,
operation,
payload,
},
AppServerFrame::Event {
request_id,
event_type,
payload,
} => Self::Event {
protocol_version: version,
request_id,
event_type,
payload,
},
AppServerFrame::Result {
request_id,
output,
side_effect_unknown,
} => Self::Result {
protocol_version: version,
request_id,
output,
side_effect_unknown,
},
AppServerFrame::Error {
request_id,
message,
} => Self::Error {
protocol_version: version,
request_id,
message,
},
AppServerFrame::Interrupt { request_id } => Self::Interrupt {
protocol_version: version,
request_id,
},
}
}
fn protocol_version(&self) -> u16 {
match self {
Self::Initialize {
protocol_version, ..
}
| Self::Ready {
protocol_version, ..
}
| Self::Request {
protocol_version, ..
}
| Self::Event {
protocol_version, ..
}
| Self::Result {
protocol_version, ..
}
| Self::Error {
protocol_version, ..
}
| Self::Interrupt {
protocol_version, ..
} => *protocol_version,
}
}
fn into_frame(self) -> Result<AppServerFrame, CodexError> {
let version = self.protocol_version();
if version != APP_SERVER_PROTOCOL_VERSION {
return Err(CodexError::Protocol(format!(
"App Server protocolVersion 不支持: expected={} actual={version}",
APP_SERVER_PROTOCOL_VERSION
)));
}
Ok(match self {
Self::Initialize { session_id, .. } => AppServerFrame::Initialize { session_id },
Self::Ready { session_id, .. } => AppServerFrame::Ready { session_id },
Self::Request {
request_id,
operation,
payload,
..
} => AppServerFrame::Request {
request_id,
operation,
payload,
},
Self::Event {
request_id,
event_type,
payload,
..
} => AppServerFrame::Event {
request_id,
event_type,
payload,
},
Self::Result {
request_id,
output,
side_effect_unknown,
..
} => AppServerFrame::Result {
request_id,
output,
side_effect_unknown,
},
Self::Error {
request_id,
message,
..
} => AppServerFrame::Error {
request_id,
message,
},
Self::Interrupt { request_id, .. } => AppServerFrame::Interrupt { request_id },
})
}
}
impl AppServerFrame {
fn request(request: &NodeRequest) -> Self {
Self::Request {
request_id: request.request_id.clone(),
operation: request.operation.clone(),
payload: request.payload.clone(),
}
}
fn into_node_event(self) -> Result<NodeEvent, CodexError> {
match self {
Self::Event {
request_id,
event_type,
payload,
} => Ok(NodeEvent {
request_id,
event_type,
payload,
}),
other => Err(CodexError::Protocol(format!(
"App Server frame 不是 event: {}",
frame_kind(&other)
))),
}
}
fn into_node_result(self) -> Result<NodeResult, CodexError> {
match self {
Self::Result {
request_id,
output,
side_effect_unknown,
} => Ok(NodeResult {
request_id,
output,
side_effect_unknown,
}),
Self::Error {
request_id,
message,
} => Err(CodexError::Protocol(format!(
"App Server 返回错误 (request_id={request_id}): {message}"
))),
other => Err(CodexError::Protocol(format!(
"App Server frame 不是 result: {}",
frame_kind(&other)
))),
}
}
}
fn frame_kind(frame: &AppServerFrame) -> &'static str {
match frame {
AppServerFrame::Initialize { .. } => "initialize",
AppServerFrame::Ready { .. } => "ready",
AppServerFrame::Request { .. } => "request",
AppServerFrame::Event { .. } => "event",
AppServerFrame::Result { .. } => "result",
AppServerFrame::Error { .. } => "error",
AppServerFrame::Interrupt { .. } => "interrupt",
}
}
/// 以有界 JSONL frame 驱动 [`AppServerChannel`]。读写端由调用方注入,因而
/// 可以接真实 child process 的 stdin/stdout,也可以在测试中使用 Cursor;本
/// 类型本身不负责创建进程、重连或保存外部 session。
pub struct JsonLineAppServerChannel<R, W> {
reader: BufReader<R>,
writer: W,
max_frame_bytes: usize,
initialized: bool,
strict_protocol: bool,
}
impl<R: Read, W: Write> JsonLineAppServerChannel<R, W> {
pub fn new(reader: R, writer: W) -> Result<Self, CodexError> {
Self::with_max_frame_bytes(reader, writer, DEFAULT_MAX_OUTPUT_BYTES)
}
pub fn with_max_frame_bytes(
reader: R,
writer: W,
max_frame_bytes: usize,
) -> Result<Self, CodexError> {
if max_frame_bytes == 0 {
return Err(CodexError::InvalidConfig(
"App Server max_frame_bytes 必须大于 0".to_owned(),
));
}
Ok(Self {
reader: BufReader::new(reader),
writer,
max_frame_bytes,
initialized: false,
strict_protocol: false,
})
}
pub fn max_frame_bytes(&self) -> usize {
self.max_frame_bytes
}
pub fn into_parts(self) -> (R, W) {
(self.reader.into_inner(), self.writer)
}
fn write_frame(&mut self, frame: &AppServerFrame) -> Result<(), CodexError> {
let wire = AppServerWireFrame::from_frame(frame.clone());
let mut encoded = serde_json::to_vec(&wire)
.map_err(|error| CodexError::Protocol(format!("App Server frame 编码失败: {error}")))?;
if encoded.len().saturating_add(1) > self.max_frame_bytes {
return Err(CodexError::Protocol(format!(
"App Server frame 超过 {} 字节限制",
self.max_frame_bytes
)));
}
encoded.push(b'\n');
self.writer
.write_all(&encoded)
.map_err(|error| CodexError::Protocol(format!("App Server frame 写入失败: {error}")))?;
self.writer
.flush()
.map_err(|error| CodexError::Protocol(format!("App Server flush 失败: {error}")))
}
/// 从 BufReader 读取一行,同时限制没有换行符的 frame,避免对端发送
/// 无限长单行 JSON 时把内存耗尽。
fn read_frame(&mut self) -> Result<Option<AppServerFrame>, CodexError> {
loop {
let mut bytes = Vec::new();
loop {
let (take, has_newline) = {
let available = self.reader.fill_buf().map_err(|error| {
CodexError::Protocol(format!("App Server frame 读取失败: {error}"))
})?;
if available.is_empty() {
if bytes.is_empty() {
return Ok(None);
}
return Err(CodexError::Protocol(
"App Server 在 frame 结束前关闭连接".to_owned(),
));
}
let newline = available.iter().position(|byte| *byte == b'\n');
(
newline.map_or(available.len(), |index| index + 1),
newline.is_some(),
)
};
if bytes.len().saturating_add(take) > self.max_frame_bytes {
return Err(CodexError::Protocol(format!(
"App Server frame 超过 {} 字节限制",
self.max_frame_bytes
)));
}
let available = self.reader.fill_buf().map_err(|error| {
CodexError::Protocol(format!("App Server frame 读取失败: {error}"))
})?;
bytes.extend_from_slice(&available[..take]);
self.reader.consume(take);
if has_newline {
break;
}
}
while matches!(bytes.last(), Some(b'\n' | b'\r')) {
bytes.pop();
}
// Ignore empty keep-alive lines without recursive calls. A peer can
// send an arbitrary number of blank lines, so recursion here would
// grow the stack for otherwise harmless protocol input.
if bytes.iter().all(u8::is_ascii_whitespace) {
continue;
}
let raw = serde_json::from_slice::<Value>(&bytes).map_err(|error| {
CodexError::Protocol(format!("App Server JSON frame 无效: {error}"))
})?;
if self.strict_protocol && raw.get("protocolVersion").is_none() {
return Err(CodexError::Protocol(
"App Server frame 缺少 protocolVersion".to_owned(),
));
}
let wire = serde_json::from_value::<AppServerWireFrame>(raw).map_err(|error| {
CodexError::Protocol(format!(
"App Server JSON frame 无效或缺少 protocolVersion: {error}"
))
})?;
return wire.into_frame().map(Some);
}
}
}
impl<R: Read + Send, W: Write + Send> AppServerChannel for JsonLineAppServerChannel<R, W> {
fn initialize(&mut self, session_id: &str) -> Result<(), CodexError> {
if session_id.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"App Server session_id 不能为空".to_owned(),
));
}
if self.initialized {
return Ok(());
}
self.write_frame(&AppServerFrame::Initialize {
session_id: session_id.to_owned(),
})?;
// 握手成功后要求后续 request/event/result 都带显式版本;惰性 legacy
// 模式仍允许读取旧 fixture,以保持 `new` 的兼容性。
self.strict_protocol = true;
let Some(frame) = self.read_frame()? else {
return Err(CodexError::Protocol(
"App Server 握手后未收到 ready".to_owned(),
));
};
match frame {
AppServerFrame::Ready {
session_id: ready_session,
} if ready_session == session_id => {
self.initialized = true;
Ok(())
}
AppServerFrame::Ready {
session_id: actual_session,
} => Err(CodexError::Protocol(format!(
"App Server ready session_id 不匹配: expected={session_id}, actual={actual_session}",
))),
other => Err(CodexError::Protocol(format!(
"App Server 握手后收到 {} frame,而不是 ready",
frame_kind(&other)
))),
}
}
fn send(&mut self, request: NodeRequest) -> Result<NodeResult, CodexError> {
self.send_with_events(request, &mut |_| {})
}
fn send_with_events(
&mut self,
request: NodeRequest,
events: &mut dyn FnMut(NodeEvent),
) -> Result<NodeResult, CodexError> {
validate_node_request(&request)?;
self.write_frame(&AppServerFrame::request(&request))?;
loop {
let Some(frame) = self.read_frame()? else {
return Err(CodexError::Protocol(
"App Server 在返回 result 前关闭连接".to_owned(),
));
};
match frame {
AppServerFrame::Event { ref request_id, .. } => {
if request_id != &request.request_id {
return Err(CodexError::Protocol(format!(
"App Server event request_id 不匹配: expected={} actual={request_id}",
request.request_id
)));
}
let event = frame.into_node_event()?;
events(event);
}
AppServerFrame::Result { ref request_id, .. }
| AppServerFrame::Error { ref request_id, .. } => {
if request_id != &request.request_id {
return Err(CodexError::Protocol(format!(
"App Server result request_id 不匹配: expected={} actual={request_id}",
request.request_id
)));
}
return frame.into_node_result();
}
other => {
return Err(CodexError::Protocol(format!(
"App Server 在等待 result 时收到 {} frame",
frame_kind(&other)
)));
}
}
}
}
fn interrupt(&mut self, request_id: &str) -> Result<(), CodexError> {
if request_id.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"App Server interrupt request_id 不能为空".to_owned(),
));
}
self.write_frame(&AppServerFrame::Interrupt {
request_id: request_id.to_owned(),
})
}
}
/// 一个最小的 Codex App Server JSON-RPC/stdio 通道。
///
/// App Server 当前在 stdio 上使用逐行 JSON-RPC;有些发行版省略 wire 上的
/// `jsonrpc` 字段,因此读取时接受缺省或 `"2.0"`,写入时沿用省略字段的形式。
/// 这里仅提供中立 `AppServerChannel` 所需的 request/event/result 边界,不绑定
/// 任何具体 Codex 发行版、生成 schema 或 thread/turn 生命周期。需要真实
/// `threadId`/`turnId`、审批和工具请求处理的调用方应在更上层实现版本适配。
pub struct JsonRpcAppServerChannel<R, W> {
reader: BufReader<R>,
writer: W,
max_frame_bytes: usize,
initialized: bool,
}
impl<R: Read, W: Write> JsonRpcAppServerChannel<R, W> {
pub fn new(reader: R, writer: W) -> Result<Self, CodexError> {
Self::with_max_frame_bytes(reader, writer, DEFAULT_JSON_RPC_FRAME_BYTES)
}
pub fn with_max_frame_bytes(
reader: R,
writer: W,
max_frame_bytes: usize,
) -> Result<Self, CodexError> {
if max_frame_bytes == 0 {
return Err(CodexError::InvalidConfig(
"JSON-RPC max_frame_bytes 必须大于 0".to_owned(),
));
}
Ok(Self {
reader: BufReader::new(reader),
writer,
max_frame_bytes,
initialized: false,
})
}
pub fn max_frame_bytes(&self) -> usize {
self.max_frame_bytes
}
pub fn into_parts(self) -> (R, W) {
(self.reader.into_inner(), self.writer)
}
fn write_message(&mut self, message: &Value) -> Result<(), CodexError> {
let mut encoded = serde_json::to_vec(message)
.map_err(|error| CodexError::Protocol(format!("JSON-RPC frame 编码失败: {error}")))?;
if encoded.len().saturating_add(1) > self.max_frame_bytes {
return Err(CodexError::Protocol(format!(
"JSON-RPC frame 超过 {} 字节限制",
self.max_frame_bytes
)));
}
encoded.push(b'\n');
self.writer
.write_all(&encoded)
.map_err(|error| CodexError::Protocol(format!("JSON-RPC frame 写入失败: {error}")))?;
self.writer
.flush()
.map_err(|error| CodexError::Protocol(format!("JSON-RPC flush 失败: {error}")))
}
/// 读取一个有界 JSONL frame。EOF 时若已有完整 JSON 仍接受无尾换行的
/// frame,方便调用方把固定的 Cursor/管道输出接入测试。
fn read_message(&mut self) -> Result<Option<Value>, CodexError> {
loop {
let mut bytes = Vec::new();
loop {
let available = self.reader.fill_buf().map_err(map_json_rpc_read_error)?;
if available.is_empty() {
if bytes.is_empty() {
return Ok(None);
}
break;
}
let newline = available.iter().position(|byte| *byte == b'\n');
let take = newline.map_or(available.len(), |index| index + 1);
if bytes.len().saturating_add(take) > self.max_frame_bytes {
return Err(CodexError::Protocol(format!(
"JSON-RPC frame 超过 {} 字节限制",
self.max_frame_bytes
)));
}
bytes.extend_from_slice(&available[..take]);
self.reader.consume(take);
if newline.is_some() {
break;
}
}
while matches!(bytes.last(), Some(b'\n' | b'\r')) {
bytes.pop();
}
if bytes.iter().all(u8::is_ascii_whitespace) {
continue;
}
let message = serde_json::from_slice::<Value>(&bytes)
.map_err(|error| CodexError::Protocol(format!("JSON-RPC frame 无效: {error}")))?;
if !message.is_object() {
return Err(CodexError::Protocol("JSON-RPC frame 必须是对象".to_owned()));
}
validate_optional_json_rpc_version(&message)?;
return Ok(Some(message));
}
}
/// 将中立 server request 交给调用方,并保证无论 handler 成功还是失败,
/// 都先写回一个 JSON-RPC response,避免对端永久等待。handler 只负责
/// 当前请求的 JSON 值,不会获得通道的其它状态。
fn handle_server_request(
&mut self,
request: CodexServerRequest,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<(), CodexError> {
match handler.handle(&request) {
Ok(response) => self.write_server_response(&request, response),
Err(error) => {
self.write_server_response(
&request,
CodexServerRequestResponse::error(-32601, error.to_string()),
)?;
Err(error)
}
}
}
fn write_server_response(
&mut self,
request: &CodexServerRequest,
response: CodexServerRequestResponse,
) -> Result<(), CodexError> {
let message = match response {
CodexServerRequestResponse::Result(result) => {
json!({"id": request.id, "result": result})
}
CodexServerRequestResponse::Error {
code,
message,
data,
} => {
let mut error = json!({"code": code, "message": message});
if let Some(data) = data {
error["data"] = data;
}
json!({"id": request.id, "error": error})
}
};
self.write_message(&message)
}
/// 等待一个 JSON-RPC response,同时处理期间到达的 notification 和
/// server request。`events` 为 `None` 时通知只被消费,不进入回调。
fn wait_for_response_with_handler(
&mut self,
expected_id: &str,
operation: &str,
mut events: Option<&mut dyn FnMut(NodeEvent)>,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<Value, CodexError> {
loop {
let Some(message) = self.read_message()? else {
return Err(CodexError::Protocol(format!(
"JSON-RPC {operation} response 前连接已关闭"
)));
};
// Request/notification 与 response 是互斥 envelope;先做这个
// 检查,再调用 handler,避免无效 frame 诱发上层副作用。
validate_json_rpc_envelope(&message)?;
let method = json_rpc_method(&message)?;
let id = json_rpc_id(&message)?;
if let Some(method) = method {
if let Some(id) = id {
self.handle_server_request(
CodexServerRequest {
id,
method: method.to_owned(),
params: message.get("params").cloned().unwrap_or(Value::Null),
},
handler,
)?;
} else {
if let Some(events) = events.as_deref_mut() {
events(NodeEvent {
request_id: expected_id.to_owned(),
event_type: method.to_owned(),
payload: message.get("params").cloned().unwrap_or(Value::Null),
});
}
}
continue;
}
let Some(id) = id else {
return Err(CodexError::Protocol(format!(
"JSON-RPC {operation} response 缺少 id"
)));
};
if !json_rpc_id_matches(&id, expected_id) {
return Err(CodexError::Protocol(format!(
"JSON-RPC {operation} response id 不匹配: expected={expected_id} actual={id}"
)));
}
return parse_json_rpc_result(&message, operation);
}
}
}
fn map_json_rpc_read_error(error: io::Error) -> CodexError {
match error.kind() {
io::ErrorKind::TimedOut => CodexError::Timeout,
io::ErrorKind::Interrupted => CodexError::Interrupted,
_ => CodexError::Protocol(format!("JSON-RPC frame 读取失败: {error}")),
}
}
impl<R: Read + Send, W: Write + Send> AppServerChannel for JsonRpcAppServerChannel<R, W> {
fn initialize(&mut self, session_id: &str) -> Result<(), CodexError> {
let mut handler = RejectingServerRequestHandler;
self.initialize_with_handler(session_id, &mut handler)
}
fn send(&mut self, request: NodeRequest) -> Result<NodeResult, CodexError> {
self.send_with_events(request, &mut |_| {})
}
fn send_with_events(
&mut self,
request: NodeRequest,
events: &mut dyn FnMut(NodeEvent),
) -> Result<NodeResult, CodexError> {
let mut handler = RejectingServerRequestHandler;
self.send_with_events_and_handler(request, events, &mut handler)
}
fn interrupt(&mut self, request_id: &str) -> Result<(), CodexError> {
let mut handler = RejectingServerRequestHandler;
self.interrupt_with_handler(request_id, &mut handler)
}
}
impl<R: Read, W: Write> JsonRpcAppServerChannel<R, W> {
/// 完成握手并允许调用方处理握手响应前到达的 server request。
///
/// 默认 [`AppServerChannel::initialize`] 仍使用拒绝 handler;需要审批或
/// 其它请求的宿主应显式调用此方法,避免在通道内自动放行任何副作用。
pub fn initialize_with_handler(
&mut self,
session_id: &str,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<(), CodexError> {
if session_id.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"JSON-RPC session_id 不能为空".to_owned(),
));
}
if self.initialized {
return Err(CodexError::Protocol(
"JSON-RPC connection 已经 initialized".to_owned(),
));
}
// `session_id` 只作为本通道的字符串关联 ID;它不是 Codex 的 thread
// 或 turn ID,也不构成对某个 Codex 版本 schema 的兼容声明。
self.write_message(&json!({
"method": "initialize",
"id": session_id,
"params": {
"clientInfo": {
"name": "agent-runtime",
"version": env!("CARGO_PKG_VERSION")
}
}
}))?;
self.wait_for_response_with_handler(session_id, "initialize", None, handler)?;
// 官方协议要求 initialize response 后再发 initialized notification。
self.write_message(&json!({
"method": "initialized",
"params": {}
}))?;
self.initialized = true;
Ok(())
}
/// 发送请求并在等待 response 时处理中立 server request。
pub fn send_with_events_and_handler(
&mut self,
request: NodeRequest,
events: &mut dyn FnMut(NodeEvent),
handler: &mut dyn CodexServerRequestHandler,
) -> Result<NodeResult, CodexError> {
validate_node_request(&request)?;
if !self.initialized {
return Err(CodexError::Protocol(
"JSON-RPC request 必须在 initialize 后发送".to_owned(),
));
}
self.write_message(&json!({
"method": request.operation,
"id": request.request_id,
"params": request.payload
}))?;
let output = self.wait_for_response_with_handler(
&request.request_id,
&request.operation,
Some(events),
handler,
)?;
Ok(NodeResult {
request_id: request.request_id,
output,
side_effect_unknown: false,
})
}
/// 发送中立 `turn/interrupt` 并允许处理响应前到达的 server request。
pub fn interrupt_with_handler(
&mut self,
request_id: &str,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<(), CodexError> {
if request_id.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"JSON-RPC interrupt request_id 不能为空".to_owned(),
));
}
if !self.initialized {
return Err(CodexError::Protocol(
"JSON-RPC interrupt 必须在 initialize 后发送".to_owned(),
));
}
// AppServerChannel 目前只暴露一个中立 request_id,没有真实 Codex
// 所需的 threadId/turnId;把它放入 params 作为关联值,版本特定调用方
// 可在更上层提供完整 turn/interrupt 映射。这里使用 request 形状,便于
// 服务端记录/拒绝,而不伪造某个发行版的成功语义。
self.write_message(&json!({
"method": "turn/interrupt",
"id": request_id,
"params": {"requestId": request_id}
}))?;
self.wait_for_response_with_handler(request_id, "turn/interrupt", None, handler)?;
Ok(())
}
}
/// 一个可以安全并发复用的 JSON-RPC transport router。
///
/// 这是一个纯内存/通用 transport 组件:它只负责给每个 outbound request
/// 分配字符串 ID、串行化 writer、在后台 reader 中按 ID 分发 response,及
/// 保存有限数量的 notification。它不接入内部 `ProcessControl`,也不创建
/// 进程或假设 Codex 的 thread/turn 生命周期;真实进程接线仍待上层适配器
/// 后续完成。带 ID 的 server request 当前没有 handler,因此会触发 fail-closed
/// 并唤醒所有 pending request,而不会把它误当成 response。
pub struct JsonRpcAppServerRouter<R, W> {
state: Arc<JsonRpcRouterState>,
writer: Arc<Mutex<W>>,
reader_join: Mutex<Option<std::thread::JoinHandle<()>>>,
next_request_id: AtomicU64,
max_frame_bytes: usize,
// The reader is moved into the background thread during construction; it
// is not shared by callers. A function-pointer marker keeps that fact in
// the auto-trait model, so a non-Sync reader can still back a Sync router.
_reader_type: std::marker::PhantomData<fn() -> R>,
}
struct JsonRpcRouterState {
pending: Mutex<JsonRpcRouterPendingState>,
notifications: Mutex<Receiver<CodexAppServerNotification>>,
stop: AtomicBool,
}
struct JsonRpcRouterPendingState {
terminal: Option<String>,
max_pending_responses: usize,
pending: HashMap<String, SyncSender<Result<Value, String>>>,
}
impl<R, W> std::fmt::Debug for JsonRpcAppServerRouter<R, W> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("JsonRpcAppServerRouter")
.field("max_frame_bytes", &self.max_frame_bytes)
.finish_non_exhaustive()
}
}
impl<R: Read + Send + 'static, W: Write + Send + 'static> JsonRpcAppServerRouter<R, W> {
/// 使用默认 frame、pending response 和 notification 上限创建 router。
pub fn new(reader: R, writer: W) -> Result<Self, CodexError> {
Self::with_limits(
reader,
writer,
DEFAULT_JSON_RPC_FRAME_BYTES,
DEFAULT_MAX_PENDING_NOTIFICATIONS,
DEFAULT_MAX_PENDING_NOTIFICATIONS,
)
}
/// 创建带显式边界的 router。
///
/// `max_pending_responses` 和 `max_notifications` 都必须大于零;写入和
/// 读取的每一行 JSON 也受 `max_frame_bytes` 限制。所有边界一旦被突破,
/// router 就进入终态并唤醒已注册请求,不继续消费未知协议。
pub fn with_limits(
reader: R,
writer: W,
max_frame_bytes: usize,
max_pending_responses: usize,
max_notifications: usize,
) -> Result<Self, CodexError> {
if max_frame_bytes == 0 {
return Err(CodexError::InvalidConfig(
"JSON-RPC router max_frame_bytes 必须大于 0".to_owned(),
));
}
if max_pending_responses == 0 {
return Err(CodexError::InvalidConfig(
"JSON-RPC router max_pending_responses 必须大于 0".to_owned(),
));
}
if max_notifications == 0 {
return Err(CodexError::InvalidConfig(
"JSON-RPC router max_notifications 必须大于 0".to_owned(),
));
}
let (notification_tx, notification_rx) = mpsc::sync_channel(max_notifications);
let state = Arc::new(JsonRpcRouterState {
pending: Mutex::new(JsonRpcRouterPendingState {
terminal: None,
max_pending_responses,
pending: HashMap::with_capacity(max_pending_responses),
}),
notifications: Mutex::new(notification_rx),
stop: AtomicBool::new(false),
});
let reader_state = Arc::clone(&state);
let reader_join = std::thread::Builder::new()
.name("agent-codex-json-rpc-router".to_owned())
.spawn(move || {
json_rpc_router_reader_loop(reader, reader_state, notification_tx, max_frame_bytes)
})
.map_err(|error| {
CodexError::Protocol(format!("JSON-RPC router reader 启动失败: {error}"))
})?;
Ok(Self {
state,
writer: Arc::new(Mutex::new(writer)),
reader_join: Mutex::new(Some(reader_join)),
next_request_id: AtomicU64::new(1),
max_frame_bytes,
_reader_type: std::marker::PhantomData,
})
}
pub fn max_frame_bytes(&self) -> usize {
self.max_frame_bytes
}
/// 发送一个并发安全的 JSON-RPC request。
///
/// 请求先进入有界 pending map,再在 writer mutex 下写出;因此多个调用
/// 可以同时等待各自 response,且 reader 可按任意顺序完成它们。超时会
/// 删除自己的 pending entry;之后到达同一 ID 的 response 会被视为协议
/// 错误并使 router fail-closed。
pub fn request(
&self,
method: impl Into<String>,
params: Value,
timeout: Duration,
) -> Result<Value, CodexError> {
let method = method.into();
if method.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"JSON-RPC router method 不能为空".to_owned(),
));
}
let id_number = self.next_request_id.fetch_add(1, Ordering::Relaxed);
if id_number == u64::MAX {
return Err(CodexError::Protocol(
"JSON-RPC router request id 已耗尽".to_owned(),
));
}
let id = format!("agent-runtime-router-{id_number}");
let (response_tx, response_rx) = mpsc::sync_channel(1);
{
let mut pending =
self.state.pending.lock().map_err(|_| {
CodexError::Protocol("JSON-RPC router pending 锁已损坏".to_owned())
})?;
if let Some(error) = pending.terminal.as_ref() {
return Err(CodexError::Protocol(error.clone()));
}
if pending.pending.len() >= pending.max_pending_responses {
return Err(CodexError::Protocol(
"JSON-RPC router pending response 队列已满".to_owned(),
));
}
pending.pending.insert(id.clone(), response_tx);
}
let write_result = self.write_message(&json!({
"jsonrpc": JSON_RPC_VERSION,
"id": id,
"method": method,
"params": params,
}));
if let Err(error) = write_result {
self.fail_router(format!("JSON-RPC router request 写入失败: {error}"));
// A concurrent cancel/terminate may already have published a more
// useful terminal reason. Prefer it over the lower-level broken
// pipe error so pending callers observe deterministic cancellation.
return Err(self.terminal_error().unwrap_or(error));
}
match response_rx.recv_timeout(timeout) {
Ok(Ok(result)) => Ok(result),
Ok(Err(error)) => Err(CodexError::Protocol(error)),
Err(RecvTimeoutError::Timeout) => {
let removed = self.remove_pending(&id);
if removed {
Err(CodexError::Timeout)
} else {
// A reader failure may win the race with timeout. Prefer
// the terminal reason when one is already available.
Err(self.terminal_error().unwrap_or(CodexError::Timeout))
}
}
Err(RecvTimeoutError::Disconnected) => {
Err(self.terminal_error().unwrap_or_else(|| {
CodexError::Protocol("JSON-RPC router response channel 已关闭".to_owned())
}))
}
}
}
/// 发送真实形状的 `turn/interrupt`。该方法使用 router 自己分配的独立
/// request IDparams 只包含调用方给出的 thread/turn 身份。
pub fn turn_interrupt(
&self,
thread_id: &str,
turn_id: &str,
timeout: Duration,
) -> Result<(), CodexError> {
validate_non_empty_id(thread_id, "JSON-RPC router thread_id")?;
validate_non_empty_id(turn_id, "JSON-RPC router turn_id")?;
self.request(
"turn/interrupt",
json!({"threadId": thread_id, "turnId": turn_id}),
timeout,
)?;
Ok(())
}
/// 非阻塞地消费一条无 id notification。超时只是“当前没有通知”,而
/// reader EOF、非法 frame、未知 response 或队列溢出会返回终态错误。
pub fn try_recv_notification(&self) -> Result<Option<CodexAppServerNotification>, CodexError> {
let receiver = self.state.notifications.lock().map_err(|_| {
CodexError::Protocol("JSON-RPC router notification 锁已损坏".to_owned())
})?;
match receiver.try_recv() {
Ok(notification) => Ok(Some(notification)),
Err(mpsc::TryRecvError::Empty) => self.terminal_error().map_or(Ok(None), Err),
Err(mpsc::TryRecvError::Disconnected) => self.terminal_error().map_or(Ok(None), Err),
}
}
/// 在给定时限内等待一条 notification;返回 `Ok(None)` 表示时限内没有
/// 通知。该方法不处理带 id 的 server request,后者由 reader fail-closed。
pub fn recv_notification(
&self,
timeout: Duration,
) -> Result<Option<CodexAppServerNotification>, CodexError> {
let receiver = self.state.notifications.lock().map_err(|_| {
CodexError::Protocol("JSON-RPC router notification 锁已损坏".to_owned())
})?;
match receiver.recv_timeout(timeout) {
Ok(notification) => Ok(Some(notification)),
Err(RecvTimeoutError::Timeout) => self.terminal_error().map_or(Ok(None), Err),
Err(RecvTimeoutError::Disconnected) => self.terminal_error().map_or(Ok(None), Err),
}
}
/// Close the router and wake every pending request. This only closes the
/// generic transport; a process-backed wrapper must also terminate its
/// child, which is why `CodexAppServerProcessRouter` calls this method
/// together with `ProcessControl` shutdown.
pub fn shutdown(&self, reason: impl Into<String>) {
self.fail_router(reason.into());
}
fn write_message(&self, message: &Value) -> Result<(), CodexError> {
let mut encoded = serde_json::to_vec(message)
.map_err(|error| CodexError::Protocol(format!("JSON-RPC router 编码失败: {error}")))?;
if encoded.len().saturating_add(1) > self.max_frame_bytes {
return Err(CodexError::Protocol(format!(
"JSON-RPC router frame 超过 {} 字节限制",
self.max_frame_bytes
)));
}
encoded.push(b'\n');
let mut writer = self
.writer
.lock()
.map_err(|_| CodexError::Protocol("JSON-RPC router writer 锁已损坏".to_owned()))?;
writer
.write_all(&encoded)
.map_err(|error| CodexError::Protocol(format!("JSON-RPC router 写入失败: {error}")))?;
writer
.flush()
.map_err(|error| CodexError::Protocol(format!("JSON-RPC router flush 失败: {error}")))
}
fn remove_pending(&self, id: &str) -> bool {
self.state
.pending
.lock()
.map(|mut pending| pending.pending.remove(id).is_some())
.unwrap_or(false)
}
fn terminal_error(&self) -> Option<CodexError> {
self.state
.pending
.lock()
.ok()
.and_then(|pending| pending.terminal.clone())
.map(CodexError::Protocol)
}
fn fail_router(&self, reason: String) {
json_rpc_router_fail(&self.state, reason);
}
}
impl<R, W> Drop for JsonRpcAppServerRouter<R, W> {
fn drop(&mut self) {
self.state.stop.store(true, Ordering::Release);
json_rpc_router_fail(&self.state, "JSON-RPC router 已关闭".to_owned());
// std::thread::JoinHandle 没有可取消的 joinpoll is_finished for a
// short bounded grace period, then drop the handle to detach a reader
// whose generic Read implementation is still blocked. Pipe owners
// should be closed by the transport so the normal path joins promptly.
let Some(handle) = self
.reader_join
.lock()
.ok()
.and_then(|mut slot| slot.take())
else {
return;
};
let deadline = Instant::now() + JSON_RPC_ROUTER_JOIN_GRACE;
while !handle.is_finished() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(1));
}
if handle.is_finished() {
let _ = handle.join();
}
}
}
fn json_rpc_router_reader_loop<R: Read>(
reader: R,
state: Arc<JsonRpcRouterState>,
notification_tx: SyncSender<CodexAppServerNotification>,
max_frame_bytes: usize,
) {
let mut reader = BufReader::new(reader);
loop {
if state.stop.load(Ordering::Acquire) {
break;
}
let message = match read_json_rpc_router_message(&mut reader, max_frame_bytes) {
Ok(Some(message)) => message,
Ok(None) => {
json_rpc_router_fail(&state, "JSON-RPC router reader EOF".to_owned());
break;
}
Err(error) => {
json_rpc_router_fail(&state, error.to_string());
break;
}
};
if let Err(error) = route_json_rpc_router_message(&state, &notification_tx, message) {
json_rpc_router_fail(&state, error);
break;
}
}
}
fn route_json_rpc_router_message(
state: &JsonRpcRouterState,
notification_tx: &SyncSender<CodexAppServerNotification>,
message: Value,
) -> Result<(), String> {
validate_json_rpc_envelope(&message).map_err(|error| error.to_string())?;
let method = json_rpc_method(&message).map_err(|error| error.to_string())?;
let id = json_rpc_id(&message).map_err(|error| error.to_string())?;
if let Some(method) = method {
if id.is_some() {
return Err(format!(
"JSON-RPC router 不支持带 id 的 server request: {method}"
));
}
let notification = notification_from_message(method, &message);
notification_tx
.try_send(notification)
.map_err(|error| match error {
TrySendError::Full(_) => "JSON-RPC router notification 队列已满".to_owned(),
TrySendError::Disconnected(_) => {
"JSON-RPC router notification 队列已关闭".to_owned()
}
})?;
return Ok(());
}
let Some(id) = id else {
return Err("JSON-RPC router frame 既不是 notification 也不是 response".to_owned());
};
let Value::String(id) = id else {
return Err("JSON-RPC router response id 必须是字符串".to_owned());
};
if message.get("result").is_none() && message.get("error").is_none() {
return Err("JSON-RPC router response 缺少 result 或 error".to_owned());
}
// Parse the envelope before removing its pending entry. A malformed
// result/error envelope must fail the entire router, while a valid
// JSON-RPC error remains a normal per-request result.
let result = parse_json_rpc_router_response(&message)?;
let sender = {
let mut pending = state
.pending
.lock()
.map_err(|_| "JSON-RPC router pending 锁已损坏".to_owned())?;
if pending.terminal.is_some() {
return Err("JSON-RPC router 已进入终态".to_owned());
}
pending
.pending
.remove(&id)
.ok_or_else(|| format!("JSON-RPC router response id 不匹配或重复: {id}"))?
};
sender
.send(result)
.map_err(|_| "JSON-RPC router response receiver 已关闭".to_owned())
}
fn parse_json_rpc_router_response(message: &Value) -> Result<Result<Value, String>, String> {
if message.get("result").is_some() && message.get("error").is_some() {
return Err("JSON-RPC router response 同时包含 result 和 error".to_owned());
}
if let Some(error) = message.get("error") {
let Some(error_object) = error.as_object() else {
return Err("JSON-RPC router error 必须是对象".to_owned());
};
let Some(code) = error_object.get("code").and_then(Value::as_i64) else {
return Err("JSON-RPC router error code 必须是整数".to_owned());
};
let Some(message_text) = error_object.get("message").and_then(Value::as_str) else {
return Err("JSON-RPC router error message 必须是字符串".to_owned());
};
return Ok(Err(format!(
"JSON-RPC router response error ({code}): {message_text}"
)));
}
message
.get("result")
.cloned()
.map(Ok)
.ok_or_else(|| "JSON-RPC router response 缺少 result 或 error".to_owned())
}
fn json_rpc_router_fail(state: &JsonRpcRouterState, reason: String) {
let senders = {
let Ok(mut pending) = state.pending.lock() else {
return;
};
if pending.terminal.is_some() {
return;
}
pending.terminal = Some(reason.clone());
pending
.pending
.drain()
.map(|(_, sender)| sender)
.collect::<Vec<_>>()
};
state.stop.store(true, Ordering::Release);
for sender in senders {
let _ = sender.send(Err(reason.clone()));
}
}
fn read_json_rpc_router_message<R: Read>(
reader: &mut BufReader<R>,
max_frame_bytes: usize,
) -> Result<Option<Value>, CodexError> {
loop {
let mut bytes = Vec::new();
loop {
let available = reader.fill_buf().map_err(map_json_rpc_read_error)?;
if available.is_empty() {
if bytes.is_empty() {
return Ok(None);
}
break;
}
let newline = available.iter().position(|byte| *byte == b'\n');
let take = newline.map_or(available.len(), |index| index + 1);
if bytes.len().saturating_add(take) > max_frame_bytes {
return Err(CodexError::Protocol(format!(
"JSON-RPC router frame 超过 {max_frame_bytes} 字节限制"
)));
}
bytes.extend_from_slice(&available[..take]);
reader.consume(take);
if newline.is_some() {
break;
}
}
while matches!(bytes.last(), Some(b'\n' | b'\r')) {
bytes.pop();
}
if bytes.iter().all(u8::is_ascii_whitespace) {
continue;
}
let message = serde_json::from_slice::<Value>(&bytes).map_err(|error| {
CodexError::Protocol(format!("JSON-RPC router frame 无效: {error}"))
})?;
if !message.is_object() {
return Err(CodexError::Protocol(
"JSON-RPC router frame 必须是对象".to_owned(),
));
}
validate_optional_json_rpc_version(&message)?;
return Ok(Some(message));
}
}
/// 真实 app-server 进程的共享控制面。
///
/// stdout 由单独 reader 线程持续排空,但线程只通过有界 channel 向协议层交付
/// 字节。`terminate` 会先设置停止标记,再终止 process group、wait/reap child
/// 最后 join reader;因此超时、取消和 `Drop` 都不会留下后台 reader 或孤儿进程。
struct ProcessControl {
child: Mutex<Option<Child>>,
last_exit: Mutex<Option<ExitStatus>>,
process_lifecycle_sink: Mutex<Option<Arc<dyn CodexProcessLifecycleSink>>>,
process_lifecycle_emitted: AtomicBool,
reader_stop: AtomicBool,
writer_stop: AtomicBool,
reader_closed: AtomicBool,
reader_error: AtomicBool,
cancelled: AtomicBool,
terminated: AtomicBool,
forced_termination: AtomicBool,
/// Serializes the short pending/active transition with cancellation.
///
/// `operation_active` alone leaves a race between a backend registering a
/// request and `begin_operation` setting that flag. The backend reserves
/// the operation under this gate, so a matching cancel cannot be mistaken
/// for an idle no-op in that window.
operation_gate: Mutex<()>,
/// Non-zero while a backend has reserved the next client operation. The
/// token keeps a direct public process call from consuming another
/// backend's reservation when both callers race for the client mutex.
operation_pending: AtomicU64,
operation_active: AtomicBool,
deadline: Mutex<Option<Instant>>,
reader_join: Mutex<Option<std::thread::JoinHandle<()>>>,
writer_join: Mutex<Option<std::thread::JoinHandle<()>>>,
}
impl ProcessControl {
fn new(child: Child) -> Self {
Self {
child: Mutex::new(Some(child)),
last_exit: Mutex::new(None),
process_lifecycle_sink: Mutex::new(None),
process_lifecycle_emitted: AtomicBool::new(false),
reader_stop: AtomicBool::new(false),
writer_stop: AtomicBool::new(false),
reader_closed: AtomicBool::new(false),
reader_error: AtomicBool::new(false),
cancelled: AtomicBool::new(false),
terminated: AtomicBool::new(false),
forced_termination: AtomicBool::new(false),
operation_gate: Mutex::new(()),
operation_pending: AtomicU64::new(0),
operation_active: AtomicBool::new(false),
deadline: Mutex::new(None),
reader_join: Mutex::new(None),
writer_join: Mutex::new(None),
}
}
fn install_reader(&self, join: std::thread::JoinHandle<()>) {
if let Ok(mut slot) = self.reader_join.lock() {
*slot = Some(join);
}
}
fn install_writer(&self, join: std::thread::JoinHandle<()>) {
if let Ok(mut slot) = self.writer_join.lock() {
*slot = Some(join);
}
}
fn install_process_lifecycle_sink(&self, sink: Arc<dyn CodexProcessLifecycleSink>) {
if let Ok(mut slot) = self.process_lifecycle_sink.lock() {
*slot = Some(sink);
}
}
/// Emit at most once, after all process-owned locks and worker joins are
/// complete. Sink errors are observational and never change the process
/// result or cancellation classification.
fn emit_process_lifecycle(&self, reason: CodexProcessLifecycleReason) {
let sink = self
.process_lifecycle_sink
.lock()
.ok()
.and_then(|slot| slot.clone());
let Some(sink) = sink else {
return;
};
if self.process_lifecycle_emitted.swap(true, Ordering::AcqRel) {
return;
}
let exit_code = self
.last_exit
.lock()
.ok()
.and_then(|value| value.as_ref().map(process_exit_code));
let _ = sink.record(&CodexProcessLifecycleEvent { reason, exit_code });
}
fn begin_operation(
&self,
timeout: Duration,
reservation: Option<u64>,
) -> Result<(), CodexError> {
// 子进程可能在上一轮响应后立即退出;先尝试回收并把连接标成终态,
// 避免下一轮把已结束的 stdin 当成可复用连接。
// 如果是自然非零退出,退出码仍是最有用的诊断;不能因为调用方
// 恰好晚于 child 调度就把一个已知的 Exit(code) 降级成泛化的
// ProcessTerminated。主动 terminate 的路径不会在这里重新进入操作。
if let Some(status) = self.reap_if_exited()
&& !status.success()
{
return Err(process_exit_error(&status));
}
if self.terminated.load(Ordering::Acquire) {
return Err(CodexError::ProcessTerminated);
}
let deadline = Instant::now()
.checked_add(timeout)
.unwrap_or_else(Instant::now);
let cancelled_during_begin = {
let gate = self.operation_gate.lock().map_err(|_| {
CodexError::Protocol("Codex 进程 supervisor operation 锁已损坏".to_owned())
})?;
if self.operation_active.load(Ordering::Acquire) {
return Err(CodexError::InvalidConfig(
"Codex 进程已有 operation 在执行".to_owned(),
));
}
let pending = self.operation_pending.load(Ordering::Acquire);
match reservation {
Some(token) if pending == token => {
// Only the backend that received this token may consume
// the reservation. A matching cancel leaves `cancelled`
// set so the operation is aborted before dispatch.
self.operation_pending.store(0, Ordering::Release);
}
Some(_) => {
return Err(CodexError::InvalidConfig(
"Codex 进程 operation reservation 已失效".to_owned(),
));
}
None if pending != 0 => {
// A direct public process method must not consume a
// reservation owned by the backend. It can retry after
// the backend has completed its operation.
return Err(CodexError::InvalidConfig(
"Codex 进程已有待处理 backend operation".to_owned(),
));
}
None => {
// An idle direct `process.cancel()` remains a no-op and
// its stale flag is cleared here.
self.cancelled.store(false, Ordering::Release);
}
}
self.operation_active.store(true, Ordering::Release);
match self.deadline.lock() {
Ok(mut value) => {
*value = Some(deadline);
let cancelled = self.cancelled.load(Ordering::Acquire);
if cancelled {
*value = None;
}
cancelled
}
Err(_) => {
self.operation_active.store(false, Ordering::Release);
// deadline 锁损坏时也要收束已经启动的 child,不能把一个
// 无法再设置截止时间的后台进程留给调用方自行猜测。
drop(gate);
self.terminate();
return Err(CodexError::Protocol(
"Codex 进程 supervisor deadline 锁已损坏".to_owned(),
));
}
}
};
if cancelled_during_begin {
self.operation_active.store(false, Ordering::Release);
// 这里的 cancel 可能在 active 标记被观察前到达;直接终止可
// 避免 begin 返回后遗留一个永远没有 reader 操作的 child。
self.terminate_with_reason(CodexProcessLifecycleReason::Cancel);
return Err(CodexError::Interrupted);
}
Ok(())
}
/// Reserve a backend request before it performs the client-locking I/O.
/// This closes the cancellation window between `ExternalBackend::invoke`
/// registration and `begin_operation`. The token prevents an unrelated
/// direct process call from consuming this reservation.
fn reserve_operation(&self) -> Result<u64, CodexError> {
let _gate = self.operation_gate.lock().map_err(|_| {
CodexError::Protocol("Codex 进程 supervisor operation 锁已损坏".to_owned())
})?;
if self.terminated.load(Ordering::Acquire) {
return Err(CodexError::ProcessTerminated);
}
if self.operation_pending.load(Ordering::Acquire) != 0
|| self.operation_active.load(Ordering::Acquire)
{
return Err(CodexError::Protocol(
"Codex 进程已有 operation 在执行".to_owned(),
));
}
self.cancelled.store(false, Ordering::Release);
let token = NEXT_PROCESS_OPERATION_ID.fetch_add(1, Ordering::Relaxed);
// Zero is reserved for “no pending reservation”; wraparound is not
// realistically reachable, but skip it if a long-lived process ever
// exhausts the counter.
let token = if token == 0 { 1 } else { token };
self.operation_pending.store(token, Ordering::Release);
Ok(token)
}
/// Release a reservation when validation fails before `begin_operation`.
fn release_operation_reservation(&self, token: u64) {
if let Ok(_gate) = self.operation_gate.lock() {
if self.operation_pending.load(Ordering::Acquire) == token {
self.operation_pending.store(0, Ordering::Release);
}
if !self.operation_active.load(Ordering::Acquire) {
self.cancelled.store(false, Ordering::Release);
}
}
}
fn finish_operation(&self) {
if let Ok(_gate) = self.operation_gate.lock() {
self.operation_pending.store(0, Ordering::Release);
self.operation_active.store(false, Ordering::Release);
self.cancelled.store(false, Ordering::Release);
if let Ok(mut value) = self.deadline.lock() {
*value = None;
}
}
}
/// Mark a pending/active operation cancelled and report whether the
/// cancellation won the operation race. `None` means the supervisor lock
/// was poisoned, so callers must not claim a successful cancellation.
fn cancel_and_report(&self) -> Option<bool> {
// Keep the pending/active check and flag write under the same gate as
// reserve/begin. A request reserved by the backend is cancellable
// even though its blocking I/O has not started yet.
let (accepted, should_terminate) = match self.operation_gate.lock() {
Ok(_gate) => {
let pending = self.operation_pending.load(Ordering::Acquire);
let active = self.operation_active.load(Ordering::Acquire);
if pending != 0 || active {
self.cancelled.store(true, Ordering::Release);
(true, active)
} else {
// An idle direct cancel is an actual no-op; do not leave a
// flag that can affect a later operation.
(false, false)
}
}
Err(_) => return None,
};
if should_terminate {
self.terminate_with_reason(CodexProcessLifecycleReason::Cancel);
}
Some(accepted)
}
fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Acquire)
}
fn deadline_expired(&self) -> bool {
self.deadline
.lock()
.ok()
.and_then(|value| *value)
.is_some_and(|deadline| Instant::now() >= deadline)
}
fn remaining(&self) -> Option<Duration> {
self.deadline
.lock()
.ok()
.and_then(|value| *value)
.map(|deadline| deadline.saturating_duration_since(Instant::now()))
}
fn child_failure(&self) -> Option<CodexError> {
let status = self
.reap_if_exited()
.or_else(|| {
self.reader_closed
.load(Ordering::Acquire)
.then(|| self.wait_for_exit_grace())
.flatten()
})
.or_else(|| self.last_exit.lock().ok().and_then(|value| *value))?;
if status.success() {
None
} else {
Some(process_exit_error(&status))
}
}
fn exit_code(&self) -> Option<i32> {
if let Some(status) = self.last_exit.lock().ok().and_then(|value| *value) {
return Some(process_exit_code(&status));
}
// stdout EOF and child wait are independent kernel notifications. A
// short grace probe makes the observable exit code deterministic for
// short-lived fixtures without holding a live app-server open.
if self.reader_closed.load(Ordering::Acquire)
&& let Some(status) = self.wait_for_exit_grace()
{
return Some(process_exit_code(&status));
}
None
}
fn wait_for_exit_grace(&self) -> Option<ExitStatus> {
// stdout EOF 与 wait 状态不是同一个内核事件;在短命 fixture 中,
// reader 可能先看到 EOF 而 try_wait 还暂时返回 None。给 child 一个
// 很短的收敛窗口,避免把真实的非零退出误报成泛化协议错误,同时
// 不让一个仍存活的 app-server 挡住后续 terminate。
let deadline = Instant::now() + PROCESS_EXIT_GRACE;
loop {
if let Some(status) = self.reap_if_exited() {
return Some(status);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(1));
}
}
/// 非阻塞检查并回收自然退出的 child。
fn reap_if_exited(&self) -> Option<ExitStatus> {
let status = {
let mut child_slot = self.child.lock().ok()?;
let child = child_slot.as_mut()?;
let status = child.try_wait().ok()??;
// child 已经退出,但它启动的后代可能仍持有 stdout pipe;先
// 收束同一 process group,再 join reader,避免自然 EOF 路径被
// 孤儿后代拖成永久阻塞。没有后代时该 kill 会安全地返回失败。
#[cfg(unix)]
let _ = terminate_process_group(child.id());
// `try_wait` 已确认退出;再调用 wait 取得最终回收语义,并从控制面
// 移除句柄,防止长期持有 zombie/失效 child。
let _ = child.wait();
child_slot.take();
status
};
self.terminated.store(true, Ordering::Release);
self.reader_stop.store(true, Ordering::Release);
self.writer_stop.store(true, Ordering::Release);
if let Ok(mut last_exit) = self.last_exit.lock() {
*last_exit = Some(status);
}
self.join_reader_and_writer();
self.emit_process_lifecycle(CodexProcessLifecycleReason::NaturalExit);
Some(status)
}
fn join_reader_and_writer(&self) {
if let Ok(mut join_slot) = self.reader_join.lock()
&& let Some(join) = join_slot.take()
{
let _ = join.join();
}
if let Ok(mut join_slot) = self.writer_join.lock()
&& let Some(join) = join_slot.take()
{
let _ = join.join();
}
}
fn terminate(&self) {
self.terminate_with_reason(CodexProcessLifecycleReason::ExplicitTerminate);
}
fn terminate_with_reason(&self, reason: CodexProcessLifecycleReason) {
self.forced_termination.store(true, Ordering::Release);
self.terminated.store(true, Ordering::Release);
self.reader_stop.store(true, Ordering::Release);
self.writer_stop.store(true, Ordering::Release);
if let Ok(mut child_slot) = self.child.lock()
&& let Some(mut child) = child_slot.take()
{
// 先观察退出状态;即使 child 已退出,也要在 wait/reap 前收束
// 同一 process group,因为后台后代可能仍持有 stdout/stderr pipe。
// child 尚未 wait,因此其 PID 不能在这里被系统复用。
let already_exited = child.try_wait().ok().flatten().is_some();
if !already_exited {
if let Some(status) = terminate_child(&mut child)
&& let Ok(mut last_exit) = self.last_exit.lock()
{
*last_exit = Some(status);
}
} else {
#[cfg(unix)]
let _ = terminate_process_group(child.id());
if let Ok(status) = child.wait()
&& let Ok(mut last_exit) = self.last_exit.lock()
{
*last_exit = Some(status);
}
}
}
self.join_reader_and_writer();
self.emit_process_lifecycle(reason);
}
}
/// 把真实 child stdout 转成有界、可取消的 `Read`。
///
/// `BufReader::fill_buf` 本身是阻塞 API,所以 process adapter 不直接把
/// `ChildStdout` 交给它,而是由 reader 线程读取固定大小块;读线程在 channel
/// 满时使用 `try_send` 轮询停止标记,取消时不会永远卡在发送上。
struct ProcessReader {
receiver: Receiver<Vec<u8>>,
buffered: std::collections::VecDeque<u8>,
control: Arc<ProcessControl>,
/// Router-owned readers stay alive between requests, so they use a short
/// polling interval instead of the per-operation deadline used by the
/// sequential process client.
persistent: bool,
}
fn spawn_process_reader(
stdout: impl Read + Send + 'static,
control: Arc<ProcessControl>,
) -> (ProcessReader, std::thread::JoinHandle<()>) {
spawn_process_reader_with_mode(stdout, control, false)
}
fn spawn_process_reader_with_mode(
mut stdout: impl Read + Send + 'static,
control: Arc<ProcessControl>,
persistent: bool,
) -> (ProcessReader, std::thread::JoinHandle<()>) {
let (sender, receiver) = mpsc::sync_channel(PROCESS_READER_CHANNEL_CHUNKS);
let reader_control = Arc::clone(&control);
let join = std::thread::spawn(move || {
let mut chunk = [0_u8; PROCESS_READER_CHUNK_BYTES];
loop {
if reader_control.reader_stop.load(Ordering::Acquire) {
break;
}
let count = match stdout.read(&mut chunk) {
Ok(0) => {
reader_control.reader_closed.store(true, Ordering::Release);
break;
}
Err(_) => {
reader_control.reader_error.store(true, Ordering::Release);
reader_control.reader_closed.store(true, Ordering::Release);
break;
}
Ok(count) => count,
};
let mut pending = chunk[..count].to_vec();
loop {
if reader_control.reader_stop.load(Ordering::Acquire) {
return;
}
match sender.try_send(pending) {
Ok(()) => break,
Err(TrySendError::Full(bytes)) => {
pending = bytes;
std::thread::sleep(Duration::from_millis(1));
}
Err(TrySendError::Disconnected(_)) => return,
}
}
}
});
(
ProcessReader {
receiver,
buffered: std::collections::VecDeque::new(),
control,
persistent,
},
join,
)
}
enum ProcessWriteRequest {
Bytes(Vec<u8>, SyncSender<io::Result<usize>>),
Flush(SyncSender<io::Result<usize>>),
}
/// 把 ChildStdin 的阻塞写入移到一个可回收的专用线程。
///
/// app-server 通常会立即读取 stdin,但协议 frame 仍可能达到上限;直接在
/// 调用线程 `write_all` 会让 deadline 失效。这里用小型有界队列和逐请求 ack,
/// 让 timeout/cancel 能通过关闭 child 解除阻塞,再由 supervisor join 线程。
struct ProcessWriter {
sender: SyncSender<ProcessWriteRequest>,
control: Arc<ProcessControl>,
/// Persistent JSON-RPC routers do not reserve one deadline per request;
/// their wrapper still terminates the process when a request times out.
persistent: bool,
}
fn spawn_process_writer(
stdin: ChildStdin,
control: Arc<ProcessControl>,
) -> (ProcessWriter, std::thread::JoinHandle<()>) {
spawn_process_writer_with_mode(stdin, control, false)
}
fn spawn_process_writer_with_mode(
mut stdin: ChildStdin,
control: Arc<ProcessControl>,
persistent: bool,
) -> (ProcessWriter, std::thread::JoinHandle<()>) {
let (sender, receiver) = mpsc::sync_channel(PROCESS_WRITER_CHANNEL_REQUESTS);
let writer_control = Arc::clone(&control);
let join = std::thread::spawn(move || {
loop {
if writer_control.writer_stop.load(Ordering::Acquire) {
break;
}
let request = match receiver.recv_timeout(Duration::from_millis(10)) {
Ok(request) => request,
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => break,
};
match request {
ProcessWriteRequest::Bytes(bytes, ack) => {
let result = stdin.write_all(&bytes).map(|()| bytes.len());
let failed = result.is_err();
let _ = ack.send(result);
if failed {
break;
}
}
ProcessWriteRequest::Flush(ack) => {
let result = stdin.flush();
let failed = result.is_err();
let _ = ack.send(result.map(|()| 0));
if failed {
break;
}
}
}
}
});
(
ProcessWriter {
sender,
control,
persistent,
},
join,
)
}
impl ProcessWriter {
fn remaining(&self) -> io::Result<Duration> {
if self.control.is_cancelled() {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"Codex process cancelled",
));
}
self.control.remaining().ok_or_else(|| {
io::Error::new(
io::ErrorKind::BrokenPipe,
"Codex process operation has no deadline",
)
})
}
fn send_request(
&self,
mut request: ProcessWriteRequest,
ack: Receiver<io::Result<usize>>,
) -> io::Result<usize> {
loop {
if self.persistent
&& (self.control.is_cancelled()
|| self.control.writer_stop.load(Ordering::Acquire)
|| self.control.terminated.load(Ordering::Acquire))
{
return Err(io::Error::new(
if self.control.is_cancelled() {
io::ErrorKind::Interrupted
} else {
io::ErrorKind::BrokenPipe
},
"Codex process writer 已关闭",
));
}
let remaining = if self.persistent {
Duration::from_millis(10)
} else {
let remaining = self.remaining()?;
if remaining.is_zero() {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"Codex process operation timed out",
));
}
remaining
};
match self.sender.try_send(request) {
Ok(()) => break,
Err(TrySendError::Full(value)) => {
request = value;
std::thread::sleep(Duration::from_millis(1).min(remaining));
}
Err(TrySendError::Disconnected(_)) => {
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"Codex process stdin writer 已关闭",
));
}
}
}
loop {
if self.persistent
&& (self.control.is_cancelled()
|| self.control.writer_stop.load(Ordering::Acquire)
|| self.control.terminated.load(Ordering::Acquire))
{
return Err(io::Error::new(
if self.control.is_cancelled() {
io::ErrorKind::Interrupted
} else {
io::ErrorKind::BrokenPipe
},
"Codex process writer 已关闭",
));
}
let remaining = if self.persistent {
Duration::from_millis(10)
} else {
let remaining = self.remaining()?;
if remaining.is_zero() {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"Codex process operation timed out",
));
}
remaining
};
match ack.recv_timeout(remaining) {
Ok(result) => return result,
Err(RecvTimeoutError::Timeout) if self.persistent => continue,
Err(RecvTimeoutError::Timeout) => {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"Codex process operation timed out",
));
}
Err(RecvTimeoutError::Disconnected) => {
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"Codex process stdin writer 已关闭",
));
}
}
}
}
}
impl Write for ProcessWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if bytes.is_empty() {
return Ok(0);
}
let (ack_sender, ack_receiver) = mpsc::sync_channel(1);
self.send_request(
ProcessWriteRequest::Bytes(bytes.to_vec(), ack_sender),
ack_receiver,
)
}
fn flush(&mut self) -> io::Result<()> {
let (ack_sender, ack_receiver) = mpsc::sync_channel(1);
self.send_request(ProcessWriteRequest::Flush(ack_sender), ack_receiver)
.map(|_| ())
}
}
impl Read for ProcessReader {
fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
if output.is_empty() {
return Ok(0);
}
loop {
if !self.buffered.is_empty() {
let count = output.len().min(self.buffered.len());
for slot in &mut output[..count] {
// `count` is bounded by the deque length, so pop always succeeds.
*slot = self.buffered.pop_front().expect("buffered length checked");
}
return Ok(count);
}
if self.control.is_cancelled() {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"Codex process cancelled",
));
}
if self.persistent
&& (self.control.reader_stop.load(Ordering::Acquire)
|| self.control.terminated.load(Ordering::Acquire))
{
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"Codex process reader 已关闭",
));
}
let remaining = if self.persistent {
Duration::from_millis(10)
} else {
let Some(remaining) = self.control.remaining() else {
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"Codex process operation has no deadline",
));
};
if remaining.is_zero() {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"Codex process operation timed out",
));
}
remaining
};
match self.receiver.recv_timeout(remaining) {
Ok(bytes) => self.buffered.extend(bytes),
Err(mpsc::RecvTimeoutError::Timeout) if self.persistent => continue,
Err(mpsc::RecvTimeoutError::Timeout) => {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"Codex process operation timed out",
));
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
// reader 已看到 EOF;立即尝试 wait/reap,避免自然退出的
// child 在长寿命 adapter 中滞留成 zombie。
let _ = self.control.reap_if_exited();
return Ok(0);
}
}
}
}
}
/// 真实 Codex app-server 的最小进程适配器。
///
/// 它复用 [`CodexAppServerClient`] 的窄 V2 JSON-RPC 形状,并把 client 的
/// `Read`/`Write` 接到真实 `std::process::Command` 的 stdout/stdin。配置只接受
/// 显式 program/argv;适配器不解析 shell、不猜测发行版 wire,也不声称兼容
/// 任意 Codex 版本。一次操作超时或取消后,进程会被永久收束,不能自动重连或
/// 重放已经发出的请求。
pub struct CodexAppServerProcess {
client: Mutex<CodexAppServerClient<ProcessReader, ProcessWriter>>,
control: Arc<ProcessControl>,
config: CodexAppServerProcessConfig,
}
impl std::fmt::Debug for CodexAppServerProcess {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CodexAppServerProcess")
.field("program", &redact_arg(&self.config.program))
.field("args", &format!("<{} args>", self.config.args.len()))
.field("protocol", &CodexAppServerProtocol::V2.name())
.field(
"terminated",
&self.control.terminated.load(Ordering::Acquire),
)
.finish()
}
}
impl CodexAppServerProcess {
/// 按显式 argv 启动一个长期 app-server childspawn 成功后才创建 reader。
pub fn spawn(config: CodexAppServerProcessConfig) -> Result<Self, CodexError> {
config.validate()?;
let mut command = Command::new(&config.program);
command
.args(&config.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
// stderr 不进入协议流;丢弃它避免未处理日志填满第二条 pipe。
.stderr(Stdio::null());
configure_process_group(&mut command);
let mut child = command.spawn().map_err(|_| CodexError::Spawn)?;
let stdout = match child.stdout.take() {
Some(stdout) => stdout,
None => {
terminate_child(&mut child);
return Err(CodexError::Spawn);
}
};
let stdin = match child.stdin.take() {
Some(stdin) => stdin,
None => {
terminate_child(&mut child);
return Err(CodexError::Spawn);
}
};
let control = Arc::new(ProcessControl::new(child));
let (reader, join) = spawn_process_reader(stdout, Arc::clone(&control));
control.install_reader(join);
let (writer, writer_join) = spawn_process_writer(stdin, Arc::clone(&control));
control.install_writer(writer_join);
let client = match CodexAppServerClient::with_protocol_and_max_frame_bytes(
reader,
writer,
CodexAppServerProtocol::V2,
config.max_frame_bytes,
) {
Ok(client) => client,
Err(error) => {
control.terminate();
return Err(error);
}
};
Ok(Self {
client: Mutex::new(client),
control,
config,
})
}
pub fn config(&self) -> &CodexAppServerProcessConfig {
&self.config
}
/// Attach a one-shot process lifecycle observer before the first operation.
/// The process remains owned by this value; the observer only receives a
/// best-effort event after natural exit or explicit termination.
pub fn with_process_lifecycle_sink<S>(self, sink: S) -> Self
where
S: CodexProcessLifecycleSink + 'static,
{
self.control.install_process_lifecycle_sink(Arc::new(sink));
self
}
pub const fn protocol(&self) -> CodexAppServerProtocol {
CodexAppServerProtocol::V2
}
/// 返回本地 JSON-RPC client 是否已经完成 `initialize`。
///
/// 这是一个只读生命周期检查,不会向 child 发送 frame,也不会隐式执行
/// 握手。Process backend 用它把“尚未初始化”的调用挡在 dispatch 边界
/// 之前;需要启动握手的调用方仍应显式调用 [`Self::initialize`] 或其
/// 带参数变体。
pub fn is_initialized(&self) -> Result<bool, CodexError> {
self.client
.lock()
.map(|client| client.initialized)
.map_err(|_| CodexError::Protocol("Codex app-server client 锁已损坏".to_owned()))
}
pub fn is_terminated(&self) -> bool {
let _ = self.control.reap_if_exited();
self.control.terminated.load(Ordering::Acquire)
}
/// 返回已观察到的 child 退出码;child 仍运行或退出状态尚未可见时为
/// `None`。被 signal 终止时返回负 signal number,与内部诊断保持一致。
pub fn exit_code(&self) -> Option<i32> {
let _ = self.control.reap_if_exited();
self.control.exit_code()
}
/// 取消当前阻塞中的 client 操作。未知/空闲操作按幂等 no-op 处理;如果确实
/// 有 in-flight 请求,child 会被终止并 reap,后续不能继续复用该连接。
pub fn cancel(&self) -> Result<(), CodexError> {
let _ = self.control.cancel_and_report();
Ok(())
}
fn cancel_and_report(&self) -> Option<bool> {
self.control.cancel_and_report()
}
/// 显式关闭 child;与 `Drop` 相同会终止 process group 并 join reader。
pub fn terminate(&self) {
self.control.terminate();
}
pub fn initialize(&self) -> Result<CodexInitializeResult, CodexError> {
self.with_client(|client| client.initialize())
}
pub fn initialize_with_client_info(
&self,
name: &str,
version: &str,
) -> Result<CodexInitializeResult, CodexError> {
self.with_client(|client| client.initialize_with_client_info(name, version))
}
/// 使用版本适配器编码的原始 `initialize` 参数启动握手。
pub fn initialize_with_params(
&self,
params: Value,
) -> Result<CodexInitializeResult, CodexError> {
self.with_client(|client| client.initialize_with_params(params))
}
/// 初始化并把握手期间的 server request 交给调用方。handler 在持有
/// client 锁时执行,不应从回调中重入此 process 的其它方法。
pub fn initialize_with_client_info_and_handler(
&self,
name: &str,
version: &str,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<CodexInitializeResult, CodexError> {
self.with_client(|client| {
client.initialize_with_client_info_and_handler(name, version, handler)
})
}
/// 参数化握手版本;handler 在持有 client 锁时执行,不应重入此 process。
pub fn initialize_with_params_and_handler(
&self,
params: Value,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<CodexInitializeResult, CodexError> {
self.with_client(|client| client.initialize_with_params_and_handler(params, handler))
}
pub fn thread_start(
&self,
params: CodexThreadStartParams,
) -> Result<CodexThreadStartResult, CodexError> {
self.with_client(|client| client.thread_start(params))
}
pub fn thread_start_with_handler(
&self,
params: CodexThreadStartParams,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<CodexThreadStartResult, CodexError> {
self.with_client(|client| client.thread_start_with_handler(params, handler))
}
pub fn turn_start(
&self,
params: CodexTurnStartParams,
) -> Result<CodexTurnStartResult, CodexError> {
self.with_client(|client| client.turn_start(params))
}
pub fn turn_start_with_handler(
&self,
params: CodexTurnStartParams,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<CodexTurnStartResult, CodexError> {
self.with_client(|client| client.turn_start_with_handler(params, handler))
}
pub fn turn_interrupt(&self, thread_id: &str, turn_id: &str) -> Result<(), CodexError> {
self.with_client(|client| client.turn_interrupt(thread_id, turn_id))
}
pub fn turn_interrupt_with_handler(
&self,
thread_id: &str,
turn_id: &str,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<(), CodexError> {
self.with_client(|client| client.turn_interrupt_with_handler(thread_id, turn_id, handler))
}
pub fn poll_notification(&self) -> Result<Option<CodexAppServerNotification>, CodexError> {
self.with_client(|client| client.poll_notification())
}
pub fn poll_notification_with_handler(
&self,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<Option<CodexAppServerNotification>, CodexError> {
self.with_client(|client| client.poll_notification_with_handler(handler))
}
/// 发送一个由具体版本适配器编码的请求,并持续处理 server request。
pub fn request_with_server_handler(
&self,
method: &str,
params: Value,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<Value, CodexError> {
self.with_client(|client| client.request_with_server_handler(method, params, handler))
}
/// Internal backend path that consumes the reservation created before the
/// caller released its request-registration lock. Keeping this separate
/// from the public method prevents an unrelated direct process operation
/// from accidentally consuming that reservation.
fn request_with_server_handler_reserved(
&self,
reservation: u64,
method: &str,
params: Value,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<Value, CodexError> {
self.with_client_reserved(reservation, |client| {
client.request_with_server_handler(method, params, handler)
})
}
/// Internal process-backend path that also forwards response-time
/// notifications as neutral `NodeEvent` values. The reservation keeps the
/// process operation bound to the request registered by the backend.
fn request_with_server_handler_reserved_with_events(
&self,
reservation: u64,
method: &str,
params: Value,
event_request_id: &str,
events: &mut dyn FnMut(NodeEvent),
handler: &mut dyn CodexServerRequestHandler,
) -> Result<Value, CodexError> {
self.with_client_reserved(reservation, |client| {
client.request_with_events_and_server_handler(
method,
params,
event_request_id,
events,
handler,
)
})
}
fn with_client<T>(
&self,
operation: impl FnOnce(
&mut CodexAppServerClient<ProcessReader, ProcessWriter>,
) -> Result<T, CodexError>,
) -> Result<T, CodexError> {
self.with_client_reservation(None, operation)
}
fn with_client_reserved<T>(
&self,
reservation: u64,
operation: impl FnOnce(
&mut CodexAppServerClient<ProcessReader, ProcessWriter>,
) -> Result<T, CodexError>,
) -> Result<T, CodexError> {
self.with_client_reservation(Some(reservation), operation)
}
fn with_client_reservation<T>(
&self,
reservation: Option<u64>,
operation: impl FnOnce(
&mut CodexAppServerClient<ProcessReader, ProcessWriter>,
) -> Result<T, CodexError>,
) -> Result<T, CodexError> {
let mut client = self
.client
.lock()
.map_err(|_| CodexError::Protocol("Codex app-server client 锁已损坏".to_owned()))?;
self.control
.begin_operation(self.config.timeout(), reservation)?;
let result = operation(&mut client);
// 即使本轮拿到了合法响应,fixture/child 也可能随后正常退出;尽早
// 回收可避免把已结束的进程句柄留到下一次调用或 Drop。
let _ = self.control.reap_if_exited();
let cancelled = self.control.is_cancelled();
let timed_out =
matches!(result, Err(CodexError::Timeout)) || self.control.deadline_expired();
self.control.finish_operation();
// EOF and the child's wait status are delivered by different kernel
// paths. Inspect the status (including the short EOF grace window)
// before killing an apparently idle process; otherwise a natural
// non-zero exit can race with reader EOF and be misreported as a
// supervisor-forced termination.
let child_error = if !cancelled && !timed_out {
self.control.child_failure()
} else {
None
};
// EOF 表示这条 stdio 连接已经不可再用。若 child 尚未自行退出(例如
// 它关闭 stdout 后仍保留后台工作),也要收束 process group,避免
// `poll_notification -> None` 留下一个没有协议通道的孤儿进程。
let eof_shutdown = self.control.reader_closed.load(Ordering::Acquire)
&& !self.control.terminated.load(Ordering::Acquire);
if eof_shutdown {
let reason = if self.control.reader_error.load(Ordering::Acquire) {
CodexProcessLifecycleReason::ReaderError
} else {
CodexProcessLifecycleReason::ReaderEof
};
self.control.terminate_with_reason(reason);
}
if cancelled {
self.control
.terminate_with_reason(CodexProcessLifecycleReason::Cancel);
return Err(CodexError::Interrupted);
}
if timed_out {
self.control
.terminate_with_reason(CodexProcessLifecycleReason::Timeout);
return Err(CodexError::Timeout);
}
if self.control.forced_termination.load(Ordering::Acquire) && !eof_shutdown {
return Err(CodexError::ProcessTerminated);
}
// 即使操作已经拿到一个看似合法的响应,也要检查本轮是否紧接着
// 以非零状态退出;否则 `thread/start` 的成功值会掩盖 child failure。
if let Some(exit_error) = child_error.or_else(|| self.control.child_failure()) {
self.control.terminate();
return Err(exit_error);
}
match result {
Ok(value) => Ok(value),
Err(error) if matches!(&error, CodexError::Protocol(_)) => {
// 协议流一旦失步,不能把同一条连接交给下一次请求;即使
// child 仍活着,也先收束 process group,避免残留 frame
// 被错误地解释成下一次 response。
self.control.terminate();
Err(error)
}
Err(error) => Err(error),
}
}
}
/// 使用通用并发 JSON-RPC router 的 app-server 进程接缝。
///
/// 与 [`CodexAppServerProcess`] 的窄、版本中立 client 不同,这个入口把同一
/// `ProcessControl` 接到可并发的 [`JsonRpcAppServerRouter`]:每个请求可以独立
/// 等待 responsenotification 由有界队列承接,超时/协议错误会终止并回收
/// child。调用方仍需自行发送适配器所需的 `initialize`/`initialized` frame
/// 这里不猜测某个 Codex 发行版的 wire schema。
pub struct CodexAppServerProcessRouter {
router: JsonRpcAppServerRouter<ProcessReader, ProcessWriter>,
control: Arc<ProcessControl>,
config: CodexAppServerProcessConfig,
}
impl std::fmt::Debug for CodexAppServerProcessRouter {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CodexAppServerProcessRouter")
.field("program", &redact_arg(&self.config.program))
.field("args", &format!("<{} args>", self.config.args.len()))
.field("router", &self.router)
.field(
"terminated",
&self.control.terminated.load(Ordering::Acquire),
)
.finish()
}
}
impl CodexAppServerProcessRouter {
/// 按显式 argv 启动一个支持并发请求的长连接 app-server。
pub fn spawn(config: CodexAppServerProcessConfig) -> Result<Self, CodexError> {
config.validate()?;
let mut command = Command::new(&config.program);
command
.args(&config.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
configure_process_group(&mut command);
let mut child = command.spawn().map_err(|_| CodexError::Spawn)?;
let stdout = match child.stdout.take() {
Some(stdout) => stdout,
None => {
terminate_child(&mut child);
return Err(CodexError::Spawn);
}
};
let stdin = match child.stdin.take() {
Some(stdin) => stdin,
None => {
terminate_child(&mut child);
return Err(CodexError::Spawn);
}
};
let control = Arc::new(ProcessControl::new(child));
// A router request owns its own timeout, so the process I/O adapters
// use short cancellation polls instead of the sequential deadline.
let (reader, reader_join) =
spawn_process_reader_with_mode(stdout, Arc::clone(&control), true);
control.install_reader(reader_join);
let (writer, writer_join) =
spawn_process_writer_with_mode(stdin, Arc::clone(&control), true);
control.install_writer(writer_join);
let router = match JsonRpcAppServerRouter::with_limits(
reader,
writer,
config.max_frame_bytes,
DEFAULT_MAX_PENDING_NOTIFICATIONS,
DEFAULT_MAX_PENDING_NOTIFICATIONS,
) {
Ok(router) => router,
Err(error) => {
control.terminate();
return Err(error);
}
};
Ok(Self {
router,
control,
config,
})
}
pub fn config(&self) -> &CodexAppServerProcessConfig {
&self.config
}
/// 安装一个与窄 process adapter 相同的 emit-once 生命周期观察器。
pub fn with_process_lifecycle_sink<S>(self, sink: S) -> Self
where
S: CodexProcessLifecycleSink + 'static,
{
self.control.install_process_lifecycle_sink(Arc::new(sink));
self
}
pub fn request(
&self,
method: impl Into<String>,
params: Value,
timeout: Duration,
) -> Result<Value, CodexError> {
self.finish_router_result(self.router.request(method, params, timeout))
}
pub fn turn_interrupt(
&self,
thread_id: &str,
turn_id: &str,
timeout: Duration,
) -> Result<(), CodexError> {
self.finish_router_result(self.router.turn_interrupt(thread_id, turn_id, timeout))
}
pub fn try_recv_notification(&self) -> Result<Option<CodexAppServerNotification>, CodexError> {
self.finish_router_result(self.router.try_recv_notification())
}
pub fn recv_notification(
&self,
timeout: Duration,
) -> Result<Option<CodexAppServerNotification>, CodexError> {
self.finish_router_result(self.router.recv_notification(timeout))
}
/// Router operations are not tied to the sequential operation reservation;
/// cancellation therefore closes the child directly and remains bounded.
pub fn cancel(&self) -> Result<(), CodexError> {
self.router
.shutdown("Codex app-server router 已取消".to_owned());
self.control
.terminate_with_reason(CodexProcessLifecycleReason::Cancel);
Ok(())
}
pub fn terminate(&self) {
self.router
.shutdown("Codex app-server router 已终止".to_owned());
self.control
.terminate_with_reason(CodexProcessLifecycleReason::ExplicitTerminate);
}
pub fn is_terminated(&self) -> bool {
let _ = self.control.reap_if_exited();
self.control.terminated.load(Ordering::Acquire)
}
pub fn exit_code(&self) -> Option<i32> {
let _ = self.control.reap_if_exited();
self.control.exit_code()
}
fn finish_router_result<T>(&self, result: Result<T, CodexError>) -> Result<T, CodexError> {
if let Err(error) = &result
&& matches!(
error,
CodexError::Protocol(_)
| CodexError::Timeout
| CodexError::Interrupted
| CodexError::ProcessTerminated
| CodexError::Exit(_)
| CodexError::Signal(_)
)
&& !is_scoped_router_error(error)
{
self.shutdown_after_router_error();
}
result
}
fn shutdown_after_router_error(&self) {
if self.control.reader_closed.load(Ordering::Acquire)
&& self.control.reap_if_exited().is_some()
{
return;
}
if !self.control.terminated.load(Ordering::Acquire) {
let reason = if self.control.reader_error.load(Ordering::Acquire) {
CodexProcessLifecycleReason::ReaderError
} else {
CodexProcessLifecycleReason::ExplicitTerminate
};
self.control.terminate_with_reason(reason);
}
}
}
fn is_scoped_router_error(error: &CodexError) -> bool {
// A valid JSON-RPC error response belongs to one request and does not
// poison the stream; malformed envelopes and transport failures still do.
matches!(
error,
CodexError::Protocol(message)
if message.starts_with("JSON-RPC router response error (")
)
}
impl Drop for CodexAppServerProcessRouter {
fn drop(&mut self) {
self.router
.shutdown("Codex app-server router 已关闭".to_owned());
if self.control.reader_closed.load(Ordering::Acquire)
&& self.control.reap_if_exited().is_some()
{
return;
}
if !self.control.terminated.load(Ordering::Acquire) {
self.control
.terminate_with_reason(CodexProcessLifecycleReason::Drop);
}
}
}
/// 由 Codex app-server 分配、可由宿主持久化的真实 session 身份。
///
/// 这是故意保持中立的 durable metadata:它只表达 thread/turn 身份,不携带
/// SQLite、Runtime 或某个 Codex 发行版的完整 wire 状态。
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct CodexSessionMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turn_id: Option<String>,
}
impl CodexSessionMetadata {
pub fn from_thread_start(result: &CodexThreadStartResult) -> Self {
Self {
thread_id: Some(result.thread_id.clone()),
turn_id: None,
}
}
pub fn from_turn_start(result: &CodexTurnStartResult) -> Self {
Self {
thread_id: None,
turn_id: Some(result.turn_id.clone()),
}
}
fn validate(&self) -> Result<(), CodexError> {
if self
.thread_id
.as_deref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(CodexError::InvalidConfig(
"Codex session metadata thread_id 不能为空".to_owned(),
));
}
if self
.turn_id
.as_deref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(CodexError::InvalidConfig(
"Codex session metadata turn_id 不能为空".to_owned(),
));
}
Ok(())
}
fn merge(&mut self, update: Self) {
if update.thread_id.is_some() {
self.thread_id = update.thread_id;
}
if update.turn_id.is_some() {
self.turn_id = update.turn_id;
}
}
}
/// Codex 外部会话的生命周期状态;它不等同于 Core run 状态。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CodexSessionLifecycleStatus {
Active,
Completed,
Failed,
Cancelled,
Unknown,
}
impl CodexSessionLifecycleStatus {
pub const fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
Self::Unknown => "unknown",
}
}
}
/// 一次 Codex process/backend 收束时传给宿主的中立生命周期观察值。
#[derive(Clone, Debug, PartialEq)]
pub struct CodexSessionLifecycle {
pub metadata: CodexSessionMetadata,
pub status: CodexSessionLifecycleStatus,
pub external_id: Option<String>,
pub exit_code: Option<i32>,
pub cancel_result: Option<String>,
}
/// 宿主提供的 metadata 持久化接口;agent-codex 不依赖具体 Store 实现。
pub trait CodexSessionMetadataSink: Send + Sync {
fn persist(&self, metadata: &CodexSessionMetadata) -> Result<(), CodexError>;
/// 生命周期扩展保持默认实现,避免破坏只关心 thread/turn 的旧宿主。
/// 没有任何远端身份时不要求旧 sink 凭空创建一条 durable 记录。
/// 实现应把它视为一次观察回调,不要同步重入同一个 backend。
fn persist_lifecycle(&self, lifecycle: &CodexSessionLifecycle) -> Result<(), CodexError> {
if lifecycle.metadata.thread_id.is_none() && lifecycle.metadata.turn_id.is_none() {
return Ok(());
}
self.persist(&lifecycle.metadata)
}
/// Optional process-level observation. Legacy metadata-only sinks keep a
/// no-op default; hosts that need process exit/termination audit can opt in.
fn persist_process_lifecycle(
&self,
_event: &CodexProcessLifecycleEvent,
) -> Result<(), CodexError> {
Ok(())
}
}
struct SessionProcessLifecycleForwarder {
sink: Arc<dyn CodexSessionMetadataSink>,
}
impl CodexProcessLifecycleSink for SessionProcessLifecycleForwarder {
fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> {
self.sink.persist_process_lifecycle(event)
}
}
/// 把一个已经完成握手的 app-server 进程接到 Core 的同步
/// [`ExternalBackend`] 端口。
///
/// 这是一个有意很窄的桥:`BackendRequest::operation` 会原样作为 JSON-RPC
/// method`payload` 会原样作为 params;本类型不猜测 Codex 版本 schema,也
/// 不把 Core 的 request ID 改写成 wire ID(底层 client 会自行分配数字 ID)。
/// 调用方必须先完成版本适配器所需的 `initialize`,或者使用
/// [`Self::new_initialized`] 的默认握手。默认 `invoke` 使用拒绝式
/// [`CodexServerRequestHandler`];需要审批/工具等 server request 的宿主必须
/// 显式调用 [`Self::invoke_with_handler`],不能因 method 被识别就自动放行。
///
/// 一个实例同时只允许一个 Core request。`cancel` 只匹配当前 request ID,且
/// 取消语义是终止整个 app-server child;已经写入 wire 的请求结果因此按
/// `UnknownSideEffect` 交给 Runtime 对账,不会被当成可安全重放。
pub struct CodexAppServerProcessBackend {
process: CodexAppServerProcess,
active_request_id: Mutex<Option<String>>,
/// Typed `thread/start`/`turn/start` 调用得到的真实身份快照。
session_metadata: Mutex<CodexSessionMetadata>,
/// 宿主可选的持久化出口;适配器不绑定具体数据库或 Runtime Store。
session_metadata_sink: Option<Arc<dyn CodexSessionMetadataSink>>,
/// Backend-instance nonce keeps durable external IDs distinct after an
/// application restart, where the in-process request counter resets.
instance_nonce: String,
}
impl std::fmt::Debug for CodexAppServerProcessBackend {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let active = self
.active_request_id
.lock()
.ok()
.and_then(|value| value.as_ref().map(|_| true))
.unwrap_or(false);
formatter
.debug_struct("CodexAppServerProcessBackend")
.field("process", &self.process)
.field("active_request", &active)
.field(
"session_metadata_sink",
&self.session_metadata_sink.is_some(),
)
.finish()
}
}
impl CodexAppServerProcessBackend {
/// 使用已完成 `initialize` 的 process 构造桥接器。
///
/// 自定义版本适配器应先调用 `initialize_with_params*`,再使用本构造器;
/// 构造器不会偷偷发送第二次握手。
pub fn from_initialized(process: CodexAppServerProcess) -> Result<Self, CodexError> {
if !process.is_initialized()? {
return Err(CodexError::InvalidConfig(
"Codex process backend 要求先完成 initialize".to_owned(),
));
}
Ok(Self {
process,
active_request_id: Mutex::new(None),
session_metadata: Mutex::new(CodexSessionMetadata::default()),
session_metadata_sink: None,
instance_nonce: new_backend_nonce(),
})
}
/// 完成默认 clientInfo 握手后构造桥接器。
///
/// 需要具体发行版参数或 server-request handler 的调用方应使用
/// [`Self::from_initialized`],自行完成版本化握手后再接入。
pub fn new_initialized(process: CodexAppServerProcess) -> Result<Self, CodexError> {
process.initialize()?;
Self::from_initialized(process)
}
pub fn process(&self) -> &CodexAppServerProcess {
&self.process
}
pub fn into_process(self) -> CodexAppServerProcess {
self.process
}
/// 安装一个中立的 session metadata 持久化出口。
///
/// 这里用泛型接收宿主自己的 Store,避免让 agent-codex 依赖 SQLite、
/// Host 或 Runtime。每次 typed `thread_start`/`turn_start` 成功后,sink
/// 会收到合并后的完整快照;sink 失败会原样返回给调用方。
pub fn with_session_metadata_sink<S>(mut self, sink: S) -> Self
where
S: CodexSessionMetadataSink + 'static,
{
let sink: Arc<dyn CodexSessionMetadataSink> = Arc::new(sink);
self.process
.control
.install_process_lifecycle_sink(Arc::new(SessionProcessLifecycleForwarder {
sink: Arc::clone(&sink),
}));
self.session_metadata_sink = Some(sink);
self
}
/// 读取当前已提取的真实 Codex session 身份。
pub fn session_metadata(&self) -> Result<CodexSessionMetadata, CodexError> {
self.session_metadata
.lock()
.map(|metadata| metadata.clone())
.map_err(|_| CodexError::Protocol("Codex session metadata 锁已损坏".to_owned()))
}
/// 合并并记录一份身份更新。这个入口也允许宿主从已有 Store 恢复快照;
/// 只有非空字段会覆盖旧值,避免一次部分响应清掉另一个真实 ID。
pub fn record_session_metadata(&self, update: CodexSessionMetadata) -> Result<(), CodexError> {
update.validate()?;
let mut metadata = self
.session_metadata
.lock()
.map_err(|_| CodexError::Protocol("Codex session metadata 锁已损坏".to_owned()))?;
let mut merged = metadata.clone();
merged.merge(update);
if merged == *metadata {
return Ok(());
}
if let Some(sink) = self.session_metadata_sink.as_ref() {
// 先落持久化,再替换内存快照;这样返回成功时两者保持一致。
sink.persist(&merged)?;
}
*metadata = merged;
Ok(())
}
fn persist_lifecycle(
&self,
status: CodexSessionLifecycleStatus,
external_id: Option<String>,
cancel_result: Option<String>,
) -> Result<(), CodexError> {
let Some(sink) = self.session_metadata_sink.as_ref() else {
return Ok(());
};
let metadata = self.session_metadata()?;
sink.persist_lifecycle(&CodexSessionLifecycle {
metadata,
status,
external_id,
exit_code: self.process.exit_code(),
cancel_result,
})
}
/// 通过 typed app-server 方法创建 thread,并提取服务端分配的真实 ID。
pub fn thread_start(
&self,
params: CodexThreadStartParams,
) -> Result<CodexThreadStartResult, CodexError> {
let result = self.process.thread_start(params)?;
self.record_session_metadata(CodexSessionMetadata::from_thread_start(&result))?;
Ok(result)
}
/// 带 server-request handler 的 thread 创建入口。
pub fn thread_start_with_handler(
&self,
params: CodexThreadStartParams,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<CodexThreadStartResult, CodexError> {
let result = self.process.thread_start_with_handler(params, handler)?;
self.record_session_metadata(CodexSessionMetadata::from_thread_start(&result))?;
Ok(result)
}
/// 通过 typed app-server 方法启动 turn,并提取服务端分配的真实 ID。
pub fn turn_start(
&self,
params: CodexTurnStartParams,
) -> Result<CodexTurnStartResult, CodexError> {
let result = self.process.turn_start(params)?;
self.record_session_metadata(CodexSessionMetadata::from_turn_start(&result))?;
Ok(result)
}
/// 带 server-request handler 的 turn 启动入口。
pub fn turn_start_with_handler(
&self,
params: CodexTurnStartParams,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<CodexTurnStartResult, CodexError> {
let result = self.process.turn_start_with_handler(params, handler)?;
self.record_session_metadata(CodexSessionMetadata::from_turn_start(&result))?;
Ok(result)
}
/// 在调用方提供 server-request handler 时执行一次原始 JSON-RPC method。
///
/// handler 在 process 的 client 锁内执行,不能从回调重入 `process()` 的
/// 请求方法;默认实现见 [`ExternalBackend::invoke`] 的拒绝路径。
pub fn invoke_with_handler(
&self,
request: &BackendRequest,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<BackendResult, ExternalError> {
self.invoke_with_node_events_optional(request, None, handler)
}
/// Execute one process request and forward response-time notifications as
/// neutral `NodeEvent` values. The event callback is observational only;
/// callers that need Core state changes must explicitly map and persist the
/// events (for example through [`NodeRuntimeEventMapper`]).
pub fn invoke_with_node_events(
&self,
request: &BackendRequest,
events: &mut dyn FnMut(NodeEvent),
handler: &mut dyn CodexServerRequestHandler,
) -> Result<BackendResult, ExternalError> {
self.invoke_with_node_events_optional(request, Some(events), handler)
}
fn invoke_with_node_events_optional(
&self,
request: &BackendRequest,
events: Option<&mut dyn FnMut(NodeEvent)>,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<BackendResult, ExternalError> {
// BackendRequest 目前主要由 Core 构造,但它也实现 Deserialize;在
// 适配器边界再次构造 NodeRequest,确保反序列化数据不会绕过 method/ID
// 校验而进入子进程。
let node = NodeRequest::try_new(
request.request_id().to_owned(),
request.operation().to_owned(),
request.payload().clone(),
)
.map_err(|error| ExternalError::new(ExternalErrorKind::InvalidInput, error.to_string()))?;
let reservation = self.begin_request(node.request_id.as_str())?;
let initialized = match self.process.is_initialized() {
Ok(initialized) => initialized,
Err(error) => {
self.end_request(node.request_id.as_str(), reservation);
// 读取本地生命周期状态尚未触碰 wire;保留该错误的
// pre-dispatch 类别(例如 poisoned lock -> InvalidInput),
// 不要误报成未知远端副作用。
return Err(ExternalError::new(
error.external_error_kind(),
error.to_string(),
));
}
};
if !initialized {
self.end_request(node.request_id.as_str(), reservation);
return Err(ExternalError::new(
ExternalErrorKind::InvalidInput,
"Codex process backend 要求先完成 initialize",
));
}
let result = if let Some(events) = events {
self.process
.request_with_server_handler_reserved_with_events(
reservation,
&node.operation,
node.payload,
&node.request_id,
events,
handler,
)
} else {
self.process.request_with_server_handler_reserved(
reservation,
&node.operation,
node.payload,
handler,
)
};
self.end_request(node.request_id.as_str(), reservation);
let output = match result {
Ok(output) => output,
Err(error) => {
// 这里已经完成 initialize 并进入真实 child 的请求路径;即使
// handler 返回 InvalidConfig,也不能证明远端没有执行副作用。
// handler 错误会让本轮 response 不再继续 drain,因而这条 stdio
// 连接不能安全复用;连同分类一起收束 child,避免遗留孤儿进程
// 或把本轮残留 response 当成下一次请求的结果。
self.process.terminate();
let mapped = ExternalError::new(
error.external_error_kind_after_process_dispatch(),
error.to_string(),
);
// A sink failure must not hide the original process/protocol
// classification; the request is already in reconciliation.
// A concrete child exit is a failed process observation even
// though the external operation itself remains unknown.
let lifecycle_status = match error {
CodexError::Exit(_) | CodexError::Signal(_) => {
CodexSessionLifecycleStatus::Failed
}
_ => CodexSessionLifecycleStatus::Unknown,
};
let _ = self.persist_lifecycle(lifecycle_status, None, None);
return Err(mapped);
}
};
let value = match BackendResult::try_new(request.request_id(), output) {
Ok(value) => value,
Err(error) => {
// 返回值契约失败同样不能证明请求没有被执行;交给 reconciliation。
let _ = self.persist_lifecycle(CodexSessionLifecycleStatus::Unknown, None, None);
return Err(ExternalError::new(
ExternalErrorKind::UnknownSideEffect,
error.to_string(),
));
}
};
let external_id = format!(
"codex-app-server-{}-{}",
self.instance_nonce,
NEXT_APP_SERVER_BACKEND_ID.fetch_add(1, Ordering::Relaxed),
);
let value = value.with_external_id(external_id).map_err(|error| {
ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string())
})?;
// `thread/start` and `turn/start` responses are acceptance events for
// a long-lived app-server. Keep the external session active while
// that child is still alive; a short-lived fixture that exits after
// its response is treated as completed instead.
let process_terminated = self.process.is_terminated();
let status = if value.side_effect_unknown() {
CodexSessionLifecycleStatus::Unknown
} else if !process_terminated
&& matches!(request.operation(), "thread/start" | "turn/start")
{
CodexSessionLifecycleStatus::Active
} else {
CodexSessionLifecycleStatus::Completed
};
self.persist_lifecycle(status, value.external_id().map(str::to_owned), None)
.map_err(|error| {
ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string())
})?;
Ok(value)
}
/// Explicitly bridge a process-backed request into Core runtime events.
/// Request, response-time notification, and final result are emitted in
/// order; this method does not persist events or create another runtime.
pub fn invoke_with_runtime_events(
&self,
request: &BackendRequest,
mapper: &mut NodeRuntimeEventMapper,
sink: &mut dyn FnMut(RuntimeEvent),
) -> Result<BackendResult, ExternalError> {
let mut handler = RejectingServerRequestHandler;
self.invoke_with_runtime_events_and_handler(request, mapper, sink, &mut handler)
}
/// Runtime-event bridge variant with an explicit server-request handler.
pub fn invoke_with_runtime_events_and_handler(
&self,
request: &BackendRequest,
mapper: &mut NodeRuntimeEventMapper,
sink: &mut dyn FnMut(RuntimeEvent),
handler: &mut dyn CodexServerRequestHandler,
) -> Result<BackendResult, ExternalError> {
let node = NodeRequest::try_new(
request.request_id().to_owned(),
request.operation().to_owned(),
request.payload().clone(),
)
.map_err(|error| ExternalError::new(ExternalErrorKind::InvalidInput, error.to_string()))?;
let request_event = mapper.map_request(&node).map_err(|error| {
ExternalError::new(ExternalErrorKind::InvalidInput, error.to_string())
})?;
sink(request_event);
let mut mapping_error = None;
let result = self.invoke_with_node_events(
request,
&mut |event| {
if mapping_error.is_some() {
return;
}
match mapper.map_event(&event) {
Ok(runtime_event) => sink(runtime_event),
Err(error) => mapping_error = Some(error),
}
},
handler,
)?;
if let Some(error) = mapping_error {
return Err(ExternalError::new(
ExternalErrorKind::UnknownSideEffect,
error.to_string(),
));
}
let result_event = mapper
.map_result(&NodeResult {
request_id: result.request_id().to_owned(),
output: result.output().clone(),
side_effect_unknown: result.side_effect_unknown(),
})
.map_err(|error| {
ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string())
})?;
sink(result_event);
Ok(result)
}
fn begin_request(&self, request_id: &str) -> Result<u64, ExternalError> {
let mut active = self.active_request_id.lock().map_err(|_| {
ExternalError::new(
ExternalErrorKind::Unavailable,
"Codex process backend active request 锁已损坏",
)
})?;
if active.is_some() {
return Err(ExternalError::new(
ExternalErrorKind::InvalidInput,
"Codex process backend 同时只允许一个 request",
));
}
// Reserve the process operation while the active-request lock is held.
// `cancel` takes this lock before inspecting the reservation, so it
// cannot slip between registration and ProcessControl::begin_operation.
let reservation =
self.process.control.reserve_operation().map_err(|error| {
ExternalError::new(error.external_error_kind(), error.to_string())
})?;
*active = Some(request_id.to_owned());
Ok(reservation)
}
fn end_request(&self, request_id: &str, reservation: u64) {
if let Ok(mut active) = self.active_request_id.lock()
&& active.as_deref() == Some(request_id)
{
*active = None;
// Clear a reservation left by a pre-dispatch validation failure;
// a started operation is cleared by finish_operation itself.
self.process
.control
.release_operation_reservation(reservation);
}
}
}
impl ExternalBackend for CodexAppServerProcessBackend {
fn invoke(&self, request: &BackendRequest) -> Result<BackendResult, ExternalError> {
let mut handler = RejectingServerRequestHandler;
self.invoke_with_handler(request, &mut handler)
}
fn cancel(&self, request_id: &str) -> Result<(), ExternalError> {
if request_id.trim().is_empty() {
return Err(ExternalError::new(
ExternalErrorKind::InvalidInput,
"Codex cancel request_id 不能为空",
));
}
let matches_active = {
let active = self.active_request_id.lock().map_err(|_| {
ExternalError::new(
ExternalErrorKind::Unavailable,
"Codex process backend active request 锁已损坏",
)
})?;
active.as_deref() == Some(request_id)
};
if matches_active {
// 只在 request ID 匹配时终止 child;未知 ID 按幂等 no-op 处理,避免
// 一个迟到的取消误杀另一个已开始的 app-server 操作。
match self.process.cancel_and_report() {
Some(true) => {
let _ = self.persist_lifecycle(
CodexSessionLifecycleStatus::Cancelled,
None,
Some("ok".to_owned()),
);
}
Some(false) => {
// The request completed before this late cancel acquired
// the process gate; do not overwrite a completed lifecycle.
}
None => {
let _ = self.persist_lifecycle(
CodexSessionLifecycleStatus::Unknown,
None,
Some("unknown".to_owned()),
);
return Err(ExternalError::new(
ExternalErrorKind::Unavailable,
"Codex process cancel 状态锁已损坏",
));
}
}
}
Ok(())
}
}
impl Drop for CodexAppServerProcess {
fn drop(&mut self) {
self.control
.terminate_with_reason(CodexProcessLifecycleReason::Drop);
}
}
/// 当前 Codex app-server v2 请求形状的显式版本标签。
///
/// Codex 不会在 stdio frame 中发送可用于协商的版本号;官方仓库要求调用方
/// 从所运行的二进制生成并固定对应 schema。因此这个标签只约束本客户端使用的
/// v2 `thread/*`/`turn/*` 字段形状,不能替代对外部 Codex 二进制版本的 pin。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CodexAppServerProtocol {
V2,
}
impl CodexAppServerProtocol {
pub const fn v2() -> Self {
Self::V2
}
pub const fn name(self) -> &'static str {
match self {
Self::V2 => "v2",
}
}
}
/// `initialize` 成功响应中当前 v2 schema 的稳定元数据字段。
///
/// 这些字段在不同 Codex 构建中可能缺失或增加,所以客户端只保留可识别的
/// 字符串字段;thread/turn 的身份字段则会严格校验。
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexInitializeResult {
pub user_agent: Option<String>,
pub codex_home: Option<String>,
pub platform_family: Option<String>,
pub platform_os: Option<String>,
}
/// `thread/start` 的窄请求 DTO。
///
/// 只暴露当前 v2 中已经稳定且无需额外枚举定义的字段;其它版本特定参数应由
/// 上层通过专用适配器传递,避免在通用客户端里伪造完整 generated schema。
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexThreadStartParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ephemeral: Option<bool>,
}
impl CodexThreadStartParams {
pub fn new() -> Self {
Self::default()
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub fn with_ephemeral(mut self, ephemeral: bool) -> Self {
self.ephemeral = Some(ephemeral);
self
}
}
/// `thread/start` 返回的稳定身份边界。
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CodexThreadStartResult {
pub thread_id: String,
}
/// 当前客户端支持的最小 `turn/start` 输入项。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum CodexUserInput {
Text { text: String },
}
impl CodexUserInput {
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
}
/// `turn/start` 的窄请求 DTO。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexTurnStartParams {
pub thread_id: String,
pub input: Vec<CodexUserInput>,
}
impl CodexTurnStartParams {
pub fn try_new(
thread_id: impl Into<String>,
input: Vec<CodexUserInput>,
) -> Result<Self, CodexError> {
let thread_id = thread_id.into();
if thread_id.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"Codex thread_id 不能为空".to_owned(),
));
}
Ok(Self { thread_id, input })
}
fn validate(&self) -> Result<(), CodexError> {
validate_non_empty_id(&self.thread_id, "Codex thread_id")
}
pub fn text(thread_id: impl Into<String>, text: impl Into<String>) -> Result<Self, CodexError> {
Self::try_new(thread_id, vec![CodexUserInput::text(text)])
}
}
/// `turn/start` 只表示服务端接受并创建了一个 turn;最终状态必须由后续通知
/// (通常是 `turn/completed`)确认。
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CodexTurnStartResult {
pub turn_id: String,
}
/// app-server 发出的无 id 通知。调用方必须显式 `poll_notification` 消费它。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexAppServerNotification {
pub method: String,
pub params: Value,
}
/// Codex app-server 发给客户端的 JSON-RPC 请求的中立 envelope。
///
/// 真实 v2 schema 为审批、动态工具和 MCP elicitation 定义了多种 method
/// 这里只保留稳定的 `id`/`method`/`params` 外壳,不把任何一种请求的参数
/// 结构复制进通用客户端。上层可以按自己支持的 method 做版本化解码。
#[derive(Clone, Debug, PartialEq)]
pub struct CodexServerRequest {
id: Value,
method: String,
params: Value,
}
/// App Server server-request 的中立类别。
///
/// 这些类别只用于路由、审计和选择版本化解码器,不代表允许执行或自动批准。
/// 未知 method 始终落到 [`Self::Unknown`],调用方不得因为类别识别成功就绕过
/// 自己的权限策略。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CodexServerRequestKind {
/// 命令、文件变更或权限等需要上层决议的审批请求。
Approval,
/// Codex 请求宿主执行一个动态工具。
ToolCall,
/// 请求用户输入。
UserInput,
/// MCP elicitation 请求。
Elicitation,
/// 当前版本适配器未识别的请求。
Unknown,
}
impl CodexServerRequest {
pub fn id(&self) -> &Value {
&self.id
}
pub fn method(&self) -> &str {
&self.method
}
pub fn params(&self) -> &Value {
&self.params
}
/// 按稳定的 method 名称做最小中立分类。
///
/// 这里不解码 params,也不把分类结果转换为审批决定;具体 wire 字段仍由
/// 版本化 adapter(例如 `codex_0_152_1`)负责校验。
pub fn kind(&self) -> CodexServerRequestKind {
match self.method.as_str() {
"item/commandExecution/requestApproval"
| "item/fileChange/requestApproval"
| "item/permissions/requestApproval"
| "applyPatchApproval"
| "execCommandApproval" => CodexServerRequestKind::Approval,
"item/tool/call" => CodexServerRequestKind::ToolCall,
"item/tool/requestUserInput" => CodexServerRequestKind::UserInput,
"mcpServer/elicitation/request" => CodexServerRequestKind::Elicitation,
_ => CodexServerRequestKind::Unknown,
}
}
/// 是否属于当前中立映射已知的 method。未知请求仍必须由上层显式处理,
/// 不会因为返回 `false` 而被自动丢弃或重放。
pub fn is_known(&self) -> bool {
self.kind() != CodexServerRequestKind::Unknown
}
}
/// 对 server request 的中立 JSON-RPC 响应。
#[derive(Clone, Debug, PartialEq)]
pub enum CodexServerRequestResponse {
Result(Value),
Error {
code: i64,
message: String,
data: Option<Value>,
},
}
impl CodexServerRequestResponse {
pub fn result(value: Value) -> Self {
Self::Result(value)
}
pub fn error(code: i64, message: impl Into<String>) -> Self {
Self::Error {
code,
message: message.into(),
data: None,
}
}
pub fn error_with_data(code: i64, message: impl Into<String>, data: Value) -> Self {
Self::Error {
code,
message: message.into(),
data: Some(data),
}
}
}
/// 处理真实 app-server server request 的回调。
///
/// 回调只负责当前请求的 JSON 值,具体审批、工具和权限策略仍属于上层;
/// 未注入 handler 时客户端继续发送标准 `-32601`,保持安全的拒绝默认值。
pub trait CodexServerRequestHandler {
fn handle(
&mut self,
request: &CodexServerRequest,
) -> Result<CodexServerRequestResponse, CodexError>;
}
impl<F> CodexServerRequestHandler for F
where
F: FnMut(&CodexServerRequest) -> Result<CodexServerRequestResponse, CodexError>,
{
fn handle(
&mut self,
request: &CodexServerRequest,
) -> Result<CodexServerRequestResponse, CodexError> {
self(request)
}
}
struct RejectingServerRequestHandler;
impl CodexServerRequestHandler for RejectingServerRequestHandler {
fn handle(
&mut self,
request: &CodexServerRequest,
) -> Result<CodexServerRequestResponse, CodexError> {
Err(CodexError::Protocol(format!(
"Codex server-initiated request 不受支持: {}",
request.method
)))
}
}
/// 面向当前 Codex app-server v2 wire 的窄 JSONL 客户端。
///
/// 这个客户端和 [`JsonRpcAppServerChannel`] 有意并存:后者仍是本仓库的中立
/// `AppServerChannel` fixture,而这里提供真实 v2 生命周期的最小身份边界。
/// 它不实现完整 generated schema,也不创建后台异步线程;请求期间的审批/工具
/// 等 server request 可通过显式 handler 处理,未提供 handler 时默认拒绝。请求
/// 返回后,通知必须由调用方持续调用 [`Self::poll_notification`] 读取。外部
/// Codex 二进制和生成 schema 仍须由部署方固定,不能把 `V2` 当成发行版兼容承诺。
pub struct CodexAppServerClient<R, W> {
transport: JsonRpcAppServerChannel<R, W>,
protocol: CodexAppServerProtocol,
initialized: bool,
next_request_id: u64,
pending_notifications: VecDeque<CodexAppServerNotification>,
}
impl<R: Read, W: Write> CodexAppServerClient<R, W> {
/// 使用当前窄 v2 请求形状创建客户端。
pub fn new(reader: R, writer: W) -> Result<Self, CodexError> {
Self::with_protocol(reader, writer, CodexAppServerProtocol::V2)
}
pub fn with_protocol(
reader: R,
writer: W,
protocol: CodexAppServerProtocol,
) -> Result<Self, CodexError> {
Self::with_protocol_and_max_frame_bytes(
reader,
writer,
protocol,
DEFAULT_JSON_RPC_FRAME_BYTES,
)
}
pub fn with_max_frame_bytes(
reader: R,
writer: W,
max_frame_bytes: usize,
) -> Result<Self, CodexError> {
Self::with_protocol_and_max_frame_bytes(
reader,
writer,
CodexAppServerProtocol::V2,
max_frame_bytes,
)
}
pub fn with_protocol_and_max_frame_bytes(
reader: R,
writer: W,
protocol: CodexAppServerProtocol,
max_frame_bytes: usize,
) -> Result<Self, CodexError> {
Ok(Self {
transport: JsonRpcAppServerChannel::with_max_frame_bytes(
reader,
writer,
max_frame_bytes,
)?,
protocol,
initialized: false,
next_request_id: 1,
pending_notifications: VecDeque::new(),
})
}
pub const fn protocol(&self) -> CodexAppServerProtocol {
self.protocol
}
pub fn max_frame_bytes(&self) -> usize {
self.transport.max_frame_bytes()
}
pub fn into_parts(self) -> (R, W) {
self.transport.into_parts()
}
/// 使用本 crate 的中立 client metadata 完成一次 v2 initialize 握手。
pub fn initialize(&mut self) -> Result<CodexInitializeResult, CodexError> {
self.initialize_with_client_info("agent-runtime", env!("CARGO_PKG_VERSION"))
}
/// 允许宿主显式提供 client name/version;这两个字段只用于 app-server 握手
/// 和审计,不用于猜测 Codex 的发行版版本。
pub fn initialize_with_client_info(
&mut self,
name: &str,
version: &str,
) -> Result<CodexInitializeResult, CodexError> {
let mut handler = RejectingServerRequestHandler;
self.initialize_with_client_info_and_handler(name, version, &mut handler)
}
/// 使用调用方已经按具体版本 schema 编码好的 `initialize` 参数完成握手。
///
/// 通用客户端只检查参数是 JSON 对象;字段级校验必须由版本适配器完成,
/// 这样不会把某个 Codex 发行版的 capabilities 误写进中立层。
pub fn initialize_with_params(
&mut self,
params: Value,
) -> Result<CodexInitializeResult, CodexError> {
let mut handler = RejectingServerRequestHandler;
self.initialize_with_params_and_handler(params, &mut handler)
}
/// 使用调用方提供的 server-request handler 完成参数化 `initialize` 握手。
pub fn initialize_with_params_and_handler(
&mut self,
params: Value,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<CodexInitializeResult, CodexError> {
if !params.is_object() {
return Err(CodexError::InvalidConfig(
"Codex initialize params 必须是对象".to_owned(),
));
}
if self.initialized {
return Err(CodexError::Protocol(
"Codex app-server connection 已经 initialized".to_owned(),
));
}
let id = self.allocate_request_id()?;
self.transport.write_message(&json!({
"id": id,
"method": "initialize",
"params": params,
}))?;
let result = self.wait_for_response_with_handler(&id, "initialize", handler)?;
let metadata = parse_initialize_result(&result)?;
// 官方 wire 的 initialized notification 没有 id,也没有 params。
self.transport
.write_message(&json!({"method": "initialized"}))?;
self.initialized = true;
Ok(metadata)
}
/// 初始化时允许上层处理 server request;默认的
/// [`Self::initialize_with_client_info`] 对未知请求保持拒绝。
pub fn initialize_with_client_info_and_handler(
&mut self,
name: &str,
version: &str,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<CodexInitializeResult, CodexError> {
if name.trim().is_empty() || version.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"Codex initialize clientInfo 的 name/version 不能为空".to_owned(),
));
}
self.initialize_with_params_and_handler(
json!({
"clientInfo": {
"name": name,
"version": version,
}
}),
handler,
)
}
/// 创建一个新 thread,并只返回服务端分配的 thread ID。
pub fn thread_start(
&mut self,
params: CodexThreadStartParams,
) -> Result<CodexThreadStartResult, CodexError> {
let mut handler = RejectingServerRequestHandler;
self.thread_start_with_handler(params, &mut handler)
}
/// 创建 thread,并在等待响应期间把 server request 交给调用方处理。
pub fn thread_start_with_handler(
&mut self,
params: CodexThreadStartParams,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<CodexThreadStartResult, CodexError> {
self.require_initialized()?;
let params = serde_json::to_value(params).map_err(|error| {
CodexError::Protocol(format!("Codex thread/start 参数编码失败: {error}"))
})?;
let result = self.request_with_server_handler("thread/start", params, handler)?;
let thread_id = required_nested_string(&result, "thread", "id", "thread/start")?;
Ok(CodexThreadStartResult { thread_id })
}
/// 启动一个 turn。返回值只代表 turn 已被接受;最终完成状态不在这里猜测。
pub fn turn_start(
&mut self,
params: CodexTurnStartParams,
) -> Result<CodexTurnStartResult, CodexError> {
let mut handler = RejectingServerRequestHandler;
self.turn_start_with_handler(params, &mut handler)
}
/// 启动 turn,并在等待接受响应期间处理 server request。
pub fn turn_start_with_handler(
&mut self,
params: CodexTurnStartParams,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<CodexTurnStartResult, CodexError> {
self.require_initialized()?;
params.validate()?;
let params = serde_json::to_value(params).map_err(|error| {
CodexError::Protocol(format!("Codex turn/start 参数编码失败: {error}"))
})?;
let result = self.request_with_server_handler("turn/start", params, handler)?;
let turn_id = required_nested_string(&result, "turn", "id", "turn/start")?;
Ok(CodexTurnStartResult { turn_id })
}
/// 发送真实 v2 `turn/interrupt`,必须显式提供 thread 和 turn 身份,并消费
/// 匹配的 JSON-RPC response;不会把中立 request ID 塞进错误的 params 字段。
pub fn turn_interrupt(&mut self, thread_id: &str, turn_id: &str) -> Result<(), CodexError> {
let mut handler = RejectingServerRequestHandler;
self.turn_interrupt_with_handler(thread_id, turn_id, &mut handler)
}
/// 发送 `turn/interrupt`,并允许上层处理响应前到达的 server request。
pub fn turn_interrupt_with_handler(
&mut self,
thread_id: &str,
turn_id: &str,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<(), CodexError> {
self.require_initialized()?;
validate_non_empty_id(thread_id, "Codex thread_id")?;
validate_non_empty_id(turn_id, "Codex turn_id")?;
let _ = self.request_with_server_handler(
"turn/interrupt",
json!({"threadId": thread_id, "turnId": turn_id}),
handler,
)?;
Ok(())
}
/// 显式读取一条通知;EOF 返回 `None`。通知在请求响应到达前也会先进入
/// 有界调用方队列,避免丢失 `thread/started` 等生命周期事件。
pub fn poll_notification(&mut self) -> Result<Option<CodexAppServerNotification>, CodexError> {
let mut handler = RejectingServerRequestHandler;
self.poll_notification_with_handler(&mut handler)
}
/// 读取一条通知;如果先收到带 id 的 server request,则调用 handler 回应
/// 后继续读取,直到得到通知或 EOF。这样审批/动态工具等上层扩展无需复制
/// JSON-RPC framing,但仍由调用方决定是否允许具体 method。
pub fn poll_notification_with_handler(
&mut self,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<Option<CodexAppServerNotification>, CodexError> {
self.require_initialized()?;
if let Some(notification) = self.pending_notifications.pop_front() {
return Ok(Some(notification));
}
loop {
let Some(message) = self.transport.read_message()? else {
return Ok(None);
};
if let Some(request) = server_request_from_message(&message)? {
self.handle_server_request(request, handler)?;
continue;
}
return self.message_as_notification(message);
}
}
fn require_initialized(&self) -> Result<(), CodexError> {
if self.initialized {
Ok(())
} else {
Err(CodexError::Protocol(
"Codex app-server request 必须在 initialize 后发送".to_owned(),
))
}
}
fn allocate_request_id(&mut self) -> Result<Value, CodexError> {
let id = self.next_request_id;
self.next_request_id = self
.next_request_id
.checked_add(1)
.ok_or_else(|| CodexError::Protocol("Codex app-server request id 已耗尽".to_owned()))?;
Ok(json!(id))
}
/// 发送一个已初始化的 JSON-RPC request,并对等待期间的 server request
/// 使用调用方提供的 handler。返回值仍是未绑定厂商 schema 的 JSON。
pub fn request_with_server_handler(
&mut self,
method: &str,
params: Value,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<Value, CodexError> {
self.require_initialized()?;
if method.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"Codex app-server method 不能为空".to_owned(),
));
}
let id = self.allocate_request_id()?;
self.transport.write_message(&json!({
"id": id,
"method": method,
"params": params,
}))?;
self.wait_for_response_with_handler(&id, method, handler)
}
/// 发送一个已初始化的 JSON-RPC request,并将等待 response 期间到达的
/// 无 id notification 转换为带当前 request id 的中立 `NodeEvent`。该
/// 回调只观察协议流,不改变 client/Host 状态;现有无事件入口继续丢弃
/// 这些通知(同时保留 pending notification 轮询语义仅供旧路径使用)。
fn request_with_events_and_server_handler(
&mut self,
method: &str,
params: Value,
event_request_id: &str,
events: &mut dyn FnMut(NodeEvent),
handler: &mut dyn CodexServerRequestHandler,
) -> Result<Value, CodexError> {
self.require_initialized()?;
if method.trim().is_empty() {
return Err(CodexError::InvalidConfig(
"Codex app-server method 不能为空".to_owned(),
));
}
let id = self.allocate_request_id()?;
self.transport.write_message(&json!({
"id": id,
"method": method,
"params": params,
}))?;
self.wait_for_response_with_events_and_handler(
&id,
method,
event_request_id,
Some(events),
handler,
)
}
fn wait_for_response_with_handler(
&mut self,
expected_id: &Value,
method: &str,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<Value, CodexError> {
self.wait_for_response_with_events_and_handler(expected_id, method, "", None, handler)
}
fn wait_for_response_with_events_and_handler(
&mut self,
expected_id: &Value,
method: &str,
event_request_id: &str,
mut events: Option<&mut dyn FnMut(NodeEvent)>,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<Value, CodexError> {
loop {
let Some(message) = self.transport.read_message()? else {
return Err(CodexError::Protocol(format!(
"Codex {method} response 前连接已关闭"
)));
};
if message.get("method").is_some()
&& (message.get("result").is_some() || message.get("error").is_some())
{
return Err(CodexError::Protocol(format!(
"Codex {method} frame 同时包含 method 与 result/error"
)));
}
let method_name = json_rpc_method(&message)?;
let id = json_rpc_id(&message)?;
if let Some(method_name) = method_name {
if let Some(id) = id {
let request = CodexServerRequest {
id,
method: method_name.to_owned(),
params: message.get("params").cloned().unwrap_or(Value::Null),
};
self.handle_server_request(request, handler)?;
continue;
}
if let Some(events) = events.as_deref_mut() {
events(NodeEvent {
request_id: event_request_id.to_owned(),
event_type: method_name.to_owned(),
payload: message.get("params").cloned().unwrap_or(Value::Null),
});
} else {
self.enqueue_notification(notification_from_message(method_name, &message))?;
}
continue;
}
let Some(id) = id else {
return Err(CodexError::Protocol(format!(
"Codex {method} response 缺少 id"
)));
};
if &id != expected_id {
return Err(CodexError::Protocol(format!(
"Codex {method} response id 不匹配: expected={expected_id} actual={id}"
)));
}
return parse_json_rpc_result(&message, method);
}
}
fn handle_server_request(
&mut self,
request: CodexServerRequest,
handler: &mut dyn CodexServerRequestHandler,
) -> Result<(), CodexError> {
match handler.handle(&request) {
Ok(response) => self.write_server_response(&request, response),
Err(error) => {
// Always answer a server request before returning the handler
// error; otherwise the app-server can remain blocked waiting
// for a response that the caller already abandoned.
self.write_server_response(
&request,
CodexServerRequestResponse::error(-32601, error.to_string()),
)?;
Err(error)
}
}
}
fn write_server_response(
&mut self,
request: &CodexServerRequest,
response: CodexServerRequestResponse,
) -> Result<(), CodexError> {
let message = match response {
CodexServerRequestResponse::Result(result) => {
json!({"id": request.id, "result": result})
}
CodexServerRequestResponse::Error {
code,
message,
data,
} => {
let mut error = json!({"code": code, "message": message});
if let Some(data) = data {
error["data"] = data;
}
json!({"id": request.id, "error": error})
}
};
self.transport.write_message(&message)
}
fn message_as_notification(
&mut self,
message: Value,
) -> Result<Option<CodexAppServerNotification>, CodexError> {
if message.get("method").is_some()
&& (message.get("result").is_some() || message.get("error").is_some())
{
return Err(CodexError::Protocol(
"Codex notification 同时包含 method 与 result/error".to_owned(),
));
}
let method = json_rpc_method(&message)?;
let id = json_rpc_id(&message)?;
let Some(method) = method else {
return Err(CodexError::Protocol(
"Codex poll_notification 收到 response,而不是 notification".to_owned(),
));
};
if id.is_some() {
// `poll_notification_with_handler` handles requests before this
// helper is reached. Keep this guard for malformed/internal calls
// instead of exposing a request as a notification.
return Err(CodexError::Protocol(format!(
"Codex message 带 id,不是 notification: {method}"
)));
}
Ok(Some(notification_from_message(method, &message)))
}
fn enqueue_notification(
&mut self,
notification: CodexAppServerNotification,
) -> Result<(), CodexError> {
if self.pending_notifications.len() >= DEFAULT_MAX_PENDING_NOTIFICATIONS {
return Err(CodexError::Protocol(format!(
"Codex pending notification 队列超过 {} 条限制",
DEFAULT_MAX_PENDING_NOTIFICATIONS
)));
}
self.pending_notifications.push_back(notification);
Ok(())
}
}
fn server_request_from_message(message: &Value) -> Result<Option<CodexServerRequest>, CodexError> {
// JSON-RPC request/notification 与 response 是互斥 envelope。轮询路径也
// 必须拒绝这个形状,不能把它降级成普通 server request 后继续读,否则
// handler 可能对一条本来就无效的 frame 产生副作用。
validate_json_rpc_envelope(message)?;
let method = json_rpc_method(message)?;
let Some(method) = method else {
return Ok(None);
};
let Some(id) = json_rpc_id(message)? else {
return Ok(None);
};
Ok(Some(CodexServerRequest {
id,
method: method.to_owned(),
params: message.get("params").cloned().unwrap_or(Value::Null),
}))
}
fn notification_from_message(method: &str, message: &Value) -> CodexAppServerNotification {
CodexAppServerNotification {
method: method.to_owned(),
params: message.get("params").cloned().unwrap_or(Value::Null),
}
}
fn validate_non_empty_id(value: &str, field: &str) -> Result<(), CodexError> {
if value.trim().is_empty() {
Err(CodexError::InvalidConfig(format!("{field} 不能为空")))
} else {
Ok(())
}
}
fn parse_initialize_result(value: &Value) -> Result<CodexInitializeResult, CodexError> {
if !value.is_object() {
return Err(CodexError::Protocol(
"Codex initialize result 必须是对象".to_owned(),
));
}
Ok(CodexInitializeResult {
user_agent: optional_string_field(value, "userAgent", "initialize")?,
codex_home: optional_string_field(value, "codexHome", "initialize")?,
platform_family: optional_string_field(value, "platformFamily", "initialize")?,
platform_os: optional_string_field(value, "platformOs", "initialize")?,
})
}
fn optional_string_field(
value: &Value,
field: &str,
operation: &str,
) -> Result<Option<String>, CodexError> {
match value.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(text)) => Ok(Some(text.clone())),
Some(actual) => Err(CodexError::Protocol(format!(
"Codex {operation} 字段 {field} 必须是字符串: {actual}"
))),
}
}
fn required_nested_string(
value: &Value,
object_field: &str,
string_field: &str,
operation: &str,
) -> Result<String, CodexError> {
let object = value
.get(object_field)
.and_then(Value::as_object)
.ok_or_else(|| {
CodexError::Protocol(format!(
"Codex {operation} result 缺少对象字段 {object_field}"
))
})?;
let text = object
.get(string_field)
.and_then(Value::as_str)
.ok_or_else(|| {
CodexError::Protocol(format!(
"Codex {operation} result 缺少字符串字段 {object_field}.{string_field}"
))
})?;
if text.trim().is_empty() {
return Err(CodexError::Protocol(format!(
"Codex {operation} result 的 {object_field}.{string_field} 不能为空"
)));
}
Ok(text.to_owned())
}
fn validate_optional_json_rpc_version(message: &Value) -> Result<(), CodexError> {
if let Some(version) = message.get("jsonrpc")
&& version.as_str() != Some(JSON_RPC_VERSION)
{
return Err(CodexError::Protocol(format!(
"JSON-RPC jsonrpc 版本不支持: expected={JSON_RPC_VERSION} actual={version}"
)));
}
Ok(())
}
fn json_rpc_method(message: &Value) -> Result<Option<&str>, CodexError> {
match message.get("method") {
None => Ok(None),
Some(Value::String(method)) => Ok(Some(method)),
Some(_) => Err(CodexError::Protocol(
"JSON-RPC method 必须是字符串".to_owned(),
)),
}
}
fn validate_json_rpc_envelope(message: &Value) -> Result<(), CodexError> {
if message.get("method").is_some()
&& (message.get("result").is_some() || message.get("error").is_some())
{
return Err(CodexError::Protocol(
"JSON-RPC frame 同时包含 method 与 result/error".to_owned(),
));
}
Ok(())
}
fn json_rpc_id(message: &Value) -> Result<Option<Value>, CodexError> {
match message.get("id") {
None => Ok(None),
Some(id @ Value::String(_)) | Some(id @ Value::Number(_)) => Ok(Some(id.clone())),
Some(Value::Null) => Err(CodexError::Protocol("JSON-RPC id 不能为 null".to_owned())),
Some(_) => Err(CodexError::Protocol(
"JSON-RPC id 必须是字符串或数字".to_owned(),
)),
}
}
fn json_rpc_id_matches(id: &Value, expected: &str) -> bool {
// JSON-RPC ID 按 JSON 值而不是文本渲染结果比较:数字 `1` 不能匹配字符串
// `"1"`。本通道发出字符串 ID,因此只有相同字符串响应才能结束请求。
matches!(id, Value::String(value) if value == expected)
}
fn parse_json_rpc_result(message: &Value, operation: &str) -> Result<Value, CodexError> {
if message.get("result").is_some() && message.get("error").is_some() {
return Err(CodexError::Protocol(format!(
"JSON-RPC {operation} response 同时包含 result 和 error"
)));
}
if let Some(error) = message.get("error") {
let Some(error_object) = error.as_object() else {
return Err(CodexError::Protocol(format!(
"JSON-RPC {operation} error 必须是对象"
)));
};
let code = error_object
.get("code")
.and_then(Value::as_i64)
.map_or_else(|| "unknown".to_owned(), |code| code.to_string());
let text = error_object
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown error");
return Err(CodexError::Protocol(format!(
"JSON-RPC {operation} error ({code}): {text}"
)));
}
message.get("result").cloned().ok_or_else(|| {
CodexError::Protocol(format!(
"JSON-RPC {operation} response 缺少 result 或 error"
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use agent_runtime_core::{BackendRequest, RunSnapshot, RuntimeSnapshot, apply_event};
use serde_json::json;
use std::io::Cursor;
use std::sync::{Arc, Mutex};
#[test]
fn cli_config_rejects_unapproved_arguments_and_secrets() {
let config = CodexCliConfig::try_new("codex")
.unwrap()
.with_args(["--model", "gpt-test"])
.with_allowed_arg_prefixes(["--model"]);
assert!(CodexCliBackend::new(config).is_err());
let config = CodexCliConfig::try_new("codex")
.unwrap()
.with_args(["--model=gpt-test"])
.with_allowed_arg_prefixes(["--model"]);
assert!(CodexCliBackend::new(config).is_ok());
let secret = CodexCliConfig::try_new("codex")
.unwrap()
.with_args(["--api_key=secret"])
.with_allowed_arg_prefixes(["--api"]);
assert!(CodexCliBackend::new(secret).is_err());
}
#[test]
fn cli_config_rejects_common_secret_argument_spellings() {
let secret_args = vec![
vec!["--API-KEY=secret"],
vec!["--token", "secret"],
vec!["--access-token=secret"],
vec!["Authorization: Bearer secret"],
vec!["--header=authorization: bearer secret"],
vec!["bearer secret"],
];
for args in secret_args {
let display = format!("{args:?}");
let config = CodexCliConfig::try_new("codex")
.unwrap()
.with_args(args)
.with_allowed_arg_prefixes(["-", "Authorization:", "bearer", "secret"]);
assert!(
CodexCliBackend::new(config).is_err(),
"secret argv should be rejected: {display}"
);
}
// Matching is by a normalized complete key, so an unrelated option such
// as `--tokenizer` remains usable when explicitly whitelisted.
let safe = CodexCliConfig::try_new("codex")
.unwrap()
.with_args(["--tokenizer=sentencepiece"])
.with_allowed_arg_prefixes(["--tokenizer"]);
assert!(CodexCliBackend::new(safe).is_ok());
}
#[test]
fn cli_and_process_configs_reject_empty_or_controlled_arg_prefixes() {
let cli = CodexCliConfig::try_new("codex")
.unwrap()
.with_args(["--danger"])
.with_allowed_arg_prefixes([""]);
assert!(matches!(
CodexCliBackend::new(cli),
Err(CodexError::InvalidConfig(message)) if message.contains("前缀")
));
let process = CodexAppServerProcessConfig::try_new("codex")
.unwrap()
.with_args(["--danger"])
.with_allowed_arg_prefixes(["\n"]);
assert!(matches!(
process.validate(),
Err(CodexError::InvalidConfig(message)) if message.contains("前缀")
));
}
#[test]
fn cli_config_rejects_zero_limits_at_serde_and_runtime_boundaries() {
for document in [
r#"{"program":"codex","timeoutMs":0}"#,
r#"{"program":"codex","timeout_ms":0}"#,
] {
let error = serde_json::from_str::<CodexCliConfig>(document).unwrap_err();
assert!(error.to_string().contains("timeout_ms"));
}
for document in [
r#"{"program":"codex","maxOutputBytes":0}"#,
r#"{"program":"codex","max_output_bytes":0}"#,
] {
let error = serde_json::from_str::<CodexCliConfig>(document).unwrap_err();
assert!(error.to_string().contains("max_output_bytes"));
}
let mut timeout_zero = CodexCliConfig::try_new("codex").unwrap();
timeout_zero.timeout_ms = 0;
assert!(matches!(
CodexCliBackend::new(timeout_zero),
Err(CodexError::InvalidConfig(message)) if message.contains("timeout")
));
let mut output_zero = CodexCliConfig::try_new("codex").unwrap();
output_zero.max_output_bytes = 0;
assert!(matches!(
CodexCliBackend::new(output_zero),
Err(CodexError::InvalidConfig(message)) if message.contains("max_output_bytes")
));
}
#[test]
fn cli_config_lets_serde_report_integer_overflow_and_rejects_submillisecond_timeout() {
let timeout_overflow = format!(
r#"{{"program":"codex","timeoutMs":{}}}"#,
u128::from(u64::MAX) + 1
);
assert!(serde_json::from_str::<CodexCliConfig>(&timeout_overflow).is_err());
let output_overflow = format!(
r#"{{"program":"codex","maxOutputBytes":{}}}"#,
(usize::MAX as u128) + 1
);
assert!(serde_json::from_str::<CodexCliConfig>(&output_overflow).is_err());
let config = CodexCliConfig::try_new("codex").unwrap();
assert!(matches!(
config.with_timeout(Duration::from_nanos(1)),
Err(CodexError::InvalidConfig(message)) if message.contains("timeout")
));
let overflow_duration = Duration::from_millis(u64::MAX)
.checked_add(Duration::from_millis(1))
.expect("Duration can represent this conversion-overflow case");
assert!(matches!(
CodexCliConfig::try_new("codex")
.unwrap()
.with_timeout(overflow_duration),
Err(CodexError::InvalidConfig(message)) if message.contains("u64")
));
// No arbitrary upper bound is imposed: the integer type itself remains
// the serde boundary for representable maximum values.
let max_values = format!(
r#"{{"program":"codex","timeoutMs":{},"maxOutputBytes":{}}}"#,
u64::MAX,
usize::MAX
);
let config = serde_json::from_str::<CodexCliConfig>(&max_values).unwrap();
assert_eq!(config.timeout_ms, u64::MAX);
assert_eq!(config.max_output_bytes, usize::MAX);
assert!(CodexCliBackend::new(config).is_ok());
}
#[test]
fn app_server_process_config_rejects_zero_limits_at_serde_and_runtime_boundaries() {
for document in [
r#"{"program":"codex","timeoutMs":0}"#,
r#"{"program":"codex","timeout_ms":0}"#,
] {
let error = serde_json::from_str::<CodexAppServerProcessConfig>(document).unwrap_err();
assert!(error.to_string().contains("timeout_ms"));
}
for document in [
r#"{"program":"codex","maxFrameBytes":0}"#,
r#"{"program":"codex","max_frame_bytes":0}"#,
] {
let error = serde_json::from_str::<CodexAppServerProcessConfig>(document).unwrap_err();
assert!(error.to_string().contains("max_frame_bytes"));
}
let mut timeout_zero = CodexAppServerProcessConfig::try_new("codex").unwrap();
timeout_zero.timeout_ms = 0;
assert!(matches!(
timeout_zero.validate(),
Err(CodexError::InvalidConfig(message)) if message.contains("timeout")
));
let mut frame_zero = CodexAppServerProcessConfig::try_new("codex").unwrap();
frame_zero.max_frame_bytes = 0;
assert!(matches!(
frame_zero.validate(),
Err(CodexError::InvalidConfig(message)) if message.contains("max_frame_bytes")
));
}
#[test]
fn app_server_process_config_uses_checked_timeout_millis_conversion() {
let config = CodexAppServerProcessConfig::try_new("codex").unwrap();
assert!(matches!(
config.with_timeout(Duration::from_nanos(1)),
Err(CodexError::InvalidConfig(message)) if message.contains("timeout")
));
let overflow_duration = Duration::from_millis(u64::MAX)
.checked_add(Duration::from_millis(1))
.expect("Duration can represent this conversion-overflow case");
assert!(matches!(
CodexAppServerProcessConfig::try_new("codex")
.unwrap()
.with_timeout(overflow_duration),
Err(CodexError::InvalidConfig(message)) if message.contains("u64")
));
let timeout_overflow = format!(
r#"{{"program":"codex","timeoutMs":{}}}"#,
u128::from(u64::MAX) + 1
);
assert!(serde_json::from_str::<CodexAppServerProcessConfig>(&timeout_overflow).is_err());
let frame_overflow = format!(
r#"{{"program":"codex","maxFrameBytes":{}}}"#,
(usize::MAX as u128) + 1
);
assert!(serde_json::from_str::<CodexAppServerProcessConfig>(&frame_overflow).is_err());
}
#[cfg(unix)]
#[test]
fn cli_backend_maps_local_fake_process_result() {
let config = CodexCliConfig::try_new("sh")
.unwrap()
.with_args(["-c", "cat >/dev/null; printf '{\"ok\":true}'"])
.with_allowed_arg_prefixes(["-", "cat"]);
let backend = CodexCliBackend::new(config).unwrap();
let request =
BackendRequest::try_new("request-1", "run-1", "turn", json!({"text":"hi"})).unwrap();
let result = backend.invoke(&request).unwrap();
assert_eq!(result.output(), &json!({"ok":true}));
assert!(result.external_id().is_some_and(|value| {
value.starts_with(&format!("codex-cli-{}-", backend.instance_nonce))
}));
}
#[cfg(unix)]
#[test]
fn cli_backend_drains_large_stdout_and_reports_output_limit() {
let config = CodexCliConfig::try_new("sh")
.unwrap()
.with_args(["-c", "printf '%4096s' x"])
.with_allowed_arg_prefixes(["-", "printf"])
.with_max_output_bytes(128)
.unwrap();
let backend = CodexCliBackend::new(config).unwrap();
let request = BackendRequest::try_new("request-large", "run-1", "turn", json!({})).unwrap();
let error = backend.invoke_cli(&request).unwrap_err();
assert!(matches!(error, CodexError::OutputTooLarge));
}
#[cfg(unix)]
#[test]
fn cli_nonzero_exit_is_an_unknown_external_side_effect() {
let config = CodexCliConfig::try_new("sh")
.unwrap()
.with_args(["-c", "cat >/dev/null; printf '{\"ok\":true}'; exit 7"])
.with_allowed_arg_prefixes(["-", "cat"]);
let backend = CodexCliBackend::new(config).unwrap();
let request =
BackendRequest::try_new("request-failed", "run-1", "turn", json!({})).unwrap();
let error = backend.invoke(&request).unwrap_err();
assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect);
assert!(error.message().contains("未正常完成"));
}
#[cfg(unix)]
#[test]
fn cli_success_with_malformed_stdout_is_an_unknown_external_side_effect() {
let config = CodexCliConfig::try_new("sh")
.unwrap()
.with_args(["-c", "cat >/dev/null; printf 'not-json'; exit 0"])
.with_allowed_arg_prefixes(["-", "cat"]);
let backend = CodexCliBackend::new(config).unwrap();
let request =
BackendRequest::try_new("request-malformed", "run-1", "turn", json!({})).unwrap();
let error = backend.invoke(&request).unwrap_err();
assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect);
assert!(error.message().contains("输出不是有效 JSON"));
}
#[cfg(unix)]
#[test]
fn cli_output_limit_is_an_unknown_external_side_effect() {
let config = CodexCliConfig::try_new("sh")
.unwrap()
.with_args(["-c", "printf '%4096s' x"])
.with_allowed_arg_prefixes(["-", "printf"])
.with_max_output_bytes(128)
.unwrap();
let backend = CodexCliBackend::new(config).unwrap();
let request =
BackendRequest::try_new("request-large-backend", "run-1", "turn", json!({})).unwrap();
let error = backend.invoke(&request).unwrap_err();
assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect);
assert!(error.message().contains("输出超过限制"));
}
#[test]
fn cli_supervisor_classifies_post_spawn_failures_as_unknown() {
let post_spawn = [
CodexError::Protocol("pipe state lost".to_owned()),
CodexError::Timeout,
CodexError::Interrupted,
CodexError::InvalidOutput,
CodexError::OutputTooLarge,
CodexError::Exit(7),
CodexError::Signal(9),
CodexError::ProcessTerminated,
];
for error in post_spawn {
assert_eq!(
error.external_error_kind_for_cli_failure(),
ExternalErrorKind::UnknownSideEffect,
"post-spawn error must require reconciliation: {error:?}"
);
}
assert_eq!(
CodexError::InvalidConfig("bad argv".to_owned()).external_error_kind_for_cli_failure(),
ExternalErrorKind::InvalidInput
);
assert_eq!(
CodexError::Spawn.external_error_kind_for_cli_failure(),
ExternalErrorKind::Unavailable
);
}
#[cfg(unix)]
#[test]
fn cli_supervisor_enforces_timeout() {
let config = CodexCliConfig::try_new("sh")
.unwrap()
.with_args(["-c", "cat >/dev/null; sleep 2; printf '{\"ok\":true}'"])
.with_allowed_arg_prefixes(["-", "cat", "sleep", "printf"])
.with_timeout(Duration::from_millis(30))
.unwrap();
let backend = CodexCliBackend::new(config).unwrap();
let request =
BackendRequest::try_new("request-timeout", "run-1", "turn", json!({})).unwrap();
let error = backend.invoke_cli(&request).unwrap_err();
assert!(matches!(error, CodexError::Timeout));
}
#[cfg(unix)]
#[test]
fn cli_supervisor_supports_explicit_cancel() {
let config = CodexCliConfig::try_new("sh")
.unwrap()
.with_args(["-c", "cat >/dev/null; sleep 5; printf '{\"ok\":true}'"])
.with_allowed_arg_prefixes(["-", "cat", "sleep", "printf"])
.with_timeout(Duration::from_secs(10))
.unwrap();
let backend = Arc::new(CodexCliBackend::new(config).unwrap());
let request =
BackendRequest::try_new("request-cancel", "run-1", "turn", json!({})).unwrap();
let worker_backend = Arc::clone(&backend);
let worker = std::thread::spawn(move || worker_backend.invoke_cli(&request));
// 给 supervisor 一个机会完成 spawn 并登记 request_idcancel 本身仍是
// 幂等的,未知 ID 也不会导致宿主失败。
std::thread::sleep(Duration::from_millis(40));
backend.cancel_cli("request-cancel").unwrap();
let cancelled_at = Instant::now();
let result = worker.join().expect("CLI worker thread");
assert!(matches!(result, Err(CodexError::Interrupted)));
assert!(cancelled_at.elapsed() < Duration::from_secs(2));
assert!(backend.cancel_cli("request-cancel").is_ok());
assert!(matches!(
backend.cancel_cli(""),
Err(CodexError::InvalidConfig(_))
));
}
#[cfg(unix)]
#[test]
fn cli_supervisor_maps_signal_exit() {
let config = CodexCliConfig::try_new("sh")
.unwrap()
.with_args(["-c", "cat >/dev/null; kill -TERM $$"])
.with_allowed_arg_prefixes(["-", "cat", "kill"]);
let backend = CodexCliBackend::new(config).unwrap();
let request =
BackendRequest::try_new("request-signal", "run-1", "turn", json!({})).unwrap();
let error = backend.invoke_cli(&request).unwrap_err();
assert!(matches!(error, CodexError::Signal(15)));
}
struct FakeChannel {
interrupted: bool,
}
impl AppServerChannel for FakeChannel {
fn send(&mut self, request: NodeRequest) -> Result<NodeResult, CodexError> {
Ok(NodeResult {
request_id: request.request_id,
output: json!({"operation": request.operation}),
side_effect_unknown: false,
})
}
fn interrupt(&mut self, _request_id: &str) -> Result<(), CodexError> {
self.interrupted = true;
Ok(())
}
}
struct BlockingChannel {
started: mpsc::SyncSender<()>,
release: mpsc::Receiver<()>,
channel_interrupts: Arc<AtomicBool>,
}
impl AppServerChannel for BlockingChannel {
fn send(&mut self, request: NodeRequest) -> Result<NodeResult, CodexError> {
self.started
.send(())
.map_err(|_| CodexError::Protocol("测试请求启动通知失败".to_owned()))?;
self.release
.recv()
.map_err(|_| CodexError::Protocol("测试请求未收到释放信号".to_owned()))?;
Ok(NodeResult {
request_id: request.request_id,
output: json!({"ok": true}),
side_effect_unknown: false,
})
}
fn interrupt(&mut self, _request_id: &str) -> Result<(), CodexError> {
self.channel_interrupts.store(true, Ordering::Release);
Ok(())
}
}
#[test]
fn app_server_out_of_band_interrupt_hook_runs_while_invoke_is_blocked() {
let (started_tx, started_rx) = mpsc::sync_channel(1);
let (release_tx, release_rx) = mpsc::sync_channel(1);
let channel_interrupts = Arc::new(AtomicBool::new(false));
let hook_calls = Arc::new(Mutex::new(Vec::<String>::new()));
let hook_calls_clone = hook_calls.clone();
let backend = Arc::new(
CodexAppServerBackend::new(
BlockingChannel {
started: started_tx,
release: release_rx,
channel_interrupts: channel_interrupts.clone(),
},
"session-out-of-band",
)
.unwrap()
.with_interrupt_hook(move |request_id| {
hook_calls_clone
.lock()
.map_err(|_| CodexError::Protocol("测试 hook 锁已损坏".to_owned()))?
.push(request_id.to_owned());
Ok(())
}),
);
let request = NodeRequest::try_new("blocked-request", "turn", json!({})).unwrap();
let invoke_backend = backend.clone();
let invoke = std::thread::spawn(move || invoke_backend.invoke_node(request));
started_rx
.recv_timeout(Duration::from_secs(1))
.expect("invoke should reach the blocking channel");
let interrupt_backend = backend.clone();
let (interrupt_tx, interrupt_rx) = mpsc::sync_channel(1);
let interrupt = std::thread::spawn(move || {
let result = interrupt_backend.interrupt("blocked-request");
interrupt_tx
.send(result)
.expect("interrupt result receiver");
});
// The hook must complete while `send` still owns the channel mutex.
// Keep the wait bounded so a regression to the old lock-only path
// fails the test instead of hanging the whole test process.
let interrupt_result = interrupt_rx.recv_timeout(Duration::from_secs(1));
release_tx.send(()).unwrap();
let result = invoke
.join()
.expect("invoke thread should finish")
.expect("released invoke should return its result");
interrupt_result
.expect("interrupt hook should finish before invoke is released")
.expect("out-of-band interrupt hook should succeed");
interrupt.join().expect("interrupt thread should finish");
assert_eq!(
hook_calls.lock().unwrap().as_slice(),
&["blocked-request".to_owned()],
"hook must run before the blocked invoke is released"
);
assert!(
!channel_interrupts.load(Ordering::Acquire),
"opt-in hook should not wait for or call the channel mutex path"
);
assert_eq!(result.output, json!({"ok": true}));
}
#[test]
fn app_server_maps_node_result_and_interrupt() {
let backend =
CodexAppServerBackend::new(FakeChannel { interrupted: false }, "session-1").unwrap();
let request = BackendRequest::try_new("request-2", "run-1", "turn", json!({})).unwrap();
let result = backend.invoke(&request).unwrap();
assert_eq!(result.output(), &json!({"operation":"turn"}));
backend.cancel("request-2").unwrap();
}
struct InterruptProtocolErrorChannel;
impl AppServerChannel for InterruptProtocolErrorChannel {
fn send(&mut self, request: NodeRequest) -> Result<NodeResult, CodexError> {
Ok(NodeResult {
request_id: request.request_id,
output: json!({"ok": true}),
side_effect_unknown: false,
})
}
fn interrupt(&mut self, _request_id: &str) -> Result<(), CodexError> {
Err(CodexError::Protocol("interrupt response lost".to_owned()))
}
}
#[test]
fn app_server_cancel_protocol_failure_is_unknown_after_dispatch() {
let backend =
CodexAppServerBackend::new(InterruptProtocolErrorChannel, "session-interrupt-error")
.unwrap();
let error = backend
.cancel("request-active")
.expect_err("interrupt protocol failure should be returned");
assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect);
}
struct MismatchedChannel;
impl AppServerChannel for MismatchedChannel {
fn send(&mut self, request: NodeRequest) -> Result<NodeResult, CodexError> {
Ok(NodeResult {
request_id: format!("{}-other", request.request_id),
output: json!({"ok": true}),
side_effect_unknown: false,
})
}
}
#[test]
fn app_server_rejects_mismatched_response_request_id() {
let backend = CodexAppServerBackend::new(MismatchedChannel, "session-mismatch").unwrap();
let request =
BackendRequest::try_new("request-mismatch", "run-1", "turn", json!({})).unwrap();
let error = backend.invoke(&request).unwrap_err();
// The request frame was already handed to the channel; a mismatched
// response cannot prove whether the remote operation ran. Keep it in
// the reconciliation/unknown-side-effect lane rather than allowing a
// generic retry policy to replay it.
assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect);
assert!(error.message().contains("request_id 不匹配"));
}
struct InvalidConfigChannel;
impl AppServerChannel for InvalidConfigChannel {
fn send(&mut self, _request: NodeRequest) -> Result<NodeResult, CodexError> {
Err(CodexError::InvalidConfig(
"fixture rejected configuration".to_owned(),
))
}
}
#[test]
fn app_server_preserves_pre_dispatch_configuration_errors() {
let backend = CodexAppServerBackend::new(InvalidConfigChannel, "session-config").unwrap();
let request =
BackendRequest::try_new("request-config", "run-1", "turn", json!({})).unwrap();
let error = backend.invoke(&request).unwrap_err();
assert_eq!(error.kind(), ExternalErrorKind::InvalidInput);
assert!(error.message().contains("fixture rejected configuration"));
}
struct ProtocolErrorChannel;
impl AppServerChannel for ProtocolErrorChannel {
fn send(&mut self, _request: NodeRequest) -> Result<NodeResult, CodexError> {
Err(CodexError::Protocol("response frame lost".to_owned()))
}
}
#[test]
fn app_server_maps_post_dispatch_protocol_errors_to_unknown_side_effect() {
let backend = CodexAppServerBackend::new(ProtocolErrorChannel, "session-protocol").unwrap();
let request =
BackendRequest::try_new("request-protocol", "run-1", "turn", json!({})).unwrap();
let error = backend.invoke(&request).unwrap_err();
assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect);
assert!(error.message().contains("response frame lost"));
}
#[test]
fn event_mapping_keeps_only_neutral_fields() {
let event = NodeEvent {
request_id: "r1".to_owned(),
event_type: "delta".to_owned(),
payload: json!({"text":"ok"}),
};
assert_eq!(node_event_json(&event)["eventType"], "delta");
}
fn mapper_running_snapshot() -> RuntimeSnapshot {
let mut snapshot = RuntimeSnapshot::try_new("runtime-mapper").expect("runtime");
apply_event(
&mut snapshot,
&RuntimeEvent::runtime_created("runtime-mapper", 1, 1).expect("runtime event"),
)
.expect("create runtime");
let run = RunSnapshot::try_new("run-mapper", "agent-mapper", "task", 1).expect("run");
apply_event(
&mut snapshot,
&RuntimeEvent::run_created("runtime-mapper", 2, 2, &run).expect("run event"),
)
.expect("create run");
apply_event(
&mut snapshot,
&RuntimeEvent::status_changed(
"runtime-mapper",
3,
3,
"run-mapper",
RuntimeEventKind::RunStarted,
)
.expect("start event"),
)
.expect("start run");
snapshot
}
#[test]
fn node_runtime_mapper_preserves_request_and_result_through_core_reducer() {
let mut snapshot = mapper_running_snapshot();
let mut mapper = NodeRuntimeEventMapper::try_new(
snapshot.runtime_id(),
"run-mapper",
snapshot.revision(),
10,
)
.expect("mapper");
let request = NodeRequest::try_new(
"node-request-1",
"turn",
json!({"prompt":"hello","nested":{"ok":true}}),
)
.expect("request");
let request_event = mapper.map_request(&request).expect("request event");
assert_eq!(request_event.kind(), RuntimeEventKind::MessageAppended);
assert_eq!(request_event.run_id(), Some("run-mapper"));
assert_eq!(request_event.revision(), 4);
assert!(
request_event
.detail()
.to_string()
.contains("node-request-1")
);
apply_event(&mut snapshot, &request_event).expect("request accepted by core");
let result = NodeResult {
request_id: "node-request-1".to_owned(),
output: json!({"answer":"ok","tokens":3}),
side_effect_unknown: false,
};
let completion = mapper.map_result(&result).expect("result event");
assert_eq!(completion.kind(), RuntimeEventKind::RunCompleted);
assert_eq!(completion.revision(), 5);
assert_eq!(completion.detail()["output"], result.output);
assert_eq!(completion.detail()["requestId"], "node-request-1");
apply_event(&mut snapshot, &completion).expect("completion accepted by core");
let run = snapshot.run("run-mapper").expect("run");
assert_eq!(run.status(), agent_runtime_core::RunStatus::Completed);
assert_eq!(run.final_text(), Some(r#"{"answer":"ok","tokens":3}"#));
}
#[test]
fn node_runtime_mapper_maps_tool_events_and_keeps_revisions_contiguous() {
let mut snapshot = mapper_running_snapshot();
let mut mapper = NodeRuntimeEventMapper::try_new(
"runtime-mapper",
"run-mapper",
snapshot.revision(),
20,
)
.expect("mapper");
let request = NodeRequest::try_new(
"node-tool-1",
"tool-call",
json!({"callId":"call-1","name":"lookup","arguments":{"q":"rust"}}),
)
.expect("tool request");
let call_event = mapper.map_request(&request).expect("tool call event");
assert_eq!(call_event.kind(), RuntimeEventKind::ToolCallRequested);
assert_eq!(call_event.revision(), 4);
apply_event(&mut snapshot, &call_event).expect("tool call accepted by core");
assert_eq!(
snapshot.run("run-mapper").expect("run").status(),
agent_runtime_core::RunStatus::WaitingForTool
);
let tool_event = NodeEvent {
request_id: "node-tool-1".to_owned(),
event_type: "tool-result".to_owned(),
payload: json!({"callId":"call-1","output":{"rows":1}}),
};
let result_event = mapper.map_event(&tool_event).expect("tool result event");
assert_eq!(result_event.kind(), RuntimeEventKind::ToolCallCompleted);
assert_eq!(result_event.revision(), 5);
apply_event(&mut snapshot, &result_event).expect("tool result accepted by core");
assert_eq!(
snapshot.run("run-mapper").expect("run").status(),
agent_runtime_core::RunStatus::Running
);
let completion = mapper
.map_result(&NodeResult {
request_id: "node-tool-1".to_owned(),
output: json!("done"),
side_effect_unknown: false,
})
.expect("completion event");
assert_eq!(completion.revision(), 6);
apply_event(&mut snapshot, &completion).expect("completion accepted by core");
assert_eq!(
snapshot.run("run-mapper").expect("run").status(),
agent_runtime_core::RunStatus::Completed
);
}
#[test]
fn node_runtime_mapper_accepts_audited_codex_notification_aliases() {
let mut mapper =
NodeRuntimeEventMapper::try_new("runtime-mapper", "run-mapper", 3, 25).expect("mapper");
mapper
.map_request(&NodeRequest::try_new("codex-turn", "turn", json!({})).unwrap())
.expect("request event");
let delta = mapper
.map_event(&NodeEvent {
request_id: "codex-turn".to_owned(),
event_type: "item/agentMessage/delta".to_owned(),
payload: json!({"delta": "hello"}),
})
.expect("Codex agent message delta alias");
assert_eq!(delta.kind(), RuntimeEventKind::MessageAppended);
assert_eq!(delta.revision(), 5);
let completed = mapper
.map_event(&NodeEvent {
request_id: "codex-turn".to_owned(),
event_type: "turn/completed".to_owned(),
payload: json!({"turn": {"status": "completed"}}),
})
.expect("Codex turn completed alias");
assert_eq!(completed.kind(), RuntimeEventKind::RunCompleted);
assert_eq!(completed.revision(), 6);
let mut tool_mapper =
NodeRuntimeEventMapper::try_new("runtime-mapper", "run-mapper", 3, 25)
.expect("tool mapper");
let call = tool_mapper
.map_request(
&NodeRequest::try_new(
"codex-tool",
"tool-call",
json!({"callId":"call-1","name":"lookup","arguments":{}}),
)
.unwrap(),
)
.expect("tool request");
assert_eq!(call.kind(), RuntimeEventKind::ToolCallRequested);
let result = tool_mapper
.map_event(&NodeEvent {
request_id: "codex-tool".to_owned(),
event_type: "item/tool/result".to_owned(),
payload: json!({"callId":"call-1","output":{"ok":true}}),
})
.expect("Codex tool result alias");
assert_eq!(result.kind(), RuntimeEventKind::ToolCallCompleted);
}
#[test]
fn node_runtime_mapper_rejects_unknown_events_and_mismatched_request_ids() {
let mut mapper =
NodeRuntimeEventMapper::try_new("runtime-mapper", "run-mapper", 3, 30).expect("mapper");
mapper
.map_request(&NodeRequest::try_new("node-request-2", "turn", json!({})).unwrap())
.expect("request event");
assert_eq!(mapper.revision(), 4);
let unknown = mapper.map_event(&NodeEvent {
request_id: "node-request-2".to_owned(),
event_type: "future-vendor-event".to_owned(),
payload: json!({"x":1}),
});
assert!(matches!(unknown, Err(CodexError::Protocol(_))));
assert_eq!(
mapper.revision(),
4,
"failed mapping must not consume revision"
);
let mismatched_event = mapper.map_event(&NodeEvent {
request_id: "another-request".to_owned(),
event_type: "delta".to_owned(),
payload: json!({"text":"ignored"}),
});
assert!(matches!(mismatched_event, Err(CodexError::Protocol(_))));
let mismatched_result = mapper.map_result(&NodeResult {
request_id: "another-request".to_owned(),
output: json!("ignored"),
side_effect_unknown: false,
});
assert!(matches!(mismatched_result, Err(CodexError::Protocol(_))));
assert_eq!(mapper.revision(), 4);
}
#[test]
fn node_runtime_mapper_keeps_unknown_side_effect_output_until_reconciliation() {
let mut snapshot = mapper_running_snapshot();
let mut mapper = NodeRuntimeEventMapper::try_new(
"runtime-mapper",
"run-mapper",
snapshot.revision(),
40,
)
.expect("mapper");
mapper
.map_request(&NodeRequest::try_new("node-unknown-1", "turn", json!({})).unwrap())
.map(|event| apply_event(&mut snapshot, &event).expect("request event"))
.expect("request mapping");
let unknown_result = mapper
.map_result(&NodeResult {
request_id: "node-unknown-1".to_owned(),
output: json!({"remoteId":"external-7","state":"accepted"}),
side_effect_unknown: true,
})
.expect("reconciliation event");
assert_eq!(
unknown_result.kind(),
RuntimeEventKind::ReconciliationRequired
);
assert_eq!(
unknown_result.detail()["output"],
json!({"remoteId":"external-7","state":"accepted"})
);
apply_event(&mut snapshot, &unknown_result).expect("reconciliation accepted by core");
let reconciled = mapper
.map_event(&NodeEvent {
request_id: "node-unknown-1".to_owned(),
event_type: "reconciled".to_owned(),
payload: json!({"remoteId":"external-7"}),
})
.expect("reconciled event");
assert_eq!(reconciled.kind(), RuntimeEventKind::RunReconciled);
apply_event(&mut snapshot, &reconciled).expect("reconciled accepted by core");
assert_eq!(
snapshot.run("run-mapper").expect("run").status(),
agent_runtime_core::RunStatus::Running
);
let completion = mapper
.map_result(&NodeResult {
request_id: "node-unknown-1".to_owned(),
output: json!("confirmed"),
side_effect_unknown: false,
})
.expect("final result event");
apply_event(&mut snapshot, &completion).expect("final result accepted by core");
assert_eq!(
snapshot.run("run-mapper").expect("run").status(),
agent_runtime_core::RunStatus::Completed
);
}
struct EventChannel;
impl AppServerChannel for EventChannel {
fn send(&mut self, request: NodeRequest) -> Result<NodeResult, CodexError> {
Ok(NodeResult {
request_id: request.request_id,
output: json!({"ok": true}),
side_effect_unknown: false,
})
}
fn send_with_events(
&mut self,
request: NodeRequest,
events: &mut dyn FnMut(NodeEvent),
) -> Result<NodeResult, CodexError> {
events(NodeEvent {
request_id: request.request_id.clone(),
event_type: "delta".to_owned(),
payload: json!({"text": "hi"}),
});
self.send(request)
}
}
#[test]
fn app_server_forwards_neutral_event_stream() {
let backend = CodexAppServerBackend::new(EventChannel, "session-events").unwrap();
let request = NodeRequest {
request_id: "r-events".to_owned(),
operation: "turn".to_owned(),
payload: json!({}),
};
let mut events = Vec::new();
let result = backend
.invoke_node_with_events(request, &mut |event| events.push(event))
.unwrap();
assert_eq!(result.output, json!({"ok": true}));
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, "delta");
}
#[test]
fn app_server_runtime_event_bridge_feeds_core_reducer() {
let backend = CodexAppServerBackend::new(EventChannel, "session-runtime-events").unwrap();
let mut snapshot = mapper_running_snapshot();
let mut mapper = NodeRuntimeEventMapper::try_new(
snapshot.runtime_id(),
"run-mapper",
snapshot.revision(),
50,
)
.unwrap();
let mut kinds = Vec::new();
let result = backend
.invoke_node_with_runtime_events(
NodeRequest::try_new("bridge-request", "turn", json!({"prompt":"hi"})).unwrap(),
&mut mapper,
&mut |event| {
kinds.push(event.kind());
apply_event(&mut snapshot, &event).expect("mapped event should reduce");
},
)
.expect("runtime event bridge should complete");
assert_eq!(result.output, json!({"ok": true}));
assert_eq!(kinds.len(), 3, "request, delta and result are all observed");
assert_eq!(kinds[0], RuntimeEventKind::MessageAppended);
assert_eq!(kinds[1], RuntimeEventKind::MessageAppended);
assert_eq!(kinds[2], RuntimeEventKind::RunCompleted);
assert_eq!(mapper.revision(), 6);
assert_eq!(
snapshot.run("run-mapper").unwrap().status(),
agent_runtime_core::RunStatus::Completed
);
}
#[test]
fn node_request_validates_fields_and_can_generate_id() {
assert!(NodeRequest::try_new("", "turn", json!({})).is_err());
assert!(NodeRequest::try_new("r1", "", json!({})).is_err());
let request = NodeRequest::with_generated_id("turn", json!({})).unwrap();
assert!(request.request_id.starts_with("codex-node-"));
}
#[derive(Clone, Default)]
struct SharedWriter(Arc<Mutex<Vec<u8>>>);
impl Write for SharedWriter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("writer lock").extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn jsonl_channel_writes_request_and_forwards_event_before_result() {
let input = concat!(
r#"{"kind":"event","requestId":"wire-1","eventType":"delta","payload":{"text":"hi"}}"#,
"\n",
r#"{"kind":"result","requestId":"wire-1","output":{"ok":true},"sideEffectUnknown":false}"#,
"\n",
);
let written = Arc::new(Mutex::new(Vec::new()));
let channel = JsonLineAppServerChannel::new(
Cursor::new(input.as_bytes().to_vec()),
SharedWriter(written.clone()),
)
.unwrap();
let backend = CodexAppServerBackend::new(channel, "wire-session").unwrap();
let request = NodeRequest {
request_id: "wire-1".to_owned(),
operation: "turn".to_owned(),
payload: json!({"message":"hello"}),
};
let mut events = Vec::new();
let result = backend
.invoke_node_with_events(request, &mut |event| events.push(event))
.unwrap();
assert_eq!(result.output, json!({"ok":true}));
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, "delta");
let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap();
let frame: AppServerFrame = serde_json::from_str(lines.trim()).unwrap();
assert_eq!(
frame,
AppServerFrame::Request {
request_id: "wire-1".to_owned(),
operation: "turn".to_owned(),
payload: json!({"message":"hello"}),
}
);
let wire: AppServerWireFrame = serde_json::from_str(lines.trim()).unwrap();
assert!(matches!(
wire,
AppServerWireFrame::Request {
protocol_version: APP_SERVER_PROTOCOL_VERSION,
..
}
));
}
#[test]
fn jsonl_channel_supports_versioned_startup_handshake() {
let input = concat!(
r#"{"kind":"ready","protocolVersion":1,"sessionId":"handshake-session"}"#,
"\n",
r#"{"kind":"event","protocolVersion":1,"requestId":"wire-handshake","eventType":"delta","payload":{"text":"hi"}}"#,
"\n",
r#"{"kind":"result","protocolVersion":1,"requestId":"wire-handshake","output":{"ok":true}}"#,
"\n",
);
let written = Arc::new(Mutex::new(Vec::new()));
let channel = JsonLineAppServerChannel::new(
Cursor::new(input.as_bytes().to_vec()),
SharedWriter(written.clone()),
)
.unwrap();
let backend = CodexAppServerBackend::new_initialized(channel, "handshake-session").unwrap();
let result = backend
.invoke_node(NodeRequest::try_new("wire-handshake", "turn", json!({})).unwrap())
.unwrap();
assert_eq!(result.output, json!({"ok":true}));
let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap();
let wire: AppServerWireFrame = serde_json::from_str(lines.lines().next().unwrap()).unwrap();
assert!(matches!(
wire,
AppServerWireFrame::Initialize {
protocol_version: APP_SERVER_PROTOCOL_VERSION,
session_id
} if session_id == "handshake-session"
));
}
#[test]
fn jsonl_channel_skips_many_blank_frames_without_recursion() {
// 空白行是合法的 keep-alive 输入;数量不应影响读取栈深度。
let mut input = "\n".repeat(8_192);
input.push_str(concat!(
r#"{"kind":"ready","protocolVersion":1,"sessionId":"blank-session"}"#,
"\n",
r#"{"kind":"result","protocolVersion":1,"requestId":"blank-1","output":{"ok":true}}"#,
"\n",
));
let channel =
JsonLineAppServerChannel::new(Cursor::new(input.into_bytes()), Vec::<u8>::new())
.unwrap();
let backend = CodexAppServerBackend::new_initialized(channel, "blank-session").unwrap();
let result = backend
.invoke_node(NodeRequest::try_new("blank-1", "turn", json!({})).unwrap())
.unwrap();
assert_eq!(result.output, json!({"ok":true}));
}
#[test]
fn jsonl_channel_rejects_unknown_protocol_version() {
let input = concat!(
r#"{"kind":"ready","protocolVersion":99,"sessionId":"bad-version"}"#,
"\n",
);
let channel =
JsonLineAppServerChannel::new(Cursor::new(input.as_bytes().to_vec()), Vec::<u8>::new())
.unwrap();
let error = CodexAppServerBackend::new_initialized(channel, "bad-version").unwrap_err();
assert!(error.to_string().contains("protocolVersion 不支持"));
}
#[test]
fn jsonl_channel_requires_version_after_handshake() {
let input = concat!(
r#"{"kind":"ready","protocolVersion":1,"sessionId":"strict-session"}"#,
"\n",
r#"{"kind":"result","requestId":"strict-1","output":{"ok":true}}"#,
"\n",
);
let channel =
JsonLineAppServerChannel::new(Cursor::new(input.as_bytes().to_vec()), Vec::<u8>::new())
.unwrap();
let backend = CodexAppServerBackend::new_initialized(channel, "strict-session").unwrap();
let error = backend
.invoke_node(NodeRequest::try_new("strict-1", "turn", json!({})).unwrap())
.unwrap_err();
assert!(error.to_string().contains("缺少 protocolVersion"));
}
#[test]
fn jsonl_channel_rejects_mismatched_event_and_bounds_frames() {
let mismatch = concat!(
r#"{"kind":"event","requestId":"other","eventType":"delta","payload":{}}"#,
"\n",
);
let channel = JsonLineAppServerChannel::new(
Cursor::new(mismatch.as_bytes().to_vec()),
Vec::<u8>::new(),
)
.unwrap();
let backend = CodexAppServerBackend::new(channel, "wire-session").unwrap();
let error = backend
.invoke_node(NodeRequest {
request_id: "wire-2".to_owned(),
operation: "turn".to_owned(),
payload: json!({}),
})
.unwrap_err();
assert!(error.to_string().contains("event request_id 不匹配"));
let oversized = format!(
"{{\"kind\":\"result\",\"requestId\":\"wire-3\",\"output\":{{\"x\":\"{}\"}}}}\n",
"x".repeat(64)
);
let channel = JsonLineAppServerChannel::with_max_frame_bytes(
Cursor::new(oversized.into_bytes()),
Vec::<u8>::new(),
32,
)
.unwrap();
let backend = CodexAppServerBackend::new(channel, "wire-session").unwrap();
let error = backend
.invoke_node(NodeRequest {
request_id: "wire-3".to_owned(),
operation: "turn".to_owned(),
payload: json!({}),
})
.unwrap_err();
assert!(error.to_string().contains("超过 32 字节限制"));
}
#[test]
fn jsonl_channel_interrupt_writes_control_frame() {
let written = Arc::new(Mutex::new(Vec::new()));
let channel = JsonLineAppServerChannel::new(
Cursor::new(Vec::<u8>::new()),
SharedWriter(written.clone()),
)
.unwrap();
let backend = CodexAppServerBackend::new(channel, "wire-session").unwrap();
backend.interrupt("wire-4").unwrap();
let frame: AppServerFrame =
serde_json::from_slice(written.lock().unwrap().as_slice()).unwrap();
assert_eq!(
frame,
AppServerFrame::Interrupt {
request_id: "wire-4".to_owned()
}
);
}
#[test]
fn json_rpc_channel_performs_handshake_and_streams_notification() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":"rpc-session","result":{"userAgent":"fixture"}}"#,
"\n",
r#"{"method":"item/agentMessage/delta","params":{"delta":"hi"}}"#,
"\n",
r#"{"id":"rpc-node","result":{"ok":true}}"#,
"\n",
);
let written = Arc::new(Mutex::new(Vec::new()));
let mut channel = JsonRpcAppServerChannel::new(
Cursor::new(input.as_bytes().to_vec()),
SharedWriter(written.clone()),
)
.unwrap();
channel.initialize("rpc-session").unwrap();
let mut events = Vec::new();
let result = channel
.send_with_events(
NodeRequest::try_new("rpc-node", "turn/start", json!({"input": []})).unwrap(),
&mut |event| events.push(event),
)
.unwrap();
assert_eq!(result.output, json!({"ok":true}));
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, "item/agentMessage/delta");
assert_eq!(events[0].payload, json!({"delta":"hi"}));
let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap();
let messages = lines
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(messages.len(), 3);
assert_eq!(messages[0]["method"], "initialize");
assert_eq!(messages[0]["id"], "rpc-session");
assert!(messages[0].get("jsonrpc").is_none());
assert_eq!(messages[1]["method"], "initialized");
assert!(messages[1].get("id").is_none());
assert_eq!(messages[2]["method"], "turn/start");
assert_eq!(messages[2]["id"], "rpc-node");
}
#[test]
fn json_rpc_channel_rejects_mismatched_response_id() {
let input = concat!(
r#"{"id":"rpc-session","result":{}}"#,
"\n",
r#"{"id":1,"result":{}}"#,
"\n",
);
let mut channel =
JsonRpcAppServerChannel::new(Cursor::new(input.as_bytes().to_vec()), Vec::<u8>::new())
.unwrap();
channel.initialize("rpc-session").unwrap();
let error = channel
.send(NodeRequest::try_new("1", "turn/start", json!({})).unwrap())
.unwrap_err();
assert!(error.to_string().contains("response id 不匹配"));
}
#[test]
fn json_rpc_channel_rejects_server_request_and_writes_error_response() {
let input = concat!(
r#"{"id":"rpc-session","result":{}}"#,
"\n",
r#"{"id":"server-1","method":"item/commandExecution/requestApproval","params":{}}"#,
"\n",
);
let written = Arc::new(Mutex::new(Vec::new()));
let mut channel = JsonRpcAppServerChannel::new(
Cursor::new(input.as_bytes().to_vec()),
SharedWriter(written.clone()),
)
.unwrap();
channel.initialize("rpc-session").unwrap();
let error = channel
.send(NodeRequest::try_new("rpc-node", "turn/start", json!({})).unwrap())
.unwrap_err();
assert!(error.to_string().contains("server-initiated request"));
let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap();
let response = lines
.lines()
.last()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.unwrap();
assert_eq!(response["id"], "server-1");
assert_eq!(response["error"]["code"], -32601);
}
#[test]
fn json_rpc_channel_handler_answers_server_request_during_initialize() {
let input = concat!(
r#"{"id":"rpc-session","method":"item/commandExecution/requestApproval","params":{"command":["echo"]}}"#,
"\n",
r#"{"id":"rpc-session","result":{}}"#,
"\n",
);
let written = Arc::new(Mutex::new(Vec::new()));
let mut channel = JsonRpcAppServerChannel::new(
Cursor::new(input.as_bytes().to_vec()),
SharedWriter(written.clone()),
)
.unwrap();
let mut seen = Vec::new();
channel
.initialize_with_handler("rpc-session", &mut |request: &CodexServerRequest| {
seen.push(request.method().to_owned());
Ok(CodexServerRequestResponse::result(
json!({"decision":"accept"}),
))
})
.unwrap();
assert_eq!(seen, ["item/commandExecution/requestApproval"]);
let messages = String::from_utf8(written.lock().unwrap().clone())
.unwrap()
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>();
let response = messages
.iter()
.find(|message| {
message["id"] == "rpc-session" && message["result"]["decision"] == "accept"
})
.expect("server request response");
assert_eq!(response["result"]["decision"], "accept");
assert!(
messages
.iter()
.any(|message| message["method"] == "initialized")
);
}
#[test]
fn json_rpc_channel_handler_answers_server_request_during_send() {
let input = concat!(
r#"{"id":"rpc-session","result":{}}"#,
"\n",
r#"{"id":"server-1","method":"item/tool/call","params":{"name":"lookup"}}"#,
"\n",
r#"{"id":"rpc-node","result":{"ok":true}}"#,
"\n",
);
let written = Arc::new(Mutex::new(Vec::new()));
let mut channel = JsonRpcAppServerChannel::new(
Cursor::new(input.as_bytes().to_vec()),
SharedWriter(written.clone()),
)
.unwrap();
channel.initialize("rpc-session").unwrap();
let mut events = Vec::new();
let mut seen = Vec::new();
let result = channel
.send_with_events_and_handler(
NodeRequest::try_new("rpc-node", "turn/start", json!({})).unwrap(),
&mut |event| events.push(event),
&mut |request: &CodexServerRequest| {
seen.push((request.method().to_owned(), request.params().clone()));
Ok(CodexServerRequestResponse::result(json!({"success":true})))
},
)
.unwrap();
assert_eq!(result.output, json!({"ok":true}));
assert!(events.is_empty());
assert_eq!(seen[0].0, "item/tool/call");
assert_eq!(seen[0].1["name"], "lookup");
let messages = String::from_utf8(written.lock().unwrap().clone())
.unwrap()
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>();
let response = messages
.iter()
.find(|message| message["id"] == "server-1")
.expect("server request response");
assert_eq!(response["result"]["success"], true);
}
#[test]
fn json_rpc_channel_handler_error_is_replied_and_propagated() {
let input = concat!(
r#"{"id":"rpc-session","result":{}}"#,
"\n",
r#"{"id":"server-1","method":"item/tool/call","params":{}}"#,
"\n",
);
let written = Arc::new(Mutex::new(Vec::new()));
let mut channel = JsonRpcAppServerChannel::new(
Cursor::new(input.as_bytes().to_vec()),
SharedWriter(written.clone()),
)
.unwrap();
channel.initialize("rpc-session").unwrap();
let error = channel
.send_with_events_and_handler(
NodeRequest::try_new("rpc-node", "turn/start", json!({})).unwrap(),
&mut |_| {},
&mut |_request: &CodexServerRequest| {
Err(CodexError::Protocol("fixture denied".to_owned()))
},
)
.unwrap_err();
assert!(error.to_string().contains("fixture denied"));
let response = String::from_utf8(written.lock().unwrap().clone())
.unwrap()
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.find(|message| message["id"] == "server-1")
.expect("handler error response");
assert_eq!(response["error"]["code"], -32601);
assert_eq!(
response["error"]["message"],
"Codex 协议错误: fixture denied"
);
}
#[test]
fn json_rpc_channel_rejects_mixed_envelope_before_handler() {
let input = concat!(
r#"{"id":"rpc-session","result":{}}"#,
"\n",
r#"{"id":"server-1","method":"item/tool/call","params":{},"result":{}}"#,
"\n",
);
let mut channel =
JsonRpcAppServerChannel::new(Cursor::new(input.as_bytes().to_vec()), Vec::<u8>::new())
.unwrap();
channel.initialize("rpc-session").unwrap();
let mut called = false;
let error = channel
.send_with_events_and_handler(
NodeRequest::try_new("rpc-node", "turn/start", json!({})).unwrap(),
&mut |_| {},
&mut |_request: &CodexServerRequest| {
called = true;
Ok(CodexServerRequestResponse::result(json!({})))
},
)
.unwrap_err();
assert!(!called);
assert!(
error
.to_string()
.contains("同时包含 method 与 result/error")
);
}
#[test]
fn json_rpc_poll_rejects_mixed_server_request_response_envelope() {
let input = concat!(
// A JSON-RPC message cannot be both a request and a response. The
// polling path must reject it before invoking the handler.
r#"{"id":1,"result":{}}"#,
"\n",
r#"{"id":"server-1","method":"item/tool/call","params":{},"result":{}}"#,
"\n",
);
let mut client =
CodexAppServerClient::new(Cursor::new(input.as_bytes().to_vec()), Vec::<u8>::new())
.unwrap();
client.initialize().unwrap();
let mut handler_called = false;
let error = client
.poll_notification_with_handler(&mut |_: &CodexServerRequest| {
handler_called = true;
Ok(CodexServerRequestResponse::result(json!({})))
})
.unwrap_err();
assert!(!handler_called);
assert!(
error
.to_string()
.contains("同时包含 method 与 result/error")
);
}
#[test]
fn server_request_kind_is_neutral_and_unknown_is_not_authorized() {
let cases = [
(
"item/commandExecution/requestApproval",
CodexServerRequestKind::Approval,
),
(
"item/fileChange/requestApproval",
CodexServerRequestKind::Approval,
),
(
"item/permissions/requestApproval",
CodexServerRequestKind::Approval,
),
("item/tool/call", CodexServerRequestKind::ToolCall),
(
"item/tool/requestUserInput",
CodexServerRequestKind::UserInput,
),
(
"mcpServer/elicitation/request",
CodexServerRequestKind::Elicitation,
),
("future/server/request", CodexServerRequestKind::Unknown),
];
for (method, expected_kind) in cases {
let request = CodexServerRequest {
id: json!(1),
method: method.to_owned(),
params: json!({"opaque": true}),
};
assert_eq!(request.kind(), expected_kind);
assert_eq!(
request.is_known(),
expected_kind != CodexServerRequestKind::Unknown
);
}
}
#[test]
fn error_classification_keeps_dispatch_boundary_explicit() {
assert_eq!(
CodexError::InvalidConfig("bad request".to_owned()).external_error_kind(),
ExternalErrorKind::InvalidInput
);
assert_eq!(
CodexError::Protocol("frame lost".to_owned()).external_error_kind(),
ExternalErrorKind::InvalidInput
);
assert_eq!(
CodexError::Protocol("frame lost".to_owned()).external_error_kind_after_dispatch(),
ExternalErrorKind::UnknownSideEffect
);
assert_eq!(
CodexError::InvalidConfig("handler rejected".to_owned())
.external_error_kind_after_process_dispatch(),
ExternalErrorKind::UnknownSideEffect
);
assert_eq!(
CodexError::Timeout.external_error_kind_after_dispatch(),
ExternalErrorKind::UnknownSideEffect
);
assert_eq!(
CodexError::Spawn.external_error_kind_for_cli_failure(),
ExternalErrorKind::Unavailable
);
}
#[test]
fn json_rpc_channel_enforces_bounded_input_and_output_frames() {
let input = format!(
"{{\"id\":\"rpc-session\",\"result\":{{\"x\":\"{}\"}}}}\n",
"x".repeat(64)
);
let mut channel = JsonRpcAppServerChannel::with_max_frame_bytes(
Cursor::new(input.into_bytes()),
Vec::<u8>::new(),
32,
)
.unwrap();
let error = channel.initialize("rpc-session").unwrap_err();
assert!(error.to_string().contains("超过 32 字节限制"));
let mut channel = JsonRpcAppServerChannel::with_max_frame_bytes(
Cursor::new(concat!(r#"{"id":"rpc-session","result":{}}"#, "\n").as_bytes()),
Vec::<u8>::new(),
128,
)
.unwrap();
channel.initialize("rpc-session").unwrap();
let error = channel
.send(
NodeRequest::try_new("rpc-node", "turn/start", json!({"x": "x".repeat(128)}))
.unwrap(),
)
.unwrap_err();
assert!(error.to_string().contains("超过 128 字节限制"));
}
#[test]
fn json_rpc_channel_interrupt_writes_turn_interrupt_request() {
let written = Arc::new(Mutex::new(Vec::new()));
let mut channel = JsonRpcAppServerChannel::new(
Cursor::new(
concat!(
r#"{"id":"rpc-session","result":{}}"#,
"\n",
r#"{"id":"rpc-node","result":{}}"#,
"\n"
)
.as_bytes(),
),
SharedWriter(written.clone()),
)
.unwrap();
assert!(matches!(
channel.interrupt("rpc-node"),
Err(CodexError::Protocol(message)) if message.contains("initialize")
));
channel.initialize("rpc-session").unwrap();
channel.interrupt("rpc-node").unwrap();
let message: Value = written
.lock()
.unwrap()
.split(|byte| *byte == b'\n')
.rfind(|line| !line.is_empty())
.map(|line| serde_json::from_slice(line).unwrap())
.unwrap();
assert_eq!(message["method"], "turn/interrupt");
assert_eq!(message["id"], "rpc-node");
assert_eq!(message["params"]["requestId"], "rpc-node");
}
#[test]
fn json_rpc_channel_interrupt_propagates_error_response() {
let mut channel = JsonRpcAppServerChannel::new(
Cursor::new(
concat!(
r#"{"id":"rpc-session","result":{}}"#,
"\n",
r#"{"id":"rpc-node","error":{"code":-32000,"message":"busy"}}"#,
"\n"
)
.as_bytes(),
),
Vec::<u8>::new(),
)
.unwrap();
channel.initialize("rpc-session").unwrap();
let error = channel.interrupt("rpc-node").unwrap_err();
assert!(
error
.to_string()
.contains("turn/interrupt error (-32000): busy")
);
}
#[test]
fn codex_v2_client_initializes_and_starts_thread() {
let input = concat!(
r#"{"id":1,"result":{"userAgent":"codex","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"linux"}}"#,
"\n",
r#"{"id":2,"result":{"thread":{"id":"thr_123"}}}"#,
"\n",
r#"{"method":"thread/started","params":{"thread":{"id":"thr_123"}}}"#,
"\n",
);
let written = Arc::new(Mutex::new(Vec::new()));
let mut client = CodexAppServerClient::with_protocol(
Cursor::new(input.as_bytes().to_vec()),
SharedWriter(written.clone()),
CodexAppServerProtocol::V2,
)
.unwrap();
let initialized = client
.initialize_with_client_info("test-client", "0.1.0")
.unwrap();
assert_eq!(initialized.user_agent.as_deref(), Some("codex"));
assert_eq!(initialized.platform_os.as_deref(), Some("linux"));
assert_eq!(client.protocol(), CodexAppServerProtocol::V2);
let thread = client
.thread_start(
CodexThreadStartParams::new()
.with_model("gpt-5.1-codex")
.with_cwd("/tmp/project")
.with_ephemeral(true),
)
.unwrap();
assert_eq!(thread.thread_id, "thr_123");
let notification = client.poll_notification().unwrap().unwrap();
assert_eq!(notification.method, "thread/started");
let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap();
let messages = lines
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(messages.len(), 3);
assert_eq!(messages[0]["method"], "initialize");
assert_eq!(messages[0]["id"], 1);
assert_eq!(messages[0]["params"]["clientInfo"]["name"], "test-client");
assert_eq!(messages[1]["method"], "initialized");
assert!(messages[1].get("id").is_none());
assert!(messages[1].get("params").is_none());
assert_eq!(messages[2]["method"], "thread/start");
assert_eq!(messages[2]["id"], 2);
assert_eq!(messages[2]["params"]["model"], "gpt-5.1-codex");
assert_eq!(messages[2]["params"]["cwd"], "/tmp/project");
assert_eq!(messages[2]["params"]["ephemeral"], true);
}
#[test]
fn codex_v2_client_turn_start_returns_id_and_polls_lifecycle_notifications() {
let input = concat!(
r#"{"id":1,"result":{}}"#,
"\n",
r#"{"id":2,"result":{"thread":{"id":"thr_123"}}}"#,
"\n",
r#"{"method":"thread/started","params":{"thread":{"id":"thr_123"}}}"#,
"\n",
r#"{"id":3,"result":{"turn":{"id":"turn_456","status":"inProgress"}}}"#,
"\n",
r#"{"method":"turn/started","params":{"turn":{"id":"turn_456"}}}"#,
"\n",
r#"{"method":"turn/completed","params":{"turn":{"id":"turn_456","status":"completed"}}}"#,
"\n",
);
let mut client =
CodexAppServerClient::new(Cursor::new(input.as_bytes().to_vec()), Vec::<u8>::new())
.unwrap();
client.initialize().unwrap();
let thread = client
.thread_start(CodexThreadStartParams::default())
.unwrap();
assert_eq!(thread.thread_id, "thr_123");
assert_eq!(
client.poll_notification().unwrap().unwrap().method,
"thread/started"
);
let turn = client
.turn_start(CodexTurnStartParams::text("thr_123", "Run tests").unwrap())
.unwrap();
assert_eq!(turn.turn_id, "turn_456");
assert_eq!(
client.poll_notification().unwrap().unwrap().method,
"turn/started"
);
assert_eq!(
client.poll_notification().unwrap().unwrap().method,
"turn/completed"
);
assert!(client.poll_notification().unwrap().is_none());
}
#[test]
fn codex_v2_client_interrupt_uses_thread_and_turn_ids() {
let input = concat!(
r#"{"id":1,"result":{}}"#,
"\n",
r#"{"id":2,"result":{"thread":{"id":"thr_123"}}}"#,
"\n",
r#"{"id":3,"result":{"turn":{"id":"turn_456"}}}"#,
"\n",
r#"{"id":4,"result":{}}"#,
"\n",
);
let written = Arc::new(Mutex::new(Vec::new()));
let mut client = CodexAppServerClient::new(
Cursor::new(input.as_bytes().to_vec()),
SharedWriter(written.clone()),
)
.unwrap();
client.initialize().unwrap();
client
.thread_start(CodexThreadStartParams::default())
.unwrap();
client
.turn_start(CodexTurnStartParams::text("thr_123", "hello").unwrap())
.unwrap();
client.turn_interrupt("thr_123", "turn_456").unwrap();
let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap();
let messages = lines
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>();
let interrupt = messages.last().unwrap();
assert_eq!(interrupt["method"], "turn/interrupt");
assert_eq!(interrupt["id"], 4);
assert_eq!(
interrupt["params"],
json!({
"threadId": "thr_123",
"turnId": "turn_456"
})
);
}
#[test]
fn codex_v2_client_rejects_empty_turn_thread_id_before_writing() {
let mut client = CodexAppServerClient::new(
Cursor::new(concat!(r#"{"id":1,"result":{}}"#, "\n").as_bytes()),
Vec::<u8>::new(),
)
.unwrap();
client.initialize().unwrap();
let error = client
.turn_start(CodexTurnStartParams {
thread_id: " ".to_owned(),
input: Vec::new(),
})
.unwrap_err();
assert!(error.to_string().contains("thread_id 不能为空"));
}
#[test]
fn codex_v2_client_rejects_server_request_and_replies_with_json_rpc_error() {
let input = concat!(
r#"{"id":1,"result":{}}"#,
"\n",
r#"{"id":2,"method":"item/commandExecution/requestApproval","params":{"threadId":"thr_123"}}"#,
"\n",
);
let written = Arc::new(Mutex::new(Vec::new()));
let mut client = CodexAppServerClient::new(
Cursor::new(input.as_bytes().to_vec()),
SharedWriter(written.clone()),
)
.unwrap();
client.initialize().unwrap();
let error = client
.thread_start(CodexThreadStartParams::default())
.unwrap_err();
assert!(error.to_string().contains("server-initiated request"));
let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap();
let response = lines
.lines()
.last()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.unwrap();
assert_eq!(response["id"], 2);
assert_eq!(response["error"]["code"], -32601);
}
#[test]
fn codex_v2_client_dispatches_server_request_to_handler_and_writes_result() {
let input = concat!(
r#"{"id":1,"result":{}}"#,
"\n",
r#"{"id":"approval-1","method":"item/commandExecution/requestApproval","params":{"threadId":"thr_123"}}"#,
"\n",
r#"{"id":2,"result":{"thread":{"id":"thr_123"}}}"#,
"\n",
);
let written = Arc::new(Mutex::new(Vec::new()));
let mut client = CodexAppServerClient::new(
Cursor::new(input.as_bytes().to_vec()),
SharedWriter(written.clone()),
)
.unwrap();
client.initialize().unwrap();
let mut seen = Vec::new();
let thread = client
.thread_start_with_handler(
CodexThreadStartParams::default(),
&mut |request: &CodexServerRequest| {
seen.push((request.method().to_owned(), request.params().clone()));
Ok(CodexServerRequestResponse::result(json!({
"decision": "accept"
})))
},
)
.unwrap();
assert_eq!(thread.thread_id, "thr_123");
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].0, "item/commandExecution/requestApproval");
assert_eq!(seen[0].1["threadId"], "thr_123");
let messages = String::from_utf8(written.lock().unwrap().clone())
.unwrap()
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>();
let response = messages
.iter()
.find(|message| message["id"] == "approval-1")
.expect("server request response");
assert_eq!(response["result"]["decision"], "accept");
}
#[test]
fn codex_v2_client_poll_handler_consumes_server_request_before_notification() {
let input = concat!(
r#"{"id":1,"result":{}}"#,
"\n",
r#"{"id":"approval-1","method":"item/tool/requestUserInput","params":{"questions":[]}}"#,
"\n",
r#"{"method":"turn/completed","params":{"turn":{"id":"turn_1"}}}"#,
"\n",
);
let written = Arc::new(Mutex::new(Vec::new()));
let mut client = CodexAppServerClient::new(
Cursor::new(input.as_bytes().to_vec()),
SharedWriter(written.clone()),
)
.unwrap();
client.initialize().unwrap();
let mut seen_method = None;
let notification = client
.poll_notification_with_handler(&mut |request: &CodexServerRequest| {
seen_method = Some(request.method().to_owned());
Ok(CodexServerRequestResponse::error_with_data(
-32602,
"unsupported input shape",
json!({"retryable": false}),
))
})
.unwrap()
.unwrap();
assert_eq!(seen_method.as_deref(), Some("item/tool/requestUserInput"));
assert_eq!(notification.method, "turn/completed");
let messages = String::from_utf8(written.lock().unwrap().clone())
.unwrap()
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>();
let response = messages
.iter()
.find(|message| message["id"] == "approval-1")
.expect("server request error response");
assert_eq!(response["error"]["code"], -32602);
assert_eq!(response["error"]["data"]["retryable"], false);
}
#[test]
fn codex_v2_client_bounds_notifications_seen_before_response() {
let mut input = String::from(r#"{"id":1,"result":{}}"#);
input.push('\n');
for index in 0..=DEFAULT_MAX_PENDING_NOTIFICATIONS {
input.push_str(&format!(r#"{{"method":"progress/{index}","params":{{}}}}"#));
input.push('\n');
}
input.push_str(r#"{"id":2,"result":{"thread":{"id":"thr_123"}}}"#);
input.push('\n');
let mut client =
CodexAppServerClient::new(Cursor::new(input.into_bytes()), Vec::<u8>::new()).unwrap();
client.initialize().unwrap();
let error = client
.thread_start(CodexThreadStartParams::default())
.unwrap_err();
assert!(error.to_string().contains("pending notification 队列超过"));
}
#[cfg(unix)]
fn process_fixture_config(script: &str) -> CodexAppServerProcessConfig {
CodexAppServerProcessConfig::try_new("sh")
.unwrap()
.with_args(["-c", script])
// 只允许 fixture 所需的显式 argvscript 本身仍作为一个完整
// argv 元素传递,适配器不会替调用方解析 shell 字符串。
.with_allowed_arg_prefixes(["-", "read", "printf"])
}
#[cfg(unix)]
#[test]
fn app_server_process_performs_real_stdio_handshake_and_thread_start() {
let process = CodexAppServerProcess::spawn(process_fixture_config(
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{\"userAgent\":\"fixture\"}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thr_fixture\"}}}'",
))
.unwrap();
let metadata = process.initialize().unwrap();
assert_eq!(metadata.user_agent.as_deref(), Some("fixture"));
let thread = process
.thread_start(CodexThreadStartParams::default())
.unwrap();
assert_eq!(thread.thread_id, "thr_fixture");
// 这个一次性 fixture 在返回 thread/start 后主动 EOFadapter 应在
// 观察到 EOF 时回收 child,而不是把已结束连接继续当成长连接复用。
let deadline = Instant::now() + Duration::from_secs(1);
while !process.is_terminated() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(2));
}
assert!(process.is_terminated());
process.terminate();
}
#[cfg(unix)]
#[test]
fn app_server_process_reports_malformed_output_without_reusing_child() {
let process = CodexAppServerProcess::spawn(process_fixture_config(
// 保持 child 存活一小段时间,确认协议错误会主动 poison 连接,
// 而不是等自然 EOF 后才回收。
"read -r line; printf '%s\\n' 'not-json'; sleep 5",
))
.unwrap();
let error = process.initialize().unwrap_err();
assert!(error.to_string().contains("JSON-RPC frame 无效"));
assert!(process.is_terminated());
}
#[cfg(unix)]
#[test]
fn app_server_process_reaps_successful_eof_before_next_operation() {
let process = CodexAppServerProcess::spawn(process_fixture_config(
// 背景后代故意短暂持有 stdout;自然 EOF 回收必须收束整个
// process group 后再 join reader,不能把孤儿 pipe 留成阻塞。
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; sleep 5 & exit 0",
))
.unwrap();
let started = Instant::now();
process.initialize().unwrap();
// `with_client` 会在响应返回后主动 try_wait;若调度尚未让 shell
// 完成退出,下一次操作也会再次探测并拒绝复用已结束连接。
let deadline = Instant::now() + Duration::from_secs(1);
while !process.is_terminated() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(2));
}
assert!(process.is_terminated());
assert!(started.elapsed() < Duration::from_secs(1));
assert!(matches!(
process.initialize(),
Err(CodexError::ProcessTerminated)
));
process.terminate();
}
#[cfg(unix)]
#[test]
fn app_server_process_reaps_nonzero_exit_and_rejects_later_use() {
let process =
CodexAppServerProcess::spawn(process_fixture_config("read -r line; exit 7")).unwrap();
let error = process.initialize().unwrap_err();
assert!(matches!(error, CodexError::Exit(7)));
assert!(process.is_terminated());
assert_eq!(process.exit_code(), Some(7));
assert!(matches!(
process.initialize(),
Err(CodexError::ProcessTerminated)
));
}
#[cfg(unix)]
#[test]
fn app_server_process_timeout_kills_process_group_and_joins_reader() {
let config = process_fixture_config("read -r line; sleep 5 & wait")
.with_timeout(Duration::from_millis(40))
.unwrap();
let process = CodexAppServerProcess::spawn(config).unwrap();
let started = Instant::now();
let error = process.initialize().unwrap_err();
assert!(matches!(error, CodexError::Timeout));
assert!(started.elapsed() < Duration::from_secs(2));
assert!(process.is_terminated());
}
#[cfg(unix)]
#[test]
fn app_server_process_cancel_from_another_thread_is_bounded() {
let process = Arc::new(
CodexAppServerProcess::spawn(process_fixture_config("read -r line; sleep 5")).unwrap(),
);
let worker_process = Arc::clone(&process);
let worker = std::thread::spawn(move || worker_process.initialize());
let deadline = Instant::now() + Duration::from_secs(1);
while !process.is_terminated() && Instant::now() < deadline {
// cancel 在操作尚未登记时是幂等 no-op;循环只为覆盖线程调度
// 窗口,确保一旦 initialize 进入阻塞就能立即收束 child。
process.cancel().unwrap();
std::thread::sleep(Duration::from_millis(5));
}
let result = worker.join().expect("app-server worker thread");
assert!(matches!(result, Err(CodexError::Interrupted)));
assert!(process.is_terminated());
}
#[cfg(unix)]
fn process_backend_fixture_config(script: &str) -> CodexAppServerProcessConfig {
CodexAppServerProcessConfig::try_new("sh")
.unwrap()
.with_args(["-c", script])
.with_allowed_arg_prefixes(["-", "read", "printf", "sleep"])
}
#[cfg(unix)]
#[test]
fn app_server_process_backend_requires_explicit_initialize() {
let process =
CodexAppServerProcess::spawn(process_backend_fixture_config("sleep 5")).unwrap();
let backend = CodexAppServerProcessBackend::from_initialized(process);
assert!(matches!(
backend,
Err(CodexError::InvalidConfig(message)) if message.contains("先完成 initialize")
));
}
#[cfg(unix)]
#[test]
fn app_server_process_backend_maps_a_completed_request() {
let process = CodexAppServerProcess::spawn(process_backend_fixture_config(
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":2,\"result\":{\"ok\":true}}'; sleep 5",
))
.unwrap();
let backend = CodexAppServerProcessBackend::new_initialized(process).unwrap();
let request =
BackendRequest::try_new("process-request", "run-1", "turn/start", json!({"x":1}))
.unwrap();
let result = backend.invoke(&request).unwrap();
assert_eq!(result.request_id(), "process-request");
assert_eq!(result.output(), &json!({"ok":true}));
assert!(result.external_id().is_some_and(|value| {
value.starts_with(&format!("codex-app-server-{}-", backend.instance_nonce))
}));
assert!(!result.side_effect_unknown());
}
#[cfg(unix)]
#[test]
fn app_server_process_backend_runtime_event_bridge_maps_notification_order() {
let process = CodexAppServerProcess::spawn(process_backend_fixture_config(
// initialize consumes the first request/initialized pair. The
// next request emits one id-less notification before its response;
// the process bridge must forward that notification immediately as
// a NodeEvent instead of hiding it in the polling queue.
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"method\":\"delta\",\"params\":{\"text\":\"hello\"}}'; printf '%s\\n' '{\"id\":2,\"result\":{\"output\":\"done\"}}'",
))
.unwrap();
let backend = CodexAppServerProcessBackend::new_initialized(process).unwrap();
let mut snapshot = mapper_running_snapshot();
let mut mapper = NodeRuntimeEventMapper::try_new(
snapshot.runtime_id(),
"run-mapper",
snapshot.revision(),
60,
)
.unwrap();
let request = BackendRequest::try_new(
"process-bridge-request",
"run-mapper",
"turn/start",
json!({"prompt":"hi"}),
)
.unwrap();
let mut kinds = Vec::new();
let result = backend
.invoke_with_runtime_events(&request, &mut mapper, &mut |event| {
kinds.push(event.kind());
apply_event(&mut snapshot, &event).expect("mapped process event should reduce");
})
.expect("process runtime event bridge should complete");
assert_eq!(result.output(), &json!({"output":"done"}));
assert_eq!(
kinds.len(),
3,
"request, notification and result are ordered"
);
assert_eq!(kinds[0], RuntimeEventKind::MessageAppended);
assert_eq!(kinds[1], RuntimeEventKind::MessageAppended);
assert_eq!(kinds[2], RuntimeEventKind::RunCompleted);
assert_eq!(mapper.revision(), 6);
assert_eq!(
snapshot.run("run-mapper").unwrap().status(),
agent_runtime_core::RunStatus::Completed
);
backend.process().terminate();
}
#[cfg(unix)]
#[test]
fn app_server_process_backend_records_real_thread_and_turn_ids() {
#[derive(Clone)]
struct RecordingSink(Arc<Mutex<Vec<CodexSessionMetadata>>>);
impl CodexSessionMetadataSink for RecordingSink {
fn persist(&self, metadata: &CodexSessionMetadata) -> Result<(), CodexError> {
self.0
.lock()
.expect("metadata sink lock")
.push(metadata.clone());
Ok(())
}
}
let process = CodexAppServerProcess::spawn(process_backend_fixture_config(
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-real\"}}}'; read -r line; printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-real\"}}}'",
))
.unwrap();
let persisted = Arc::new(Mutex::new(Vec::new()));
let backend = CodexAppServerProcessBackend::new_initialized(process)
.unwrap()
.with_session_metadata_sink(RecordingSink(Arc::clone(&persisted)));
let thread = backend
.thread_start(CodexThreadStartParams::default())
.unwrap();
assert_eq!(thread.thread_id, "thread-real");
let turn = backend
.turn_start(CodexTurnStartParams::text("thread-real", "hello").unwrap())
.unwrap();
assert_eq!(turn.turn_id, "turn-real");
assert_eq!(
backend.session_metadata().unwrap(),
CodexSessionMetadata {
thread_id: Some("thread-real".to_owned()),
turn_id: Some("turn-real".to_owned()),
}
);
assert_eq!(
*persisted.lock().unwrap(),
vec![
CodexSessionMetadata {
thread_id: Some("thread-real".to_owned()),
turn_id: None,
},
CodexSessionMetadata {
thread_id: Some("thread-real".to_owned()),
turn_id: Some("turn-real".to_owned()),
},
]
);
backend.process().terminate();
}
#[cfg(unix)]
#[test]
fn app_server_process_backend_reports_lifecycle_to_sink() {
#[derive(Clone)]
struct LifecycleSink(Arc<Mutex<Vec<CodexSessionLifecycle>>>);
impl CodexSessionMetadataSink for LifecycleSink {
fn persist(&self, _metadata: &CodexSessionMetadata) -> Result<(), CodexError> {
Ok(())
}
fn persist_lifecycle(
&self,
lifecycle: &CodexSessionLifecycle,
) -> Result<(), CodexError> {
self.0
.lock()
.expect("lifecycle sink lock")
.push(lifecycle.clone());
Ok(())
}
}
let process = CodexAppServerProcess::spawn(process_backend_fixture_config(
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":2,\"result\":{\"ok\":true}}'; sleep 5",
))
.unwrap();
let persisted = Arc::new(Mutex::new(Vec::new()));
let backend = CodexAppServerProcessBackend::new_initialized(process)
.unwrap()
.with_session_metadata_sink(LifecycleSink(Arc::clone(&persisted)));
backend
.record_session_metadata(CodexSessionMetadata {
thread_id: Some("thread-life".to_owned()),
turn_id: Some("turn-life".to_owned()),
})
.unwrap();
let request =
BackendRequest::try_new("lifecycle-request", "run-1", "turn/start", json!({})).unwrap();
let result = backend.invoke(&request).unwrap();
let lifecycle = persisted
.lock()
.unwrap()
.last()
.cloned()
.expect("lifecycle should be observed");
assert_eq!(lifecycle.status, CodexSessionLifecycleStatus::Active);
assert_eq!(lifecycle.metadata.thread_id.as_deref(), Some("thread-life"));
assert_eq!(lifecycle.metadata.turn_id.as_deref(), Some("turn-life"));
assert_eq!(lifecycle.external_id.as_deref(), result.external_id());
assert_eq!(lifecycle.exit_code, None);
let lifecycle_count = persisted.lock().unwrap().len();
// A cancel that arrives after the operation has already completed is
// an idempotent no-op and must not overwrite the completed observation.
backend.cancel("lifecycle-request").unwrap();
assert_eq!(persisted.lock().unwrap().len(), lifecycle_count);
backend.process().terminate();
}
#[cfg(unix)]
#[test]
fn app_server_process_backend_reports_natural_exit_code_on_failure() {
#[derive(Clone)]
struct LifecycleSink(Arc<Mutex<Vec<CodexSessionLifecycle>>>);
impl CodexSessionMetadataSink for LifecycleSink {
fn persist(&self, _metadata: &CodexSessionMetadata) -> Result<(), CodexError> {
Ok(())
}
fn persist_lifecycle(
&self,
lifecycle: &CodexSessionLifecycle,
) -> Result<(), CodexError> {
self.0
.lock()
.expect("lifecycle sink lock")
.push(lifecycle.clone());
Ok(())
}
}
let process = CodexAppServerProcess::spawn(process_backend_fixture_config(
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; exit 7",
))
.unwrap();
let persisted = Arc::new(Mutex::new(Vec::new()));
let backend = CodexAppServerProcessBackend::new_initialized(process)
.unwrap()
.with_session_metadata_sink(LifecycleSink(Arc::clone(&persisted)));
backend
.record_session_metadata(CodexSessionMetadata {
thread_id: Some("thread-failure".to_owned()),
turn_id: Some("turn-failure".to_owned()),
})
.unwrap();
let request =
BackendRequest::try_new("failure-request", "run-1", "turn/start", json!({})).unwrap();
let error = backend.invoke(&request).unwrap_err();
assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect);
let lifecycle = persisted
.lock()
.unwrap()
.last()
.cloned()
.expect("failed lifecycle should be observed");
assert_eq!(lifecycle.status, CodexSessionLifecycleStatus::Failed);
assert_eq!(lifecycle.exit_code, Some(7));
}
#[cfg(unix)]
#[test]
fn app_server_process_lifecycle_sink_emits_natural_exit_once() {
#[derive(Clone)]
struct LifecycleSink(Arc<Mutex<Vec<CodexProcessLifecycleEvent>>>);
impl CodexProcessLifecycleSink for LifecycleSink {
fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> {
self.0
.lock()
.expect("process lifecycle sink lock")
.push(event.clone());
Ok(())
}
}
let events = Arc::new(Mutex::new(Vec::new()));
let process = CodexAppServerProcess::spawn(process_fixture_config("read -r line; exit 7"))
.unwrap()
.with_process_lifecycle_sink(LifecycleSink(Arc::clone(&events)));
assert!(matches!(process.initialize(), Err(CodexError::Exit(7))));
assert!(process.is_terminated());
assert_eq!(
*events.lock().unwrap(),
vec![CodexProcessLifecycleEvent {
reason: CodexProcessLifecycleReason::NaturalExit,
exit_code: Some(7),
}]
);
process.terminate();
assert_eq!(events.lock().unwrap().len(), 1);
}
#[cfg(unix)]
#[test]
fn app_server_process_lifecycle_sink_emits_explicit_terminate_once() {
#[derive(Clone)]
struct LifecycleSink(Arc<Mutex<Vec<CodexProcessLifecycleEvent>>>);
impl CodexProcessLifecycleSink for LifecycleSink {
fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> {
self.0
.lock()
.expect("process lifecycle sink lock")
.push(event.clone());
Ok(())
}
}
let events = Arc::new(Mutex::new(Vec::new()));
let process = CodexAppServerProcess::spawn(process_backend_fixture_config("sleep 5"))
.unwrap()
.with_process_lifecycle_sink(LifecycleSink(Arc::clone(&events)));
process.terminate();
process.terminate();
let events = events.lock().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(
events[0].reason,
CodexProcessLifecycleReason::ExplicitTerminate
);
assert!(events[0].exit_code.is_some());
}
#[cfg(unix)]
#[test]
fn app_server_backend_forwards_process_lifecycle_to_session_sink() {
#[derive(Clone)]
struct SessionSink(Arc<Mutex<Vec<CodexProcessLifecycleEvent>>>);
impl CodexSessionMetadataSink for SessionSink {
fn persist(&self, _metadata: &CodexSessionMetadata) -> Result<(), CodexError> {
Ok(())
}
fn persist_process_lifecycle(
&self,
event: &CodexProcessLifecycleEvent,
) -> Result<(), CodexError> {
self.0
.lock()
.expect("session process sink lock")
.push(event.clone());
Ok(())
}
}
let process = CodexAppServerProcess::spawn(process_backend_fixture_config(
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; sleep 5",
))
.unwrap();
let observed = Arc::new(Mutex::new(Vec::new()));
let backend = CodexAppServerProcessBackend::new_initialized(process)
.unwrap()
.with_session_metadata_sink(SessionSink(Arc::clone(&observed)));
backend.process().terminate();
let observed = observed.lock().unwrap();
assert_eq!(observed.len(), 1);
assert_eq!(
observed[0].reason,
CodexProcessLifecycleReason::ExplicitTerminate
);
}
#[cfg(unix)]
#[test]
fn app_server_process_lifecycle_sink_emits_drop_once() {
#[derive(Clone)]
struct LifecycleSink(Arc<Mutex<Vec<CodexProcessLifecycleEvent>>>);
impl CodexProcessLifecycleSink for LifecycleSink {
fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> {
self.0
.lock()
.expect("process lifecycle sink lock")
.push(event.clone());
Ok(())
}
}
let events = Arc::new(Mutex::new(Vec::new()));
{
let process = CodexAppServerProcess::spawn(process_backend_fixture_config("sleep 5"))
.unwrap()
.with_process_lifecycle_sink(LifecycleSink(Arc::clone(&events)));
drop(process);
}
let events = events.lock().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].reason, CodexProcessLifecycleReason::Drop);
}
#[cfg(unix)]
#[test]
fn app_server_process_lifecycle_sink_reports_timeout() {
#[derive(Clone)]
struct LifecycleSink(Arc<Mutex<Vec<CodexProcessLifecycleEvent>>>);
impl CodexProcessLifecycleSink for LifecycleSink {
fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> {
self.0
.lock()
.expect("process lifecycle sink lock")
.push(event.clone());
Ok(())
}
}
let config = process_fixture_config("read -r line; sleep 5")
.with_timeout(Duration::from_millis(40))
.unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let process = CodexAppServerProcess::spawn(config)
.unwrap()
.with_process_lifecycle_sink(LifecycleSink(Arc::clone(&events)));
assert!(matches!(process.initialize(), Err(CodexError::Timeout)));
let events = events.lock().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].reason, CodexProcessLifecycleReason::Timeout);
}
#[cfg(unix)]
#[test]
fn app_server_process_lifecycle_sink_distinguishes_reader_eof() {
#[derive(Clone)]
struct LifecycleSink(Arc<Mutex<Vec<CodexProcessLifecycleEvent>>>);
impl CodexProcessLifecycleSink for LifecycleSink {
fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> {
self.0
.lock()
.expect("process lifecycle sink lock")
.push(event.clone());
Ok(())
}
}
// Close stdout after the handshake while keeping the child alive. The
// operation cleanup observes EOF and the supervisor must report
// ReaderEof, not a generic explicit termination.
let process = CodexAppServerProcess::spawn(process_backend_fixture_config(
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; exec 1>&-; sleep 5",
))
.unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let process = process.with_process_lifecycle_sink(LifecycleSink(Arc::clone(&events)));
// The supervisor terminates the still-running child after EOF; the
// initialize call may surface the resulting signal error.
let _ = process.initialize();
let _ = process.poll_notification();
let events = events.lock().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].reason, CodexProcessLifecycleReason::ReaderEof);
}
#[test]
fn backend_instance_nonce_is_unique_for_each_instance() {
let config = CodexCliConfig::try_new("codex").unwrap();
let first = CodexCliBackend::new(config.clone()).unwrap();
let second = CodexCliBackend::new(config).unwrap();
assert_ne!(first.instance_nonce, second.instance_nonce);
assert!(first.instance_nonce.contains('-'));
assert!(second.instance_nonce.contains('-'));
}
#[cfg(unix)]
#[test]
fn app_server_process_backend_single_cancel_before_io_is_not_lost() {
// Keep the fixture alive after initialize. The short request timeout
// makes the pre-fix behavior (cancel flag cleared by begin_operation,
// then a blocked request) fail quickly as Timeout instead of hanging a
// test worker indefinitely.
let config = process_backend_fixture_config(
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; sleep 5",
)
.with_timeout(Duration::from_millis(100))
.unwrap();
let process = CodexAppServerProcess::spawn(config).unwrap();
process.initialize().unwrap();
let backend = CodexAppServerProcessBackend::from_initialized(process).unwrap();
// This is the exact registration -> begin_operation window: register
// once, cancel once, then enter the process call. No retry loop is
// allowed to hide a lost cancellation.
let reservation = backend.begin_request("pre-io-cancel").unwrap();
backend.cancel("pre-io-cancel").unwrap();
let mut handler = RejectingServerRequestHandler;
let result = backend.process().request_with_server_handler_reserved(
reservation,
"turn/start",
json!({}),
&mut handler,
);
backend.end_request("pre-io-cancel", reservation);
assert!(matches!(result, Err(CodexError::Interrupted)));
assert!(backend.process().is_terminated());
}
#[cfg(unix)]
#[test]
fn app_server_process_backend_rejects_server_request_as_unknown_side_effect() {
let process = CodexAppServerProcess::spawn(process_backend_fixture_config(
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":99,\"method\":\"item/commandExecution/requestApproval\",\"params\":{}}'; read -r line; sleep 5",
))
.unwrap();
let backend = CodexAppServerProcessBackend::new_initialized(process).unwrap();
let request =
BackendRequest::try_new("reject-request", "run-1", "turn/start", json!({})).unwrap();
let error = backend.invoke(&request).unwrap_err();
assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect);
assert!(error.message().contains("server-initiated request"));
assert!(backend.process().is_terminated());
}
#[cfg(unix)]
#[test]
fn app_server_process_backend_maps_handler_invalid_config_to_unknown_side_effect() {
let process = CodexAppServerProcess::spawn(process_backend_fixture_config(
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":99,\"method\":\"item/commandExecution/requestApproval\",\"params\":{}}'; read -r line; sleep 5",
))
.unwrap();
let backend = CodexAppServerProcessBackend::new_initialized(process).unwrap();
let request =
BackendRequest::try_new("handler-config-request", "run-1", "turn/start", json!({}))
.unwrap();
let mut handler = |_request: &CodexServerRequest| {
Err(CodexError::InvalidConfig(
"approval policy rejected request".to_owned(),
))
};
let error = backend
.invoke_with_handler(&request, &mut handler)
.unwrap_err();
assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect);
assert!(error.message().contains("approval policy rejected request"));
// Handler failure aborts the in-flight response wait; explicitly close
// this fixture so the test never leaves a live child behind.
backend.process().terminate();
}
#[cfg(unix)]
#[test]
fn app_server_process_backend_allows_only_explicit_server_request_handler() {
let process = CodexAppServerProcess::spawn(process_backend_fixture_config(
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":99,\"method\":\"item/commandExecution/requestApproval\",\"params\":{}}'; read -r line; printf '%s\\n' '{\"id\":2,\"result\":{\"ok\":true}}'",
))
.unwrap();
let backend = CodexAppServerProcessBackend::new_initialized(process).unwrap();
let request =
BackendRequest::try_new("handler-request", "run-1", "turn/start", json!({})).unwrap();
let mut handled_method = None;
let result = backend
.invoke_with_handler(&request, &mut |server_request: &CodexServerRequest| {
handled_method = Some(server_request.method().to_owned());
Ok(CodexServerRequestResponse::result(json!({"approved":true})))
})
.unwrap();
assert_eq!(
handled_method.as_deref(),
Some("item/commandExecution/requestApproval")
);
assert_eq!(result.output(), &json!({"ok":true}));
assert!(!result.side_effect_unknown());
}
#[cfg(unix)]
#[test]
fn app_server_process_backend_cancel_matches_request_and_is_bounded() {
let process = CodexAppServerProcess::spawn(process_backend_fixture_config(
"read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; sleep 5",
))
.unwrap();
let backend = Arc::new(CodexAppServerProcessBackend::new_initialized(process).unwrap());
let request =
BackendRequest::try_new("cancel-request", "run-1", "turn/start", json!({})).unwrap();
let worker_backend = Arc::clone(&backend);
let worker = std::thread::spawn(move || worker_backend.invoke(&request));
let deadline = Instant::now() + Duration::from_secs(1);
while !backend.process().is_terminated() && Instant::now() < deadline {
// Unknown IDs are a bounded no-op; retry the matching cancellation
// until the invoke thread has registered its active request.
backend.cancel("other-request").unwrap();
backend.cancel("cancel-request").unwrap();
std::thread::sleep(Duration::from_millis(5));
}
let result = worker.join().expect("process backend invoke thread");
assert!(matches!(
result,
Err(error) if error.kind() == ExternalErrorKind::UnknownSideEffect
));
assert!(backend.process().is_terminated());
assert!(backend.cancel("cancel-request").is_ok());
assert!(matches!(
backend.cancel(""),
Err(error) if error.kind() == ExternalErrorKind::InvalidInput
));
}
#[cfg(unix)]
#[test]
fn app_server_process_terminate_kills_group_after_child_exit() {
// The shell exits immediately but its background descendant keeps the
// stdout pipe open. Polling `try_wait` observes the already-exited
// child without invoking `reap_if_exited`, so the test exercises the
// terminate() branch that previously skipped process-group cleanup.
let process = Arc::new(
CodexAppServerProcess::spawn(process_backend_fixture_config("sleep 30 & exit 0"))
.unwrap(),
);
let child_pid = process
.control
.child
.lock()
.unwrap()
.as_ref()
.expect("spawned child")
.id();
let deadline = Instant::now() + Duration::from_secs(1);
loop {
let exited = process
.control
.child
.lock()
.unwrap()
.as_mut()
.expect("child should remain unreaped")
.try_wait()
.unwrap()
.is_some();
if exited {
break;
}
assert!(Instant::now() < deadline, "fixture child did not exit");
std::thread::sleep(Duration::from_millis(2));
}
let worker_process = Arc::clone(&process);
let (done_sender, done_receiver) = mpsc::channel();
let worker = std::thread::spawn(move || {
worker_process.terminate();
done_sender.send(()).expect("terminate completion receiver");
});
if done_receiver.recv_timeout(Duration::from_secs(2)).is_err() {
// Keep the regression bounded even when run against the old
// implementation: release the descendant, join the worker, then
// fail with an actionable assertion instead of leaking a process.
let _ = terminate_process_group(child_pid);
assert!(
done_receiver.recv_timeout(Duration::from_secs(2)).is_ok(),
"terminate remained blocked after process-group cleanup"
);
worker.join().expect("terminate worker");
panic!("terminate did not kill an exited child's process group");
}
worker.join().expect("terminate worker");
assert!(process.is_terminated());
}
#[cfg(unix)]
#[test]
fn json_rpc_router_dispatches_concurrent_responses_out_of_order() {
use std::os::unix::net::UnixStream;
let (client, server) = UnixStream::pair().expect("unix stream pair");
let client_reader = client.try_clone().expect("client reader clone");
let router = Arc::new(JsonRpcAppServerRouter::new(client_reader, client).unwrap());
let server_thread = std::thread::spawn(move || {
let mut server_reader =
BufReader::new(server.try_clone().expect("server reader clone"));
let mut server_writer = server;
let mut requests = Vec::new();
for _ in 0..2 {
let mut line = String::new();
server_reader
.read_line(&mut line)
.expect("read router request");
requests.push(serde_json::from_str::<Value>(&line).expect("request JSON"));
}
// Reverse the responses to prove the reader routes by ID rather
// than by the order in which requests were written. Derive the
// payload from each method so thread scheduling cannot affect the
// expected result.
for request in requests.into_iter().rev() {
let output = match request["method"].as_str() {
Some("first") => "first",
Some("second") => "second",
other => panic!("unexpected router method: {other:?}"),
};
writeln!(
server_writer,
"{}",
json!({"jsonrpc":"2.0", "id": request["id"], "result": {"output": output}})
)
.expect("write router response");
}
});
let first_router = Arc::clone(&router);
let first = std::thread::spawn(move || {
first_router.request("first", json!({}), Duration::from_secs(1))
});
let second_router = Arc::clone(&router);
let second = std::thread::spawn(move || {
second_router.request("second", json!({}), Duration::from_secs(1))
});
let first = first.join().expect("first request thread").unwrap();
let second = second.join().expect("second request thread").unwrap();
assert_eq!(first["output"], "first");
assert_eq!(second["output"], "second");
server_thread.join().expect("router server thread");
drop(router);
}
#[cfg(unix)]
#[test]
fn json_rpc_router_mixes_notification_and_response() {
use std::os::unix::net::UnixStream;
let (client, server) = UnixStream::pair().expect("unix stream pair");
let client_reader = client.try_clone().expect("client reader clone");
let router = JsonRpcAppServerRouter::new(client_reader, client).unwrap();
let server_thread = std::thread::spawn(move || {
let mut server_reader =
BufReader::new(server.try_clone().expect("server reader clone"));
let mut server_writer = server;
let mut line = String::new();
server_reader
.read_line(&mut line)
.expect("read router request");
let request = serde_json::from_str::<Value>(&line).expect("request JSON");
writeln!(
server_writer,
"{}",
json!({
"jsonrpc": "2.0",
"method": "turn/started",
"params": {"turnId": "turn-1"}
})
)
.expect("write notification");
writeln!(
server_writer,
"{}",
json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": {"accepted": true}
})
)
.expect("write response");
});
let result = router
.request(
"turn/start",
json!({"threadId": "thread-1"}),
Duration::from_secs(1),
)
.unwrap();
assert_eq!(result["accepted"], true);
let notification = router
.recv_notification(Duration::from_secs(1))
.unwrap()
.expect("notification");
assert_eq!(notification.method, "turn/started");
assert_eq!(notification.params["turnId"], "turn-1");
server_thread.join().expect("router server thread");
}
#[cfg(unix)]
#[test]
fn json_rpc_router_timeout_releases_bounded_pending_slot() {
use std::os::unix::net::UnixStream;
let (client, server) = UnixStream::pair().expect("unix stream pair");
let client_reader = client.try_clone().expect("client reader clone");
let router = JsonRpcAppServerRouter::with_limits(
client_reader,
client,
DEFAULT_JSON_RPC_FRAME_BYTES,
1,
DEFAULT_MAX_PENDING_NOTIFICATIONS,
)
.unwrap();
let server_thread = std::thread::spawn(move || {
let mut server_reader =
BufReader::new(server.try_clone().expect("server reader clone"));
let mut server_writer = server;
let mut first_line = String::new();
server_reader
.read_line(&mut first_line)
.expect("read first request");
// Keep the first response absent. The client timeout must remove
// its entry before this second request is accepted.
std::thread::sleep(Duration::from_millis(80));
let mut second_line = String::new();
server_reader
.read_line(&mut second_line)
.expect("read second request");
let second = serde_json::from_str::<Value>(&second_line).expect("second request JSON");
writeln!(
server_writer,
"{}",
json!({
"jsonrpc": "2.0",
"id": second["id"],
"result": {"afterTimeout": true}
})
)
.expect("write second response");
});
assert!(matches!(
router.request("slow", json!({}), Duration::from_millis(10)),
Err(CodexError::Timeout)
));
let result = router
.request("after-timeout", json!({}), Duration::from_secs(1))
.unwrap();
assert_eq!(result["afterTimeout"], true);
server_thread.join().expect("router server thread");
}
#[cfg(unix)]
#[test]
fn json_rpc_router_turn_interrupt_uses_independent_request_and_params() {
use std::os::unix::net::UnixStream;
let (client, server) = UnixStream::pair().expect("unix stream pair");
let client_reader = client.try_clone().expect("client reader clone");
let router = JsonRpcAppServerRouter::new(client_reader, client).unwrap();
let server_thread = std::thread::spawn(move || {
let mut server_reader =
BufReader::new(server.try_clone().expect("server reader clone"));
let mut server_writer = server;
let mut line = String::new();
server_reader
.read_line(&mut line)
.expect("read interrupt request");
let request = serde_json::from_str::<Value>(&line).expect("interrupt JSON");
assert_eq!(request["method"], "turn/interrupt");
assert_eq!(
request["params"],
json!({"threadId":"thread-42", "turnId":"turn-7"})
);
assert!(request["id"].as_str().is_some());
writeln!(
server_writer,
"{}",
json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": {"interrupted": true}
})
)
.expect("write interrupt response");
});
router
.turn_interrupt("thread-42", "turn-7", Duration::from_secs(1))
.unwrap();
server_thread.join().expect("router server thread");
}
#[cfg(unix)]
#[test]
fn json_rpc_router_rejects_unknown_response_and_fails_closed() {
use std::os::unix::net::UnixStream;
let (client, server) = UnixStream::pair().expect("unix stream pair");
let client_reader = client.try_clone().expect("client reader clone");
let router = JsonRpcAppServerRouter::new(client_reader, client).unwrap();
let server_thread = std::thread::spawn(move || {
let mut server_reader =
BufReader::new(server.try_clone().expect("server reader clone"));
let mut server_writer = server;
let mut line = String::new();
server_reader
.read_line(&mut line)
.expect("read router request");
writeln!(
server_writer,
"{}",
json!({
"jsonrpc": "2.0",
"id": "not-the-request-id",
"result": {"unexpected": true}
})
)
.expect("write unknown response");
});
let error = router
.request("known", json!({}), Duration::from_secs(1))
.expect_err("unknown response id must fail closed");
assert!(error.to_string().contains("id 不匹配或重复"));
let terminal = router
.request("after-failure", json!({}), Duration::from_secs(1))
.expect_err("router must remain terminal after protocol failure");
assert!(terminal.to_string().contains("id 不匹配或重复"));
server_thread.join().expect("router server thread");
}
#[cfg(unix)]
#[test]
fn json_rpc_router_keeps_valid_request_error_scoped_to_one_call() {
use std::os::unix::net::UnixStream;
let (client, server) = UnixStream::pair().expect("unix stream pair");
let client_reader = client.try_clone().expect("client reader clone");
let router = JsonRpcAppServerRouter::new(client_reader, client).unwrap();
let server_thread = std::thread::spawn(move || {
let mut server_reader =
BufReader::new(server.try_clone().expect("server reader clone"));
let mut server_writer = server;
let mut first_line = String::new();
server_reader
.read_line(&mut first_line)
.expect("read first request");
let first = serde_json::from_str::<Value>(&first_line).expect("first request JSON");
writeln!(
server_writer,
"{}",
json!({
"jsonrpc": "2.0",
"id": first["id"],
"error": {"code": -32000, "message": "busy"}
})
)
.expect("write request error");
let mut second_line = String::new();
server_reader
.read_line(&mut second_line)
.expect("read second request");
let second = serde_json::from_str::<Value>(&second_line).expect("second request JSON");
writeln!(
server_writer,
"{}",
json!({
"jsonrpc": "2.0",
"id": second["id"],
"result": {"ok": true}
})
)
.expect("write second response");
});
let error = router
.request("first", json!({}), Duration::from_secs(1))
.expect_err("valid JSON-RPC error should reach its request");
assert!(error.to_string().contains("busy"));
assert_eq!(
router
.request("second", json!({}), Duration::from_secs(1))
.unwrap()["ok"],
true
);
server_thread.join().expect("router server thread");
}
#[cfg(unix)]
#[test]
fn app_server_process_router_uses_process_control_for_long_lived_wire() {
// This fixture keeps the child alive across two requests and emits a
// notification between them. It exercises the process-backed router,
// rather than only the in-memory UnixStream transport tests above.
let process = CodexAppServerProcessRouter::spawn(process_backend_fixture_config(
r#"read -r line; id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p'); printf '{"jsonrpc":"2.0","method":"turn/started","params":{"turnId":"turn-fixture"}}\n'; printf '{"jsonrpc":"2.0","id":"%s","result":{"accepted":true}}\n' "$id"; read -r line; id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p'); printf '{"jsonrpc":"2.0","id":"%s","result":{"interrupted":true}}\n' "$id""#,
))
.unwrap();
let result = process
.request("initialize", json!({}), Duration::from_secs(1))
.unwrap();
assert_eq!(result["accepted"], true);
let notification = process
.recv_notification(Duration::from_secs(1))
.unwrap()
.expect("fixture notification");
assert_eq!(notification.method, "turn/started");
process
.turn_interrupt("thread-fixture", "turn-fixture", Duration::from_secs(1))
.unwrap();
process.terminate();
assert!(process.is_terminated());
}
#[cfg(unix)]
#[test]
fn app_server_process_router_cancel_wakes_pending_request() {
let process = Arc::new(
CodexAppServerProcessRouter::spawn(process_backend_fixture_config(
"read -r line; sleep 5",
))
.unwrap(),
);
let worker_process = Arc::clone(&process);
let worker = std::thread::spawn(move || {
worker_process.request("initialize", json!({}), Duration::from_secs(5))
});
std::thread::sleep(Duration::from_millis(20));
process.cancel().unwrap();
let result = worker
.join()
.expect("router request worker should join promptly");
assert!(matches!(result, Err(CodexError::Protocol(message)) if message.contains("取消")));
assert!(process.is_terminated());
}
#[cfg(unix)]
#[test]
fn app_server_process_router_keeps_scoped_remote_error_reusable() {
let process = CodexAppServerProcessRouter::spawn(process_backend_fixture_config(
r#"read -r line; id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p'); printf '{"jsonrpc":"2.0","id":"%s","error":{"code":-32000,"message":"busy"}}\n' "$id"; read -r line; id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p'); printf '{"jsonrpc":"2.0","id":"%s","result":{"ok":true}}\n' "$id""#,
))
.unwrap();
let error = process
.request("first", json!({}), Duration::from_secs(1))
.expect_err("remote JSON-RPC error should reach the caller");
assert!(error.to_string().contains("busy"));
assert!(!process.is_terminated());
assert_eq!(
process
.request("second", json!({}), Duration::from_secs(1))
.unwrap()["ok"],
true
);
process.terminate();
}
}