源码解析 第十三份 commit bb1af23

官方开源的本地编程 Agent

OpenAI Codex CLI 不是「又一个玩具 ReAct」。它是用 Rust 工作区写的本地 Agent: 心脏在 run_turn,手是 shell + apply_patch,闸门是 AskForApproval × 三平台沙箱,记忆纪律写在仓库自己的 AGENTS.md 里。 本站按「一点一阐释」逐点拆:两层 loop、审批×沙箱正交、AGENTS.md 的共享字节预算、stop hook 续跑、MCP 工具名的内容哈希去重。

126
Cargo workspace members
2 783
Rust 源文件(codex-rs)
2 741
行 · session/turn.rs
32 KiB
AGENTS.md 整条链共享的预算

阅读路径

  1. 先扫第 1–3 章:产品边界与 Multitool 地图
  2. 精读第 5 章 + LAB 03:run_turn 时间线
  3. 用 LAB 01/02 建立「审批 × 沙箱」正交心智
  4. 第 17 章 + 对比 MD:和 Claude Code / OpenCode 对位

完整逐点深析(约 1300+ 行 / 28 章含场景推演)见 openai-codex-源码分析.md

Part I

它是什么

先分清:Codex Web(云)≠ Codex CLI(本仓库)。我们拆的是本地跑的那个。

Chapter 01

产品定位:本地终端里的 OpenAI 编程 Agent

README 开宗明义:Codex CLI is a coding agent from OpenAI that runs locally on your computer.

安装路径三条:官方 install.sh / npm i -g @openai/codexcodex-cli/package.json)/ Homebrew cask。登录优先 ChatGPT 套餐,也可 API Key。

一句话 它和 Claude Code 抢的是同一类用户——「在自己机器上改代码」;差别是 开源 + Apache-2.0 + Rust,并且把沙箱当成一等公民。
值(bb1af23)
许可Apache-2.0 · Copyright 2025 OpenAI
社区~102k star / ~15k fork(GitHub API 抽样)
语言Rust(codex-rs/)+ 薄 Node 包装
创建2025-04-13
Chapter 02

Rust 工作区地图:126 个 crate,心脏叫 core

AGENTS.md 规定:crate 名带 codex- 前缀;抵制继续往 core 塞东西——因为 core 已经太胖。

flowchart TB
      CLI["cli · Multitool"] --> TUI["tui"]
      CLI --> EXEC["exec"]
      CLI --> CORE["core · Session/turn"]
      TUI --> CORE
      EXEC --> CORE
      CORE --> TOOLS["tools · ToolSpec"]
      CORE --> HAND["core/tools/handlers"]
      CORE --> PROTO["protocol"]
      HAND --> SB["sandboxing"]
      SB --> MAC["MacosSeatbelt"]
      SB --> LIN["LinuxSeccomp/Landlock"]
      SB --> WIN["WindowsRestrictedToken"]
      CORE --> ROLL["rollout · sessions"]
      CLI --> APPS["app-server*"]
    
图 1 客户端(TUI/Exec/App-server)→ core → 工具/沙箱/持久化。
  • core/ ~11MB:会话、turn、压缩、工具编排
  • tui/ ~14MB:交互面(模块拆分规矩极严)
  • cli/:多工具入口
  • apply-patch/linux-sandbox/login/rollout/
Chapter 03

Multitool CLI:默认进 TUI,子命令覆盖人生大事

cli/src/main.rs:无子命令时参数转发给交互 TUI;有子命令则走 Subcommand(约 122–211 行)。

子命令用途
exec / e非交互跑一轮
login / logoutChatGPT / API / device code
mcp / mcp-server管 MCP 或自己当 MCP server
resume / fork / archive会话生命周期
sandbox / doctor沙箱调试与健康检查
app-server / app编辑器/桌面桥(实验/平台相关)
apply把 agent diff git apply 到工作树
Part II

心脏

所有故事汇合到一处:RegularTaskrun_turn

Chapter 04

Thread · Session · submit

CodexThread::submitcodex_thread.rs:225)把用户操作变成 Op;Session 侧 submitsession/mod.rs:793)进入任务系统。

常规对话任务是 RegularTasktasks/regular.rs):发 TurnStarted → 吃掉 startup prewarm → 循环调用 run_turn,直到没有 pending input。

外层 loop 吃的是「用户又插话了」;内层 run_turn 的 loop 吃的是「模型还要工具」。

Chapter 05

run_turn:2741 行文件里的那颗心脏

入口:session/turn.rs:153run_turn。文件头注释写清:若模型只要助手消息,记历史后本 turn 完成;若要工具,就继续。

两层 loop(死记)
  • 外层 RegularTask:用户又插话了就再调一次 run_turn
  • 内层 run_turn:模型还要工具 / mid-turn 压缩 / stop hook 续跑就 continue
  • 停机不是 if !tool_calls,而是 follow-up × compact × stop hook 三方协议(MD 第 7 章)
LAB 03 run_turn 停机协议 这是 core/src/session/turn.rs:357-512 采样后状态机的可运行复刻。拨开关,看这一圈到底 continue 还是 break——以及凭什么。Codex 的「停」不是 if !tool_calls,是 follow-up × 压缩 × stop hook 的三方协议。
模型侧
窗口侧
stop hook 侧
sequenceDiagram
      participant RT as RegularTask
      participant Turn as run_turn
      participant Model as ModelClient
      participant Tools as ToolHandlers
      RT->>Turn: run_turn(input)
      Turn->>Turn: compact / MCP / step_context
      loop sampling_cycle
        Turn->>Model: run_sampling_request
        Model-->>Turn: assistant + tool calls
        Turn->>Tools: dispatch + sandbox + approval
        Tools-->>Turn: observations
      end
      Turn-->>RT: last_agent_message
    
图 2 RegularTask 与 run_turn 的内外两层循环。
flowchart TB
    subgraph OUT["外层 · RegularTask::run(regular.rs:76-92)"]
        A["run_turn(next_input)"] --> B{"input_queue
还有 pending?"} B -->|否| Z["return last_agent_message"] B -->|是| C["next_input = Vec::new()"] C --> A end subgraph IN["内层 · run_turn 的 loop(turn.rs:270-512)"] D["构造采样输入"] --> E["run_sampling_request"] E --> F{"needs_follow_up?"} F -->|是| G{"should_roll_over?"} G -->|是| H["mid-turn compact"] --> D G -->|否| D F -->|否| I["run_turn_stop_hooks"] I -->|block + prompt| D I -->|stop / 无事| J["break"] end A -.进入.-> D J -.返回.-> B
图 · 两层循环 外层管「用户又插话了」,内层管「模型还要工具 / 窗口顶了 / hook 要求续跑」。把两者分开是刻意的——避免把用户输入和模型 tool-followup 搅成同一种控制流。
Chapter 06

压缩与上下文纪律:写进 AGENTS.md 的工程宪法

AGENTS.md:91-100 六条,比任何博客都硬:

  1. 禁止改写历史(只增量)
  2. 少动前缀,保护 prompt cache
  3. 注入项必须有界 + 硬顶
  4. 单条不超过 10K tokens
  5. >1k tokens 的新条目按 P0 人工审
  6. 片段必须是 core/context 里实现 ContextualUserFragment 的结构体

run_turn 开头就跑 run_pre_sampling_compact;另有 remote compact / inline auto compact 家族文件(compact*.rs)。

对照 OpenCode/MiMo 用 checkpoint + FTS 记忆「外置大脑」;Codex 更强调 模型可见历史本身的洁癖
Part III

手与闸门

工具少而硬,沙箱厚而真。

Chapter 07

工具图鉴:Responses API 形状的 ToolSpec

tools/src/tool_spec.rsFunction / Namespace / ToolSearch / WebSearch / Freeform

核心 handlers(core/src/tools/handlers/mod.rs)包括:

  • ShellCommandHandler / ExecCommandHandler(unified_exec)+ WriteStdin
  • ApplyPatchHandler
  • ViewImageHandler
  • PlanHandlerRequestUserInputRequestPermissions
  • MCP + resource 读写、multi_agents、tool_search、plugins…

这与 Claude Code「40+ 细分工具」不同:Codex 更接近 shell 万能胶 + 结构化补丁,再加托管 web_search。

flowchart LR
    R["工具请求"] --> A["default_exec_approval_requirement
policy × fs.kind"] A -->|Forbidden| X["拒绝 · 命令不执行"] A -->|NeedsApproval| U["问人
with_cached_approval :71"] A -->|Skip| S U -->|同意| S["sandbox_override_for_first_attempt
:242-277"] U -->|拒绝| X S -->|NoOverride| T["SandboxAttempt
按平台默认沙箱"] S -->|Bypass| T2["SandboxAttempt
首次免沙箱"] T -->|沙箱拒绝| E["escalated retry
不再问人(已缓存批准)"] E --> T
图 · 两道正交的墙 AskForApproval 管「人同不同意」,SandboxType 管「内核允不允许」。两边都过副作用才发生;而重试升级沙箱时不会二次打扰用户——批准已被 with_cached_approval 记住。
Chapter 08

apply_patch:独立 crate + arg0 自调用

codex-apply-patch 解析 patch 语法;进程可通过 argv0=apply_patch(或拼写错误的 applypatch)进入(arg0/src/lib.rs:20-21,98-99)。UNIX 下甚至会在临时目录放 symlink,让 PATH 里能直接敲到。

补丁不是「再开一个 Node 脚本」,而是 同一二进制的第二人格——和 linux-sandbox 的 arg0 技巧同一家族。

flowchart TB
    M["模型"] --> T1["apply_patch 工具
结构化调用"] M --> T2["shell 里写 apply_patch <<'EOF'"] M --> T3["unified_exec 持久会话里写"] T2 --> I1["intercept_apply_patch
handlers/shell.rs:142"] T3 --> I2["intercept_apply_patch
unified_exec/exec_command.rs:314"] T1 --> H["ApplyPatchHandler"] I1 --> H I2 --> H H --> K["每个文件一个 approval key"] K --> O["ToolOrchestrator"] O --> FS["apply-patch crate 落盘"] U["用户"] --> A["codex apply"] --> G["git apply"]
图 · 一条不变量靠两处拦截维持 「结构化补丁必须走结构化闸门」不是靠工具自觉,而是靠在每一个能执行命令的入口都插同一个拦截器。只堵一条路等于没堵——模型换个后端就绕过去了。
Chapter 09

三平台沙箱:Seatbelt · Seccomp/Landlock · Windows Token

sandboxing/src/manager.rs:35-40

pub enum SandboxType {
    None,
    MacosSeatbelt,
    LinuxSeccomp,
    WindowsRestrictedToken,
}
LAB 05 平台沙箱选择 get_platform_sandbox() 的逐行移植(sandboxing/src/manager.rs:60-73)。试试 Windows 且不开开关——那是三平台里唯一返回 None 的分支。

Linux 侧 linux-sandbox 用 landlock ruleset 限制可写根;另有 bwrap 路径。权限画像在 protocol/src/permissions.rs(FileSystemSandboxPolicy / NetworkSandboxPolicy)。

Chapter 10

AskForApproval:四档人在回路

protocol/src/protocol.rs:908-931

和沙箱正交 AskForApproval 回答「人同不同意」;SandboxType 回答「内核允不允许」。Granular 关掉弹窗时不是放行,而是 Forbidden(MD 第 12.3 节)——防止「关弹窗=允许一切」。
LAB 01 审批 × 沙箱正交表 两个函数的忠实移植:default_exec_approval_requirementtools/sandboxing.rs:198-234)与 sandbox_override_for_first_attempt:242-277)。先试 Granular + 关掉 ASA + 非 Restricted——那一格的答案和多数人的直觉相反。
AskForApproval
FileSystemSandboxKind

默认 OnRequest。非交互 exec 场景常配更激进策略 + 沙箱,而不是把人绑在终端前。

Part IV

上下文与编排

Chapter 11

AGENTS.md:从仓库根走到 cwd 的说明书串联

core/src/agents_md.rs:默认文件名 AGENTS.md,本地覆盖 AGENTS.override.md;向上找 project root markers(默认 .git),再向下拼到 cwd。

LAB 02 AGENTS.md 字节预算模拟器 read_agents_md 那本 remaining 账本的逐行移植(core/src/agents_md.rs:107-144)。默认额度 32 KiB整条链共享。改改根目录那个 30 KB,看下游怎么被饿死。
project_doc_max_bytes
目录(root → cwd)大小 KiBoverride空白

这本账有三个反直觉的地方

行为后果
remaining 沿 root → cwd 递减仓库根的 AGENTS.md 优先吃额度;越深的目录越可能吃不到
跨过额度的文件被 truncate 而不是跳过你会拿到半截说明书——从中间断的,可能正好切掉最重要那条规则
扣减写在 if !text.trim().is_empty() 里面空白 AGENTS.md 不消耗额度,放着不碍事
实战陷阱 一个 monorepo:根 AGENTS.md 写了 30 KB 通用规范,apps/api/AGENTS.md 写了 5 KB 服务专属规则—— 后者只会被读进 2 KB,剩下 3 KB 静默丢失,日志里只有一条 project doc exceeds remaining budget; truncating

你会觉得「模型怎么不守我们服务的规矩」,但问题不在模型。 而且越具体、越该赢的规则通常恰好在链的末端——也就是最容易被截断的位置

同一目录下谁赢:override 是「替换」不是「合并」

每个目录按 candidate_filenamesagents_md.rs:234-248)的顺序探测, 命中第一个就 return:220-227):

names.push("AGENTS.override.md");   // 先
names.push("AGENTS.md");
for candidate in &config.project_doc_fallback_filenames { ... }  // 用户可配的后备名

所以「override」的准确语义是:同目录内 AGENTS.override.md 会让 AGENTS.md 完全失效—— 不是合并、不是追加。但不影响其它目录,链上别的层照常参与。

和 Claude Code 的心智不同 CLAUDE.local.md 那边是多来源叠加;这边是每层单选 + 层间串联

分隔符只出现一次,别记错

// The project-doc marker tells the model where workspace-scoped
// instructions begin, so it is only needed on the transition
// from user or internal instructions to project instructions.
let separator = if is_project && !previous_was_project { AGENTS_MD_SEPARATOR } else { "\n\n" };
--- project-doc --- 不是链上每个文件之间的分隔符 它只出现一次——在「用户/内部指令」切换到「项目指令」的那个交界处(agents_md.rs:330-343)。 链上相邻两个项目文档之间用的是普通的 \n\n

为什么:这个标记是给模型的语义路标(「从这里开始是这个工作区的规矩」),不是排版分隔线。放多了反而稀释信号。

还有一处容易漏:LoadedAgentsMd::text()两套出口——多环境(本地 + 远端 exec-server 同时在场)时切到 environment_labeled_text(),因为此时「这条规则属于哪台机器」变成必须表达的信息;单环境才走 legacy_text()

flowchart TB
    C["cwd"] --> U["向上找 project_root_markers
默认 .git"] U --> R["project root"] R --> D["dirs.reverse() → root→cwd 顺序"] D --> P["每目录 candidate_filenames 取第一个命中
AGENTS.override.md › AGENTS.md › 配置的后备名"] P --> B{"remaining > 0?"} B -->|否| K["break —— 后面的文件
根本不读"] B -->|是| T{"size > remaining?"} T -->|是| TR["truncate 拦腰截断
+ warn"] T -->|否| OK["完整读入"] TR --> M["remaining -= taken"] OK --> M M --> B
图 · 一本先到先得的账本 32 KiB 是整条链共享的额度,不是每文件上限。根目录先吃,越深的目录越容易被截断甚至完全读不到——而日志里只有一行 warn
Chapter 12

Skills · Plugins · Hooks

run_turn 在采样前 build_skills_and_plugins;工具路径跑 pre/post tool hooks(registry 里 run_pre_tool_use_hooks)。插件可安装/列举(handlers 里 request_plugin_install / list_available_plugins_to_install)。官方用户文档在 developers.openai.com,仓库 docs/skills.md 只是跳转。

flowchart TB
    IN["原始 MCP 工具
server / namespace / tool"] --> ID["拼身份串
server \0 namespace \0 connector_id"] ID --> DUP{"原始身份重复?"} DUP -->|是| SKIP["整条丢弃 + warn"] DUP -->|否| SAN["sanitize:非 [A-Za-z0-9_-] → _"] SAN --> PRE{"prefix_mcp_tool_names
且不在豁免名单?"} PRE -->|是| P1["加 mcp__ 前缀"] PRE -->|否| P2["不加"] P1 --> R1 P2 --> R1["第一轮:namespace 撞名?
→ append_namespace_hash_suffix"] R1 --> R2["第二轮:ns+name 撞名?
→ append_hash_suffix"] R2 --> SORT["sort_by(raw_tool_identity)"] SORT --> CAP["MAX_TOOL_NAME_LENGTH = 64"]
图 · 为什么用 SHA-1 而不是序号 序号依赖遍历顺序,工具名会在会话之间漂移,同时打掉 prompt cache 和模型的工具记忆。哈希由内容决定,同一个工具永远同一个名字。
Chapter 13

子 Agent:注册表限流 + nickname

agent/registry.rs 在同一用户会话内限制子 agent(thread)数量,并维护 nickname(还可 “the 2nd” 后缀)。spawn depth 有上限检查(exceeds_thread_spawn_depth_limit)。

Part V

界面与协议

Chapter 14

TUI 与 Exec:一个心脏,两张脸

交互默认 TUI(OpenTUI 风格约束见 AGENTS.md);codex exec 走非交互(docs/exec.md 外链)。ModeKind 含 Plan / Default(config_types.rs:630)。

Chapter 15

App-server · MCP:给编辑器的插座

一簇 app-server* crate 把协议暴露给 IDE/桌面;CLI 也可 mcp-server 把自己当 MCP。这是 Codex 与「纯终端玩具」分道扬镳的地方——协议面一等公民

flowchart LR
    A["codex"] --> B{"有子命令?"}
    B -->|无| C["TUI 交互"]
    B -->|exec / e| D["非交互 turn"]
    B -->|review| R2["非交互 review"]
    B -->|resume / fork| E["rollout 恢复"]
    B -->|archive / unarchive / delete| E2["会话资产管理"]
    B -->|mcp-server| F["自己当 MCP server"]
    B -->|app-server / remote-control| G["给编辑器的插座"]
    B -->|sandbox| H["沙箱里跑命令"]
    B -->|doctor / debug| I["诊断"]
    B -->|apply| J["git apply 补丁"]
    C --> TH["CodexThread"]
    D --> TH
    E --> TH
    G --> TH
    
图 · 瑞士军刀式 CLI subcommand_negates_reqs = true 让「敲 codex 就聊天」成为默认,运维能力用子命令展开而不污染主路径。所有门面最终都汇到同一个 CodexThread
LAB 04 MCP 工具名归一化 normalize_tools_for_model_with_prefix 的移植(codex-mcp/src/tools.rs:113-200):sanitize → 前缀 → 两轮撞名检测 → 内容哈希后缀 → 排序 → 64 字节上限。每行写 server|namespace|tool
接进来的 MCP 工具
Chapter 16

认证:ChatGPT · Device Code · API Key

login/:PKCE/OAuth、device code(device_code_auth.rs)、环境变量遥测(是否存在 OPENAI_API_KEY / CODEX_API_KEY)。商业上把 CLI 接到 ChatGPT 套餐,是和 Claude Code 订阅叙事对称的一手。

Part VI

对比与总结

详细对决见配套 MD;这里只留决策骨感。

Chapter 17

三方对照:Claude Code · OpenCode · Codex

维度Claude CodeOpenCodeCodex CLI
开源是 MIT是 Apache-2.0
语言TS 产品Bun+TS+EffectRust workspace
模型Anthropic 绑定多 providerOpenAI 生态为主
架构单产品循环服务器即本体core + 多客户端协议
沙箱权限闸门为主permission rulesetOS 级 Seatbelt/Landlock/Token
工具哲学细分工具全家桶大工具箱+GPT ABI 变体shell+patch+托管搜索
项目说明书CLAUDE.md多样 config/memoryAGENTS.md 链
flowchart TB
      Q["你要什么?"] --> A["官方闭源极致体验"]
      Q --> B["开源多客户端厨房"]
      Q --> C["官方开源 + OS 沙箱"]
      A --> CC["Claude Code"]
      B --> OC["OpenCode"]
      C --> CX["Codex CLI"]
    
图 3 选型骨感。完整五场对决见对比 MD。

场景级逐步推演(MD 第七部分)

本预览包文件
  • 项目分析/openai-codex-源码分析.md(深析全文)
  • 项目分析/Claude-Code-vs-OpenCode-vs-Codex-深度对比.md
  • openai-codex-解析.html(本站)
  • 参考树:参考项目/codex@bb1af23
Part VII

把点连成故事

前面每一章都是一个零件。这一部分走四条完整路径——每一步都能回到上面某一节。

Chapter 18

场景 A:交互会话里执行 rm -rf build

sequenceDiagram
    participant M as 模型
    participant SH as handlers/shell.rs
    participant OR as ToolOrchestrator
    participant U as 用户
    participant SB as Seatbelt
    M->>SH: shell { command: ["rm","-rf","build"] }
    Note over SH: :108-120 规范化 additional permissions
    Note over SH: :122-138 抬权守卫(本例没抬权,放行)
    Note over SH: :140-156 intercept_apply_patch → 不是补丁,继续
    SH->>OR: ShellRequest
    OR->>OR: default_exec_approval_requirement
OnRequest × Restricted → NeedsApproval OR->>U: 审批请求 U-->>OR: ApprovedForSession Note over OR: with_cached_approval :71 按 key 记住 OR->>SB: SandboxAttempt(NoOverride) SB-->>OR: 成功 / 拒绝 Note over OR: 若拒绝且可升级 → escalated retry,不再问人
图 · 一次写操作的完整闸门链 注意最后一步:升级沙箱重试时不会二次打扰用户——批准已被缓存,而且是按 key 缓存的,所以「子集请求」也能复用。
读这条链要记住的一件事 用户点「同意」只过了第一道墙。Seatbelt 仍然可能拒绝——比如 build/ 不在 writable_roots 里。 反过来,沙箱允许也不等于不用问人。两道墙独立生效。
Chapter 19

场景 B:上下文将爆窗时的 mid-turn 压缩

模型刚返回一批工具调用,needs_follow_up = true,同时 token_limit_reached = true。 于是 turn.rs:419

let should_roll_over = needs_follow_up
    && (sess.take_new_context_window_request().await || token_limit_reached);

进入 run_auto_compact(CompactionPhase::MidTurn),注入策略 BeforeLastUserMessage,压缩后 can_drain_pending_input = !model_needs_follow_up,然后 continue

这里有一句很坦白的注释(turn.rs:430) "as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop."

翻译:我们把「不会死循环」这件事,押在「压缩确实能把窗口拉回来」上。 没有硬性轮数上限兜底。

这是一个显式写在代码里的产品赌注——值得单独记一笔,因为大多数项目在这里会加一个 MAX_ITERATIONS。 Codex 选择相信自己的压缩器,代价是压缩一旦失效就会转圈。

顺带对照:Part II 讲过还有 pre-turn 压缩(run_pre_sampling_compact @983)和 previous-model inline 压缩(maybe_run_previous_model_inline_compact @1051,换模型或 comp_hash 变了时用旧模型做兼容压缩)。 三种压缩,三个时机

Chapter 20

场景 C:stop hook 要求续跑

模型说完了,不再要工具,needs_follow_up = false。按常规该 break——但 turn.rs:461-510 还要过一关:

stop hook 返回run_turn 的反应
should_block + 有 continuation fragments记入历史 · 开 mailbox · stop_hook_active = true · continue
should_block没有 prompt打一条 Warning,然后照常结束
should_stopbreak
都没有break,带回 last_agent_message
第二行是防御性设计 hook 说「要拦」却不给续跑内容——这是一个配错的 hook。 Codex 不会因此让会话永远停不下来,而是记一条 Warning 就放行。

🔑 hooks 在 Codex 里是控制面,不是日志面:它能改变主循环的终止判定。 但控制面必须自带「配错了也不会死」的兜底。

用上面的 LAB 03should_block 打开、有 continuation prompt 关掉,就能看到这一格。

Chapter 21

场景 D:CI 里跑 codex exec

配置为什么
AskForApproval::NeverCI 没有人可问。失败立刻回给模型,不升级成人审——模型自己消化错误
FS 用 RestrictedNever 让「人这道墙」失效了,内核那道墙必须补上
无网络或走代理网络是另一条审批带begin_network_approval),不是 FS 沙箱的附赠
最危险的组合 Never + 宽松文件系统 = 两道墙同时不设防。 Never 的语义是「不问人」,不是「安全」。

LAB 01 选 Never + Full 试一下,你会看到两关都是绿的——那正是问题所在