执行命令 · shell 与 unified exec
0.158.0 里模型执行命令只剩 unified exec 一条路:exec_command 起进程、write_stdin 续写或轮询。命令经处理器解析成 shell 参数,由编排器决定审批与沙箱,再以 PTY 或管道启动;活着的进程留在进程表里跨轮复用,输出先在 1 MiB 的头尾缓冲里收集,再按 token 预算截断给模型,同时以增量事件流给界面。
执行命令 · shell 与 unified exec
上一篇画的地图里,exec_command 是最常被调用、链条也最长的工具。这一篇从模型发出调用开始,一直跟到进程启动、输出回流。
用户看到的样子
模型跑命令时,界面上出现一个命令条目,输出实时滚动。命令在等待时间(默认 10 秒)内没结束,模型会先拿到部分输出和一个会话 ID,之后用 write_stdin 轮询或输入;这些还在跑的进程就是“后台终端”,/ps 列出它们,/stop 全部停掉(见斜杠命令)。相关配置有三项:background_terminal_max_timeout(空轮询最长等多久,默认 300000 毫秒)、allow_login_shell(默认允许以登录 shell 启动)、[shell_environment_policy](子进程能看到哪些环境变量,见配置)。输入框里行首的 ! 也能执行本地命令,但那是另一条路径,本篇最后再说。
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 | 申请提权,见审批流程 | |
write_stdin | session_id(必填) | exec_command 返回的会话 ID |
chars | 默认空串,表示只轮询不写入 | |
yield_time_ms | 写入时默认 250、上限 30000;空轮询至少 5000,上限取 background_terminal_max_timeout |
exec_command 的描述写的是 “Runs a command in a PTY”,但 tty 参数的默认值是 false(codex-rs/core/src/tools/handlers/unified_exec.rs:70)。不显式要终端时,命令跑在普通管道上,stdin 一开始就是关闭的;需要交互输入的程序,模型得显式传 tty: true。
从工具调用到进程启动
处理器先把 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 文件,这正是下一篇要讲的 shell 快照存在的原因。接着 intercept_apply_patch 检查这条命令是不是模型把补丁写进了 shell(见 apply_patch),是的话直接转去打补丁,不起进程。
open_session_with_sandbox 组装子进程环境:先按 shell_environment_policy 过滤,再注入 CODEX_THREAD_ID 等标识,最后盖上一组固定值,关掉颜色、分页器这类面向人的输出:
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(跳过、需要批准或禁止,规则见执行策略),连同请求一起交给 ToolOrchestrator。编排器驱动的 UnifiedExecRuntime 把 -lc 命令包进 shell 快照、准备网络代理要用的环境变量,再用 SandboxAttempt::env_for 把命令变换成带沙箱包装的 ExecRequest。最终落到 codex-sandboxing 的 spawn_process:
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),上层拿到的仍是同一个 UnifiedExecProcess。
交互式会话:续写与轮询
exec_command 起了进程之后,并不等它结束:
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 并从中间截断。模型最终看到的文本由下面这段拼出头部:
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》对照
工具调用里的教学实现,执行工具就是一次 await:跑完拿到结果再回填。Codex 的 unified exec 把它拆成“启动 + 轮询”:一次 exec_command 最多等 30 秒,长任务变成可续写的会话,模型可以边等边做别的事;exec_command 与 write_stdin 都声明了可并行,边流边执行讲的并发在这里照样成立。输出过长时,上下文压缩一篇的做法是把全文存盘、只给模型预览加路径;Codex 不存盘,而是先用 1 MiB 的头尾缓冲兜住内存,再按 token 预算保留头尾、截掉中间。