夜雨聆风学习资料网

ARTICLE · 1312593

Codex 源码-App Server RPC 网关——186 个 JSON-RPC 方法如何安全抵达 Agent 核心

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 处声明)。统计一下分布:

串行化 scope
方法数
典型方法
None(不串行)
53
initialize · thread/start · server/diagnostics
thread_id(threadId) 独占
47
turn/start · turn/steer · turn/interrupt
global("config") 独占
21
marketplace/add · skills/extraRoots/set
global_shared_read("config") 并发读
7
skills/list · hooks/list · modelProvider/list
其余资源 scope(process/memory/projects…)
58
fs/watch · memory/reset · project/create

为什么这样设计:这张表暴露了一个关键取舍——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 回包。代价是客户端要容忍"中断请求可能稍后才返回",换来的是状态机严格一致。

RPC 方法
串行化 scope
响应时机
thread/start
None(不排队)
受理即返回;结果走通知 / error 补偿通道
turn/start
thread_id 独占
提交完成(Started/Steered)后返回 turnId
turn/steer
thread_id 独占
立即返回;无活动 turn 时回 NotSubmitted 错误
turn/interrupt
thread_id 独占
延迟:等 TurnAborted 事件到达才回响应
skills/list(config 读)
global shared read
与其他 config 读并发执行,互不阻塞

为什么这样设计:这张表把网关的"响应契约"浓缩成一行行规则:同一线程上的 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技术推荐官」获取更多源码解析内容

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料