ARTICLE · 1312593
Codex 源码-App Server RPC 网关——186 个 JSON-RPC 方法如何安全抵达 Agent 核心
Codex 源码解析系列
第 42 讲:App Server RPC 网关——186 个 JSON-RPC 方法如何安全抵达 Agent 核心
基于 OpenAI Codex 源码 · 2026-11-05
💡 本讲一句话:Codex 的 IDE、Xcode、CLI、网页全都通过同一个 app-server 进程说话——它把 186 个 JSON-RPC 方法挡在三道门后面:initialize 握手闸门、turn 准入闸门(RAII permit + watch 通道)、按资源划分的串行队列。读完你能看懂为什么"同一线程的请求严格排队、不同线程互不阻塞、工作区切换时新 turn 会被礼貌拒绝"。
一、网关定位:一个进程,N 个客户端,M 条线程
前几讲我们一直在 core(会话运行时)和 thread-store(持久化)里打转。但别忘了:用户从来不是直接调用 core 的——IDE 插件、Xcode 扩展、CLI、网页前端,全部通过 app-server 这个 JSON-RPC 网关进程与 Codex 对话。它相当于整个系统的"总机接线员":
🔹 对上:stdio / WebSocket 两种传输,一条连接一个 session;🔹 对下:24 个 RequestProcessor,每个负责一族 RPC(account、config、fs、git、mcp、thread、turn……);🔹 中间:三道门——握手检查、turn 准入、按资源串行化。
先看 MessageProcessor 的"路由表",一眼看清网关管着哪些业务域:
📄 codex-rs/app-server/src/message_processor.rs (第 158-180 行)
account_processor: Arc<AccountRequestProcessor>, // 账户类 RPC:登录、限流、GCP setupapps_processor: AppsRequestProcessor, // App 市场 / 应用管理catalog_processor: CatalogRequestProcessor, // 模型目录查询command_exec_processor: CommandExecRequestProcessor, // 一次性命令执行(exec)process_exec_processor: ProcessExecRequestProcessor, // 长驻进程句柄操作config_processor: ConfigRequestProcessor, // 全局配置读写environment_processor: EnvironmentRequestProcessor, // 环境变量 / 环境管理external_agent_config_processor: ExternalAgentConfigRequestProcessor, // 外部 agent 接入配置feedback_processor: FeedbackRequestProcessor, // 用户反馈提交fs_processor: FsRequestProcessor, // 文件系统 RPC(读/写/watch)git_processor: GitRequestProcessor, // Git 相关查询initialize_processor: InitializeRequestProcessor, // 连接建立后的首次握手marketplace_processor: MarketplaceRequestProcessor, // 插件市场操作mcp_processor: McpRequestProcessor, // MCP server 管理 / OAuthplugin_processor: PluginRequestProcessor, // 插件安装/启用/禁用project_processor: ProjectRequestProcessor, // 项目(工作区)管理remote_control_processor: RemoteControlRequestProcessor, // 远程控制 / 配对search_processor: SearchRequestProcessor, // 模糊搜索会话thread_goal_processor: ThreadGoalRequestProcessor, // 线程目标设定thread_queue_processor: ThreadQueueRequestProcessor, // 线程队列操作thread_processor: ThreadRequestProcessor, // 线程生命周期(start/resume/archive/delete)turn_processor: TurnRequestProcessor, // turn 提交/steer/interrupt——本讲核心windows_sandbox_processor: WindowsSandboxRequestProcessor,// Windows 沙箱配置为什么这样设计:每个 processor 是一个独立的 struct,字段直接挂在 MessageProcessor 上——没有接口抽象、没有动态注册。Rust 里这种"编译期路由表"比运行时查表快且零开销:dispatch 就是一个巨大的 match,方法名到处理器是静态绑定的。代价是新增 RPC 要改这个 struct,但换来的是任何一条请求的路径在代码里都能一眼追到底——对一个安全敏感的 agent 网关来说,可审计性比扩展性更值钱。
二、第一道门:initialize 握手与实验 API
handle_client_request()(第 983-1026 行)是每条请求的入口。它先做一件事:Initialize 请求走单独通道——因为握手本身不能依赖"已初始化"状态,否则就是死锁。其余所有请求进入 dispatch_initialized_client_request(),第一关就是两连查:
📄 codex-rs/app-server/src/message_processor.rs (第 1029-1037 行)
if !session.initialized() { return Err(invalid_request("Not initialized")); // 连接还没握手 → 直接拒绝,不进业务逻辑}if let Some(reason) = codex_request.experimental_reason() && !session.experimental_api_enabled() // 方法被标记为实验性、且客户端没开实验开关{ return Err(invalid_request(experimental_required_message(reason))); // 返回明确错误,告诉客户端怎么开启}为什么这样设计:两个检查都是"快速失败"——在碰任何共享状态之前就把非法请求挡掉。experimental_reason() 是协议宏里给每个方法声明的元数据:实验性方法(比如新加的 remote-control)默认关闭,客户端必须在 initialize 时显式声明 experimentalApiEnabled 才能调用。这给了 Codex 一个"灰度发布 API"的机制——新方法可以先合进代码、对老客户端完全不可见,避免协议不兼容直接炸掉线上 IDE 插件。
三、第二道门:turn 准入——RAII permit + watch 通道
turn_admission.rs 全文只有 125 行,却解决了一个并发经典问题:"暂停"和"在途请求排空"如何原子地配合?场景:用户切换工作区(workspace),此时必须保证没有新的 turn 进来、且已经在跑的 turn 提交全部结束——否则新工作区的配置会和旧 turn 的上下文打架。
核心状态只有三个字段:
📄 codex-rs/app-server/src/turn_admission.rs (第 12-35 行)
#[derive(Debug, Default)]struct AdmissionState { closed: bool, // 关停 drain 已开始 → 从此拒绝所有新 turn paused: bool, // 工作区切换进行中 → 暂停准入 active: usize, // 当前持有的 permit 数(在途的 turn 提交)}#[derive(Clone, Debug)]pub(crate) struct TurnAdmission { state: Arc<Mutex<AdmissionState>>, // 共享状态,锁只持有很短临界区 active_tx: watch::Sender<usize>, // 广播 active 计数,pause() 靠它等在途排空}准入本身是一个 RAII permit——拿进来 active+1,drop 时自动 -1。请求无论成功、失败还是 panic,计数都不会泄漏:
📄 codex-rs/app-server/src/turn_admission.rs (第 74-90、111-121 行)
pub(crate) fn admit(&self) -> Result<TurnPermit, JSONRPCErrorError> { let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if state.closed { return Err(server_draining_error()); // 服务器正在 drain → 让客户端去别处重试 } if state.paused { return Err(invalid_request("Workspace change in progress; retry when it completes")); // 工作区切换没完成 → 拒绝并提示稍后重试 } state.active += 1; // 把本次请求计入"在途" self.active_tx.send_replace(state.active); // 通知 pause() 的等待者:计数变了 Ok(TurnPermit(self.clone())) // RAII permit:drop 时自动减一,无需手动释放}impl Drop for TurnPermit { fn drop(&mut self) { let mut state = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); state.active -= 1; // 请求结束(或失败),释放计数 self.0.active_tx.send_replace(state.active); // 若 pause() 正在等排空,这里把它唤醒 }}真正的难点在 pause():它要"关门 + 等在途全部走完"。注意它的顺序——先订阅 watch、再置 paused,否则两个并发 pause 之间可能漏掉信号:
📄 codex-rs/app-server/src/turn_admission.rs (第 38-59 行)
pub(crate) async fn pause(&self) -> Result<TurnAdmissionPause, JSONRPCErrorError> { let mut active = self.subscribe_active(); // 先订阅 active 计数,再置 paused(避免漏掉信号) { let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if state.closed || state.paused { return Err(invalid_request("Runtime changes are already paused")); // 重复 pause → 直接报错,不叠加 } state.paused = true; // 关门:此后 admit() 一律被拒 } let pause = TurnAdmissionPause(self.clone()); // RAII:drop 时自动 unpause(即使下面超时) tokio::time::timeout( std::time::Duration::from_secs(/*secs*/ 10), active.wait_for(|active| *active == 0), // 等在途 turn 提交全部结束 ) .await .map_err(|_| invalid_request("Work is still starting; retry the workspace switch"))? .map_err(|_| invalid_request("Turn admission is unavailable"))?; Ok(pause)}为什么这样设计:三个细节值得咂摸。其一,TurnAdmissionPause 也是 RAII——pause 成功返回的句柄 drop 时自动恢复准入,即使等待超时走 ? 提前退出也不会把门焊死。其二,等排空用的是 tokio watch 通道而不是轮询:permit drop 时 send_replace 一次就唤醒所有等待者,零忙等。其三,10 秒超时兜底——在途请求卡死时宁可报错让用户重试,也不让工作区切换永久挂起。整套机制没有一行 sleep、没有一把全局大锁,是 Rust 并发里"小临界区 + 通道通知"的教科书写法。
四、第三道门:按资源划分的串行队列
request_serialization.rs 回答的问题是:哪些请求必须排队,排在哪条队里?答案不是"全局一把锁"(太粗),而是按资源划 key——每条队列只串行访问同一资源的请求:
📄 codex-rs/app-server/src/request_serialization.rs (第 25-51 行)
#[derive(Clone, Debug, Eq, Hash, PartialEq)]pub(crate) enum RequestSerializationQueueKey { Global(&'static str), // 全局资源:config / account-auth / memory ... Thread { thread_id: String }, // 每线程一条独占队列(turn/start 等) ThreadPath { path: PathBuf }, // resume/fork 按 rollout 文件路径排队 CommandExecProcess { connection_id, process_id }, // command exec 会话 Process { connection_id, process_handle }, // 长驻进程句柄 FuzzyFileSearchSession { session_id: String }, // 模糊搜索会话 FsWatch { connection_id, watch_id: String }, // fs watch McpOauth { server_name: String }, // 每个 MCP server 一条 OAuth 流}这些 key 不是手写的——协议宏里每个 RPC 方法都声明了自己的 serialization scope(app-server-protocol/src/protocol/common.rs,共 186 处声明)。统计一下分布:
为什么这样设计:这张表暴露了一个关键取舍——thread/start(53 个"不串行"之一)和 turn/start(47 个线程独占之一)走的是完全不同的并发策略:前者"秒回 + 后台干活",后者"严格排队等结果"。而 config 的读写分离(21 写独占 vs 7 读并发)说明网关不是无脑加锁:读可以并行、写必须串行。这正是下面 drain 循环要实现的。
队列的排空逻辑(drain(),第 233-268 行)是整份文件最精彩的部分——它把"独占串行"和"共享读并发"统一在一个循环里:
📄 codex-rs/app-server/src/request_serialization.rs (第 233-268 行)
async fn drain(self, key: RequestSerializationQueueKey) { loop { let (requests, changed) = { let mut queues = self.inner.lock().await; let Some(queue) = queues.get_mut(&key) else { return }; // 队列已被移除 → 直接退出 match queue.requests.pop_front() { Some(request) => { let access = request.access; let mut requests = vec![request]; if access == RequestSerializationAccess::SharedRead { // 队首是读请求 → 把后面连续的读全部拉出来,准备并发执行 while queue.requests.front().is_some_and(|request| { request.access == RequestSerializationAccess::SharedRead }) { let Some(request) = queue.requests.pop_front() else { break; }; requests.push(request); } } (requests, Arc::clone(&queue.changed)) // 顺手拿走变更信号,供后续 wait_for } None => { queues.remove(&key); return; } // 队列空了 → 把 key 本身删掉,省内存 } }; if requests[0].access == RequestSerializationAccess::Exclusive { for request in requests { request.request.run().await; // 独占:严格串行,一个一个跑 } continue; } let mut running_reads = requests .into_iter() .map(|request| request.request.run()) .collect::<<FuturesUnordered<_>>(); // 共享读:并发执行,谁先完成谁先出结果为什么这样设计:三个工程细节。其一,锁的持有时间极短——pop 请求、判断 access、clone 信号全部在 inner.lock() 临界区内完成,真正的 run().await 一定发生在锁外(注意那个内层作用域块),否则并发读就退化成串行了。其二,连续读批量拉取:队首是 SharedRead 时把后面所有连续的读一次性弹出、用 FuturesUnordered 并发跑——这是"读者不互斥"的落地方式;一旦遇到写请求(Exclusive)就停止批量,保证读写顺序不乱。其三,空队列即删 key:HashMap 里不留僵尸条目,长期运行的网关不会内存膨胀。
五、thread/start:秒回 + 后台启动任务
thread/start 是网关里最"重"的 RPC——它要加载配置、校验动态工具、处理项目信任、创建线程、挂监听器。如果同步执行,客户端得盯着转圈好几秒。Codex 的选择是:RPC 只负责"受理",真正的启动丢给后台任务(thread_start_inner(),第 1249-1387 行):
📄 codex-rs/app-server/src/request_processors/thread_processor.rs (第 1353-1387 行)
let thread_start_task = async move { if let Err(error) = Self::thread_start_task( listener_task_context, thread_store, config_manager, request_id, /* …其余 20+ 参数省略… */ ) .await { outgoing.send_error(error_request_id, error).await; // 失败 → 把 JSON-RPC error 发回客户端 }};self.background_tasks .spawn(thread_start_task.instrument(request_context.span())) // spawn 后台任务并挂上 tracing span .await .map_err(|_| internal_error("thread startup task stopped before completing"))?;Ok(()) // RPC 立即返回:thread/start 是"已受理",不是"已完成"注意错误路径的处理方式:后台任务失败时主动把 error 发回客户端(send_error),而不是静默吞掉——JSON-RPC 的 request_id 早就随响应"用掉了",所以失败必须走这条补偿通道。成功路径同理:线程真正启动后,通过 thread/started 通知把 threadId 推给客户端。
后台任务里有个容易被忽略的细节——先预留 thread id、再创建线程(第 1592-1609 行):
📄 codex-rs/app-server/src/request_processors/thread_processor.rs (第 1592-1609 行)
let reserved_thread_id = if start_options.config.ephemeral { None // 临时线程不落盘 → 不需要提前预留 id} else { stage_pending_thread_metadata( // 先在存储里写一条"pending"元数据行 listener_task_context.thread_manager.as_ref(), thread_store.as_ref(), StoreThreadMetadataPatch { project_id: project_id.clone().map(Some), // 绑定工作区(如有) daybreak_enabled, // 记录 Daybreak 开关状态 ..Default::default() }, "thread/start", // 标记写入来源,便于审计追溯 ) .await?};let thread_id = reserved_thread_id .unwrap_or_else(|| listener_task_context.thread_manager.reserve_thread_id()); // 临时线程 → 只在内存里预留start_options.reserved_thread_id = Some(thread_id); // id 钉死:后续创建失败也能按这个 id 精确清理为什么这样设计:"先占坑后建房"。线程创建是个多步过程(加载配置 → 建 core 会话 → 挂监听器),任何一步失败都可能留下半成品。如果 id 是最后才生成的,失败时根本不知道该清理谁;而提前把 pending 元数据写进 thread-store、id 钉死在 start_options 里,后续无论哪步炸掉,都能按这个 id 精确回滚——这是分布式系统里"预留资源 + 幂等清理"的标准套路。ephemeral(临时)线程跳过落盘预留,则说明这条路径对性能敏感的场景做了特化。
六、turn/start:提交管线与 steer / interrupt
turn_processor.rs(约 2400 行)是网关里最厚的 processor。它的 turn_start_inner()(第 546-773 行)把用户输入变成一次 core 提交,核心调用链只有一句话:thread.start_or_steer_turn(...)——注意方法名里的 "or steer":同一个入口既可能开新 turn,也可能把输入合并进正在跑的 turn:
📄 codex-rs/app-server/src/request_processors/turn_processor.rs (第 690-740 行)
let submission = thread .start_or_steer_turn( TurnInputRequest::new(input) .with_thread_settings(thread_settings) // 每 turn 的配置覆盖(模型/effort/沙箱…) .on_start(TurnStartOptions { turn_trigger: params.turn_trigger, // 触发来源(用户 / 自动化) parent_turn_id: params.parent_turn_id, // 子 agent 场景的父 turn id initiating_agent_path: None, root_turn_id: params.root_turn_id, final_output_json_schema: params.output_schema, // 结构化输出 schema service_tier: params.service_tier_for_turn, // 服务档位(如有) cyber_access_program: params.cyber_access_program.map(Into::into), }) .with_additional_context(additional_context) // 客户端注入的附加上下文 .with_responses_metadata(params.responsesapi_client_metadata) .with_responses_metadata_patch(params.responsesapi_client_metadata_patch) .with_trace(self.request_trace_context(&request_id).await), // W3C trace context,跨进程追踪 ) .await .map_err(|err| { /* 错误映射省略 */ })?;let (turn_id, root_turn_id, started) = match submission { TurnInputSubmission::Accepted => { return Err(internal_error( // Accepted 表示"agent 收下但没启动"——对用户输入不该出现 "user input unexpectedly returned agent acceptance".to_string(), )); } TurnInputSubmission::Started { turn_id, root_turn_id } => (turn_id, root_turn_id, true), // 新 turn 已启动 TurnInputSubmission::Steered { turn_id, root_turn_id } => (turn_id, root_turn_id, false), // 合并进了在途 turn TurnInputSubmission::NotSubmitted { reason } => { /* 错误映射省略 */ } // 被拒:draining / 无活动 turn 等为什么这样设计:TurnInputSubmission 四态枚举是网关与 core 之间的"提交协议"。把 Accepted 直接当 internal error 抛掉,说明作者认为对用户输入而言"收下不启动"只可能是 bug——宁可炸响也不静默吞消息(fail loud)。而 Steered 分支返回的 started=false 会决定后续响应里带不带 "turn started" 语义:客户端据此区分"我开了新回合"和"我的话插进了正在跑的回合"。另外注意 .with_trace(...)——每个 RPC 都携带 W3C trace context,网关、core、模型调用串成一条分布式追踪链。
最微妙的是 turn/interrupt(第 1487-1509 行):它不立即回复,而是把 request_id 记进 pending 列表,等 core 真正发出 TurnAborted 事件才回响应——因为"中断请求已收到"和"turn 真的停了"是两回事:
📄 codex-rs/app-server/src/request_processors/turn_processor.rs (第 1487-1509 行)
if !is_startup_interrupt { let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; let is_running = matches!(thread.agent_status().await, AgentStatus::Running); { let mut thread_state = thread_state.lock().await; // 锁的是每线程状态(不是全局锁) if let Some(active_turn_id) = thread_state.active_turn_id() { if active_turn_id != turn_id { return Err(invalid_request(format!( "expected active turn id {turn_id} but found {active_turn_id}" // 乐观校验:turn 不匹配直接拒绝 ))); } } else if thread_state.last_terminal_turn_id.as_deref() == Some(turn_id.as_str()) || !is_running // turn 已结束、或 agent 没在跑 → 没有可中断的东西 { return Err(invalid_request("no active turn to interrupt")); } thread_state.pending_interrupts.push(request_id.clone()); // 先记账不回复——等 TurnAborted 事件再回响应 } self.outgoing.record_request_turn_id(request_id, &turn_id).await; // 绑定 request→turn,事件到达时才能找到它为什么这样设计:这是"请求-响应"语义向"事件驱动"语义的一次妥协。中断是异步的:core 可能正在执行一个工具调用,收到 interrupt 后要等当前步骤收尾才真正停。如果网关立即回 "ok",客户端会以为已经停了、继续发下一条消息——实际旧 turn 还在跑,上下文就乱了。所以这里把响应延迟到 TurnAborted 事件:pending_interrupts 记账 + request→turn 绑定,等监听器收到中止事件时再按 request_id 回包。代价是客户端要容忍"中断请求可能稍后才返回",换来的是状态机严格一致。
为什么这样设计:这张表把网关的"响应契约"浓缩成一行行规则:同一线程上的 turn 操作严格 FIFO(thread_id 独占队列保证),跨线程完全并行;重活(thread/start)异步化、轻读(skills/list)并发化、状态敏感操作(interrupt)事件驱动。客户端不需要理解内部机制,只要记住"哪些调用是即时的、哪些要等通知"——这正是 RPC 网关该提供的抽象。
七、全链路数据流:一条 turn/start 的完整旅程
请求路径(turn/start)
① 接入:transport → handle_client_request
stdio/WebSocket 帧解出 JSON-RPC,按 connection_id 找到 session。
▼
② 门一:initialized + experimental 检查
未握手或实验 API 未开启 → invalid_request,零副作用返回。
▼
③ 门二:turn_admission.admit()
closed/paused → 拒绝;否则 active+1,拿到 RAII permit。
▼
④ 门三:串行队列 enqueue(key=thread_id)
同线程 FIFO;独占请求严格串行,连续共享读批量并发。
▼
⑤ 执行:turn_processor → start_or_steer_turn
校验 + 组装 TurnInputRequest,Op 进入 codex-core agent loop。
▼
⑥ 回包:Started/Steered → JSON-RPC response
permit drop(active-1),响应经 outgoing 通道发回客户端。
事件回程(turn 运行中)
① core 发事件 → listener task 接收
ItemCompleted / TurnAborted 等,按 thread_id 路由。
▼
② 转通知 / 触发延迟响应
普通事件 → JSON-RPC notification;TurnAborted → 回 pending_interrupts。
▼
③ outgoing sender → transport 帧
按 connection_id 找到对应客户端连接发出。
▼
④ 客户端更新 UI / 状态机
IDE 面板刷新、中断确认落定,可发起下一轮交互。
📌 本讲小结:app-server 网关的精髓不在"转发",而在三套并发纪律——RAII permit 管准入(关门 + 排空原子化)、资源 key 队列管顺序(同线程 FIFO、读并发写串行)、异步任务管重活(秒回 + 事件补偿)。186 个 RPC 方法共用这套骨架,新增方法只需在协议宏里声明 scope,网关行为自动生效。下一讲我们钻进 codex-api:Responses WebSocket 如何把模型流式输出变成稳定的 JSON-RPC 通知。
📚 系列导航
← 第 41 讲:Thread Store 线程存储——JSONL 是正本,SQLite 是可重建视图
→ 第 43 讲:Codex API / Responses WebSocket——模型流式输出的稳定通道
关注公众号「AI技术推荐官」获取更多源码解析内容















































































