unified_exec 5356 行、exec-server 48145 行、shell-command 6340 行。加起来 6 万行,只为了"跑一条命令"。本章解释这 6 万行在解决什么——每一千行都对应一个真实用户会遇到的坑。
unified_exec/mod.rs 顶部的文档注释是本章最好的导览 [源码 core/src/unified_exec/mod.rs:1]:
Unified Exec: interactive process execution orchestrated with approvals + sandboxing.
Responsibilities
- Manages interactive processes (create, reuse, buffer output with caps).
- Uses the shared ToolOrchestrator to handle approval, sandbox selection, and retry semantics in a single, descriptive flow.
- Spawns the PTY from a sandbox-transformed ExecRequest; on sandbox denial, retries without sandbox when policy allows (no re-prompt thanks to caching).
- Uses the shared is_likely_sandbox_denied heuristic to keep denial messages consistent with other exec paths.
Flow at a glance (open process)
- Build a small request { command, cwd }.
- Orchestrator: approval (bypass/cache/prompt) → select sandbox → run.
- Runtime: transform SandboxTransformRequest -> ExecRequest -> spawn PTY.
- If denial, orchestrator retries with SandboxType::None.
- Process handle is returned with streaming output + metadata.
四个关键词已经出现了:交互式进程、PTY、沙箱变换、拒绝后重试。
文件划分也写在注释里:
| 文件 | 行数 | 职责 |
|---|---|---|
| process.rs | 635 | PTY 进程生命周期 + 输出缓冲 |
| process_state.rs | 27 | 本地与远程进程共享的退出/失败状态 |
| process_manager.rs | 1621 | 编排:审批、沙箱、复用、请求处理 |
| async_watcher.rs | 468 | 异步监视 |
| head_tail_buffer.rs | 169 | 输出截断缓冲 |
| shell_snapshot.rs | 40 | shell 快照接入 |
最直接的答案:很多程序在不是终端的时候行为不一样。
如果 agent 用管道跑命令,它看到的输出和用户在终端看到的不是同一个东西。用户说"我看到红色的报错",agent 说"我没看到"——这类错位是 PTY 要解决的。
代价是 PTY 会带来 ANSI 转义序列(所以有 ansi-escape crate)、终端尺寸问题、以及进程不会因为你关掉管道就退出——于是有了下面的进程管理。
unified_exec 的名字里"unified"(统一)指的是:一次性命令和长驻交互进程用同一套接口。
这解决了一个非常实际的问题:
如果每条命令一个新进程,cd 和 source 的效果就丢了。传统 agent 的解法是让模型把所有东西塞进一条命令(bash -c "cd … && … && …"),但这在交互式场景下不行——python 交互式解释器、ssh 会话、docker exec -it 都需要保持进程存活并多次交互。
process_manager.rs 的 1621 行大部分在处理这个:进程标识、复用判定、生命周期、僵尸清理、超时。
BackgroundTerminalInfo 和 terminate_background_terminal 出现在 Session 的公开接口上 [源码 core/src/tasks/mod.rs:862]:
后台终端是会话级资源,UI 可以列出来、可以杀掉。协议里也有对应的 Op::CleanBackgroundTerminals(第 3 章)。
模型的上下文装不下 npm install 的完整输出。但简单截断(只留前 N 字节)会丢掉最重要的部分——错误信息通常在最后。
HeadTailBuffer 的解法 [源码 core/src/unified_exec/head_tail_buffer.rs:5]:
A capped buffer that preserves a stable prefix ("head") and suffix ("tail"), dropping the middle once it exceeds the configured maximum. The buffer is symmetric meaning 50% of the capacity is allocated to the head and 50% is allocated to the tail.
头一半、尾一半,中间丢掉,并记录丢了多少字节(omitted_bytes),最后插一个省略标记(format_output_omission_marker)。
三个实现细节值得注意:
可迁移的判断 ⑮ 给 agent 的长输出做截断时,头尾各留一半,并明确告诉模型中间省略了多少。
头部通常有命令回显、版本信息、开始的上下文;尾部通常有错误、结论、退出状态。中间是重复的进度输出。
明确标注省略量也很重要——模型看到"省略了 240 KB"会知道自己没拿到全貌,可能会换个方式再查(比如 tail -50 或 grep);如果悄悄截断,它会以为自己看到了全部。
这是 Codex 一个比较独特的做法。
用户在自己的终端里有:别名(alias gs='git status')、函数、PATH 里的自定义目录、nvm/pyenv 之类的版本管理器、setopt 之类的 shell 选项。
agent 起的进程是非交互 shell,默认不读 .zshrc / .bashrc。于是:
shell-command/src/shell_snapshot.rs 为四种 shell 各写了一段快照脚本 [源码]:
zsh 版本的脚本干这些事 [源码 shell-command/src/shell_snapshot.rs:38]:
加载用户的 rc,然后把结果状态导出成一个可重放的脚本。 之后 agent 跑命令时先 source 这个快照,就拥有了和用户一样的环境。
有一个专门的函数把导出变量和其余状态分开 [源码 shell-command/src/shell_snapshot.rs:24]:
为什么要分开?注释说了:执行器要在 shell profile 跑完之后再施加自己的环境策略。
比如沙箱要设 TMPDIR、网络代理要设 HTTP_PROXY、遥测要设 trace 头。如果这些和用户的 profile 混在一起重放,用户 profile 里的 export HTTP_PROXY=... 就会盖掉沙箱设的值。
还有一个 EXCLUDED_EXPORT_VARS 排除列表——不是所有环境变量都该被快照(比如上一次运行留下的 CODEX_* 变量)。
可迁移的判断 ⑯ 如果你的 agent 要在用户的机器上跑命令,就得决定"agent 的 shell 环境是干净的还是像用户的"。
- 选"干净":可复现,但用户会不断遇到 command not found
- 选"像用户":好用,但要处理 rc 加载、别名冲突、环境变量优先级
Codex 选了后者,并且把成本明确化了:四种 shell 各一段脚本、导出变量单独通道、排除列表。这个选择没有对错,但不能不选——不选的结果就是默认"干净"然后不停收 bug。
exec-server 48145 行,是仅次于 core / tui / app-server 的第四大 crate。它在干什么?
看模块列表就明白了 [源码 exec-server/src/lib.rs]:
执行被抽象成"环境"(environment),环境可以是本地的,也可以是远程的。
这解释了第 4 章 StepContext 里那些看着奇怪的字段:environments: TurnEnvironmentSnapshot、selected_capability_roots、executor_capability_discovery。也解释了第 3 章 EventMsg 里的 EnvironmentConnected / EnvironmentDisconnected,和 wait_for_environment 这个工具。
noise_channel.rs 的文档注释 [源码]:
Noise channel used by the remote exec-server relay.
The harness initiates hybrid IK and pins the exec-server static key returned by the registry. The first handshake message lets the exec-server authenticate the harness static key; the exec-server then asks the registry whether that key is authorized before completing the handshake.
"Hybrid" means the session keys include both X25519 and ML-KEM-768 key agreement. Once the two-message handshake finishes, AES-GCM protects the ordered transport records carrying JSON-RPC.
翻译:远程执行走 Noise 协议的 hybrid IK 握手,密钥协商同时用 X25519(经典椭圆曲线)和 ML-KEM-768(后量子格密码),传输用 AES-GCM。
在一个编码 agent 里做后量子密码。 这个投入水平说明远程执行不是实验特性,是要上生产的。
注意 local_file_system.rs / remote_file_system.rs / sandboxed_file_system.rs。第 6 章讲 AGENTS.md 加载时用的是 ExecutorFileSystem 而不是 std::fs [源码 core/src/agents_md.rs:26]:
连"读一个 AGENTS.md"都要经过执行器抽象,因为文件可能在远程环境里,也可能被沙箱管着。
这是全局一致性的代价:一旦决定"执行环境可以是远程的",所有涉及文件和进程的代码路径都得改造。48145 行里有相当一部分是这个改造的产物。
回到模块自述里的第 4 步:
- If denial, orchestrator retries with SandboxType::None. — on sandbox denial, retries without sandbox when policy allows (no re-prompt thanks to caching)
流程是:
两个点:
① is_likely_sandbox_denied 是启发式。 沙箱拒绝在不同平台上表现不同:Linux 上是 EACCES 或 EPERM,macOS Seatbelt 会在 stderr 打特定信息,Windows 又不一样。没有可靠的统一信号,只能猜。注释说这个启发式是"shared"(共享的),保证所有 exec 路径给出一致的拒绝消息。
② 缓存审批结果避免二次询问。 用户已经批准了这条命令,重试时不该再弹一次。这个缓存是第 11 章的内容。
命令执行相关的东西还散落在别处:
| crate | 行数 | 干什么 |
|---|---|---|
| shell-escalation | 2281 | 提权执行(codex-execve-wrapper 二进制,第 2 章的 arg0) |
| network-proxy | 18491 | 网络代理与策略(沙箱允许联网时,流量走这里过滤) |
| execpolicy | 2975 | Starlark 命令策略(第 11 章) |
| file-watcher | 1492 | 文件变化监视 |
| git-utils | 4328 | git 操作(turn diff、codex apply) |
| terminal-detection | 1515 | 检测终端类型与能力 |
| process-hardening | 193 | 进程加固 |
network-proxy 的 18491 行值得单独提一句:沙箱只能开关网络,不能按域名过滤(内核机制不理解 HTTP)。要做"只允许访问 npm registry"这种策略,就得在应用层起一个代理。第 11 章会讲它和网络审批的配合。
看一眼自己机器上的 shell 快照长什么样:
下一章进入边界:四个操作系统,四套沙箱实现。