系列回顾:主循环 · 代码库工具 · REPL · 项目上下文 · Skills · 权限 + Write · MCP 概念 · MCP 实现 · Context Budget

到上一篇为止,react-agent-mini 会读、会改、能外挂 MCP、长对话还能裁预算。
但还有一环缺着:跑不了——测不过、依赖装不上、脚本输出看不见。
这篇补上 Bash:在项目目录里执行一行 shell,并且仍然走第六篇的门卫。


钩子:读得了、改得了,还跑不了

coding agent 的最小闭环其实是三步:

读(Read / Grep)→ 改(Write / Edit)→ 跑(Bash)

没有「跑」,模型只能猜「这样改应该能过」;有了 Bash,它才能自己验证:

改完 → Bash({ command: "bun test" }) → 看失败输出 → 再改

完整版 Claude Code 的 Bash 很重:危险命令规则、后台任务、sed 解析、沙箱……
mini 只保留最值得学的形状:

终端是借来的钥匙;出门前,门卫还要问一句。


用人话理解:借来的钥匙

Bash 不是「模型自己的电脑」,是 在你当前工作目录开一行终端

模型:我想执行 bun test
        ↓
门卫:允许执行命令「bun test」?[y/N]
        ├─ y → 在 cwd 起子进程,抓 stdout/stderr 还给模型
        └─ n → 命令根本不跑,只告诉模型「用户拒绝了」

和 Write / Edit 一样:isReadOnly() → false,走同一套 canUseTool
头less / pipe 默认拒绝,除非 ALLOW_WRITE=1(语义扩展为「允许写类工具,含 Bash」)。

所以这篇不是新权限柱——是证明第六篇的门卫够通用:
换一把更危险的钥匙,门卫仍然站得住。


工具长什么样?

Bash({
  command: 'bun test',           // 必填:一整行 shell
  timeout_ms: 30000,             // 可选,默认 30s,硬顶 120s
  description: '跑单元测试',      // 可选,给人类看的用途说明
})

模型看到的结果大致是:

情况 tool_result
退出码 0 合并后的 stdout/stderr(空则 (无输出)
退出码非 0 已有输出 + [命令失败:退出码 N],并标错
超时 已有输出 + [命令超时…],进程尽力杀掉,并标错
输出太长 只留前 5 万字符 + 截断说明

非零退出仍然把输出还给模型——测挂了最需要的就是那几行报错,不能只回一句「失败了」。


实现上盯住四件事

1. 固定在 cwd,不接受「换目录逃逸」

子进程的 cwd 锁死为 process.cwd(),没有「随便指定工作目录」参数。
cd elsewhere && …?那是命令字符串自己的事——和你在本机终端敲一行等价,风险也等价。

2. 真的走 shell(才能管道 / &&

function runCommand(command: string, timeoutMs: number): Promise<BashRun> {
  return new Promise(resolve => {
    const isWin = process.platform === 'win32'
    const shell = isWin
      ? process.env.ComSpec || 'cmd.exe'
      : process.env.SHELL || '/bin/bash'
    const shellArgs = isWin ? ['/d', '/s', '/c', command] : ['-c', command]

    const child = spawn(shell, shellArgs, {
      cwd: process.cwd(),
      windowsHide: true,
    })
平台 shell
Windows ComSpec / cmd.exe/c
其他 SHELL / /bin/bash-c

不用 shell: true 拼接魔法,但也改成无 shell 的 execFile——否则 bun test && echo ok 这种 demo 立刻难写。

3. 超时必须杀得死

默认 30s,入参可调,硬顶 120s(防模型传一个天文数字挂死会话)。
超时后 Windows 用 taskkill /T /F 尽量带上子进程树,Unix 用 SIGKILL

4. 输出必须能截断

MAX_BASH_OUTPUT_CHARS = 50_000
和上一篇 compact 是队友:Bash 防止单次结果炸上下文,compact 防止历史里旧结果一直堆着。


权限确认长什么样?

REPL 里不会只问「允许 Bash 吗?」,而是把命令预览亮出来:

允许执行命令「bun test」?[y/N]

命令太长会截到约 80 字符。你看见的是即将敲进终端的那一行——这比抽象的工具名重要得多。

smoke 测过三条路径:

REPL 回 n     → 命令不执行,tool_result 是拒绝
headless 默认 → 拒绝,提示设 ALLOW_WRITE=1
REPL 回 y     → echo 一类无害命令真的跑通

和主循环的关系(又对上号)

L1 CLI     → 工具表多了 Bash;确认文案多了命令预览
L2 query() → 不变
L3 runToolUse → 不变(门卫 → call → tool_result)
L4 BashTool → 新增:spawn / 超时 / 截断

Bash 不改变 ReAct 循环,只改变 Agent 能不能「验证自己的改动」。

读—改—跑闭环至此齐了。


30 秒试一把

bun run dev
# REPL 里试:
# 用 Bash 执行 echo hello-from-bash
# → 门卫问 y/N → 输入 y → 应看到 hello-from-bash

# 再试:跑测试(按你本机脚本改)
# 用 Bash 执行 bun test

headless:

# 默认拒绝
bun run dev -- "用 Bash 执行 echo hi"

# 显式放权(与 Write/Edit 相同开关)
ALLOW_WRITE=1 bun run dev -- "用 Bash 执行 echo hi"

建议先从 echobun testgit status 这类可预期、低破坏命令练手;别一上来就让模型「清理磁盘」。


刻意没做什么?

没做 意味着什么
完整命令黑名单 / 沙箱 与本机终端同等风险,靠人审 + 门卫,不做假安全感
交互式 TTY / 输密码 只适合非交互命令
后台常驻进程 超时内跑完就结束;不托管 &
PowerShell 专用封装 Windows 走 cmd;跨平台 demo 用简单命令
always-allow 记忆 每次写类调用仍问(或 headless 靠环境变量)

完整版 Claude Code 会在「钥匙」外面再加锁和监控;mini 验证的是主干:

借钥匙 → 门卫问一句 → cwd 执行 → 限时 → 截断输出 → 失败也带回日志

系列拼图(到本篇为止)

能力
1 主循环会转
2 逛代码库
3 多轮 REPL
4 项目上下文
5 Skills 按需加载
6 门卫 + Write
7–8 MCP 概念 / 接线
9 Context Budget(compact)
10 Bash:读—改—跑闭环

从「会聊天的循环」走到「能自己跑测试的 coding agent 雏形」——循环还是那个循环,能力往外挂。


你可以从这里带走什么?

  1. 读—改—跑 才是 coding agent 的最小闭环;缺 Bash 就只能「改完靠猜」。
  2. 终端是借来的钥匙,不是模型私有机房——权限必须复用门卫。
  3. 超时 + 输出截断 是终端工具的标配,和 compact 一起守上下文预算。
  4. 失败要带回输出——非零退出码对模型是调试材料,不是一句「出错了」。
  5. 首版不做假沙箱:写清楚风险,比一份骗自己的黑名单更诚实。

仓库与相关文档

欢迎 Star、Issue 和 PR。


本文基于 react-agent-mini 变更 v4-bash(受控 shell + 复用 canUseTool)撰写。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐