Codex CLI 源码架构全解剖
基于 GitHub 上 openai/codex 官方仓库 2026-07-06 的主干快照(commit 9c5be7e1),逐层拆解这个用 Rust 写成的编程 agent:你的一条消息从敲下回车,到最终回复出现在终端,中间发生的每一件事。所有结论都标注了 文件:行号,可直接对照 codex-rs/ 阅读。如果你刚读完《Claude Code 源码架构全解剖》,本文会顺带点出两者的设计差异。
版本说明(读前必看):本文对应的是一个相当新的主干快照,与网上常见的旧版 Codex 资料有几处结构性差异:① 审批策略里的 on-failure 已退役为 on-request 的别名;② Chat Completions 协议支持已被彻底删除,只剩 Responses API;③ Linux 沙箱已从 Landlock 切换为内嵌编译的 bubblewrap(Landlock 降级为 legacy 后备);④ TUI 和 codex exec 都不再直连引擎,而是统一经由内嵌的 app-server JSON-RPC 协议层。旧文章里的 Op::UserTurn、codex.rs 大文件等在这个版本里都已不存在。
00全景架构:一个二进制,五层结构
先建立整体心智模型,后面每一章都是对其中一层的放大。
Codex CLI 和 Claude Code 解决的是同一个问题——「让模型在你的电脑上安全地写代码」——但技术选型几乎完全相反:Claude Code 是 TypeScript + React 渲染到终端,Codex 是纯 Rust + ratatui;Claude Code 的安全边界主要靠「权限提示系统」,Codex 的重头戏是操作系统级沙箱(macOS Seatbelt / Linux bubblewrap+seccomp / Windows Restricted Token)。
先解释几个贯穿全文的 Rust 术语:crate 是 Rust 的包/模块单元(类似 npm 包),一个 workspace 把多个 crate 组织在一个仓库里统一构建;tokio 是 Rust 最主流的异步运行时(类似 Node.js 的事件循环,负责调度所有 async 任务);channel(通道)是异步任务之间传消息的管道,一端发送(tx)一端接收(rx)——Codex 的整个引擎就是围绕两条 channel 构建的。
仓库顶层:codex-rs/ 是 Rust workspace(全部核心实现,codex-rs/Cargo.toml:2-126 列出 123 个成员 crate);codex-cli/ 是 npm 薄壳(只负责按平台挑选原生二进制并透传信号,codex-cli/bin/codex.js:15-22);sdk/ 有 TypeScript 和 Python 两套 SDK;docs/ 的多数文档已外迁到官网、只剩重定向占位。
clap 解析 28 个子命令;arg0 用「busybox 技巧」让同一个二进制按 argv[0] 变身为沙箱助手 / apply_patch;npm 层只是选平台二进制的薄壳
immediate-mode 终端 UI:每帧重画底部 inline 视口,定稿的历史直接写进终端原生回滚缓冲(不用全屏 alt-screen)
所有前端(TUI / exec / IDE / SDK)统一经由 app-server JSON-RPC 驱动引擎;引擎内部则是 SQ/EQ(提交队列/事件队列)双通道协议
submission_loop 消费 Op、spawn Task;run_turn 是真正的 agentic 循环:调模型 → 执行工具 → 回填结果 → 再调模型,直到没有工具调用
工具注册表按模型现算工具面;每条 shell 命令过「execpolicy 规则 → 审批 → OS 沙箱 → 失败升权重试」四道关
只支持 OpenAI Responses API(Chat Completions 已删除);WebSocket 优先、HTTP SSE 兜底;ChatGPT OAuth 或 API key 认证
0.1 123 个 crate 怎么分组
| 分组 | 代表 crate | 职责 |
|---|---|---|
| 入口/分发 | cli · exec · tui · arg0 · responses-api-proxy | 命令行入口、无头模式、终端 UI、argv[0] 分发 |
| 核心引擎 | core · protocol · codex-client · codex-api · rollout · models-manager | Session/Task/Turn 循环、SQ/EQ 协议类型、HTTP/SSE 传输、会话落盘、模型元数据 |
| 配置 | config · codex-home · features · keyring-store · secrets | config.toml 分层加载、~/.codex 主目录、特性开关、凭据保管 |
| 沙箱/安全 | linux-sandbox · bwrap · sandboxing · execpolicy · shell-command · network-proxy · process-hardening | 三平台沙箱、命令安全规则、网络代理、进程加固 |
| 工具面 | tools · apply-patch · file-search · git-utils · hooks · skills · memories | 工具契约、补丁 DSL、文件搜索、钩子、技能、记忆 |
| 协议/服务化 | app-server 家族(6个) · mcp-server · rmcp-client · code-mode 家族 | IDE/SDK 的 JSON-RPC 面、MCP 双向、工具调用编程化 |
| 生态/云 | cloud-tasks · chatgpt · login · ollama · lmstudio · connectors · otel | Codex Cloud 任务、OAuth 登录、本地模型、遥测 |
三个最重要的文件:如果只看三个文件,看 core/src/session/turn.rs(run_turn 主循环)、core/src/tools/orchestrator.rs(审批+沙箱+升权重试的编排)、core/src/client.rs(请求怎么组装和流式消费)。所有路径相对 codex-rs/。
对比 Claude Code:Claude Code 是「一个 npm 包 + source map 可还原的单体 TS 应用」;Codex 是「123 个 crate 的 Rust workspace + 编译后单个原生二进制」。前者热改快、生态借力 Node;后者启动快、内存省,且能把 bubblewrap 沙箱的 C 源码直接编译进自己(bwrap/build.rs:14-19),不依赖系统组件。
01一条消息的一生 ★
这是全文的主线。从你按下回车,到回复完整出现,共 14 站。每一站在后面的章节里都有对应的放大详解。
回车:composer 产出 Submitted
底部输入框(自研 textarea,不是第三方组件)在 handle_key_event 里处理 Enter,返回 InputResult::Submitted { text, ... }。Shift+Enter/续行等在这层被排除。
tui/src/bottom_pane/chat_composer.rs:1622 · :274-278
ChatWidget 打包成 AppCommand::UserTurn
handle_composer_input_result → submit_user_message 把文本连同 cwd、审批策略、模型、推理力度打包成 AppCommand::UserTurn,经 UI 内部事件总线(AppEvent)交给 App 层。
tui/src/chatwidget/input_flow.rs:10-45 · input_submission.rs:65 · app_command.rs:34-47
App 层路由:steer 还是新 turn?
如果当前已有 turn 在跑,优先尝试 turn_steer(把新消息「转向注入」正在进行的任务,而不是排队);否则调 app_server.turn_start(...) 开新 turn。
tui/src/app/thread_routing.rs:562,578-580,653-669
JSON-RPC:turn/start 进入 app-server
TUI 本身只是内嵌 app-server 的一个客户端。ClientRequest::TurnStart 带着 thread_id、input、cwd、approval_policy 等参数发出——IDE 插件和 SDK 走的是完全相同的这条协议。
tui/src/app_server_session.rs:772-820 · app-server-protocol/src/protocol/common.rs:799
app-server 翻译成引擎协议 Op::UserInput
turn_processor 把 JSON-RPC 参数转成引擎的提交操作 Op::UserInput { items, thread_settings, ... },提交给对应线程。
app-server/src/request_processors/turn_processor.rs:522-536 · protocol/src/protocol.rs:543-555
入队 SQ:tx_sub.send(Submission)
Codex::submit 给操作分配唯一 id、包成 Submission,发进有界 async channel(SQ = Submission Queue,提交队列)。引擎的所有输入——用户消息、审批决定、中断——都走这一条队列,天然保序。
core/src/session/mod.rs:752-781 · protocol/src/protocol.rs:161-170
submission_loop 分发:造 TurnContext,spawn RegularTask
常驻的 submission_loop 收到 Op::UserInput 后,先对会话配置做快照生成本 turn 不可变的 TurnContext;若无活跃任务则 spawn_task(ctx, input, RegularTask::new())——新任务会先把旧任务以 Replaced 理由中止(一个会话同时最多一个任务)。
core/src/session/handlers.rs:703,758,260-265 · turn_context.rs:573 · tasks/mod.rs:314-323
TurnStarted 事件 + 用户消息入账
RegularTask 先发 EventMsg::TurnStarted(wire 名仍叫 task_started);用户消息经 hooks 处理后写进内存 history + rollout 落盘文件,并回发 EventMsg::UserMessage 让 UI 显示。
core/src/tasks/regular.rs:49-56 · session/turn.rs:481-507
组 Prompt,打开模型流
从 history 克隆归一化后的完整对话(for_prompt),加上按模型现算的工具表和 base_instructions,构成 Prompt;ModelClientSession::stream() 优先走 WebSocket,失败降级 HTTP Responses API(SSE)。请求体 store:false、stream:true、prompt_cache_key=thread_id。
session/turn.rs:272-278,1112 · client.rs:1734-1786,812-892
SSE 事件流:文本增量实时冒泡到 UI
流上的 OutputTextDelta 被逐段转成 EventMsg::AgentMessageContentDelta 发给客户端;TUI 按「整行提交」节奏把流式 Markdown 渐进渲染进历史区(见第 8 章)。推理摘要增量、限流快照、token 用量走各自的事件变体。
session/turn.rs:2240-2271 · codex-api/src/common.rs:74-116 · tui/src/chatwidget/streaming.rs:388
工具调用:路由 → 审批 → 沙箱 → 执行
OutputItemDone(FunctionCall) 先把调用本身记入 history,再经 ToolRouter 按名字路由到 handler,置 needs_follow_up = true。exec 类工具统一过 ToolOrchestrator 四道关:execpolicy 规则判定 → 需要时弹审批(ExecApprovalRequest 事件 + oneshot 等待用户决定)→ 套 OS 沙箱执行 → 沙箱拒绝时按策略升权重试。工具支持并行:读锁并发、写锁独占。
stream_events_utils.rs:405-444 · tools/orchestrator.rs:134 · tools/parallel.rs:133-137
结果回填,循环下一轮
工具 future 放进 FuturesOrdered 有序队列,完成后 FunctionCallOutput 经 record_conversation_items 同时写内存 history、rollout 落盘、回发客户端三件事。Completed 事件到达且 needs_follow_up 为真 → run_turn 循环 continue,用更新后的完整 history 再采样一次。
session/turn.rs:1853-1877,416 · session/mod.rs:2777-2794
终点判定:没有工具调用、没有 steer 输入
终止条件 = 模型这轮既没发 tool call、服务端也没标 end_turn:false、且用户没有中途 steer 进新输入。满足则跑 Stop hooks(可以阻止停止逼模型继续),取最后一条 assistant 消息 break 出循环。
session/turn.rs:318,371-415 · stream_events_utils.rs:443
收尾:TokenCount → TurnComplete → UI 恢复输入框
任务收尾时 flush rollout、发 EventMsg::TokenCount(token 用量+限流快照)和 EventMsg::TurnComplete;app-server 的事件泵把它翻译成 v2 通知 turn/completed;TUI 收到后合并流式渲染单元为可重排的 Markdown cell、收起工作指示、恢复输入框。
tasks/mod.rs:409-433,748-770 · app-server/src/request_processors/thread_lifecycle.rs:302-309 · tui/src/chatwidget/protocol.rs:67,232-250
对比 Claude Code:两者的 agentic 循环本质相同(「有 tool call 就再来一轮」),但消息进引擎的路径不同——Claude Code 是 UI 直接调用 query() 生成器函数(进程内函数调用);Codex 多了两跳:UI → JSON-RPC(app-server)→ SQ 队列(channel)。多这两跳换来的是任何前端(IDE/SDK/CI)都能用同一协议驱动引擎,以及输入天然串行化。
02启动流程:进程启动 → TUI 出现
从 codex 命令敲下到界面出现,经过 npm 薄壳、arg0 分发、clap 解析、配置加载、onboarding、事件循环六步。
2.1 npm 薄壳:选平台二进制
如果你用 npm install -g @openai/codex 安装,实际执行的是 codex-cli/bin/codex.js:维护「平台 → 子包」映射(如 linux x64 → @openai/codex-linux-x64,codex.js:15-22),用 require.resolve 找到平台包里的原生二进制(codex.js:79-105),然后 spawn(binaryPath, argv, {stdio:'inherit'}) 启动并透传 SIGINT/SIGTERM 信号、复现退出码(codex.js:167-200)。一切逻辑都在 Rust 里,JS 层零业务。
2.2 arg0 多调用分发:一个二进制的多重身份
Rust 侧真正的 main()(cli/src/main.rs:956-962)不直接跑业务,而是先包一层 arg0_dispatch_or_else。这是经典的 busybox 技巧:同一个物理二进制,根据「自己被以什么名字调用」(argv[0])决定扮演什么角色:
if exe_name == "codex-linux-sandbox" {
codex_linux_sandbox::run_main(); // 变身 Linux 沙箱助手(never returns)
}
if exe_name == "apply_patch" || exe_name == "applypatch" {
// 变身补丁应用器 —— 模型输出的 `apply_patch` 命令直接可执行
}
正常启动路径上还有两件事值得注意(arg0/src/lib.rs:292-312,327-437):① 加载 ~/.codex/.env 时过滤一切 CODEX_ 前缀变量防注入;② 在 $CODEX_HOME/tmp/arg0/ 建临时目录,对自身可执行文件做 apply_patch、codex-linux-sandbox 等名字的 symlink 并 prepend 进 PATH——这样模型在 shell 里写 apply_patch <<'EOF'... 天然可执行,Linux 沙箱也能 re-exec 出助手进程。最后在一个 16 MiB 栈的专用线程上构建 tokio 运行时(arg0/src/lib.rs:208-237)。
2.3 clap 解析:28 个子命令
clap 是 Rust 事实标准的命令行解析库(用 derive 宏把 struct 变成 CLI 定义)。顶层 MultitoolCli(cli/src/main.rs:94-121)把 TUI 参数平铺进根命令、子命令设为可选——所以 codex "修个 bug"(无子命令)直接进交互 TUI(main.rs:988-1001),而 codex exec / codex login / codex mcp / codex app-server / codex resume / codex cloud / codex apply / codex sandbox 等 28 个子命令(main.rs:123-212)各走各路。
2.4 TUI 启动链:run_main 的十道工序
危险旁路映射与参数整备
--dangerously-bypass-approvals-and-sandbox 被映射为「全放行沙箱 + 永不审批」(tui/src/lib.rs:857-861);解析 -c 覆盖、定位 ~/.codex、处理 --profile(Profile v2 = 在用户配置之上叠一个 <name>.config.toml,lib.rs:902-906)。
配置加载(分层合并)
load_config_or_exit 得到正式 Config(lib.rs:1064-1071)。配置分 7 层按优先级合并:MDM 托管 → 系统 → 企业托管 → 用户(~/.codex/config.toml)→ 项目(.codex/)→ 命令行 -c → legacy(config/src/config_layer_source.rs:6-46)。对比 Claude Code 的五层 settings,思路一致。
安全与登录检查
execpolicy 规则文件校验(语法错直接 exit 1,lib.rs:1145-1154)→ 登录限制 → OTel 遥测初始化。
run_ratatui_app:终端初始化 + 内嵌 app-server
tui::init() 建 inline 视口终端(lib.rs:1313-1320)→ 启动进程内 app-server 包成 AppServerSession(lib.rs:1348-1370)——TUI 从此刻起就是自己引擎的「客户端」。
Onboarding:欢迎 / 登录 / 信任目录
should_show_onboarding 判定后跑一个三步状态机(Welcome → Auth → TrustDirectory,tui/src/onboarding/onboarding_screen.rs:54-58):ChatGPT 浏览器登录或 API key,然后询问是否信任当前目录。登录后重载配置(lib.rs:1441-1468)。
App::run:进入主事件循环
resume/fork 选择器(如指定)→ app_server.bootstrap() 与启动期 hooks 并行(tokio::join!,lib.rs:1766-1769)→ App::run 建事件通道进入 select! 循环(tui/src/app.rs:759,1169)——界面出现。
2.5 AGENTS.md 什么时候被读
Codex 的「项目记忆文件」是 AGENTS.md(对应 Claude Code 的 CLAUDE.md)。发现算法在 core/src/agents_md.rs:1-16 头注释写明:以 project_root_markers(默认 .git)向上找到项目根,然后从项目根到 cwd 沿途收集所有 AGENTS.md 顺序拼接(不越过项目根);支持 AGENTS.override.md 本地覆盖(agents_md.rs:38-39)。用户级全局指令来自 $CODEX_HOME/AGENTS.md(codex-home/src/instructions/mod.rs:9-26),合并时用户层在前、项目层在后,以 --- project-doc --- 分隔(agents_md.rs:43-56)。
process-hardening:持有敏感凭据的进程(responses-api-proxy、linux-sandbox)会在 main 之前经 #[ctor] 执行加固(process-hardening/src/lib.rs:7-25):禁 core dump、禁 ptrace attach(防止其他进程 dump 出内存里的 API key)、清除 LD_PRELOAD 等可注入代码的环境变量。这是 TS 实现做不到的系统级防御。
03Agentic 主循环:Session / Task / Turn
整个产品的心脏。Claude Code 的心脏是一个 1729 行的 query.ts;Codex 把同样的逻辑拆成了一条清晰的对象链和两层嵌套循环。
3.1 持有关系:谁管着谁
ThreadManager [thread_manager.rs:182] 进程级工厂+注册表:HashMap<ThreadId, CodexThread>
└─ CodexThread [codex_thread.rs:160] 一个会话线程的外壳(旧称 conversation)
└─ Codex [session/mod.rs:389] SQ/EQ 队列对:tx_sub 发提交、rx_event 收事件
└─ Arc<Session> [session/session.rs:28] 运行时骨架:事件出口、活跃任务、输入队列、服务
├─ SessionState [state/session.rs:26] Mutex 保护的可变数据:history、限流、压缩窗口
└─ TurnContext [session/turn_context.rs:102] 每 turn 快照生成、turn 内不可变
几个 Rust 概念:Arc 是原子引用计数的共享指针(多个异步任务共享同一对象);Mutex 是互斥锁(保证同一时刻只有一个任务改数据);trait 类似接口。Session 的注释直说设计约束:「一个会话同一时刻最多一个运行中的任务,且可被用户输入打断」(session/session.rs:27)。注意 SessionState 里的 history: ContextManager 才是对话历史的真正持有者(state/session.rs:26-47)。
3.2 SQ/EQ 协议:引擎的唯一入口和唯一出口
引擎与外界的全部交互收敛为两条 channel(session/mod.rs:538-539):SQ(Submission Queue)收 Submission { id, op },EQ(Event Queue)发 Event { id, msg }(事件的 id 关联回触发它的提交)。这是一个刻意的「协议化」设计——引擎不知道也不关心对面是 TUI、IDE 还是 CI 脚本。
| Op 变体(输入) | 含义 | 位置 |
|---|---|---|
| UserInput | 用户消息(含附加上下文、输出 schema、thread 设置覆盖) | protocol.rs:543 |
| Interrupt | 中断当前任务(保留后台终端进程) | protocol.rs:518 |
| ExecApproval / PatchApproval | 用户对命令/补丁审批的决定 | protocol.rs:573,583 |
| Compact | 手动触发上下文压缩 | protocol.rs:640 |
| ThreadRollback | 回滚最近 N 个 turn | protocol.rs:652 |
| Review / Shutdown / RunUserShellCommand | 代码评审任务 / 关闭 / 用户直发 shell(TUI 的 ! 模式) | protocol.rs:655,661,668 |
| EventMsg 变体(输出) | 含义 | 位置 |
|---|---|---|
| TurnStarted / TurnComplete | turn 生命周期(wire 序列化名仍是 task_started/task_complete,历史兼容) | protocol.rs:1311,1320 |
| AgentMessageContentDelta / AgentMessage | 流式文本增量 / 完整消息 | protocol.rs:1439,1327 |
| AgentReasoning | 推理(thinking)摘要 | protocol.rs:1333 |
| ExecCommandBegin / ExecCommandEnd | 命令开始/结束(UI 画执行卡片) | protocol.rs:1366,1374 |
| ExecApprovalRequest / ApplyPatchApprovalRequest | 请求用户审批(弹窗) | protocol.rs:1379,1391 |
| TokenCount | token 用量 + 限流快照(UI 剩余上下文百分比的数据源) | protocol.rs:1324 |
| TurnAborted / SessionConfigured / ContextCompacted | 中止 / 会话就绪(spawn 后第一条必须是它)/ 压缩完成 | protocol.rs:1421,1342,1303 |
消费端是常驻的 submission_loop(session/handlers.rs:703-853):while let Ok(sub) = rx_sub.recv().await 后对 sub.op 做大 match 分发。枚举标了 #[non_exhaustive](未来可加变体),未知 op 直接忽略。
3.3 steer:中途发消息不再是「排队」而是「转向」
这个版本最有意思的语义变化:Op::UserInput 到达时若已有活跃的 Regular turn,不会中断也不会排队等下一轮,而是先尝试 steer_input——把新消息塞进 input_queue,正在跑的 run_turn 循环会在下一轮迭代开头把它吸收进对话(handlers.rs:183-275 · session/mod.rs:3827-3900)。只有「无活跃 turn」时才真正 spawn 新任务。这让「模型干活时你补一句话」变成自然的转向,而不是打断重来。
3.4 Task 与两层循环
任务抽象是 SessionTask trait(tasks/mod.rs:214-253):run(session, ctx, input, cancellation_token) → Result<Option<String>>,返回值就是最终的 assistant 消息。四个实现:RegularTask(普通对话)、CompactTask(压缩)、ReviewTask(代码评审)、UserShellCommandTask(用户直发 shell)。spawn_task 先以 Replaced 理由中止一切旧任务再起新的(tasks/mod.rs:314-323)——「一个会话一个任务」的机制保证。
RegularTask 的外层循环极短(我亲自核实过):
loop {
let last_agent_message = run_turn(
Arc::clone(&sess), Arc::clone(&ctx), ...,
next_input, prewarmed_client_session.take(),
cancellation_token.child_token(),
).await?;
// turn 跑完但期间用户 steer 进了新输入 → 再跑一个 turn 消化它
if !sess.input_queue.has_pending_input(&sess.active_turn).await {
return Ok(last_agent_message);
}
next_input = Vec::new();
}
内层 run_turn(session/turn.rs:142-459)才是 agentic 循环本体,每轮迭代:
| 阶段 | 做什么 | 位置 |
|---|---|---|
| A 吸收输入 | 把 pending 的 steer 输入并入本轮;跑 hooks 并把用户消息记入 history + rollout | turn.rs:481-507 |
| B 组请求 | clone_history().for_prompt()(归一化)+ 按模型现算工具表 + base_instructions → Prompt | turn.rs:272-278,1112 |
| C 压缩检查 | token 超限 → run_auto_compact 后 continue(第 7 章) | turn.rs:346-369,797-821 |
| D 采样 | run_sampling_request:带流级重试地调模型,消费 ResponseEvent 流 | turn.rs:1072-1167,1887 |
| E 工具回路 | FunctionCall 先记 history 再路由执行;结果经 FuturesOrdered 有序回填;needs_follow_up = true | stream_events_utils.rs:405-444 · turn.rs:1853-1877 |
| F 终点判定 | 无 tool call 且 end_turn≠false 且无 pending 输入 → Stop hooks → break | turn.rs:318,371-415 |
和 Claude Code 完全一致的核心洞察:没有任何计划器。「agent 自主多步工作」的全部秘密就是 needs_follow_up 这一个布尔值——模型不停要工具、循环不停喂结果。
3.5 中断:CancellationToken 与优雅降级
Ctrl-C 从 TUI 一路变成 Op::Interrupt(tui interaction → JSON-RPC turn/interrupt → turn_processor.rs:1383),引擎侧 interrupt_task → abort_all_tasks(Interrupted)(session/mod.rs:3913-3920)。handle_task_abort(tasks/mod.rs:829-905)的次序体现了「优雅优先」:先 cancellation_token.cancel()(协作式取消,流消费点用 .or_cancel() 响应)→ 等一个宽限期 → 超时才 handle.abort() 硬杀 tokio 任务 → 最后往 history 写一个 <turn_aborted> 标记并 flush rollout——这样 resume 恢复会话时模型能知道上一轮是被打断的,不会误以为工具调用凭空消失。
3.6 history 记录与 rollout 落盘
统一写入口 record_conversation_items(session/mod.rs:2777-2794)一次做三件事:写内存 history、包成 RolloutItem 落盘、回发客户端。落盘由 RolloutRecorder(rollout/src/recorder.rs:78-119)后台任务串行化,每条带时间戳序列化为一行 JSON,追加到 ~/.codex/sessions/rollout-<日期>-<thread_id>.jsonl(recorder.rs:1519,1811-1833)。codex resume 恢复时从新到旧反向扫描 JSONL,处理压缩检查点/回滚/中断标记后重建 history(session/rollout_reconstruction.rs:113-150)。这与 Claude Code 的 transcript JSONL 思路一致,但 Codex 把「重建」做成了显式的逆向重放算法。
04工具系统
契约与运行时分离:tools crate 定义「发给模型的长相」,core/tools 定义「在本机怎么跑」。工具表不是静态的——每个 turn 按模型和配置现算。
4.1 ToolExecutor 与四态曝光
pub trait ToolExecutor<Invocation>: Send + Sync {
fn tool_name(&self) -> ToolName;
fn spec(&self) -> ToolSpec; // 发给 API 的 JSON 契约
fn exposure(&self) -> ToolExposure { Direct } // 四态曝光
fn supports_parallel_tool_calls(&self) -> bool { false } // fail-closed:默认串行
fn handle(&self, invocation: Invocation) -> ToolExecutorFuture;
}
ToolExposure 四态(tool_executor.rs:15-37):Direct(初始工具表可见)/ Deferred(不进初始表,模型用 tool_search 工具按需发现——节省上下文的「延迟加载工具」)/ DirectModelOnly(排除出 code mode)/ Hidden(只可内部路由,模型看不见)。「模型看不到 ≠ 不能执行」:注册表收下全部 runtime,可见表只含 Direct(tools/spec_plan.rs:250-260)。
ToolSpec(tools/src/tool_spec.rs:15-53)序列化后就是 Responses API 的 Tool 对象,有两种关键形态:function(JSON Schema 参数,全部 strict:false)和 custom/freeform——不用 JSON,模型直接输出符合 Lark 语法(一种描述文法的 DSL)的原始文本,apply_patch 和 code-mode 的 exec 都走这种(免去了「往 JSON 字符串里塞多行 diff 还要转义」的痛苦)。
4.2 执行链:router → registry(hooks) → parallel → orchestrator
路由
模型流回来的 ResponseItem::FunctionCall / CustomToolCall 由 build_tool_call 翻译成三类 payload(Function/Custom/ToolSearch),按 ToolName 查 HashMap 找 handler,查不到回「unsupported call」。
tools/router.rs:113-161 · registry.rs:405-442,734-739
hooks 包裹
PreToolUse hooks 可 Block 或改写入参;PostToolUse hooks 可拒绝结果或注入 feedback——和 Claude Code 的 hook 语义几乎一比一。
registry.rs:495-539,638-656
并行门:读写锁
声明 supports_parallel_tool_calls 的工具拿读锁互相并发;默认工具拿写锁独占执行——一个 RwLock 优雅实现了 Claude Code 里「只读并发、写串行」的同款调度。
tools/parallel.rs:48,133-137
orchestrator:审批 + 沙箱 + 升权重试
exec 类工具统一编排:审批判定 → 选沙箱首跑 → 命中沙箱拒绝时(可选地再问一次用户后)去沙箱重试。文件头注释自述整个流程(第 5 章详解)。
tools/orchestrator.rs:1-8,134
4.3 五个代表性工具
shell_command —— 每次一个新进程,10 秒默认超时
- 参数(handlers/shell_spec.rs:154-222):
command/workdir(描述强调「永远设 workdir、别用 cd」)/timeout_ms(默认 10000)/login,外加沙箱申请参数sandbox_permissions/justification。 - 执行:
run_exec_like(handlers/shell.rs:63-245)先拦截 apply_patch 调用,再经 orchestrator 到 core/src/exec.rs spawn 进程。关键常量:超时退出码 124(exec.rs:65)、输出硬 cap 1 MiB(exec.rs:76)、流式增量事件每调用最多 10000 条(exec.rs:80)、孙进程继承 fd 导致的 read 卡死有 2 秒排水超时兜底(exec.rs:89)。 - 对比 Claude Code 的 BashTool:都不是持久 shell;但 Codex 没有「偷偷 pwd 记住 cd」的技巧——它直接在工具描述里禁止 cd、强制 workdir 参数。
exec_command / write_stdin —— PTY 持久会话(unified_exec)
较新模型(shell_type 为 UnifiedExec 的模型家族)拿到的不是 shell_command 而是这一对:exec_command 在 PTY(伪终端)里跑命令,yield_time_ms(250ms–30s)内没跑完就返回一个 session_id,模型随后用 write_stdin 向会话写入按键/轮询输出(handlers/shell_spec.rs:88-152)——于是模型能操作 REPL、vim、交互式安装器。会话上限 64 个,输出缓冲用「头尾各半」结构(保留稳定前缀+最新后缀,unified_exec/head_tail_buffer.rs:29-43),常量集中在 unified_exec/mod.rs:65-75。老 shell_command 同时以 Hidden 态保留可路由(spec_plan.rs:659-674)。
apply_patch —— Lark 文法的 freeform 工具 + shell 拦截双入口
Codex 不给模型 Edit/Write 这种「精确字符串替换」工具,而是一个自定义 patch DSL:
start: begin_patch hunk+ end_patch
begin_patch: "*** Begin Patch" LF
add_hunk: "*** Add File: " filename LF add_line+
delete_hunk: "*** Delete File: " filename LF
update_hunk: "*** Update File: " filename LF change_move? change?
change_move: "*** Move to: " filename LF
change_context: ("@@" | "@@ " /(.+)/) LF
change_line: ("+" | "-" | " ") /(.*)/ LF
- 双入口:① 作为 freeform 工具(模型直接输出补丁原文,不裹 JSON,handlers/apply_patch_spec.rs:9-27);② shell 拦截——
run_exec_like里maybe_parse_apply_patch识别apply_patch <<'EOF'heredoc 形式(apply-patch/src/invocation.rs:112-137),没有该工具的模型家族也能经 shell 用它。 - 容错应用:上下文匹配三级递降——精确 → 忽略行尾空白 → 两端 trim(apply-patch/src/seek_sequence.rs:12-60);parser 默认宽松模式(为 gpt-4.1 容错,parser.rs:54)。对比 Claude Code 的 Edit 工具「old_string 必须唯一精确匹配」,两家选了相反的哲学:Codex 容错宽松、Claude 严格拒绝。
- 流式 UI:参数增量经 StreamingPatchParser 消费、500ms 节流发 PatchApplyUpdated 事件(handlers/apply_patch.rs:57-140)。
update_plan —— 纯 UI 工具
参数是 plan: [{step, status ∈ pending|in_progress|completed}](至多一个 in_progress,handlers/plan_spec.rs:7-58)。执行只做一件事:发 EventMsg::PlanUpdate 给 UI 画清单,固定返回 "Plan updated"(handlers/plan.rs:22,91-95)——和 Claude Code 的 TodoWrite 一样,是给人看的「进度锚点」,对模型没有任何副作用。
web_search 与 view_image —— hosted 工具与多模态入口
- web_search 不在本地执行:它是 hosted spec(tools/hosted_spec.rs:20-52),由 OpenAI 服务端执行搜索,按 WebSearchMode(Cached/Indexed/Live/Disabled)映射外网访问参数。本地只负责把动作渲染成 UI 卡片(core/src/web_search.rs:18-30)。
- view_image:读本地图片转 data URL 作为 InputImage 注入对话(handlers/view_image.rs:52-134);是少数声明可并行的工具。注意 Codex 没有独立的文本 Read 工具——读文件一律走 shell(cat/sed),这是与 Claude Code 工具面最大的差异之一。
4.4 输出回填与截断:头尾保留,中间截断
工具输出回填为 ResponseInputItem::FunctionCallOutput { call_id, output }。截断策略与 Claude Code 的「保尾截断」不同:Codex 用 truncate_middle——预算对半分给前缀和后缀,中间插省略标记(utils/string/src/truncate.rs:7-36;token 按 4 字节 ≈ 1 token 估算)。模型级默认预算 10000 字节(protocol/src/openai_models.rs:680),exec 输出外壳统一附 Exit code / Wall time / Total output lines(core/src/tools/mod.rs:77-102)。
4.5 code_mode:把工具调用变成写 JavaScript
Codex 特有的实验能力:开启后模型拿到一个 freeform 的 exec 工具,直接写 JS 代码在嵌入的 V8 隔离环境(deno_core,无 Node、无文件系统、无网络,code-mode-protocol/src/description.rs:12-18)里执行;所有普通工具变成全局 tools.* 对象上的异步函数——await tools.exec_command(...)。JS 里的嵌套调用经 DispatchBroker 回灌到同一套工具运行时(tools/parallel.rs:395-398)。这就是「工具编排编程化」:模型可以用循环、条件、变量把 10 次工具调用写成一段代码一次跑完,省下 9 轮模型往返。CodeModeOnly 模式下普通工具从可见表隐藏、只留 exec 入口(spec_plan.rs:460-471)。
05权限与沙箱 ★
Codex 的重头戏。Claude Code 的安全主线是「权限提示系统」(沙箱是辅助);Codex 反过来:OS 级沙箱是默认防线,审批弹窗是沙箱之外的例外通道。
5.1 两个正交的策略轴
第一轴 AskForApproval(何时问人),定义在 protocol/src/protocol.rs:901-925:
untrusted
只有安全白名单里的「纯读」命令自动放行,其余一律弹窗
on-request(默认)
模型自己决定何时申请升权;旧的 on-failure 已退役为它的 serde 别名(protocol.rs:910)
granular
按 5 个开关细分(沙箱升权/规则/技能/权限申请/MCP 询问);开关关闭时对应类别自动拒绝而非弹窗
never
永不问人,失败直接回给模型(CI 场景)
第二轴 SandboxPolicy(能碰什么)(protocol.rs:988-1036):danger-full-access / read-only / workspace-write(cwd 可写 + 网络默认禁)/ external-sandbox(进程已在外部沙箱内)。workspace-write 下每个可写根自动挂上受保护子路径——.git、.codex、.agents 保持只读(protocol/src/permissions.rs:23-31,991-1031),注释明说这是防 agent 改 .git/hooks 之类的提权入口。
5.2 一条命令的安全判定链
拆解 shell 包装
bash -lc "a && b | c" 先被拆成子命令数组逐个评估;拆不开的复杂脚本标记 used_complex_parsing(禁止沉淀成 allow 规则)。审批缓存的 key 也做归一化:/bin/bash -lc 与 bash -lc 等价、复杂脚本收敛成 __codex_shell_script__ 形态——防止「approve for session」被写法差异绕过。
exec_policy.rs:759-797 · command_canonicalization.rs:14-38
execpolicy 规则匹配(Starlark)
用户/管理员可在 $CODEX_HOME/rules/*.rules 用 Starlark 语法(Bazel 同款的 Python 方言)写前缀规则:prefix_rule(pattern=["rm"], decision="forbidden", justification="...")。命中 Allow 且每个子命令段都被显式覆盖才允许绕过沙箱。审批 UI 里的「记住这个决定」会把规则在线追加写盘 + 热更新;但 python3 -c、bash -lc、sudo 等「万能解释器前缀」被硬编码禁止沉淀为 allow 规则。
exec_policy.rs:49-51,307-311,352-366,376-424,52-99 · execpolicy/tests/basic.rs:171-176
未命中规则的启发式兜底
先查 known-safe 白名单(cat/ls/grep/rg/git status...,且 find 排除 -exec、rg 排除 --pre 等能执行代码的旗标,shell-command/src/command_safety/is_safe_command.rs:12-199);再查危险命令名单。普通命令在 on-request 策略下不弹窗、直接让沙箱执行约束,只有模型显式带 with_escalated_permissions 申请升权才触发弹窗。
exec_policy.rs:627-743
产出三态
ExecApprovalRequirement:Skip(可带 bypass_sandbox)/ NeedsApproval(可附建议沉淀的规则)/ Forbidden。补丁侧有平行的 assess_patch_safety:补丁完全落在可写路径内(含 .. 归一化防目录穿越)且平台沙箱可用才自动放行。
tools/sandboxing.rs:162-181 · safety.rs:32-110,135-203
5.3 审批弹窗的握手:oneshot channel
需要问人时,引擎在 request_command_approval 里创建一个 oneshot channel(一次性管道,等价于 Claude Code 那个「Promise + 队列」握手):把发送端存进 pending map、发 EventMsg::ExecApprovalRequest 给 UI,然后 rx.await 挂起等待(session/mod.rs:2122-2194)。用户在 TUI 弹窗里选择后,决定以 Op::ExecApproval { id, decision } 走 SQ 回来,handler 按 id 找到 oneshot 回填(handlers.rs:364-404)。ReviewDecision 有 7 个变体(protocol.rs:4043-4078):Approved / ApprovedForSession / Denied / Abort,外加两个「顺便落规则」变体(ApprovedExecpolicyAmendment、NetworkPolicyAmendment)和 TimedOut。收不到回复默认 Abort——fail-closed。
5.4 三平台沙箱实现
统一入口 SandboxType = None / MacosSeatbelt / LinuxSeccomp / WindowsRestrictedToken(sandboxing/src/manager.rs:36-76)。
Linux:内嵌 bubblewrap + seccomp(Landlock 已降级为 legacy)
- 文件系统:bubblewrap(bwrap,容器技术里常用的无特权命名空间沙箱)构建「默认只读 + 显式可写根叠加 + .git/.codex 保持只读」的文件系统视图(linux-sandbox/src/bwrap.rs:1-11,注释明说语义对齐 macOS Seatbelt)。妙处在 bwrap/build.rs:14-19:bubblewrap 的 C 源码被 vendor 进仓库直接编译进 Codex 二进制——不依赖各发行版千差万别的系统 bwrap。
- 网络与危险系统调用:seccomp(内核的系统调用过滤器)默认拒绝 connect/bind/listen 等网络调用、socket 仅放 AF_UNIX;并无条件封杀
ptrace和io_uring_*(io_uring 是绕过 seccomp 网络拦截的知名通道)(linux-sandbox/src/landlock.rs:165-240)。被 seccomp 击杀的进程退出码 159(128+SIGSYS)会被识别为「沙箱拒绝」。 - 为什么要独立入口:seccomp/bwrap 的限制必须在目标子进程自身、exec 前施加且不可逆——所以命令被改写成
codex-linux-sandbox --permission-profile ... -- 原命令,经 arg0 分发由同一个二进制的「沙箱人格」执行(sandboxing/src/landlock.rs:6,23-60),父进程不被自己的 seccomp 污染。 - Landlock(Linux 5.13+ 的无特权文件系统 LSM)保留为 legacy 后备,仅 feature 开启时使用(linux-sandbox/src/landlock.rs:1-11,139-163)。
macOS:Seatbelt(sandbox-exec)
用硬编码路径 /usr/bin/sandbox-exec(防 PATH 投毒,sandboxing/src/seatbelt.rs:26-30)加载 SBPL(一种 Lisp 语法的沙箱策略语言)profile 执行命令。base policy 开头即「默认全拒」:
(deny default)
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))
(allow file-write-data
(require-all (path "/dev/null") (vnode-type CHARACTER-DEVICE)))
可写根不写进策略文本,而是经 -D WRITABLE_ROOT_0=... 参数传入、策略里用 (subpath (param "WRITABLE_ROOT_0")) 引用——避免路径转义注入(seatbelt.rs:623-769)。放网时才叠加 network profile(只放 DNS/证书校验等系统服务)。
Windows:Restricted Token(默认关闭)
不是 AppContainer,而是 CreateRestrictedToken + WRITE_RESTRICTED 旗标(只对写操作检查受限 SID,实现「读多写受限」,windows-sandbox-rs/src/token.rs:459-472);文件系统靠 ACL、网络靠 WFP(Windows 过滤平台)block 过滤器(windows-sandbox-rs/src/wfp.rs:1-30)。三档配置默认 Disabled(protocol/src/config_types.rs:269-274)——Windows 沙箱是最新、默认不启用的一块。
5.5 沙箱拒绝之后:升权重试的安全阀
命令在沙箱里失败且判定为「沙箱拒绝」(靠关键词 + 退出码识别,避免把业务错误误判,sandboxing/src/denial.rs:6-53)时,orchestrator 的重试路径(tools/orchestrator.rs:289-440)层层把关:工具须声明 escalate_on_failure;审批策略须允许问人(never/on-request 下默认不重试);最关键的安全阀——若策略含 denied-read 路径,禁止用「去沙箱重试」变相提权(denied reads 只在沙箱里存在,去沙箱等于绕过,tools/sandboxing.rs:281-289)。通过全部关卡后带着理由("command failed; retry without sandbox?")再次征询用户,批准才裸跑。
5.6 网络:不是开关,是受管代理
workspace-write 下网络默认禁。放行不是「打开开关」,而是走 network-proxy crate 的受管代理:Linux 沙箱切到 ProxyRouted 模式(seccomp 只放本地代理桥的连接),出网统一由代理裁决;被拦请求带原因码翻译成人话,on-request 下触发带 host 的审批,用户批准可沉淀为 execpolicy 网络规则(allow https_connect access to <host>)持久化(core/src/network_policy_decision.rs:26-100 · exec_policy.rs:426-470)。
对比 Claude Code:Claude Code 的 bash 沙箱注释直言「排除列表只是便利功能,真正的安全边界是权限提示系统」;Codex 恰好相反——沙箱是硬边界(seccomp 杀进程、Seatbelt 拒 syscall),审批弹窗只在「要跳出沙箱」时出现。这也解释了两者默认体验差异:Codex 在 workspace-write 下大部分命令不弹窗直接跑(反正有沙箱兜底),Claude Code 则更依赖逐条审批与 allow 规则。
06模型 / API 层:Responses API、WebSocket、认证
core/src/client.rs(2402 行)+ codex-api crate。这一版只支持 OpenAI Responses API——Chat Completions 已被物理删除。
6.1 WireApi 只剩一条路
pub enum WireApi {
/// The Responses API exposed by OpenAI at `/v1/responses`.
#[default]
Responses,
}
// 反序列化 "chat" 直接报错:
"chat" => Err(custom("wire_api = \"chat\" is no longer supported..."))
这意味着 Ollama / LM Studio 这类本地模型也走 Responses API 形态的端点(内置 provider gpt-oss,localhost:11434/1234,lib.rs:422-423,495-534)。自定义 provider 只能新增不能覆盖内置(lib.rs:462-493)。
6.2 stream():WebSocket 优先,HTTP SSE 兜底
对象分两级:session 级 ModelClient 和 turn 级 ModelClientSession——后者缓存 WebSocket 连接和从响应头拿到的 x-codex-turn-state 粘性路由 token(client.rs:246-280)。stream()(client.rs:1734-1786)先试 WS,失败或不支持则落回 stream_responses_api(HTTP + SSE)。降级是单向棘轮:流级重试预算耗尽后 force_http_fallback 把本 session 永久钉在 HTTP 上(client.rs:492-511,1793-1803)。
请求体组装 build_responses_request(client.rs:812-892)的关键字段:
| 字段 | 取值 | 说明 |
|---|---|---|
| instructions | base_instructions | 主 system prompt = models-manager/prompt.md("You are a coding agent running in the Codex CLI..."),可由服务端 /models 目录按模型下发覆盖 |
| input | 归一化后的完整 history | 含 AGENTS.md、环境上下文、全部对话与工具结果 |
| store | false(仅 Azure 为 true) | 不落 OpenAI 服务端存储;配合剥 item id |
| stream / tool_choice | true / "auto" | 永远流式 |
| prompt_cache_key | thread_id | 服务端 prompt cache 按会话线程复用前缀 |
| reasoning + include | effort/summary + ["reasoning.encrypted_content"] | 推理内容以加密态往返(store:false 下维持推理链) |
对比 Claude Code 的 prompt caching:Claude Code 要在客户端精心摆 4 个 cache_control 断点;Codex 只发一个 prompt_cache_key,缓存断点完全交给服务端——这是 Responses API(有状态、服务端管理)与 Messages API(无状态、客户端管理)的协议级差异。
6.3 SSE 事件与超时
SSE 解析下沉到 codex-api crate。事件循环 process_sse_with_treatment(codex-api/src/sse/responses.rs:478-583)每次 timeout(idle_timeout, stream.next())——默认 300 秒无事件判死(model-provider-info/src/lib.rs:26);流在 response.completed 之前关闭视为错误。response.failed 按 error.code 分类(上下文超限/配额/过载/无效请求),限流错误的重试延迟是用正则从错误文案里抠出来的("Please try again in 11.054s",sse/responses.rs:585-609)。
6.4 三层重试
- 单请求 HTTP 层:默认最多 4 次,退避 = 200ms × 2^(n-1) × 0.9~1.1 抖动;只重试 5xx 与传输错误,429 不在这层重试(交上层按服务端指示的延迟处理)(codex-client/src/retry.rs:23-73 · model-provider-info/src/lib.rs:261-267)。
- 流级重连:流中断整个请求重发,默认 5 次,UI 显示 "Reconnecting... n/5"(core/src/responses_retry.rs:22-79)。
- 传输降级:WS → HTTP 永久降级(见 6.2);401 触发 token 刷新后重试(client.rs:1432-1451)。
6.5 认证:ChatGPT OAuth 或 API key
AuthMode(protocol/src/auth.rs:9-30)主线两条:ApiKey(环境变量 OPENAI_API_KEY)和 Chatgpt(订阅计划直接用)。OAuth 细节:本地起 127.0.0.1:1455 回调服务器(占用则退 1457,login/src/server.rs:54-56),标准 PKCE S256(login/src/pkce.rs:12-27),issuer auth.openai.com。token 存 $CODEX_HOME/auth.json(新版优先系统 keyring,成功后删除文件回退,login/src/auth/storage.rs:38-61);主动刷新条件 = JWT 过期前 5 分钟或距上次刷新超 8 天(login/src/auth/manager.rs:177-178,2520-2542)。ChatGPT 计划用户不走 api.openai.com,而是 https://chatgpt.com/backend-api/codex(model-provider-info/src/lib.rs:38,241-258)。这与 Claude Code 的「API key vs Claude 订阅 OAuth」双轨一模一样,连 localhost 回调监听的手法都相同。
07上下文管理与压缩:四种 compact
上下文窗口有限。Codex 的续命手段从 Claude Code 的「客户端三级压缩」演化成了「归一化 + 四种压缩实现按能力分发」。
7.1 history 归一化:发请求前的清洗
ContextManager 是内存 history 的持有者,发请求前 for_prompt() 做三步清洗(core/src/context_manager/history.rs:121-146,359-367):① 为没有输出的工具调用合成 "aborted" 输出(API 要求 call 与 output 严格配对,中断后的孤儿 call 会 400——与 Claude Code 的「合成 tool_result」同款问题同款解法;合成 id 用固定 UUID 命名空间,注释警告改了会「改变模型可见 ID、炸掉 prompt cache」,normalize.rs:15-63);② 删孤儿输出;③ 模型不支持图片时把图片换成占位文本。
7.2 触发条件:90% 水位
每次采样前检查(session/turn.rs:797-821):token 用量 ≥ auto_compact_token_limit 即触发。该上限默认 = 模型上下文窗口的 90%(protocol/src/openai_models.rs:441-452),context_window 元数据来自 models-manager(内置表 + 服务端 /models 目录 + 本地缓存,兜底 272000,models-manager/src/model_info.rs:95-97)。此外切换到更小窗口的模型也会触发预压缩(turn.rs:835-909)。
7.3 四种实现按能力分发
① 开新窗口(不摘要)
直接 start_new_context_window,跳过一切摘要。compact_token_budget.rs:20-24
② 远端压缩 v2
往 input 末尾 push CompactionTrigger 项走流式 /responses,服务端做摘要;保留消息预算 64K token。compact_remote_v2.rs:52-56
③ 远端压缩 v1
一次性 POST /responses/compact,超时 = idle_timeout × 4。client.rs:513-618
④ 本地摘要压缩
把摘要指令当用户消息发给模型自己写交接摘要(最像 Claude Code 的 auto-compact)。compact.rs:220-377
本地压缩(④)的细节:摘要 prompt 开头是 "You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task."(prompts/templates/compact/prompt.md,可用 compact_prompt 配置覆盖);压缩请求本身超窗则从头删 item 重试(保住前缀缓存);成功后重建 history = 最近的用户消息(倒序回填、总预算 20000 token)+ 摘要作为一条 user 消息(compact.rs:53,598-656),并向用户发警告「长会话多次压缩会降低模型准确性」。分发逻辑在 session/turn.rs:917-977。
对比 Claude Code:Claude Code 的 compact 全在客户端(9 小节固定摘要格式 + 重注入读过的文件);Codex 的首选路径是把压缩也交给服务端(毕竟 Responses API 是有状态协议,服务端本来就有完整对话)。此外 Codex 没有 microcompact(清旧工具输出)这一级——工具输出在入账时就被 truncate_middle 钳到 10K 字节了,脏东西根本进不了 history。
08TUI 层:ratatui 的 inline viewport
不是全屏应用。Codex 把「定稿的历史」直接写进终端原生回滚缓冲,底部只保留一个动态高度的活动视口——这是与 Claude Code(fork 版 Ink 整屏管理)最有辨识度的架构差异。
8.1 渲染模型:两条正交路径
ratatui 是 Rust 主流的 immediate-mode 终端 UI 库:没有持久 widget 树,每帧由代码把 UI 画进离屏 Buffer,库对比前后帧只重画变化的单元格(和 Ink 的帧 diff 殊途同归)。crossterm 是底层终端驱动(raw mode、按键流、ANSI 命令)。
① 活动视口(每帧重画)
schedule_frame() → TuiEvent::Draw → chat_widget.desired_height(width) 动态定高
→ tui.draw(...) 在 SynchronizedUpdate 内重画底部视口 tui.rs:880-935
② 定稿历史(一次性注入 scrollback,之后不再管)
InsertHistoryCell → 设置滚动区域 DECSTBM("\x1b[r") 限定在视口上方
→ 逐行 Print,行被"顶进"终端原生回滚缓冲 insert_history.rs:216-245,332-337
好处:历史滚动交给终端本体(鼠标滚轮、搜索、复制都是原生体验),Codex 只需渲染当前活动区;tui::init() 的注释直说「inline viewport; history stays in normal scrollback」(tui/src/tui.rs:375-376)。alt-screen(全屏)只在 Ctrl+T 转录浏览等 overlay 时按需进入(tui.rs:734-766)。帧率由 FrameRequester 合并调度(actor 模式,多次请求合并成一次 draw)。
8.2 事件循环与流式渲染
主循环是四路 select!(tui/src/app.rs:1169):UI 内部事件 / 活跃会话事件 / 终端按键流 / app-server 通知。流式文本的渲染节奏很讲究(tui/src/chatwidget/streaming.rs:388-427):Markdown 增量先进 MarkdownStreamCollector,只在换行边界提交整行(未完成的尾行留在活动视口里实时变化);提交动画由专用线程发 CommitTick、平滑模式一 tick 一行,落后时批量泄洪。流结束后整段流式单元被替换为持有原始 Markdown 源、可随终端 resize 重排的 AgentMarkdownCell(app_event.rs:671-683)。历史区渲染单元统一实现 HistoryCell trait(history_cell/mod.rs:189,按 approvals/exec/patches/plans 等分类)。
8.3 底部面板与输入
- composer 是自研 textarea(含 Vim 模式、kill buffer、粘贴突发检测,bottom_pane/textarea.rs:1-40);弹窗/模态是压在 composer 之上的 view 栈(bottom_pane/mod.rs:208)。
- 斜杠命令是一个 enum(
/model /permissions /review /new /resume /fork /diff /status /usage /quit...,注释特意写「不要按字母排序!枚举顺序即弹窗展示顺序」,tui/src/slash_command.rs:12)。注意命令叫/permissions而非旧文档里的/approvals。 - @文件补全:光标处检测
@前缀 token → 异步 FileSearchManager 模糊搜索 → 弹窗回填(chat_composer.rs:2371-2373)。 - 审批弹窗:ExecApprovalRequest 转成列表选择视图,用户选择后经
AppCommand::ExecApproval回流引擎(bottom_pane/approval_overlay.rs:72-80,349-386)。 - Ctrl-C 分层消费:弹窗先吃(关自己)→ 有任务在跑则中断 → 空闲双击退出(chatwidget/interaction.rs:373-420)。
- 渲染质感:Markdown 用 pulldown-cmark 解析 + 自研表格排版;语法高亮包的是 syntect(约 250 种语言);diff 渲染带行号 gutter、背景色随终端明暗自适应(tui/src/markdown_render.rs · render/highlight.rs:1-4 · diff_render.rs:1-32)。
09app-server:所有前端的统一协议层
Codex 特有的关键子系统。这一版最大的架构演进:TUI、codex exec、IDE 插件、SDK 全部收敛为同一个 JSON-RPC 协议的客户端。
9.1 为什么多此一层
引擎内部的 SQ/EQ 是 Rust 类型的 channel 协议,进程外用不了。app-server 把它包装成双向 JSON-RPC(双向 = 客户端能调服务器方法,服务器也能反过来请求客户端,比如审批弹窗)。于是同一个引擎能被四种前端驱动:TUI(进程内嵌,tui/src/lib.rs:1348-1370)、codex exec(进程内客户端,exec/src/lib.rs:787)、IDE 扩展(codex app-server 子进程 stdio)、Python SDK(app-server --listen stdio://)。协议类型还能一键导出 TypeScript 定义和 JSON Schema 给前端用(app-server-protocol/src/lib.rs:8-13)。
9.2 协议长什么样
| 方向 | 代表方法 | 位置(common.rs) |
|---|---|---|
| 客户端 → 服务器 | thread/start · thread/resume · thread/fork · turn/start · turn/steer · turn/interrupt · review/start · model/list · account/login/start | :476-995 |
| 服务器 → 客户端(请求) | item/commandExecution/requestApproval · item/fileChange/requestApproval · mcpServer/elicitation/request | :1456-1475 |
| 服务器 → 客户端(通知) | thread/started · turn/completed · item/agentMessage/delta · item/reasoning/textDelta | :1609-1697 |
概念映射:thread(会话线程)/ turn(一次用户输入触发的完整回合)/ item(回合内的条目:消息、命令执行、文件变更、推理)。app-server 内部有事件泵把引擎的 EventMsg 翻译成 v2 通知(app-server/src/request_processors/thread_lifecycle.rs:302-309)。对比 Claude Code:其 SDK 走的是自定义 stdin/stdout 控制协议且只服务 SDK;Codex 让自家 TUI 也吃自家协议(dogfooding),保证协议永远是完整的一等公民。
10扩展生态
无头模式、MCP 双向、SDK、云任务、hooks/skills/plugins——Codex 的生态位与 Claude Code 高度同构,但落点各有取舍。
10.1 codex exec:为 CI 而生的无头模式
参数面(exec/src/cli.rs):--json 输出 JSONL 事件流(thread.started / turn.completed / item.completed / error 每行一个 JSON,exec/src/exec_events.rs:11-36);--output-schema 用 JSON Schema 约束最终回答形状;-o 把最后一条消息写文件;- 从 stdin 读 prompt(与位置参数同给时 stdin 作为 <stdin> 块追加);--ephemeral 不落盘会话。CI 友好的硬性设计:turn 失败/中断或收到错误 → 进程退出码 1(exec/src/lib.rs:988-1050)。子命令 codex exec resume --last 恢复上次会话继续跑。
10.2 MCP:既当客户端又当服务器
- 当客户端:config.toml
[mcp_servers]配置外部 server(stdio 或 streamable HTTP 两种 transport;required=true时初始化失败直接让 codex exec 报错退出,config/src/mcp_types.rs:156-201,434-462)。底层用官方 Rust SDK(rmcp 1.8.0),OAuth/elicitation/重试齐全。工具命名已核实:发给模型的是{server}__{tool}(双下划线),mcp__前缀只在 hooks 视角保留为 legacy 兼容(core/src/tools/handlers/mcp.rs:29-65)。 - 当服务器:
codex mcp-server把 Codex 自己暴露为 stdio MCP server,提供codex(起会话跑任务)和codex-reply(按 thread_id 续聊)两个工具(mcp-server/src/codex_tool_config.rs:117-120,224-237)——其它 agent 可以把 Codex 当工具调。审批经 MCP elicitation 机制回传调用方。
10.3 SDK:两种语言两种接法
TypeScript SDK 的 Thread.runStreamed() 返回异步生成器,底层 spawn codex exec --experimental-json 消费 JSONL(sdk/typescript/src/exec.ts:86,151-152);Python SDK 则 spawn codex app-server --listen stdio:// 走 JSON-RPC(sdk/python/src/openai_codex/client.py:213,252-263)。同一引擎,两种传输——也侧面印证了 app-server 是终态方向。
10.4 hooks / skills / plugins / 云任务
| 机制 | 要点 | 位置 |
|---|---|---|
| Hooks | 10 种事件(PreToolUse / PermissionRequest / PostToolUse / Pre+PostCompact / SessionStart / UserPromptSubmit / SubagentStart+Stop / Stop);handler 三型:Command / Prompt / Agent | protocol.rs:1471-1490 · core/src/hook_runtime.rs:163-433 |
| Skills | SKILL.md 文件;发现根:项目 .codex/skills、~/.agents/skills、$CODEX_HOME/skills、/etc/codex/skills、项目根到 cwd 沿途 .agents/skills | core-skills/src/loader.rs:121,294-400 |
| Plugins | codex plugin add/list/remove + marketplace 体系(支持 npm 分发源、官方精选市场) | cli/src/main.rs:1064-1092 · core-plugins/src/lib.rs:31-32 |
| Review 模式 | codex review --uncommitted/--base/--commit:专用 ReviewTask + 专用 prompt 做代码评审 | cli/main.rs:130 · core/src/tasks/review.rs:49 |
| Codex Cloud | codex cloud 浏览云端任务 / codex apply 把云端 agent 产出的 diff 落到本地(git apply);exec 可 --attempts 1-4 做 best-of-N | cloud-tasks/src/cli.rs:15-49 · chatgpt/src/apply_command.rs:22-40 |
| Resume / Fork | codex resume(默认弹选择器 / --last)、codex fork、archive/delete;数据源就是 rollout JSONL | cli/main.rs:180-193 · tui/src/resume_picker.rs |
与 Claude Code 生态对照:斜杠命令 Codex 是内置 enum(不可用 markdown 自定义;旧版的 ~/.codex/prompts 自定义 prompt 机制在本快照中未找到实现,疑似已被 skills 吸收——此点未完全核实);子 agent 机制存在(multi_agents 工具族、spawn_subagent,thread_manager.rs:711)但本文未深入展开。
11文件索引速查
按「我想看 X」检索,路径相对 codex-rs/。
| 我想看… | 去这里 |
|---|---|
| 进程入口 / 子命令定义 | cli/src/main.rs:94,123-212,956 · arg0/src/lib.rs:58-163 |
| TUI 启动链 | tui/src/lib.rs:850,1282 · tui/src/app.rs:759,1169 |
| 配置分层 / -c 覆盖 | core/src/config/mod.rs:610,1229 · config/src/config_layer_source.rs:6-46 |
| SQ/EQ 协议类型 | protocol/src/protocol.rs:161(Submission),515(Op),1254(Event),1267(EventMsg) |
| 会话对象链 | core/src/thread_manager.rs:182 · codex_thread.rs:160 · session/mod.rs:389 · session/session.rs:28 · state/session.rs:26 |
| 提交分发 / steer | core/src/session/handlers.rs:703,183 · session/mod.rs:3827 |
| Task 抽象 / 中止 | core/src/tasks/mod.rs:214,314,829 · tasks/regular.rs:73 |
| run_turn 主循环 / 采样 | core/src/session/turn.rs:142,1072,1887 · stream_events_utils.rs:405 |
| 工具契约 / 曝光四态 | tools/src/tool_executor.rs:15-70 · tool_spec.rs:15-53 |
| 工具表现算 / 路由 / 并行 | core/src/tools/spec_plan.rs:171,613 · router.rs:113 · registry.rs:405 · parallel.rs:133 |
| 审批+沙箱编排 | core/src/tools/orchestrator.rs:134,289 · tools/sandboxing.rs:162,248,285 |
| shell / PTY 会话 / 截断 | core/src/exec.rs:58-89 · unified_exec/mod.rs:65-75 · utils/string/src/truncate.rs:7 |
| apply_patch DSL | apply-patch/src/parser.rs:37 · seek_sequence.rs:12 · invocation.rs:112 · handlers/apply_patch.lark |
| 审批策略 / 沙箱策略枚举 | protocol/src/protocol.rs:901,988 · permissions.rs:23,991 |
| 命令安全判定 | core/src/exec_policy.rs:269,627 · shell-command/src/command_safety/is_safe_command.rs:12 · command_canonicalization.rs:14 |
| Linux 沙箱(bwrap+seccomp) | linux-sandbox/src/bwrap.rs:1 · landlock.rs:165 · bwrap/build.rs:14 |
| macOS Seatbelt / Windows | sandboxing/src/seatbelt.rs:623 + seatbelt_base_policy.sbpl · windows-sandbox-rs/src/token.rs:460 |
| 模型客户端 / 请求组装 | core/src/client.rs:246,812,1734 · codex-api/src/common.rs:74,199 |
| SSE 解析 / 重试 | codex-api/src/sse/responses.rs:325,478 · codex-client/src/retry.rs:23 · core/src/responses_retry.rs:22 |
| 认证 / OAuth | login/src/server.rs:54 · pkce.rs:12 · auth/manager.rs:177,2520 · model-provider-info/src/lib.rs:38 |
| history 归一化 / 压缩 | core/src/context_manager/{history.rs:121,normalize.rs:17} · session/turn.rs:917 · compact.rs:220 · compact_remote_v2.rs:52 |
| rollout 落盘 / resume 重建 | rollout/src/recorder.rs:78,1811 · core/src/session/rollout_reconstruction.rs:113 |
| TUI 流式渲染 / scrollback 注入 | tui/src/chatwidget/streaming.rs:388 · insert_history.rs:216 · tui.rs:375,880 |
| 底部面板 / 斜杠命令 / 审批弹窗 | tui/src/bottom_pane/{mod.rs:208,textarea.rs,approval_overlay.rs:72} · slash_command.rs:12 |
| app-server 协议方法表 | app-server-protocol/src/protocol/common.rs:476-1697 · app-server/src/request_processors/turn_processor.rs:522 |
| exec 无头 / SDK / MCP 双向 | exec/src/{cli.rs,lib.rs:787,exec_events.rs:11} · sdk/typescript/src/exec.ts:86 · mcp-server/src/codex_tool_config.rs:117 · core/src/tools/handlers/mcp.rs:29 |
| hooks / skills / AGENTS.md | core/src/hook_runtime.rs:163 · core-skills/src/loader.rs:121 · core/src/agents_md.rs:1-56 |
11.1 未核实与存疑清单
- 仓库内
config.md的配置项清单已外迁在线文档,本文以core/config.schema.json佐证配置键存在性。 ~/.codex/prompts自定义斜杠 prompt 机制在本快照中未找到实现(疑似被 skills 体系吸收),未核实。- network-proxy 的 MITM/证书注入、Windows 沙箱 ACL 布点、guardian 自动审批子系统只做了机制定性,未逐行核实。
- WebSocket 传输的具体 URL 路径、
SUBMISSION_CHANNEL_CAPACITY数值、TUI CommitTick 间隔毫秒值未查。 spawn_task中连续两次abort_all_tasks(tasks/mod.rs:320-321)疑似防御式写法,意图未考证。