阅读路径
- 先扫第 1–3 章:产品边界与 Multitool 地图
- 精读第 5 章 + LAB 03:
run_turn时间线 - 用 LAB 01/02 建立「审批 × 沙箱」正交心智
- 第 17 章 + 对比 MD:和 Claude Code / OpenCode 对位
完整逐点深析(约 1300+ 行 / 28 章含场景推演)见 openai-codex-源码分析.md。
它是什么
先分清:Codex Web(云)≠ Codex CLI(本仓库)。我们拆的是本地跑的那个。
产品定位:本地终端里的 OpenAI 编程 Agent
README 开宗明义:Codex CLI is a coding agent from OpenAI that runs locally on your computer.
安装路径三条:官方 install.sh / npm i -g @openai/codex(codex-cli/package.json)/ Homebrew cask。登录优先 ChatGPT 套餐,也可 API Key。
| 项 | 值(bb1af23) |
|---|---|
| 许可 | Apache-2.0 · Copyright 2025 OpenAI |
| 社区 | ~102k star / ~15k fork(GitHub API 抽样) |
| 语言 | Rust(codex-rs/)+ 薄 Node 包装 |
| 创建 | 2025-04-13 |
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*"]
core/~11MB:会话、turn、压缩、工具编排tui/~14MB:交互面(模块拆分规矩极严)cli/:多工具入口apply-patch/、linux-sandbox/、login/、rollout/…
Multitool CLI:默认进 TUI,子命令覆盖人生大事
cli/src/main.rs:无子命令时参数转发给交互 TUI;有子命令则走 Subcommand(约 122–211 行)。
| 子命令 | 用途 |
|---|---|
exec / e | 非交互跑一轮 |
login / logout | ChatGPT / API / device code |
mcp / mcp-server | 管 MCP 或自己当 MCP server |
resume / fork / archive | 会话生命周期 |
sandbox / doctor | 沙箱调试与健康检查 |
app-server / app | 编辑器/桌面桥(实验/平台相关) |
apply | 把 agent diff git apply 到工作树 |
心脏
所有故事汇合到一处:RegularTask 调 run_turn。
Thread · Session · submit
CodexThread::submit(codex_thread.rs:225)把用户操作变成 Op;Session 侧 submit(session/mod.rs:793)进入任务系统。
常规对话任务是 RegularTask(tasks/regular.rs):发 TurnStarted → 吃掉 startup prewarm → 循环调用 run_turn,直到没有 pending input。
外层 loop 吃的是「用户又插话了」;内层
run_turn的 loop 吃的是「模型还要工具」。
run_turn:2741 行文件里的那颗心脏
入口:session/turn.rs:153 的 run_turn。文件头注释写清:若模型只要助手消息,记历史后本 turn 完成;若要工具,就继续。
- 外层
RegularTask:用户又插话了就再调一次run_turn - 内层
run_turn:模型还要工具 / mid-turn 压缩 / stop hook 续跑就continue - 停机不是
if !tool_calls,而是 follow-up × compact × stop hook 三方协议(MD 第 7 章)
continue 还是 break——以及凭什么。Codex 的「停」不是 if !tool_calls,是 follow-up × 压缩 × 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
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
压缩与上下文纪律:写进 AGENTS.md 的工程宪法
根 AGENTS.md:91-100 六条,比任何博客都硬:
- 禁止改写历史(只增量)
- 少动前缀,保护 prompt cache
- 注入项必须有界 + 硬顶
- 单条不超过 10K tokens
- >1k tokens 的新条目按 P0 人工审
- 片段必须是
core/context里实现ContextualUserFragment的结构体
run_turn 开头就跑 run_pre_sampling_compact;另有 remote compact / inline auto compact 家族文件(compact*.rs)。
手与闸门
工具少而硬,沙箱厚而真。
工具图鉴:Responses API 形状的 ToolSpec
tools/src/tool_spec.rs:Function / Namespace / ToolSearch / WebSearch / Freeform。
核心 handlers(core/src/tools/handlers/mod.rs)包括:
- ShellCommandHandler / ExecCommandHandler(unified_exec)+ WriteStdin
- ApplyPatchHandler
- ViewImageHandler
- PlanHandler、RequestUserInput、RequestPermissions
- 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 记住。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"]
三平台沙箱:Seatbelt · Seccomp/Landlock · Windows Token
sandboxing/src/manager.rs:35-40:
pub enum SandboxType {
None,
MacosSeatbelt,
LinuxSeccomp,
WindowsRestrictedToken,
}
get_platform_sandbox() 的逐行移植(sandboxing/src/manager.rs:60-73)。试试 Windows 且不开开关——那是三平台里唯一返回 None 的分支。
Linux 侧 linux-sandbox 用 landlock ruleset 限制可写根;另有 bwrap 路径。权限画像在 protocol/src/permissions.rs(FileSystemSandboxPolicy / NetworkSandboxPolicy)。
AskForApproval:四档人在回路
protocol/src/protocol.rs:908-931:
default_exec_approval_requirement(tools/sandboxing.rs:198-234)与 sandbox_override_for_first_attempt(:242-277)。先试 Granular + 关掉 ASA + 非 Restricted——那一格的答案和多数人的直觉相反。
默认 OnRequest。非交互 exec 场景常配更激进策略 + 沙箱,而不是把人绑在终端前。
上下文与编排
AGENTS.md:从仓库根走到 cwd 的说明书串联
core/src/agents_md.rs:默认文件名 AGENTS.md,本地覆盖 AGENTS.override.md;向上找 project root markers(默认 .git),再向下拼到 cwd。
read_agents_md 那本 remaining 账本的逐行移植(core/src/agents_md.rs:107-144)。默认额度 32 KiB,整条链共享。改改根目录那个 30 KB,看下游怎么被饿死。
这本账有三个反直觉的地方
| 行为 | 后果 |
|---|---|
remaining 沿 root → cwd 递减 | 仓库根的 AGENTS.md 优先吃额度;越深的目录越可能吃不到 |
跨过额度的文件被 truncate 而不是跳过 | 你会拿到半截说明书——从中间断的,可能正好切掉最重要那条规则 |
扣减写在 if !text.trim().is_empty() 里面 | 空白 AGENTS.md 不消耗额度,放着不碍事 |
AGENTS.md 写了 30 KB 通用规范,apps/api/AGENTS.md 写了 5 KB 服务专属规则——
后者只会被读进 2 KB,剩下 3 KB 静默丢失,日志里只有一条
project doc exceeds remaining budget; truncating。
你会觉得「模型怎么不守我们服务的规矩」,但问题不在模型。 而且越具体、越该赢的规则通常恰好在链的末端——也就是最容易被截断的位置。
同一目录下谁赢:override 是「替换」不是「合并」
每个目录按 candidate_filenames(agents_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.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" };
\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
warn。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"]
子 Agent:注册表限流 + nickname
agent/registry.rs 在同一用户会话内限制子 agent(thread)数量,并维护 nickname(还可 “the 2nd” 后缀)。spawn depth 有上限检查(exceeds_thread_spawn_depth_limit)。
界面与协议
TUI 与 Exec:一个心脏,两张脸
交互默认 TUI(OpenTUI 风格约束见 AGENTS.md);codex exec 走非交互(docs/exec.md 外链)。ModeKind 含 Plan / Default(config_types.rs:630)。
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
subcommand_negates_reqs = true 让「敲 codex 就聊天」成为默认,运维能力用子命令展开而不污染主路径。所有门面最终都汇到同一个 CodexThread。normalize_tools_for_model_with_prefix 的移植(codex-mcp/src/tools.rs:113-200):sanitize → 前缀 → 两轮撞名检测 → 内容哈希后缀 → 排序 → 64 字节上限。每行写 server|namespace|tool。
认证:ChatGPT · Device Code · API Key
login/:PKCE/OAuth、device code(device_code_auth.rs)、环境变量遥测(是否存在 OPENAI_API_KEY / CODEX_API_KEY)。商业上把 CLI 接到 ChatGPT 套餐,是和 Claude Code 订阅叙事对称的一手。
对比与总结
详细对决见配套 MD;这里只留决策骨感。
三方对照:Claude Code · OpenCode · Codex
| 维度 | Claude Code | OpenCode | Codex CLI |
|---|---|---|---|
| 开源 | 否 | 是 MIT | 是 Apache-2.0 |
| 语言 | TS 产品 | Bun+TS+Effect | Rust workspace |
| 模型 | Anthropic 绑定 | 多 provider | OpenAI 生态为主 |
| 架构 | 单产品循环 | 服务器即本体 | core + 多客户端协议 |
| 沙箱 | 权限闸门为主 | permission ruleset | OS 级 Seatbelt/Landlock/Token |
| 工具哲学 | 细分工具全家桶 | 大工具箱+GPT ABI 变体 | shell+patch+托管搜索 |
| 项目说明书 | CLAUDE.md | 多样 config/memory | AGENTS.md 链 |
flowchart TB
Q["你要什么?"] --> A["官方闭源极致体验"]
Q --> B["开源多客户端厨房"]
Q --> C["官方开源 + OS 沙箱"]
A --> CC["Claude Code"]
B --> OC["OpenCode"]
C --> CX["Codex CLI"]
场景级逐步推演(MD 第七部分)
项目分析/openai-codex-源码分析.md(深析全文)项目分析/Claude-Code-vs-OpenCode-vs-Codex-深度对比.mdopenai-codex-解析.html(本站)- 参考树:
参考项目/codex@bb1af23
把点连成故事
前面每一章都是一个零件。这一部分走四条完整路径——每一步都能回到上面某一节。
场景 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,不再问人
build/ 不在 writable_roots 里。
反过来,沙箱允许也不等于不用问人。两道墙独立生效。
场景 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。
翻译:我们把「不会死循环」这件事,押在「压缩确实能把窗口拉回来」上。 没有硬性轮数上限兜底。
这是一个显式写在代码里的产品赌注——值得单独记一笔,因为大多数项目在这里会加一个
MAX_ITERATIONS。
Codex 选择相信自己的压缩器,代价是压缩一旦失效就会转圈。
顺带对照:Part II 讲过还有 pre-turn 压缩(run_pre_sampling_compact @983)和
previous-model inline 压缩(maybe_run_previous_model_inline_compact @1051,换模型或 comp_hash 变了时用旧模型做兼容压缩)。
三种压缩,三个时机。
场景 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_stop | break |
| 都没有 | break,带回 last_agent_message |
🔑 hooks 在 Codex 里是控制面,不是日志面:它能改变主循环的终止判定。 但控制面必须自带「配错了也不会死」的兜底。
用上面的 LAB 03 把 should_block 打开、有 continuation prompt 关掉,就能看到这一格。
场景 D:CI 里跑 codex exec
| 配置 | 为什么 |
|---|---|
AskForApproval::Never | CI 没有人可问。失败立刻回给模型,不升级成人审——模型自己消化错误 |
| FS 用 Restricted | Never 让「人这道墙」失效了,内核那道墙必须补上 |
| 无网络或走代理 | 网络是另一条审批带(begin_network_approval),不是 FS 沙箱的附赠 |
Never + 宽松文件系统 = 两道墙同时不设防。
Never 的语义是「不问人」,不是「安全」。
拿 LAB 01 选 Never + Full 试一下,你会看到两关都是绿的——那正是问题所在。