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

第9章:命令执行 —— 快照、统一执行与后台终端

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

第9章:命令执行 —— 快照、统一执行与后台终端

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)

  1. Build a small request { command, cwd }.
  2. Orchestrator: approval (bypass/cache/prompt) → select sandbox → run.
  3. Runtime: transform SandboxTransformRequest -> ExecRequest -> spawn PTY.
  4. If denial, orchestrator retries with SandboxType::None.
  5. Process handle is returned with streaming output + metadata.

四个关键词已经出现了:交互式进程PTY沙箱变换拒绝后重试

文件划分也写在注释里:

文件行数职责
process.rs635PTY 进程生命周期 + 输出缓冲
process_state.rs27本地与远程进程共享的退出/失败状态
process_manager.rs1621编排:审批、沙箱、复用、请求处理
async_watcher.rs468异步监视
head_tail_buffer.rs169输出截断缓冲
shell_snapshot.rs40shell 快照接入

二、为什么是 PTY 而不是管道

最直接的答案:很多程序在不是终端的时候行为不一样。

  • git 不给你彩色输出,也不分页
  • npm / cargo 不显示进度条
  • 交互式程序(pythonpsqlssh)直接不工作
  • ls 不按列排版

如果 agent 用管道跑命令,它看到的输出和用户在终端看到的不是同一个东西。用户说"我看到红色的报错",agent 说"我没看到"——这类错位是 PTY 要解决的。

代价是 PTY 会带来 ANSI 转义序列(所以有 ansi-escape crate)、终端尺寸问题、以及进程不会因为你关掉管道就退出——于是有了下面的进程管理。


三、进程复用:长驻会话

unified_exec 的名字里"unified"(统一)指的是:一次性命令和长驻交互进程用同一套接口

text
create(新开一个进程)
   ↓
reuse(往已有进程里写输入、读输出)
   ↓
close(关掉)

这解决了一个非常实际的问题:

text
模型想跑:cd /project && source venv/bin/activate && pytest

如果每条命令一个新进程,cdsource 的效果就丢了。传统 agent 的解法是让模型把所有东西塞进一条命令(bash -c "cd … && … && …"),但这在交互式场景下不行——python 交互式解释器、ssh 会话、docker exec -it 都需要保持进程存活并多次交互。

process_manager.rs 的 1621 行大部分在处理这个:进程标识、复用判定、生命周期、僵尸清理、超时

BackgroundTerminalInfoterminate_background_terminal 出现在 Session 的公开接口上 [源码 core/src/tasks/mod.rs:862]:

rust
pub(crate) async fn list_background_terminals(&self) -> Vec<BackgroundTerminalInfo>
pub(crate) async fn terminate_background_terminal(&self, process_id: i32) -> bool

后台终端是会话级资源,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.

rust
pub(crate) struct HeadTailBuffer<const MAX_BYTES: usize = UNIFIED_EXEC_OUTPUT_MAX_BYTES> {
    head: Vec<u8>,
    tail: VecDeque<u8>,
    omitted_bytes: usize,
}

impl<const MAX_BYTES: usize> HeadTailBuffer<MAX_BYTES> {
    const HEAD_BUDGET: usize = MAX_BYTES / 2;
    const TAIL_BUDGET: usize = MAX_BYTES.saturating_sub(Self::HEAD_BUDGET);

头一半、尾一半,中间丢掉,并记录丢了多少字节omitted_bytes),最后插一个省略标记(format_output_omission_marker)。

三个实现细节值得注意:

  • head: Vec<u8> vs tail: VecDeque<u8> —— 头部只追加不删(Vec 够),尾部要滚动窗口(VecDeque 两端 O(1))
  • 常量泛型 const MAX_BYTES —— 容量是编译期常量,不同调用点可以有不同上限,零运行时开销
  • 按字节而不是按行 —— 一行可能有 100 MB(比如 minified JS 或者一个巨大的 JSON)

可迁移的判断 ⑮ 给 agent 的长输出做截断时,头尾各留一半,并明确告诉模型中间省略了多少。

头部通常有命令回显、版本信息、开始的上下文;尾部通常有错误、结论、退出状态。中间是重复的进度输出。

明确标注省略量也很重要——模型看到"省略了 240 KB"会知道自己没拿到全貌,可能会换个方式再查(比如 tail -50grep);如果悄悄截断,它会以为自己看到了全部。


五、Shell 快照:让 agent 的 shell 像用户的 shell

这是 Codex 一个比较独特的做法。

5.1 问题

用户在自己的终端里有:别名(alias gs='git status')、函数、PATH 里的自定义目录、nvm/pyenv 之类的版本管理器、setopt 之类的 shell 选项。

agent 起的进程是非交互 shell,默认不读 .zshrc / .bashrc。于是:

  • 用户说"跑 gs 看看",agent 说 command not found
  • 用户的 node 是 nvm 装的,agent 找不到
  • 用户的 python 指向虚拟环境,agent 用的是系统的

5.2 解法

shell-command/src/shell_snapshot.rs 为四种 shell 各写了一段快照脚本 [源码]:

rust
pub fn snapshot_script(shell_type: ShellType) -> Option<String> {
    match shell_type {
        ShellType::Zsh => Some(zsh_snapshot_script()),
        ShellType::Bash => Some(bash_snapshot_script()),
        ShellType::Sh => Some(sh_snapshot_script()),
        ShellType::PowerShell => Some(powershell_snapshot_script().to_string()),
        ShellType::Cmd => None,             // cmd 没有可快照的状态
    }
}

zsh 版本的脚本干这些事 [源码 shell-command/src/shell_snapshot.rs:38]:

zsh
if -n "$ZDOTDIR"; then rc="$ZDOTDIR/.zshrc"; else rc="$HOME/.zshrc"; fi
-r "$rc" && . "$rc"                    # 1. 加载用户的 rc
print '# Snapshot file'
print '# Unset all aliases to avoid conflicts with functions'
print 'unalias -a 2>/dev/null || true'       # 2. 先清空别名(避免和函数冲突)
print '# Functions'
functions                                     # 3. 导出所有函数定义
setopt | sed 's/^/setopt /'                  # 4. 导出所有 shell 选项
…

加载用户的 rc,然后把结果状态导出成一个可重放的脚本。 之后 agent 跑命令时先 source 这个快照,就拥有了和用户一样的环境。

5.3 环境变量单独处理

有一个专门的函数把导出变量和其余状态分开 [源码 shell-command/src/shell_snapshot.rs:24]:

rust
/// Captures shell state and a separate NUL-delimited exported environment.
///
/// Keeping exports outside the restorable script lets executors apply their
/// environment policy after shell profiles have run.
pub fn snapshot_state_and_environment_script(shell_type: ShellType) -> Option<String> {
    let script = snapshot_script(shell_type)?;
    let (state, _) = script.split_once(EXPORT_CAPTURE_MARKER)?;
    Some(format!("{state}printf '\\0'\n/usr/bin/env -0\n"))
}

为什么要分开?注释说了:执行器要在 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:把执行抽象成远程

exec-server 48145 行,是仅次于 core / tui / app-server 的第四大 crate。它在干什么?

看模块列表就明白了 [源码 exec-server/src/lib.rs]:

text
local_process.rs      remote_process.rs
local_file_system.rs  remote_file_system.rs
relay.rs  relay_proto.rs  forward.rs
noise_channel.rs  noise_relay.rs
environment.rs  environment_bootstrap.rs  environment_registry.rs
fs_sandbox.rs  process_sandbox.rs  sandboxed_file_open.rs
capability_discovery.rs

执行被抽象成"环境"(environment),环境可以是本地的,也可以是远程的。

这解释了第 4 章 StepContext 里那些看着奇怪的字段:environments: TurnEnvironmentSnapshotselected_capability_rootsexecutor_capability_discovery。也解释了第 3 章 EventMsg 里的 EnvironmentConnected / EnvironmentDisconnected,和 wait_for_environment 这个工具。

6.1 加密的远程通道

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 里做后量子密码。 这个投入水平说明远程执行不是实验特性,是要上生产的。

6.2 文件系统也走同一层

注意 local_file_system.rs / remote_file_system.rs / sandboxed_file_system.rs。第 6 章讲 AGENTS.md 加载时用的是 ExecutorFileSystem 而不是 std::fs [源码 core/src/agents_md.rs:26]:

rust
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::GetMetadataOptions;
use codex_exec_server::ReadFileOptions;

连"读一个 AGENTS.md"都要经过执行器抽象,因为文件可能在远程环境里,也可能被沙箱管着。

这是全局一致性的代价:一旦决定"执行环境可以是远程的",所有涉及文件和进程的代码路径都得改造。48145 行里有相当一部分是这个改造的产物。


七、沙箱拒绝后的重试

回到模块自述里的第 4 步:

  1. If denial, orchestrator retries with SandboxType::None. — on sandbox denial, retries without sandbox when policy allows (no re-prompt thanks to caching)

流程是:

text
在沙箱里跑 → 失败
    ↓
is_likely_sandbox_denied 启发式判断:这是沙箱拒绝还是命令本身错了?
    ↓ (是沙箱拒绝)
策略允许出沙箱吗?
    ↓ (允许)
不带沙箱重跑 —— 而且不再问一次用户(审批结果已缓存)

两个点:

is_likely_sandbox_denied 是启发式。 沙箱拒绝在不同平台上表现不同:Linux 上是 EACCESEPERM,macOS Seatbelt 会在 stderr 打特定信息,Windows 又不一样。没有可靠的统一信号,只能猜。注释说这个启发式是"shared"(共享的),保证所有 exec 路径给出一致的拒绝消息

② 缓存审批结果避免二次询问。 用户已经批准了这条命令,重试时不该再弹一次。这个缓存是第 11 章的内容。


八、还有多少没讲

命令执行相关的东西还散落在别处:

crate行数干什么
shell-escalation2281提权执行(codex-execve-wrapper 二进制,第 2 章的 arg0)
network-proxy18491网络代理与策略(沙箱允许联网时,流量走这里过滤)
execpolicy2975Starlark 命令策略(第 11 章)
file-watcher1492文件变化监视
git-utils4328git 操作(turn diff、codex apply
terminal-detection1515检测终端类型与能力
process-hardening193进程加固

network-proxy 的 18491 行值得单独提一句:沙箱只能开关网络,不能按域名过滤(内核机制不理解 HTTP)。要做"只允许访问 npm registry"这种策略,就得在应用层起一个代理。第 11 章会讲它和网络审批的配合。


九、动手复核

bash
cd codex/codex-rs

# 1. 模块自述(本章的导览)
sed -n '1,25p' core/src/unified_exec/mod.rs

# 2. 头尾缓冲
sed -n '1,40p' core/src/unified_exec/head_tail_buffer.rs

# 3. 四种 shell 的快照脚本
sed -n '1,60p' shell-command/src/shell_snapshot.rs

# 4. 远程执行的加密握手
sed -n '1,12p' exec-server/src/noise_channel.rs

# 5. exec-server 的模块地图(本地/远程成对出现)
sed -n '1,45p' exec-server/src/lib.rs

# 6. 执行相关 crate 的总账
for c in exec-server shell-command shell-escalation network-proxy execpolicy; do
  echo -n "$c: "; find $c -name '*.rs' -print0 | xargs -0 cat | wc -l
done

看一眼自己机器上的 shell 快照长什么样:

bash
codex debug --help    # 有若干调试子命令

十、总结

  1. 用 PTY 不用管道,因为很多程序在非终端下行为不同。代价是 ANSI 处理、终端尺寸、进程生命周期
  2. 进程可复用:一次性命令和长驻交互进程走同一套接口,后台终端是会话级资源,UI 可列可杀
  3. 输出截断头尾各一半,并告诉模型省略了多少字节——错误通常在尾部,上下文通常在头部
  4. Shell 快照让 agent 的环境像用户的环境:加载 rc、导出函数与选项、环境变量走单独通道以便执行器后置施加自己的策略
  5. 执行被抽象成"环境",可以是远程的——远程通道用 Noise hybrid IK(X25519 + ML-KEM-768 后量子)+ AES-GCM。连读文件都走这层抽象
  6. 沙箱拒绝后按策略降级重试,靠启发式判断"是不是沙箱拒的",靠审批缓存避免二次询问
  7. 6 万行的真相:PTY、进程复用、输出截断、shell 环境、远程执行、沙箱降级——每一项都对应一类真实的用户投诉

下一章进入边界:四个操作系统,四套沙箱实现。


  • 第8章-工具系统-注册路由与延迟加载
  • 第10章-沙箱-四个操作系统四套实现
  • 第11章-审批与策略-从静态规则到模型判官 —— 审批缓存与网络策略
  • Pi 教程第 5 章 —— 无沙箱设计的对照

本章目录
一、先看模块的自述二、为什么是 PTY 而不是管道三、进程复用:长驻会话四、输出截断:头尾保留五、Shell 快照:让 agent 的 shell 像用户的 shell六、exec-server:把执行抽象成远程七、沙箱拒绝后的重试八、还有多少没讲九、动手复核十、总结Related Documents
苏ICP备2025204887号-2