app-server · 所有前端共用的服务端
TUI、codex exec、IDE 扩展与桌面端都不直接调用 codex-core,而是经 JSON-RPC 访问同一个 app-server:要么在进程内嵌一份,要么通过 stdio、Unix socket 或 WebSocket 连一份。本篇拆开 MessageProcessor 的分派与按资源串行、线程事件翻译成通知的监听任务,以及审批这类服务端请求怎样一来一回。
app-server · 所有前端共用的服务端
一条消息的生命周期里,TUI 把输入交出去之后,接手的并不是 agent 内核,而是一个“服务端”。这一篇看的就是它:codex-rs/app-server(crate 名 codex-app-server,去掉测试约 4.4 万行),以及前端访问它用的 codex-app-server-client。
用户看到的样子
对 IDE 扩展、桌面端这类集成方来说,app-server 就是一条命令 codex app-server,默认在 stdio 上收发 JSONL,每行一条 JSON-RPC 消息;传输方式、主要方法与守护进程的用法见手册SDK 与 App Server。终端用户很少直接碰到它:codex exec 把它嵌在自己的进程里,交互式 TUI 则默认拉起一个本机后台守护进程再连上去:
| 前端 | 怎么连到 app-server | 代码里的身份 |
|---|---|---|
| TUI | 功能开关 daemon_auto_start 默认开启,自动拉起本机守护进程并经 Unix socket 连接;带 --no-daemon、--oss、多数 -c 覆盖等参数时改为进程内嵌;--remote 连远程 WebSocket | 客户端名 codex-tui,内嵌时会话来源 cli |
codex exec | 进程内嵌 | 客户端名 codex_exec,SessionSource::Exec |
| IDE 扩展、桌面端 | 子进程 codex app-server,走 stdio | 会话来源 SessionSource::VSCode |
| Python SDK | 子进程 codex app-server --listen stdio:// | 见codex exec 与 SDK |
IDE 扩展和桌面端的代码不在这个仓库里,表里那一行依据的是仓库内的痕迹:codex app-server 子命令固定以 SessionSource::VSCode 启动服务端(codex-rs/cli/src/main.rs:1270),独立二进制 codex-app-server 的 --session-source 默认值也是 vscode;CLI 参数 --analytics-default-enabled 的注释说 VS Code 扩展这类第一方用例会带上它;initialize 的处理代码对 stdio 连接上名为 Codex Desktop 的客户端有专门分支。
为什么要共用一个服务端
答案写在注释里。in_process.rs 开头说,进程内模式“preserve app-server semantics while avoiding a process boundary”;客户端门面则明说,它刻意保留服务端的请求、通知、事件模型,而不是把 core 的运行时句柄直接交给调用方:
/// The facade intentionally preserves the server's request/notification/event
/// model instead of exposing direct core runtime handles. That keeps in-process
/// callers aligned with app-server behavior while still avoiding a process
/// boundary.
pub struct InProcessAppServerClient {
command_tx: mpsc::Sender<ClientCommand>,
event_rx: mpsc::UnboundedReceiver<InProcessServerEvent>,
worker_handle: tokio::task::JoinHandle<()>,
}
// ...
pub enum AppServerClient {
InProcess(InProcessAppServerClient),
Remote(RemoteAppServerClient),
}(codex-rs/app-server-client/src/lib.rs:324)
于是所有前端面对同一份行为契约:同样的参数校验、同样的线程生命周期、同样的审批往返,差别只在传输。两种客户端向上暴露同一个事件类型 AppServerEvent(Lagged、ServerNotification、ServerRequest、Disconnected),TUI 在嵌入与远程之间切换不必改会话逻辑。迁移还没有百分之百完成:同一文件里的 legacy_core 模块把少量 core 配置类型转手给 TUI,注释称它是过渡用的,新行为“should prefer the app-server protocol methods”。
全景
独立进程的入口是 run_main_with_transport_options(codex-rs/app-server/src/lib.rs:489)。它起两个任务:处理循环接收连接事件、分派请求;出站循环负责往各连接写消息。OutboundControlEvent 的注释解释了这样拆的原因:写连接可能很慢,不能拖住请求分派。进程内模式(codex-rs/app-server/src/in_process.rs:427 的 start_uninitialized)复用同一个 MessageProcessor 和同一套出站路由,只是把传输换成有界的内存通道,连接号固定为 ConnectionId(0)。
请求:从一行 JSON 到某个 processor
处理循环收到的 TransportEvent 有四种:ConnectionOpened、ConnectionClosed、IncomingMessage、DaemonShutdown。IncomingMessage 里装的是四类 JSON-RPC 消息之一:Request 交给 MessageProcessor::process_request;Response 与 Error 是客户端对服务端请求的回答(见下文审批一节);Notification 只记一行日志。这意味着握手第二步的 initialized 通知在服务端并不改变任何状态,连接在 initialize 请求处理完就已就绪;initialize 的响应带回 userAgent、codexHome、platformFamily、platformOs,远程客户端靠它们认识对端。
MessageProcessor(codex-rs/app-server/src/message_processor.rs:140)按领域持有 23 个 *RequestProcessor:线程、轮次、配置、账号、插件、MCP、文件系统、命令执行等,实现都在 request_processors/ 目录。请求反序列化成 ClientRequest 之后,先过几道关:
if !session.initialized() {
return Err(invalid_request("Not initialized"));
}
// ...
let serialization_scope = codex_request.serialization_scope();
// ...
if let Some(scope) = serialization_scope {
let (key, access) = RequestSerializationQueueKey::from_scope(connection_id, scope);
self.request_serialization_queues
.enqueue(key, access, request)
.await;
} else {
tokio::spawn(async move {
request.run().await;
});
}(codex-rs/app-server/src/message_processor.rs:971)
- 握手与实验开关:没
initialize的连接一律Not initialized;方法或字段标了实验性、而连接没在initialize里声明experimentalApi的,同样拒绝。 - 轮次准入:
thread/start、turn/start、thread/archive等会改变运行状态的请求,要先从TurnAdmission拿许可(中间删掉的那段)。监听 socket 的服务端收到关停信号或守护进程的关停请求后进入排空期:新许可一律拒发,已经在跑的轮次跑完才真正退出,再来一次信号则强制退出。stdio 模式没有这一步,唯一的连接一断,进程就收尾。 - 按资源串行:每个方法在协议定义里声明自己的串行化范围(由下一篇讲的宏生成),映射成
RequestSerializationQueueKey:全局的某个名字、某个线程、某个线程文件路径、某个命令进程、某个文件监视等。同一键上的请求排进同一条队列、依次执行;标成SharedRead的读请求若连续排在队首,会被一次取出并发执行。没有范围的请求直接tokio::spawn。turn/start、turn/interrupt、thread/archive的范围都是thread_id,所以同一线程上的操作严格按到达顺序生效,不同线程之间互不阻塞;thread/start没有范围,并发新建互不等待。
过了关的请求进入 handle_initialized_client_request 里那个覆盖 170 个请求变体的 match,交给对应的 processor。处理函数返回 Result<Option<ClientResponsePayload>, JSONRPCErrorError>:Some 由 MessageProcessor 统一回响应,None 表示 processor 自己已经回过,Err 变成 JSON-RPC 错误。以 turn/start 为例,TurnRequestProcessor 最终调用 CodexThread::start_or_steer_turn:线程空闲就开新一轮,正有一轮在跑就把输入并入那一轮。响应立刻返回一个状态为 InProgress 的 Turn,后面发生的一切都靠通知送达。
事件:从 EventMsg 到通知
每个被加载的线程都有一个监听任务,由 ensure_listener_task_running(codex-rs/app-server/src/request_processors/thread_lifecycle.rs:214)启动。它在一个 select! 里同时等四样东西:取消信号、发给本线程的内部命令、CodexThread::next_event() 吐出的下一个事件,以及“该卸载了”的触发。每拿到一个事件,它先查出此刻订阅了这个线程的连接,包成一个 ThreadScopedOutgoingMessageSender,再交给 apply_bespoke_event_handling(codex-rs/app-server/src/bespoke_event_handling.rs:141)翻译,所以通知只发给订阅者。翻译是一个按 EventMsg 分支的大 match:TurnStarted 变成 turn/started;ItemStarted、ItemCompleted 与各种增量交给 app-server-protocol 里的 item_event_to_server_notification,变成 item/started、item/completed、item/agentMessage/delta 等;ExecCommandBegin、McpToolCallBegin 这些旧式事件在这里被直接忽略,注释说 core 仍发它们只是为了原始事件和 rollout 的兼容,v2 客户端看的是统一的 item 生命周期。
线程状态另有一条线。ThreadWatchManager(codex-rs/app-server/src/thread_status.rs:19)为每个线程记几项运行事实:是否加载、是否在跑、待决的审批数与提问数、是否出过系统错误,再推导出 ThreadStatus:未加载、空闲、系统错误,或带 WaitingOnApproval、WaitingOnUserInput 标记的活动态;状态一变就广播 thread/status/changed。它顺带维护“正在运行的轮次数”,关停排空等的就是这个数归零。监听任务里的卸载触发也依赖它:线程既没有订阅者、又不处于活动态,持续 thread_unload_delay_secs(默认 60 秒,codex-rs/core/src/config/mod.rs:3900)之后就被卸载。core 自己创建的线程(例如子代理)会经 ThreadManager 的广播被发现,处理循环把它们自动挂到所有已初始化的连接上。
所有出站消息最后汇成 OutgoingEnvelope:发给某个连接,或广播。出站路由(codex-rs/app-server/src/transport.rs:204 的 route_outgoing_envelope)逐连接过滤:没开实验开关的连接收不到实验性通知,initialize 里 optOutNotificationMethods 列出的方法也被跳过。stdio 连接的写队列容量是 128(CHANNEL_CAPACITY),WebSocket 与 Unix socket 连接放宽到 32 * 1024(WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY,注释说客户端在输出突发时可能短暂落后);这类可以断开的连接一旦队列写满就被直接断开,日志写 disconnecting slow connection after outbound queue filled,stdio 连接则原地等待。
服务端请求:审批怎么一来一回
模型要跑一条需要批准的命令时,core 发出 EventMsg::ExecApprovalRequest,之后的往返是这样的:
几个细节:
- 服务端请求的 id 来自
OutgoingMessageSender里一个自增整数;请求发给订阅该线程的所有连接,谁先答复算谁的,回调一旦被取走,后到的答复只会得到一条could not find callback警告。 - 某个连接通过
thread/resume重新加入一个线程时,服务端会把该线程还没答复的请求重发给它(replay_requests_to_connection_for_thread),断线重连不会把审批弄丢。 - 新一轮开始、本轮结束或被中断时,
abort_pending_server_requests用一个data.reason为turnTransition的错误结束所有悬而未决的请求,回调任务见到这种错误就直接返回,不再向 core 提交决定。 - 答复解析失败或客户端回了错误,都按拒绝处理,宁可不执行,不会默认放行。文件修改审批的映射最直观:
fn map_file_change_approval_decision(decision: FileChangeApprovalDecision) -> ReviewDecision {
match decision {
FileChangeApprovalDecision::Accept => ReviewDecision::Approved,
FileChangeApprovalDecision::AcceptForSession => ReviewDecision::ApprovedForSession,
FileChangeApprovalDecision::Decline => ReviewDecision::denied("rejected by user"),
FileChangeApprovalDecision::Cancel => ReviewDecision::Abort,
}
}
// ...
drop(permission_guard);
// ...
if let Err(err) = codex
.submit(Op::PatchApproval {
id: item_id,
decision,
})
.await(codex-rs/app-server/src/bespoke_event_handling.rs:1908)
permission_guard 是 ThreadWatchManager 发的守卫,析构时把待审批计数减一,线程状态随之从 WaitingOnApproval 退回普通活动态。向用户提问(item/tool/requestUserInput)、MCP 的 elicitation、动态工具调用(item/tool/call)都是同一个模式:发请求、登记回调、另起任务等答复,再把结果换成 Op 交回 core。
进程内与远程两种客户端
InProcessAppServerClient 把类型化的 ClientRequest 直接送进 MessageProcessor::process_client_request,省掉了 JSON 反序列化,但结果仍然是 JSON-RPC 的 result 信封(RequestResult);README 解释说这是有意为之,进程内路径只去掉进程边界,不引入第二套响应契约。背压策略写在 in_process.rs 里:运行时队列全部有界,普通通知在队列满时丢弃并记一条日志;server_notification_requires_delivery(codex-rs/app-server/src/in_process.rs:114)列出的少数通知必须送达,会等队列腾出空位,包括 turn/completed、线程队列与设置的变更、附件变更,以及异步投递的 agent 消息完成。服务端请求排不进队列时不会被悄悄丢掉,而是以 OVERLOADED_ERROR_CODE 失败回 MessageProcessor,免得审批永远挂着。门面这一层再加一个工作任务:请求在独立任务里等待,面向调用方的事件队列是无界的,这样调用方在等某个请求的响应时,排在前面没读的通知不会把响应堵死。另外,进程内客户端不支持 account/chatgptAuthTokens/refresh,工作任务收到就直接以 -32000 拒绝。
RemoteAppServerClient(codex-rs/app-server-client/src/remote.rs:161)总是讲 WebSocket 帧:要么是 TCP 上的 ws://、wss://,要么是本机 Unix socket 上的 WebSocket(握手 URL 固定写成 ws://localhost/rpc,字节实际走 socket)。连接与 initialize 各有 10 秒超时,单条消息上限 128 MiB,bearer token 只允许用在 wss:// 或回环地址的 ws:// 上;对端发来不认识的服务端请求,回 -32601。守护进程怎么启动、TUI 何时自动连上它,见守护进程与传输。
和《从 LLM 到 Coding Agent》对照
那本书的 permissions 把权限闸门写在同一进程里:canUseTool 判出 ask,就 await 一次 promptUser 等用户点头。Codex 把“问用户”这一步拆成了跨进程的协议往返:core 只负责发出审批事件、等待 Op,谁来问、在哪台机器上问、同时有几个窗口可以答,都由 app-server 和前端决定。那本书强调的 fail-closed 在这里更显必要:跨进程的答复可能丢失、超时或格式不对,所以一律按拒绝处理。
OpenCode 走的是同一条路:HTTP 服务端一篇里,它的终端、Web、桌面端与 SDK 都通过同一套 HTTP API 访问内核,TUI 则把同一棵路由树包成进程内的 fetch 调用。Codex 的差别在于协议是双向的 JSON-RPC,服务端可以反过来向客户端发请求。