Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/Codex Harness/第2章

第2章:工程骨架 —— 102 个 crate 与单二进制多入口

8 分钟 · 更新于 2026-09-01

第2章:工程骨架 —— 102 个 crate 与单二进制多入口

本章回答两个问题:这 102 个 crate 按什么逻辑组织,以及一个可执行文件怎么同时扮演 7 个不同的程序。第二个问题的答案(arg0 技巧)是本章最值得抄的一招。


一、先看地图:102 个 crate 分成七组

codex-rs/ 是一个 Cargo workspace,Cargo.toml 里列了 102 个成员 [源码 codex-rs/Cargo.toml]。按职责可以归成七组:

组 1 · 引擎(agent 内核)

crate行数职责
core332152会话、循环、工具、上下文、压缩、安全决策
core-api135对外暴露的最小 API 面
core-plugins42435插件加载、MCP 路由、市场、命令迁移
protocol26045SQ/EQ 协议类型定义
config24853配置加载与合并
state21920运行时状态

core 一个 crate 占了引擎的一大半。这里要提醒一句:它内部是有清晰分区的,不是一个 33 万行的泥球——

text
core/src/
├── session/         会话状态与生命周期(30045 行)
├── tasks/           任务调度:regular / compact / review / user_shell(2290 行)
├── tools/           工具注册、路由、处理器、运行时
├── context/         上下文片段
├── context_manager/ 历史管理与归一化(4184 行)
├── agent/           多 agent 控制与注册表(7546 行)
├── guardian/        模型判官
├── sandboxing/      沙箱接入
├── exec_policy/     执行策略
├── unified_exec/    统一命令执行(5356 行)
├── plugins/         插件接入
└── config/          配置

组 2 · 前端与接入面

crate行数职责
tui277181终端界面
cli32059命令行解析与子命令分发
exec11027无头执行模式
app-server + -protocol + -transport + -client + -daemon199943给 IDE / 桌面 App 的 JSON-RPC 服务
mcp-server / codex-mcp / rmcp-client48313MCP 的服务端与客户端两侧
cloud-tasks 系列6635云端任务

组 3 · 执行与终端

exec-server(48145)、shell-command(6340)、shell-escalation(2281)、apply-patch(5929)、file-systemfile-watcherfile-searchgit-utils

组 4 · 安全与隔离

sandboxing(8357,统一抽象)、linux-sandbox(9694)、windows-sandbox-rs(19852)、bwrap(151)、execpolicy(2975)、network-proxy(18491)、process-hardeningsecretskeyring-store

组 5 · 模型与协议

codex-apimodel-providermodel-provider-infomodels-managerhttp-clientwebsocket-clientresponses-api-proxybackend-clientollamalmstudioprompts

组 6 · 持久化与可观测

rollout(14835)、rollout-tracethread-store(29563)、historymessage-historymemoriesotelanalyticsfeedbackdiagnostics

组 7 · 扩展平面

ext/ 下 14 个内部扩展 + plugin + skills + hooks + connectors + code-mode 四件套。

读源码的入口建议 不要从 core/src/lib.rs 开始读——它主要是 pub use 转发。core/src/session/turn.rs:153run_turn 开始,那是整个系统的主循环,顺着它往外读,会自然经过上下文、工具、压缩、钩子、安全五个子系统。第 4 章就是这么组织的。


二、拆成 102 个的真实动机

dsh 拆成 185 个包,动机是可替换(第 4 章 seam 架构)。Codex 拆成 102 个 crate,动机完全不同,有三条:

2.1 编译时间

Rust 的增量编译以 crate 为单位。core 有 33 万行,改一行就要重编整个 crate;如果所有代码都在 core 里,那就是 147 万行的重编。把 tuiapp-server、沙箱、协议拆出去之后,改 TUI 不触发 core 重编,改 core 不触发 TUI 重编。

这是 Rust 项目特有的拆分压力,TypeScript 项目(Pi、dsh)感受不到。所以不要把"Codex 拆了 102 个"和"dsh 拆了 185 个"当成同一类现象。

2.2 编译目标裁剪

有些 crate 只在特定平台编译。linux-sandbox 只在 Linux,windows-sandbox-rs 只在 Windows,bwrap 只在有 bubblewrap 的 Linux。拆成独立 crate 后,条件编译写在依赖声明里而不是散在代码中的 #[cfg] 里。

2.3 独立二进制目标

这是最有意思的一条,也是下一节的主题。16 个 crate 一共声明了 21 个 bin 目标 [源码,各 Cargo.toml],其中和运行时分发直接相关的是这些:

二进制名来源 crate作用
codexcli主程序
apply_patchapply-patch独立的补丁应用工具
codex-execpolicyexecpolicy执行策略调试
codex-execve-wrappershell-escalation提权执行包装器
codex-code-mode-hostcode-mode-hostCode Mode 宿主
codex-responses-api-proxyresponses-api-proxyResponses API 代理
codex-windows-sandbox-setupwindows-sandbox-rsWindows 沙箱环境准备
codex-command-runnerwindows-sandbox-rsWindows 沙箱内命令执行器
bwrapbwrap内置的 bubblewrap
logs_clientcli日志客户端

问题来了:分发的时候只有一个 codex 可执行文件。这些东西去哪了?


三、arg0 技巧:一个二进制伪装成七个程序

3.1 问题

Codex 需要在运行时以子进程方式调用自己的某些能力:

  • 在 Linux 上跑沙箱,需要一个专门的进程当沙箱启动器(因为 Landlock 规则一旦施加就不可撤销,必须在新进程里加)
  • 模型调用 apply_patch 时,某些路径下要以外部命令方式执行
  • shell 提权需要一个 execve 包装器

传统做法是分发多个可执行文件。但那样安装脚本、包管理器、更新逻辑全都要处理 N 个文件的版本一致性。

3.2 Codex 的解法

源码注释把动机写得很清楚 [源码 arg0/src/lib.rs:311]:

While we want to deploy the Codex CLI as a single executable for simplicity, we also want to expose some of its functionality as distinct CLIs, so we use the "arg0 trick" to determine which CLI to dispatch.

(我们希望为了简单起见把 Codex CLI 部署为单个可执行文件,但同时又想把它的一部分功能暴露成独立的 CLI,所以用「arg0 技巧」来决定分发到哪个 CLI。)

Unix 上,进程启动时 argv[0] 是调用者给的名字,不一定等于文件名。如果给同一个二进制建若干符号链接,用不同名字调用它,它就能通过 argv[0] 知道"这次我该扮演谁"。

实现是一个函数 [源码 arg0/src/lib.rs:60 arg0_dispatch()]:

rust
pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
    let mut args = std::env::args_os();
    let argv0 = args.next().unwrap_or_default();
    let exe_name = Path::new(&argv0).file_name()...;

    #[cfg(unix)]
    if exe_name == EXECVE_WRAPPER_ARG0 { /* 扮演 execve 包装器,不返回 */ }

    if exe_name == CODEX_LINUX_SANDBOX_ARG0 {
        codex_linux_sandbox::run_main();          // 扮演 Linux 沙箱启动器
    } else if exe_name == APPLY_PATCH_ARG0 || exe_name == MISSPELLED_APPLY_PATCH_ARG0 {
        codex_apply_patch::main();                // 扮演 apply_patch
    }

    // 第二级:看 argv[1]
    let argv1 = args.next().unwrap_or_default();
    if argv1 == CODEX_ARG0_EXEC_HELPER_ARG1 { codex_exec_server::run_arg0_exec_helper_main(); }
    if argv1 == CODEX_FS_HELPER_ARG1        { codex_exec_server::run_fs_helper_main(); }
    #[cfg(target_os = "windows")]
    if argv1 == CODEX_WINDOWS_SANDBOX_ARG1  { codex_windows_sandbox::run_windows_sandbox_wrapper_main(); }
    if argv1 == CODEX_CORE_APPLY_PATCH_ARG1 { /* 内部补丁应用路径 */ }

    // 都不是 → 正常启动
    load_dotenv();
    // …建符号链接目录、改 PATH…
}

两级判定:先看 argv[0](名字),再看 argv[1](第一个参数)。Windows 上没有符号链接的便利,所以走 argv[1] 这条路——注释里也直说了 arg0 技巧"在 Mac 和 Linux 上可用,Windows 不行"。

3.3 符号链接农场

光有分发逻辑还不够——得让这些名字在 PATH 里存在,模型才能直接 apply_patch 这样调。

Codex 的做法是:启动时建一个临时目录,在里面为每个别名建到自身的符号链接,把这个目录前插到 PATH,并用一个 guard 对象持有它直到进程结束 [源码 arg0/src/lib.rs:38 Arg0PathEntryGuard]:

rust
/// Keeps the per-session PATH entry alive and locked for the process lifetime.
pub struct Arg0PathEntryGuard {
    _temp_dir: TempDir,
    _lock_file: File,
    paths: Arg0DispatchPaths,
}

注意 _lock_file:临时目录里有个 .lock,防止多个 Codex 实例并发清理同一个目录。以及 guard 的生命周期管理 [源码 arg0/src/lib.rs:270]:

rust
let result = main_fn(paths).await;
// Keep the arg0 tempdir guard alive until the async entry point finishes;
// runtime paths above can point at aliases inside that directory.
drop(path_entry_guard);

如果 guard 提前析构,临时目录被删,正在跑的子进程就会拿到失效路径。这种细节是"产品级"和"能跑就行"的区别所在。

3.4 一个副产品:主线程栈

同一个文件里还藏着一个容易被忽略的工程细节 [源码 arg0/src/lib.rs:24, 232]:

rust
const TOKIO_WORKER_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024;

let handle = std::thread::Builder::new()
    .name("codex-main".to_string())
    .stack_size(TOKIO_WORKER_STACK_SIZE_BYTES)
    .spawn(move || { … })?;

注释解释了原因:

Run the async entry point on a thread with the same stack budget as Tokio workers; Runtime::block_on otherwise runs the top-level future on the caller's OS stack.

(在一个与 Tokio worker 栈预算相同的线程上运行异步入口点;否则 Runtime::block_on 会把顶层 future 跑在调用者的 OS 栈上。)

默认主线程栈 8 MB,Tokio worker 是配置出来的 16 MB。如果顶层 future 跑在主线程上,就会出现"同样的代码在 worker 上没事、在主线程上爆栈"的诡异 bug。他们的做法是手动 spawn 一个 16 MB 栈的线程,把整个 runtime 装进去

可迁移的判断 ① 把"我需要以子进程方式调用自己"设计成显式的分发入口,而不是四处 std::env::current_exe() 好处有三个:分发只有一个文件;子进程的行为是同一份代码,版本永远一致;所有"我在扮演谁"的判断集中在一个函数里,可读可测。

这一招不限于 Rust。任何需要 re-exec 自己的 CLI(构建工具、包管理器、sandbox runner)都可以用。


四、构建系统:Cargo 之外还有 Bazel

仓库根目录同时存在 Cargo.tomlMODULE.bazel / BUILD.bazelcodex-rs/ 下每个 crate 也有对应的 BUILD.bazel。另有 flake.nix(Nix)、justfile(任务运行器)、pnpm-workspace.yaml(TypeScript 侧)。

这说明两件事:

  1. CI 用的和开发者用的可能不是同一套。Bazel 提供远程构建缓存(rbe.bzl 就是 remote build execution 配置),对 147 万行 Rust 的 CI 时间是刚需
  2. 仓库不止 Rustsdk/ 下有 typescriptpython 两套 SDK,codex-cli/ 是 npm 分发壳

对读架构的人来说,Bazel 文件可以跳过。但它的存在本身是一个信号:这个项目的工程投入远超"开源一个 CLI"该有的量级


五、配置:从哪读,怎么合

config crate 24853 行,core/src/config/config_tests.rs 一个文件 12821 行——配置的测试比很多项目的全部代码都长

这个体量来自几个叠加维度:

  • 来源~/.codex/config.toml、项目级配置、~/.codex/.env、环境变量、命令行 -c key=value 覆盖
  • profile:一份配置里可以定义多个 profile,--profile 切换
  • 每轮可变:模型、沙箱策略、审批策略、cwd 都可以按轮次覆盖(第 3 章的 Op::UserTurn 携带完整的 per-turn context)
  • 特性开关features crate 管理灰度特性

.env 的加载时机也值得一提 [源码 arg0/src/lib.rs:156]:

rust
// This modifies the environment, which is not thread-safe, so do this
// before creating any threads/the Tokio runtime.
load_dotenv();

Rust 里 std::env::set_var 在多线程下是 unsafe 的。所以 .env 必须在 Tokio runtime 建立之前加载完——这个约束反过来决定了 arg0_dispatch() 必须在 main 的最前面调用。


六、这套骨架的代价

诚实地说三条:

① 上手成本极高。 102 个 crate、1359 个依赖,cargo build 首次编译在普通机器上要十几分钟。想改一行代码验证想法,反馈周期远长于 TypeScript 项目。

② 内部结构对用户封闭。 crate 之间大量使用 pub(crate)pub(super)。以第 4 章要讲的主循环为例,run_turn 的签名是 pub(crate) async fn ——你不能从外部调用它。想复用 Codex 的循环逻辑做自己的 agent?做不到,只能走 core-api(135 行)暴露的那点面,或者走协议层(第 3 章)。

③ 拆分粒度不等于可替换性。 这是它和 dsh 最本质的差别,值得重复:dsh 的 185 个包每个都对应一个"你可以换掉的位置",Codex 的 102 个 crate 对应的是"可以独立编译和测试的单元"。看到相似的数字不要推出相似的结论。


七、动手复核

bash
cd codex/codex-rs

# 1. workspace 成员数
grep -c '^    "' Cargo.toml

# 2. 所有独立二进制目标
grep -rn -A3 '^\[\[bin\]\]' */Cargo.toml | grep 'name ='

# 3. arg0 分发的全部别名常量
grep -n 'ARG0\|ARG1' arg0/src/lib.rs | head -20

# 4. core 内部分区规模
for d in core/src/*/; do
  n=$(find "$d" -name '*.rs' -print0 | xargs -0 cat | wc -l)
  echo "$n $(basename $d)"
done | sort -rn

# 5. 配置测试的体量
wc -l core/src/config/*.rs | sort -rn | head -5

验证 arg0 技巧(Linux/macOS):

bash
# 找到 codex 真实二进制
CODEX=$(readlink -f "$(which codex)")
# 用别名调用它 —— 行为完全不同
ln -sf "$CODEX" /tmp/apply_patch && /tmp/apply_patch --help

八、总结

  1. 102 个 crate 的拆分动机是编译时间、平台裁剪和独立二进制目标,不是可替换性——这是 Rust 项目特有的压力,别和 dsh 的插件化混为一谈
  2. arg0 技巧让一个文件扮演 7 个角色:两级判定(argv[0] 名字 + argv[1] 参数)、临时符号链接农场、带锁的生命周期 guard。这一招可以直接抄
  3. 工程投入远超"开源一个 CLI":Bazel + Nix + just + pnpm 四套构建工具,12821 行的配置测试
  4. 代价是封闭:主循环是 pub(crate),你用不了。想在 Codex 上做二次开发,唯一的正门是下一章的协议

下一章讲这扇正门:SQ / EQ 两条队列,以及为什么 core 完全不知道前端是谁。


  • 第1章-开篇-Codex-Harness总览
  • 第3章-协议先行-SQEQ队列与多前端
  • dsh 教程第 4 章 —— "拆分即可替换"的对照
  • README-教程总览

本章目录
一、先看地图:102 个 crate 分成七组二、拆成 102 个的真实动机三、arg0 技巧:一个二进制伪装成七个程序四、构建系统:Cargo 之外还有 Bazel五、配置:从哪读,怎么合六、这套骨架的代价七、动手复核八、总结Related Documents
苏ICP备2025204887号-2