Agent 终于有终端了:借来的钥匙 + 门卫
系列回顾:主循环 · 代码库工具 · 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"
建议先从 echo、bun test、git 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 雏形」——循环还是那个循环,能力往外挂。
你可以从这里带走什么?
- 读—改—跑 才是 coding agent 的最小闭环;缺 Bash 就只能「改完靠猜」。
- 终端是借来的钥匙,不是模型私有机房——权限必须复用门卫。
- 超时 + 输出截断 是终端工具的标配,和 compact 一起守上下文预算。
- 失败要带回输出——非零退出码对模型是调试材料,不是一句「出错了」。
- 首版不做假沙箱:写清楚风险,比一份骗自己的黑名单更诚实。
仓库与相关文档
- GitHub:https://github.com/jimchou-h/react-agent-mini
- 主循环 · 代码库工具 · REPL · 项目上下文 · Skills · 权限 + Write · MCP 概念 · MCP 实现 · Context Budget
- Bash 源码:src/tools/BashTool.ts
- 权限 smoke:src/tools/__tests__/BashPermission.smoke.test.ts
欢迎 Star、Issue 和 PR。
本文基于 react-agent-mini 变更 v4-bash(受控 shell + 复用 canUseTool)撰写。
更多推荐



所有评论(0)