Skip to content

Process Executor

本课交付结果

你将交付 runProcess(command, options),结构化返回 stdout、stderr、退出码、超时、取消和截断状态;进程使用干净环境并按 UTF-8 字节限制输出。

岗位问题

命令即使获准,也可能无限运行、打印海量日志或读取父进程密钥。直接继承环境和无界缓冲会把一次测试变成资源与凭据事故。

前置检查

前置知识快照

确认 Guard 通过:pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 12。理解进程退出码、signal、stdout/stderr 流和 AbortSignal。

Guard 回答“这个结构化命令在策略上能否进入执行”,Executor 回答“进入后最多能获得多少时间、输出与环境能力”。允许的命令仍可能卡死、输出无限日志、读取环境密钥或留下子进程。安全分类不能替代资源边界。

Node 的 spawn 接收 executable 和 argv,不需要 shell;close 表示进程退出且 stdio 已关闭,适合形成最终结果;error 表示进程根本无法启动。非零退出码是正常进程结果,不能与启动失败、超时或取消混成一种异常。

原理拆解

固定 executable 与 argv 交给 spawn,始终 shell: false;只传显式环境;流式累计字节并截断;超时或取消发送终止信号;close 时统一形成结果。非零退出码是数据,不是未捕获异常。

mermaid
flowchart TD
  A[结构化 command + budgets] --> B{signal 已取消?}
  B -- 是 --> C[不创建进程,返回 aborted]
  B -- 否 --> D[spawn shell:false + 干净 env]
  D --> E[stdout/stderr 共享字节计数]
  D --> F[timeout timer]
  D --> G[abort listener]
  E --> H{超过预算?}
  H -- 是 --> I[截断并标记]
  F -- 到期 --> J[终止进程]
  G -- 取消 --> J
  D --> K[close]
  K --> L[清 timer/listener]
  L --> M[结构化 ProcessResult]

预取消要在创建进程前处理。若 signal 已经 aborted 仍先 spawn 再 kill,哪怕只运行几毫秒也可能完成写文件或发网络请求。返回 {aborted:true, exitCode:null} 是零副作用路径,测试应通过夹具证明脚本从未输出。

环境默认从空对象构造,而不是展开 process.env。父进程常包含 API key、云凭据、代理、配置路径和调试开关;测试脚本或仓库命令都不应自动继承。产品根据需要显式加入最小 PATH、HOME 临时目录与语言运行变量,并对值脱敏审计。

stdout 与 stderr 必须共享总预算。若各自拥有完整上限,命令可以同时打印两倍数据;若只限制 stdout,错误流可绕过。收集器根据当前 used 计算剩余房间,只复制能容纳的 Buffer slice,并在任何剩余字节被丢弃时设置 truncated

按字节而不是 JavaScript 字符计数,因为流提供 Buffer,操作系统与内存成本也是字节。一个中文字符通常占多个 UTF-8 字节;先转字符串再按 length 截断会低估成本,也可能把多字节序列切成无效文本。课程实现强调硬上限,生产实现还应在最终解码时处理残缺尾字节。

超时与取消都终止进程,但原因不同。超时来自系统 wall-clock 预算,取消来自用户或上层会话;结果分别保留 timedOutaborted,让 Agent、UI 和 Trace 采取不同动作。两者发生时 exitCode 设为 null,避免把 kill signal 伪装成程序自身退出码。

timeoutMs: 0 在 Lab 中表示不设置计时器,而不是立即超时;预算校验只拒绝负数。生产合同也可以定义零为立即截止,但必须明确并测试。数字边界若含糊,会让相同配置在不同执行器中产生相反行为。

代码实验

失败实现

下面的便捷实现继承环境、缓冲无界输出且启用 shell:

ts
async function unsafe(command: string) {
  return exec(command, { env: process.env, shell: true });
}

核心安全实现保持 argv,建立共享采集器和生命周期清理:

ts
const child = spawn(command.executable, command.args, {
  cwd: options.cwd,
  env: { ...options.env },
  shell: false,
  stdio: ["ignore", "pipe", "pipe"],
});

let used = 0;
let truncated = false;
const collect = (chunk: Buffer): Buffer => {
  const room = Math.max(0, options.maxOutputBytes - used);
  const slice = chunk.subarray(0, room);
  used += slice.length;
  if (slice.length < chunk.length) truncated = true;
  return slice;
};

stdin 设为 ignore,避免子进程等待交互输入;两条输出流分别保存,预算统一计算。不要在超限后继续 Buffer.concat 空切片,生产实现可暂停读取或终止过度输出进程,进一步降低 CPU 消耗。

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/13-process-executor/starter test
pnpm --dir bootcamps/coding-agent/labs/13-process-executor/solution test

Lab 仅用 process.execPath 启动本地无害夹具。Starter 声明 实现超时与输出上限;Solution 应通过 6 项测试。

环境隔离测试先在父进程设置秘密,再让子进程读取:

ts
it("不继承父进程秘密", async () => {
  process.env.LAB_SECRET = "hidden";
  try {
    const result = await runProcess(
      { executable: process.execPath, args: ["-e", 'process.stdout.write(process.env.LAB_SECRET ?? "clean")'] },
      { timeoutMs: 1000, maxOutputBytes: 1024 },
    );
    expect(result.stdout).toBe("clean");
  } finally {
    delete process.env.LAB_SECRET;
  }
});

关键实现讲解

预取消信号应在创建子进程前返回。stdout 与 stderr 共享总字节预算,防止从另一条流绕过。超时和取消把 exitCode 设为 null,并分别保留 timedOutaborted 证据。

监听器和计时器必须在所有完成路径清理。进程正常退出后遗留 abort listener,会在长运行服务中累计并产生内存泄漏警告;计时器未清理则可能在已完成后 kill 复用的对象或让事件循环延迟退出。启动 error 路径同样需要清理,而课程最小实现可在进阶作业中补全统一 finalize。

直接 SIGKILL 简单确定,却不给进程清理机会。生产执行通常先发送 SIGTERM,等待短暂 grace period,再升级为 SIGKILL;同时终止整个进程组,避免测试启动的孙进程残留。不同平台的 signal 与进程组语义不同,必须在支持矩阵上验证。

运行与验证

真实运行输出

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 13
pnpm --filter @coding-agent/runner test

验证退出码 3 不抛错、30ms 超时能终止、4 字节预算只保留 abcd,父环境中的测试 secret 不出现在子进程。

真实 Lab 中 Starter 六项全部失败,Solution 六项全部通过:

text
starter: 6 failed, 0 passed
solution: 6 passed, 0 failed
覆盖: stdout/stderr/exitCode · 非零退出 · 超时 · 预取消 · 字节截断 · 环境隔离

再用 stdout 与 stderr 各输出三字节、总预算四字节的夹具确认共享预算;用中文输出验证按 UTF-8 字节而非字符计数;连续执行多次并检查 signal listener 没有增长。

常见失败与排查

故障案例 1

测试脚本读到父进程密钥。

症状:仓库命令打印出 CI token 或云凭据,日志与模型上下文发生泄漏。

根因:spawn 默认继承父环境,或显式传入了完整 process.env

定位:设置仅父进程拥有的测试 secret,让子进程读取;若值出现,环境边界失效。

修复:从空环境建立 allowlist,按命令需要加入最少变量,敏感值不进 Trace。

故障案例 2

返回已截断,内存仍持续增长。

症状:结果只显示少量日志,执行期间进程却缓存了完整 stdout/stderr 并触发内存压力。

根因:命令结束后才对字符串 slice,或两条流各自无界收集。

定位:运行持续输出夹具,监测收集 Buffer 与进程内存是否超过配置上限。

修复:data 事件中按共享字节预算复制,超限后丢弃、暂停或终止,并明确标记 truncated。

故障案例 3

用户取消后后台任务仍在运行。

症状:界面显示已停止,但测试子进程继续占用 CPU,甚至稍后修改文件。

根因:只让 Promise 提前返回,没有 kill 子进程组;或仅终止直接子进程,孙进程残留。

定位:夹具启动长寿命子进程并写心跳,取消后检查进程树与文件是否继续变化。

修复:取消触发进程组终止,等待 close 后再完成结果;正常结束统一移除 listener 与 timer。

课后作业

增加进程组终止和总 wall-clock 指标;写多字节、stdout/stderr 竞争及启动失败测试,并设计脱敏环境 allowlist。

进阶要求是把生命周期实现成单一 finalize 状态机,保证 error、close、timeout 与 abort 竞争时只完成一次。为超时增加 TERM 到 KILL 的升级窗口,记录实际 signal、启动耗时、运行耗时和截断字节数,并验证孙进程不会残留。

验收 Rubric

维度通过标准常见扣分
执行spawn + shell:false拼接命令字符串
预算时间和总输出都有硬上限结束后才截断
环境只传显式变量继承全部父环境

总项目增量

总项目包:@coding-agent/runner

总项目路径:packages/runner/src/process-executor.ts

总项目验证命令:pnpm --filter @coding-agent/runner test

下一课将从仓库选择测试命令,并把执行器输出归一为 Agent 可恢复的失败类别。

延伸阅读

方案对比与工程取舍

exec 接口简短并自动收集字符串,适合可信小命令,但常默认经过 shell或整体缓冲;execFile 保持程序与 argv 分离,也会缓冲输出,可用 maxBuffer 保护;spawn 提供流式数据、signal 与进程句柄,代码更多,却适合 Agent 的超时、取消和共享预算。课程选择 spawn,是因为生命周期控制本身就是学习目标。

超限后只丢弃输出最容易维持进程语义,命令仍可完成;立即 kill 能节省 CPU,却可能把本可成功的测试变成执行失败;暂停流会触发管道背压,子进程可能阻塞。产品可按命令类型选择:测试日志超限后继续并标记,疑似失控输出则终止。决定必须进入结果和 Trace。

硬字节预算保护资源,摘要算法提升可用性。简单保留前 N 字节会丢掉测试末尾的失败总结;可以分别保留头尾、优先 stderr,或让 Failure Parser 流式提取首个错误。但任何智能摘要都在硬上限之内运行,不能先收集全量再优化展示。

stdout 与 stderr 的到达顺序跨进程调度并不稳定。分开保存便于语义区分,却无法重建真实交错时间;需要时间线时,应在 data 事件记录带单调时钟的有限 chunk 事件。最终结果仍可提供聚合字符串,Trace 只保存受限事件,避免日志翻倍。

UTF-8 截断可能落在多字节字符中间,直接 toString 会产生替换符。可在预算边界回退到最后一个完整序列,或使用流式 TextDecoder;硬计费仍按原字节数。结果应标记截断,Agent 不把末尾替换符误认作真实输出。

非零退出码必须作为数据,因为测试失败正是 Agent 需要观察的正常结果。若 Executor 对 code 1 throw,上层无法统一解析 stdout/stderr,也可能把断言失败误当基础设施崩溃。只有 spawn error、内部不变量破坏等执行器自身故障才拒绝 Promise或返回专门启动失败。

signal 退出也不总是超时或取消。子进程可能自己被操作系统 OOM kill,close code 为 null 并带 signal。生产结果应记录 exitSignal,与主动 timedOut/aborted 区分。否则所有 null exitCode 都被误报成用户取消,掩盖资源问题。

超时采用 wall-clock,包含进程启动、运行和退出等待。若只给命令主体计时,系统负载下 spawn 阶段可能无限拖延。用单调时钟记录 deadline,所有阶段共享同一预算;TERM grace period是否包含在总预算中也要写进合同。

取消存在竞态:进程恰好正常退出时 signal 同时触发。实现应有完成标志与单一 finalize,第一条终态获胜并清理其他监听;不要让 Promise resolve 两次或把正常成功覆盖成 aborted。状态机测试可通过可控假 child 精确触发事件顺序。

启动失败也要结构化。找不到 executable、cwd 不存在和权限不足发生在 error 事件,可能没有 close。若直接 reject,上层要有统一捕获;更一致的接口可以返回 spawnError 与 null exitCode。无论设计哪种,必须清理 timer/listener,并与命令非零退出明确区分。

环境 allowlist 通常至少涉及 PATH、HOME、TMPDIR、语言编码与包管理器缓存。PATH 应只包含受信目录,HOME 指向隔离临时位置,缓存可按工作区隔离且不可包含凭据。某些工具没有 HOME 会失败,所以“空环境”是安全起点,不一定是最终可用配置。

凭据并非只能来自环境。cwd 中的配置文件、用户主目录、SSH agent socket、云 metadata 网络都可能提供能力。Executor 的环境隔离只是纵深防御一层;真正不可信代码还要容器、文件挂载、网络策略与最小权限身份。文章必须避免把应用层限制称为完整沙箱。

工作目录由可信 Runtime 固定并经过 realpath 验证。模型只提交命令,不提交 cwd;否则获准的 git status 可以改到其他仓库执行。多工作区产品为每个 Runtime 实例绑定一个根,切换工作区创建新执行上下文与审批身份。

stdin 默认关闭能阻止命令等待密码或用户输入,也减少意外把宿主终端连接给子进程。需要交互式工具时,应提供专门受控终端能力和清晰用户接管,不要悄悄把 Agent Executor 变成 TTY 代理。自动任务应保持非交互、可超时、可回放。

进程组终止在 Unix 可通过独立 group 与负 pid signal 实现,Windows 可能需要 Job Object 或 taskkill 等平台能力。跨平台库应抽象 terminator 并针对真实系统集成测试。只在 macOS 单测 child.kill,不能证明 CI Windows 没有残留孙进程。

容器也不自动解决终止问题。容器内 PID 1 的 signal 转发、僵尸进程回收和挂载写入仍需设计。Executor 可以运行容器命令,但容器 Runtime 配置本身是更高层可信能力,不应由模型自由拼接参数。

输出内容可能含密钥、源代码与个人数据。Trace 默认记录字节数、哈希、分类摘要和受限头尾,不把全量日志长期持久化;发送给模型前再过脱敏器。脱敏同样在预算内流式完成,不能为扫描秘密先复制完整输出。

性能测量至少拆分排队、spawn、首字节、运行、终止等待和解析时间。只有总时长时,无法判断是命令慢、环境启动慢还是 kill 不生效。wall-clock 指标和终态原因也为后续评测提供可靠特征。

测试夹具要无害且确定。使用 process.execPath -e 打印输出、返回指定 code、等待 timer,不调用真实 rm、网络或包管理器。危险行为由 mock 和参数断言证明被拒绝;安全测试本身不应产生要防止的副作用。

变异测试可以删除 shell:false、改回 process.env、让两条流各自计费、去掉 listener 清理、把非零 code throw。对应测试都应失败。如果测试只覆盖成功输出,关键安全属性在重构时没有任何保护。

走一遍正常命令生命周期。Runtime 传入 Node executable 与打印脚本,预算一秒和一千字节;signal 未取消,于是 Executor 用空环境和关闭 shell 启动。stdout 收到 out、stderr 收到 err,共享计数累加但未超限;进程 code 零关闭,清理 timer 和 listener,返回两个流、零退出码以及三个 false 标志。上层不需要读取事件细节,就能判断执行完成且证据完整。

再看非零退出。脚本调用 process.exit(3),这不是 Executor 异常:进程成功创建、按合同结束,只是程序报告失败。结果保留 code 三、已有输出与 timedOut:false。下一课 Parser 将根据日志分类原因,Agent 可修代码后重跑。若此处 throw,恢复链会在最需要日志时中断。

超时路径中,timer 先触发,设置 timedOut 再 kill。最终 close 可能携带 null code或 signal,但对外 exitCode 固定 null,表明没有可信程序退出状态。实现不能在发送 kill 时立即 resolve,因为输出流可能还有尾部数据,进程也未真正退出;等待 close 才能证明资源已回收。

预取消与运行中取消要分别测试。预取消不应调用 spawn,返回空输出;运行中取消注册一次性 listener,触发后标记 aborted、终止并等待 close。正常结束、启动失败和取消都移除监听,避免同一个长寿命 signal 累积回调。

四字节输出测试看似简单,却锁住收集位置。脚本一次写出六字节,data handler 只复制前四字节并标记截断;如果实现结束后才截字符串,这项结果也可能相同,所以还应注入多个大 chunk 或监控内部 buffer,证明从未保存超限部分。测试结果正确与资源属性正确并不总是等价。

stdout/stderr 竞争的结果取决于事件先后,因此合同不应承诺固定哪条流获得剩余预算。可以为每条流保留最小配额后共享余量,以提高诊断稳定性;也可以接受先到先得并记录事件。选哪种都要确定,评测不能假设调度顺序永远相同。

若最大输出为零,任何数据都被丢弃并把 truncated 设真,命令仍可返回退出码。这个合法配置适合只关心成功状态的检查。负预算没有意义,应该在 spawn 前失败,避免启动进程后才发现配置错误。时间预算与输出预算都遵循“先校验、后副作用”。

输出预算达到后是否 kill,需要结合任务。编译器可能先打印大量警告,最后仍成功;立即 kill 会失去结果。失控程序持续输出则应终止。接口可增加 onOutputLimit: "truncate" | "terminate",默认根据产品风险选择,但模型不能自由放宽上限。策略配置来自可信 Runtime。

超时重试也不属于 Executor。它只忠实返回 timedOut,上层结合命令幂等性、历史耗时与剩余预算决定延长或停止。执行器自动重跑可能重复安装、迁移或写入。资源层报告事实,恢复层做任务级判断。

命令完成后,Buffer 转字符串还可能抛出语义问题而非 JavaScript异常。二进制输出、混合编码和控制字符不适合直接喂模型。生产结果可保留字节哈希,文本解码采用严格 UTF-8,失败时返回受控二进制摘要。终端 ANSI 转义也应清理,防止界面控制和无效 token。

工作区权限可以让 Executor 运行在专用低权限账户下,只挂载目标仓库与临时目录。即使 Guard 或环境清理漏掉,子进程也看不到用户主目录和其他项目。对陌生仓库自动执行测试时,这比继续堆应用层正则更有效。安全能力需要从 API 一直落到操作系统。

网络默认关闭能进一步限制依赖脚本和测试外联。需要联网的任务通过独立网络策略打开指定目的地,并记录审批;不要让代理环境变量悄悄提供出口。容器 DNS、回环地址与云 metadata 也在威胁模型内,单纯清除 HTTP_PROXY 不足以称为隔离。

文件系统写入审计可以在命令前后比较工作树或使用更底层监控。标记为测试的 allow 命令若修改源码,Runtime 可显示差异并回滚临时副本。这个机制不能替代真正沙箱,却能验证 Guard 对“低风险”的假设,并为规则调整提供证据。

上线容量规划要考虑并发。每个进程都有 CPU、内存、文件描述符和输出 buffer;单调用有上限不代表一百并发安全。Runner 层还需队列、全局并发、每租户配额和进程资源限制。取消排队任务不应先 spawn,超时可从排队或执行分别计量。

最终验收是一组终态矩阵:正常零码、正常非零码、启动失败、超时、预取消、运行取消、输出截断与 signal 退出。每格检查输出、code、flags、清理和副作用。只要其中一格语义含糊,上层恢复就会靠猜测,Sandbox Runtime 也无法给用户可靠解释。

把这份矩阵放入持续集成,并在升级 Node、切换子进程库或新增平台时运行真实夹具。进程生命周期依赖操作系统语义,类型检查无法证明 signal 与 close 的时序。单元测试锁定状态机,平台集成测试锁定实际回收行为,两类证据缺一不可。只有命令退出、输出受限、秘密不可见且后台无残留,才算执行边界真正闭合。

此外还应在低配置容器中重复压力测试,确认高并发、慢磁盘与短超时不会产生双重完成、僵尸进程或预算穿透。资源紧张时仍保持同一终态语义,才能让上层 Agent依据结果稳定恢复,而不是把基础设施抖动误当代码失败。

下一课衔接

Executor 已能安全地运行获准命令并返回有限 stdout、stderr 与终态,但 Agent 仍不知道该运行 pnpm、npm 还是 pytest,也不知道日志属于类型错误、断言失败、缺依赖还是超时。下一课实现 Test Detection 与 Failure Parser,把仓库标志和有界日志转换成稳定恢复类别。

  • Node child_process spawn 生命周期。
  • SIGTERM、SIGKILL 与进程组。
  • Backpressure 与有界日志采集。

从零实现 Mini Code Agent Runtime