# 执行命令 · shell 与 unified exec

> 0.158.0 里模型执行命令只剩 unified exec 一条路：exec_command 起进程、write_stdin 续写或轮询。命令经处理器解析成 shell 参数，由编排器决定审批与沙箱，再以 PTY 或管道启动；活着的进程留在进程表里跨轮复用，输出先在 1 MiB 的头尾缓冲里收集，再按 token 预算截断给模型，同时以增量事件流给界面。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/unified-exec
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 执行命令 · shell 与 unified exec

[上一篇](https://daiw.net/manual/codex-source/tool-architecture)画的地图里，`exec_command` 是最常被调用、链条也最长的工具。这一篇从模型发出调用开始，一直跟到进程启动、输出回流。

## 用户看到的样子

模型跑命令时，界面上出现一个命令条目，输出实时滚动。命令在等待时间（默认 10 秒）内没结束，模型会先拿到部分输出和一个会话 ID，之后用 `write_stdin` 轮询或输入；这些还在跑的进程就是“后台终端”，`/ps` 列出它们，`/stop` 全部停掉（见[斜杠命令](https://daiw.net/manual/codex/slash-commands)）。相关配置有三项：`background_terminal_max_timeout`（空轮询最长等多久，默认 300000 毫秒）、`allow_login_shell`（默认允许以登录 shell 启动）、`[shell_environment_policy]`（子进程能看到哪些环境变量，见[配置](https://daiw.net/manual/codex/configuration)）。输入框里行首的 `!` 也能执行本地命令，但那是另一条路径，本篇最后再说。

0.158.0 里 shell 工具只剩这一种实现：模型目录里的 `ConfigShellToolType` 只有 `UnifiedExec` 与 `Disabled` 两个值，旧写法 `default`、`local`、`shell_command` 都是 `UnifiedExec` 的别名（`codex-rs/protocol/src/openai_models.rs:314`）。`unified_exec` 特性默认开启；按注释，只有托管要求能把它关掉，这时注册的是一次性版本的 `exec_command`：没有 `tty`、`yield_time_ms`，改用 `timeout_ms`（默认 10000 毫秒），也没有 `write_stdin`。

## 两个工具的参数

| 工具 | 参数 | 默认值与范围 |
| --- | --- | --- |
| `exec_command` | `cmd`（必填） | 交给 shell 执行的命令字符串 |
| | `workdir` | 相对路径按所选环境的 cwd 解析 |
| | `tty` | 默认 `false`：普通管道，stdin 直接关闭 |
| | `yield_time_ms` | 默认 10000，钳在 250 到 30000 之间；Windows 下限 10000 |
| | `max_output_tokens` | 默认 10000 |
| | `shell`、`login`、`environment_id` | 视配置出现：换 shell、关掉登录 shell、多环境时选环境 |
| | `sandbox_permissions`、`justification`、`prefix_rule` | 申请提权，见[审批流程](https://daiw.net/manual/codex-source/approvals-flow) |
| `write_stdin` | `session_id`（必填） | `exec_command` 返回的会话 ID |
| | `chars` | 默认空串，表示只轮询不写入 |
| | `yield_time_ms` | 写入时默认 250、上限 30000；空轮询至少 5000，上限取 `background_terminal_max_timeout` |

<Callout type="info">
  `exec_command` 的描述写的是 “Runs a command in a PTY”，但 `tty` 参数的默认值是 `false`（`codex-rs/core/src/tools/handlers/unified_exec.rs:70`）。不显式要终端时，命令跑在普通管道上，stdin 一开始就是关闭的；需要交互输入的程序，模型得显式传 `tty: true`。
</Callout>

## 从工具调用到进程启动

```mermaid
flowchart TB
  H[ExecCommandHandler::handle_call<br/>解析参数 · 选环境与 cwd] --> GC[get_command<br/>shell 派生 argv]
  GC --> IP{intercept_apply_patch<br/>是不是补丁命令}
  IP -->|是| AP[转交 apply_patch 流程]
  IP -->|否| ID[allocate_process_id<br/>随机 1000 到 99999]
  ID --> OS[open_session_with_sandbox<br/>组环境变量 · 查执行策略]
  OS --> OR[ToolOrchestrator::run<br/>审批 · 选沙箱 · 重试]
  OR --> RT[UnifiedExecRuntime::run<br/>shell 快照包装 · 网络代理]
  RT --> TF[SandboxAttempt::env_for<br/>套上沙箱命令]
  TF --> SP{本地还是远程}
  SP -->|本地| LS[codex_sandboxing::spawn_process<br/>tty 用 PTY 否则用管道]
  SP -->|远程或快照 v2| XS[exec-server 后端 start]
  LS & XS --> UP[UnifiedExecProcess]
```

处理器先把 `cmd` 变成 argv：bash、zsh、sh 是 `[shell, "-lc", cmd]`（关掉登录 shell 时是 `-c`），PowerShell 是 `-Command`（非登录时多一个 `-NoProfile`），cmd 是 `/c`（`codex-rs/core/src/shell.rs:22`）。因为 `allow_login_shell` 默认开，命令默认以登录 shell 启动，每次都要读一遍 rc 文件，这正是[下一篇](https://daiw.net/manual/codex-source/shell-command-analysis)要讲的 shell 快照存在的原因。接着 `intercept_apply_patch` 检查这条命令是不是模型把补丁写进了 shell（见 [apply_patch](https://daiw.net/manual/codex-source/apply-patch)），是的话直接转去打补丁，不起进程。

`open_session_with_sandbox` 组装子进程环境：先按 `shell_environment_policy` 过滤，再注入 `CODEX_THREAD_ID` 等标识，最后盖上一组固定值，关掉颜色、分页器这类面向人的输出：

```rust
const UNIFIED_EXEC_ENV: [(&str, &str); 10] = [
    ("NO_COLOR", "1"),
    ("TERM", "dumb"),
    ("LANG", "C.UTF-8"),
    ("LC_CTYPE", "C.UTF-8"),
    ("LC_ALL", "C.UTF-8"),
    ("COLORTERM", ""),
    ("PAGER", "cat"),
    ("GIT_PAGER", "cat"),
    ("GH_PAGER", "cat"),
    ("CODEX_CI", "1"),
];
```

（`codex-rs/core/src/unified_exec/process_manager.rs:93`）

同一个函数里还会向执行策略要一个 `ExecApprovalRequirement`（跳过、需要批准或禁止，规则见[执行策略](https://daiw.net/manual/codex-source/execpolicy)），连同请求一起交给 `ToolOrchestrator`。编排器驱动的 `UnifiedExecRuntime` 把 `-lc` 命令包进 shell 快照、准备网络代理要用的环境变量，再用 `SandboxAttempt::env_for` 把命令变换成带沙箱包装的 `ExecRequest`。最终落到 `codex-sandboxing` 的 `spawn_process`：

```rust
    let spawned = if tty {
        codex_utils_pty::pty::spawn_process(
            program,
            args,
            request.cwd,
            request.env,
            request.arg0,
            TerminalSize::default(),
            request.inherited_fds,
        )
        .await
    } else if request.stdin_open {
        codex_utils_pty::pipe::spawn_process(
            // ...
        )
        .await
    } else {
        codex_utils_pty::pipe::spawn_process_no_stdin(
```

（`codex-rs/sandboxing/src/spawn.rs:109`）

unified exec 调用时传的 `stdin_open` 就等于 `tty`，所以只有两种形态：带 PTY（默认 24 行 80 列，底层是 `portable-pty`，Windows 上是 ConPTY）的交互进程，和 stdin 关闭的管道进程。`codex-utils-pty` 把两者统一成同一个 `ProcessHandle`，stdout 与 stderr 合并成一个广播通道。环境是远程执行器，或者启用了仍在开发中的 `shell_snapshot_v2` 时，进程改由 exec-server 后端启动（见 [exec-server](https://daiw.net/manual/codex-source/exec-server)），上层拿到的仍是同一个 `UnifiedExecProcess`。

## 交互式会话：续写与轮询

`exec_command` 起了进程之后，并不等它结束：

```rust
        start_streaming_output(&process, context);
        let start = Instant::now();
        // Persist live sessions before the initial yield wait so interrupting the
        // turn cannot drop the last Arc and terminate the background process.
        let process_started_alive = !process.has_exited() && process.exit_code().is_none();
        let mut initial_exec_command_guard = if process_started_alive {
            let initial_exec_command_active = Arc::new(AtomicBool::new(true));
            self.store_process(
                // ...
            )
            .await;
            // ...
        let yield_time_ms = clamp_yield_time(request.yield_time_ms);
```

（`codex-rs/core/src/unified_exec/process_manager.rs:599`）

还活着的进程先登记进进程表，再等 `yield_time_ms` 收集输出。到点还没退出，返回的文本里就带着 `Process running with session ID 12345`，模型用这个 ID 调 `write_stdin`；退出了则是 `Process exited with code 0`。进程表是会话级的，跨轮保留，最多 64 个（`MAX_UNIFIED_EXEC_PROCESSES`）；满了就淘汰，最近用过的 8 个受保护，其余按最久未用的顺序，先淘汰已退出的、再淘汰还在跑的。会话关闭时全部终止。

`write_stdin` 的几条规则都写在 `write_stdin_inner` 里：

- 同一个终端的读写互斥（每个进程一把 `interaction_lock`），不同终端之间可以并行；
- 非 TTY 进程的 stdin 已经关闭，唯一允许的输入是 `\u{3}`（Ctrl+C），会被转成中断信号，写别的内容直接报 `StdinClosed`；
- TTY 进程写入后先睡 100 毫秒，让进程来得及反应，再开始收集输出；
- `write_stdin_approval` 特性（默认开）会比较终端启动时的权限快照与当前策略：终端当初是提权启动的、带着额外授权、或者策略已经变了，再往里写字就要重新审批；待审内容连同理由超过 8000 字节时直接拒绝，注释的说法是绝不执行“没审过的尾巴”。

## 输出：两级截断与流式事件

进程的输出同时流向两处。一处是界面：`start_streaming_output` 在 UTF-8 边界上切块，发 `ExecCommandOutputDelta` 事件，每块不超过 8192 字节、每次调用最多 10000 个事件；后台进程退出时，`spawn_exit_watcher` 再补一个带汇总输出的结束事件。另一处是模型：输出先进 `HeadTailBuffer`，容量 1 MiB，前后各留一半，中间丢掉的部分换成 `... N bytes omitted ...`；返回前再按 token 预算截一次，预算取 `max_output_tokens`（默认 10000）与模型目录里截断策略中较小的那个，超了就加一行 `Warning: truncated output` 并从中间截断。模型最终看到的文本由下面这段拼出头部：

```rust
    fn response_header(&self) -> String {
        let mut sections = Vec::new();

        if !self.chunk_id.is_empty() {
            sections.push(format!("Chunk ID: {}", self.chunk_id));
        }

        let wall_time_seconds = self.wall_time.as_secs_f64();
        sections.push(format!("Wall time: {wall_time_seconds:.4} seconds"));

        if let Some(exit_code) = self.exit_code {
            sections.push(format!("Process exited with code {exit_code}"));
        }

        if let Some(process_id) = &self.process_id {
            sections.push(format!("Process running with session ID {process_id}"));
        }
```

（`codex-rs/core/src/tools/context.rs:524`）

注意退出码只写在文本里：只要进程正常启动，`exec_command` 的工具输出就标记为成功，命令失败与否由模型读文本判断。收集循环还照顾了一个细节：会话在等用户回应（命令或补丁审批、`request_user_input`、MCP elicitation 等）期间，截止时间按暂停时长顺延，不会因为用户还没点按钮就把等待时间耗光。

## `exec.rs`：一次性执行的老路径

`codex-rs/core/src/exec.rs` 是另一套执行代码：起进程、按截止时间或取消信号收尾（超时退出码按惯例记为 124）、stdout 与 stderr 各留最多 1 MiB。它不服务模型的工具调用，而是给用户在输入框里用 `!` 执行的命令（`codex-rs/core/src/tasks/user_shell.rs`，默认超时 1 小时）与 zsh-fork 提权路径使用；app-server 的 `command/exec` 请求也借用其中的 `build_exec_request` 组装沙箱命令。

## 和《从 LLM 到 Coding Agent》对照

[工具调用](https://daiw.net/manual/llm-to-agent/tool-use)里的教学实现，执行工具就是一次 `await`：跑完拿到结果再回填。Codex 的 unified exec 把它拆成“启动 + 轮询”：一次 `exec_command` 最多等 30 秒，长任务变成可续写的会话，模型可以边等边做别的事；`exec_command` 与 `write_stdin` 都声明了可并行，[边流边执行](https://daiw.net/manual/llm-to-agent/streaming-tool-execution)讲的并发在这里照样成立。输出过长时，[上下文压缩](https://daiw.net/manual/llm-to-agent/context-compaction)一篇的做法是把全文存盘、只给模型预览加路径；Codex 不存盘，而是先用 1 MiB 的头尾缓冲兜住内存，再按 token 预算保留头尾、截掉中间。

---

上一篇：[工具系统总览 · 暴露、注册、路由与并行](https://daiw.net/manual/codex-source/tool-architecture) · 下一篇：[读懂一条命令 · 解析、安全判断与 shell 快照](https://daiw.net/manual/codex-source/shell-command-analysis)
