Bash 的双语法树解析
跨平台一致性
一、原理:shell 命令的"看似简单实际复杂"
让 AI 跑 shell 命令听起来简单——LLM 输出一段命令字符串,agent 把它丢给系统跑、把输出回流。两段代码搞定。
但实际中这是 opencode 工程化复杂度最高的工具之一——bash 工具的实现(packages/opencode/src/tool/shell.ts)有 800+ 行代码。
为什么这么复杂?因为 shell 命令藏着 4 个看不见的难题。
难题 1:命令解析的歧义性
LLM 输出:"cat file.txt && ls -la | grep test"。这段命令里有什么?
cat file.txt— 一个命令&&— 逻辑与ls -la— 另一个命令|— 管道grep test— 第三个命令
要解析它需要懂 shell 语法——管道、重定向、子 shell、变量替换、引号、转义。Bash 的语法规范是几百页的 POSIX 标准。
如果你只是简单把字符串扔给 exec()——会出 bug。某些字符(;、|、>)有特殊语义、引号嵌套规则复杂、变量替换可能展开成意外内容。
要正确处理,你需要一个 shell 语法解析器——把命令拆成语法树,然后理解每一部分。
难题 2:跨平台
ls 在 Linux/macOS 是列目录,在 Windows PowerShell 是 alias 到 Get-ChildItem。rm -rf 在 Unix 删目录,在 Windows 报错。grep 在 Linux 内置,在 Windows 默认没有。
如果你的 Agent 要在多平台跑——Mac、Linux、Windows——你不能假设所有命令都一样。要么强制用 Unix 命令(Windows 用户痛苦)、要么强制用 PowerShell(Mac/Linux 用户痛苦)、要么两套都支持。
opencode 选了"两套都支持"——这就是它有 Bash + PowerShell 双解析树的原因。
难题 3:安全
LLM 可能生成危险命令——rm -rf /、sudo shutdown、dd if=/dev/zero of=/dev/sda。这些命令一旦跑就不可逆——文件没了、系统挂了、数据丢了。
需要在执行前做安全检查。但检查"危险性"不能靠字符串匹配——rm -rf 在 /tmp/test 是安全的,在 / 是灾难。判断危险性需要理解命令的实际语义。
这又回到难题 1——没有语法解析,你不能正确判断安全性。
难题 4:超时和进程管理
LLM 跑 npm install 可能 30 秒,跑 pytest 可能 5 分钟。跑了之后用户按 Ctrl+C——子进程必须立刻被 kill,不能让它继续后台跑。
跨平台 kill 进程也不一样——Unix 用 SIGTERM/SIGKILL、Windows 用 TerminateProcess。要统一管理。
这 4 个难题加起来让 bash tool 的实现复杂——远超你看 child_process.exec("...") 想象的难度。
二、案例:opencode 的 shell.ts 双语法树
opencode 的 bash 工具实现在 packages/opencode/src/tool/shell.ts。它解决上面 4 个难题的方式让我们一个个看。
应对难题 1 + 2:双 tree-sitter 解析
opencode 用 tree-sitter——一个增量语法解析库——解析 shell 命令。它有两套语法解析:
tree-sitter-bash— 解析 Bash 语法tree-sitter-powershell— 解析 PowerShell 语法
当 LLM 输出一段命令,opencode 根据当前平台选择哪个解析器:
- macOS / Linux → Bash 解析器
- Windows → PowerShell 解析器
解析后得到一棵 AST(抽象语法树)。每个节点是一个语法构件——command、pipeline、redirect、subshell。这棵树告诉 opencode 命令的实际结构。
为什么需要两套?因为 Bash 和 PowerShell 的语法完全不一样:
- Bash:
cat file.txt | grep test - PowerShell:
Get-Content file.txt | Select-String "test"
不只是命令名不同——管道符的语义、变量展开规则、转义字符都不同。一套解析器搞不定两个语言。
opencode 选择维护两套,让每个平台都有"原生体验"。Mac/Linux 用户用 Bash 命令、Windows 用户用 PowerShell 命令、AI Agent 在哪个平台就生成哪种命令。
应对难题 3:基于 AST 的安全检查
有了 AST,opencode 可以做基于语义的安全检查。具体逻辑大概是:
analyzeCommand(ast):
for each command in ast:
if command.name in DANGEROUS_COMMANDS:
// rm, mv, dd, sudo, chmod, chown ...
check args for actual danger:
if rm with -rf or -fr or recursive:
if target is / or ~/ or relative:
HIGH_RISK
if mv to / or to system path:
MEDIUM_RISK
...
if command tries to write to:
// /etc/, /usr/, /var/, /System/, C:\Windows ...
HIGH_RISK
if command tries to exec:
// eval, source, . file
MEDIUM_RISK (depends on file)
这种基于 AST 的检查比字符串匹配精准得多:
rm -rf /tmp/test— 低风险(删 /tmp 下的子目录)rm -rf /— 极高风险(删整个根)rm -rf $(echo /)— 通过 AST 看到$(...)是子命令,结果是/——极高风险
检查通过后命令才执行;不通过则触发 permission ask——弹窗问用户"这命令看起来危险,确认要跑吗"。
应对难题 4:进程管理和 timeout
每个 bash 调用在 opencode 里启动一个独立的子进程。opencode 用 Node.js 的 child_process.spawn API,跨平台兼容。
每个进程关联一个 AbortController——用户中断或 timeout 触发时,controller 发 abort 信号,spawn 出来的进程被 kill。
Bash 工具的 description 里明确说:
Timeout in milliseconds. Defaults to 120000 (2 minutes). Maximum is 600000 (10 minutes).
默认 2 分钟、最大 10 分钟。如果命令需要更长——LLM 知道可以传更大的 timeout 参数。
Background 模式更特殊:
Set background to true to run a long-lived process and stream its output. The command runs detached and returns immediately with a handle id you can use to attach later.
跑 dev server、跑 watch 任务这种长命令,LLM 可以 background: true。bash 工具立刻返回一个 handle id,不等命令结束。后续可以通过 handle 查看输出或停止进程。
这种 background 设计让 AI Agent 能管理"长跑任务"——不需要把主循环卡在 npm run dev 上。
额外细节:cd 的特殊处理
shell 命令里 cd 比较特殊——它改的是当前进程的工作目录。但 opencode 每次 bash 调用是独立子进程——上一次 cd /tmp 对下一次没影响。
如果不处理这个,LLM 会困惑:
turn 1: bash "cd /tmp" → 进程结束,cwd 没变
turn 2: bash "ls" → 列的是项目目录,不是 /tmp
opencode 的处理:
- 跟踪一个"会话级 cwd"(cwd state)
- 检测到
cd命令后,更新这个 state - 下次 bash 调用,在 state 对应的目录下执行
这样 LLM 的 cd "记得"——跨 bash 调用持续有效。
description 里也明确这个:
The active Location is the default working directory. Use the cd command to switch into another sandbox-accessible directory.
LLM 看到这个约束,知道可以用 cd 切目录。
三、设计启示:跨平台 AI 工具的真实复杂度
这一章的核心论点:让 AI 跑 shell 命令的"工程化复杂度"远超表面——涉及语法解析、跨平台、安全、进程管理多维度。
如果你做 AI 产品有类似 bash 这种"动手能力强但风险大"的工具,下面几条原则有用:
1. 不要简单 wrap exec/spawn
直接把 LLM 输出丢给 exec() 是新手错误。你失去对命令的理解、失去安全检查、失去跨平台一致性。要用 AST 解析。
2. 跨平台从一开始就想
如果你的产品要跨平台跑,从 day 1 设计就要考虑。不要等"等以后再支持 Windows"——这种迁移成本极高。
opencode 一开始就支持两套语法,避免了后期重写。
3. 安全检查基于语义不是字符串
rm -rf / 和 rm -rf ./tmp 字符串相似但风险完全不同。要 parse 后看真实目标。
4. 危险命令 → permission ask
不要静默拒绝(用户会困惑为啥不跑)、也不要静默执行(出事了用户怨你)。明确触发 permission 弹窗——让用户决定。这是 7.3 章会展开的 "ask" 状态的产品价值。
5. timeout 默认 + 可调
默认 timeout 防止 LLM 卡死循环。但要可调——某些场景就是需要长跑。
opencode 的 2 分钟默认、10 分钟最大是个合理范围。你的产品可能不一样——但要有"默认 + 上限"的设计。
6. background mode 处理长任务
dev server、watch 任务、queue worker——这些任务可能跑几小时。如果你的 AI 产品只有"同步等命令完"模式,用户会被卡住。
支持 background mode——立刻返回 handle id、命令后台跑、可查输出、可停止。
7. cwd 跨调用持久
如果你的产品多次 bash 调用是独立进程,要维护"会话级 cwd"——让 cd 跨调用有效。否则 LLM 会困惑。
8. AbortSignal 全链路
用户中断 → tool 取消 → fork 的 fiber kill → 子进程 SIGTERM/Terminate。每一层都要传递取消信号,否则中断不彻底。
最后一个观察。Bash 工具的实现复杂度是 AI Agent 工程化的经典案例——它展示了"看起来简单实际很难"的工具长什么样。
很多 AI 产品的 bash 工具实现是简单的 exec(command) + 字符串黑名单——看似省事,实际上:
- 跨平台兼容差(Mac 上能跑的 Windows 跑不了)
- 安全检查粗糙(
rm全禁太严、rm不禁太松) - 进程泄漏(用户中断后命令还在跑)
- cwd 不一致(cd 没用)
这些问题 demo 看不到——demo 都跑简单命令。但生产环境用户每天踩坑。
把 bash 工具做扎实——AST 解析、双语法、AbortSignal、cwd state、permission ask——是 AI Agent 工程师的"硬功夫"。这部分没人会教,要自己摸——但 opencode 的实现是个不错的参考。
下一章 3.8 我们看 opencode 最贵也最特别的工具——Task。它让 AI 把活分给"子 AI",是 multi-agent 编排的基础。