202279c6d9
新增 Core、Engine、Runtime、SQLite、Provider、MCP、Skill、Codex、CLI 与 DAG crate 补齐 OpenAI endpoint 配置、Provider 实例/协议路由和统一工具权限边界 加入持久化、lease、checkpoint、reconciliation、审批恢复与消息历史回归 加入独立 workspace CI、依赖边界、能力集和 Fake Agent 测试脚本 同步建设计划、TODO、架构、测试与验收文档
9144 lines
348 KiB
Rust
9144 lines
348 KiB
Rust
//! 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 group,SIGKILL 后由调用方 wait/reap。
|
||
// SAFETY: pid 来自本进程刚 spawn 的 child,转换后取负值只用于 process
|
||
// group;signal 使用平台稳定的 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 ID,params 只包含调用方给出的 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 没有可取消的 join;poll 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, ¬ification_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 child;spawn 成功后才创建 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`]:每个请求可以独立
|
||
/// 等待 response,notification 由有界队列承接,超时/协议错误会终止并回收
|
||
/// 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_id;cancel 本身仍是
|
||
// 幂等的,未知 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 所需的显式 argv;script 本身仍作为一个完整
|
||
// 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 后主动 EOF;adapter 应在
|
||
// 观察到 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();
|
||
}
|
||
}
|