本章回答两个问题:这 102 个 crate 按什么逻辑组织,以及一个可执行文件怎么同时扮演 7 个不同的程序。第二个问题的答案(arg0 技巧)是本章最值得抄的一招。
codex-rs/ 是一个 Cargo workspace,Cargo.toml 里列了 102 个成员 [源码 codex-rs/Cargo.toml]。按职责可以归成七组:
| crate | 行数 | 职责 |
|---|---|---|
| core | 332152 | 会话、循环、工具、上下文、压缩、安全决策 |
| core-api | 135 | 对外暴露的最小 API 面 |
| core-plugins | 42435 | 插件加载、MCP 路由、市场、命令迁移 |
| protocol | 26045 | SQ/EQ 协议类型定义 |
| config | 24853 | 配置加载与合并 |
| state | 21920 | 运行时状态 |
core 一个 crate 占了引擎的一大半。这里要提醒一句:它内部是有清晰分区的,不是一个 33 万行的泥球——
| crate | 行数 | 职责 |
|---|---|---|
| tui | 277181 | 终端界面 |
| cli | 32059 | 命令行解析与子命令分发 |
| exec | 11027 | 无头执行模式 |
| app-server + -protocol + -transport + -client + -daemon | 199943 | 给 IDE / 桌面 App 的 JSON-RPC 服务 |
| mcp-server / codex-mcp / rmcp-client | 48313 | MCP 的服务端与客户端两侧 |
| cloud-tasks 系列 | 6635 | 云端任务 |
exec-server(48145)、shell-command(6340)、shell-escalation(2281)、apply-patch(5929)、file-system、file-watcher、file-search、git-utils。
sandboxing(8357,统一抽象)、linux-sandbox(9694)、windows-sandbox-rs(19852)、bwrap(151)、execpolicy(2975)、network-proxy(18491)、process-hardening、secrets、keyring-store。
codex-api、model-provider、model-provider-info、models-manager、http-client、websocket-client、responses-api-proxy、backend-client、ollama、lmstudio、prompts。
rollout(14835)、rollout-trace、thread-store(29563)、history、message-history、memories、otel、analytics、feedback、diagnostics。
ext/ 下 14 个内部扩展 + plugin + skills + hooks + connectors + code-mode 四件套。
读源码的入口建议 不要从 core/src/lib.rs 开始读——它主要是 pub use 转发。从 core/src/session/turn.rs:153 的 run_turn 开始,那是整个系统的主循环,顺着它往外读,会自然经过上下文、工具、压缩、钩子、安全五个子系统。第 4 章就是这么组织的。
dsh 拆成 185 个包,动机是可替换(第 4 章 seam 架构)。Codex 拆成 102 个 crate,动机完全不同,有三条:
Rust 的增量编译以 crate 为单位。core 有 33 万行,改一行就要重编整个 crate;如果所有代码都在 core 里,那就是 147 万行的重编。把 tui、app-server、沙箱、协议拆出去之后,改 TUI 不触发 core 重编,改 core 不触发 TUI 重编。
这是 Rust 项目特有的拆分压力,TypeScript 项目(Pi、dsh)感受不到。所以不要把"Codex 拆了 102 个"和"dsh 拆了 185 个"当成同一类现象。
有些 crate 只在特定平台编译。linux-sandbox 只在 Linux,windows-sandbox-rs 只在 Windows,bwrap 只在有 bubblewrap 的 Linux。拆成独立 crate 后,条件编译写在依赖声明里而不是散在代码中的 #[cfg] 里。
这是最有意思的一条,也是下一节的主题。16 个 crate 一共声明了 21 个 bin 目标 [源码,各 Cargo.toml],其中和运行时分发直接相关的是这些:
| 二进制名 | 来源 crate | 作用 |
|---|---|---|
| codex | cli | 主程序 |
| apply_patch | apply-patch | 独立的补丁应用工具 |
| codex-execpolicy | execpolicy | 执行策略调试 |
| codex-execve-wrapper | shell-escalation | 提权执行包装器 |
| codex-code-mode-host | code-mode-host | Code Mode 宿主 |
| codex-responses-api-proxy | responses-api-proxy | Responses API 代理 |
| codex-windows-sandbox-setup | windows-sandbox-rs | Windows 沙箱环境准备 |
| codex-command-runner | windows-sandbox-rs | Windows 沙箱内命令执行器 |
| bwrap | bwrap | 内置的 bubblewrap |
| logs_client | cli | 日志客户端 |
问题来了:分发的时候只有一个 codex 可执行文件。这些东西去哪了?
Codex 需要在运行时以子进程方式调用自己的某些能力:
传统做法是分发多个可执行文件。但那样安装脚本、包管理器、更新逻辑全都要处理 N 个文件的版本一致性。
源码注释把动机写得很清楚 [源码 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()]:
两级判定:先看 argv[0](名字),再看 argv[1](第一个参数)。Windows 上没有符号链接的便利,所以走 argv[1] 这条路——注释里也直说了 arg0 技巧"在 Mac 和 Linux 上可用,Windows 不行"。
光有分发逻辑还不够——得让这些名字在 PATH 里存在,模型才能直接 apply_patch 这样调。
Codex 的做法是:启动时建一个临时目录,在里面为每个别名建到自身的符号链接,把这个目录前插到 PATH,并用一个 guard 对象持有它直到进程结束 [源码 arg0/src/lib.rs:38 Arg0PathEntryGuard]:
注意 _lock_file:临时目录里有个 .lock,防止多个 Codex 实例并发清理同一个目录。以及 guard 的生命周期管理 [源码 arg0/src/lib.rs:270]:
如果 guard 提前析构,临时目录被删,正在跑的子进程就会拿到失效路径。这种细节是"产品级"和"能跑就行"的区别所在。
同一个文件里还藏着一个容易被忽略的工程细节 [源码 arg0/src/lib.rs:24, 232]:
注释解释了原因:
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.toml 和 MODULE.bazel / BUILD.bazel,codex-rs/ 下每个 crate 也有对应的 BUILD.bazel。另有 flake.nix(Nix)、justfile(任务运行器)、pnpm-workspace.yaml(TypeScript 侧)。
这说明两件事:
对读架构的人来说,Bazel 文件可以跳过。但它的存在本身是一个信号:这个项目的工程投入远超"开源一个 CLI"该有的量级。
config crate 24853 行,core/src/config/ 里 config_tests.rs 一个文件 12821 行——配置的测试比很多项目的全部代码都长。
这个体量来自几个叠加维度:
.env 的加载时机也值得一提 [源码 arg0/src/lib.rs:156]:
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 对应的是"可以独立编译和测试的单元"。看到相似的数字不要推出相似的结论。
验证 arg0 技巧(Linux/macOS):
下一章讲这扇正门:SQ / EQ 两条队列,以及为什么 core 完全不知道前端是谁。