Shell / 命令执行
a3647eb025c7 · 官方 Release
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
07-shell-execution - 章节摘要:沿一次测试命令,理解 shell tool 的 parse、collect、ask、run 管线,以及外部路径、输出截断、取消和超时边界。
- 教程版本:
v1.18.16 - 源码基线:
a3647eb025c7615159d417dcc49fc39fdaeba65b - 章节元数据:/versions/v1-18-16/data/chapters.json
- 源码映射:/versions/v1-18-16/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/tool/shell.tspackages/opencode/src/session/prompt.tspackages/opencode/src/session/run-state.tspackages/opencode/src/permission/index.ts
源码基线:
v1.18.16(提交a3647eb025c7)。本章用pnpm test和带路径的命令做教学追踪;除非明确说明,它们都是典型调用,不是本章真实执行记录。
0. 本章学习目标
Section titled “0. 本章学习目标”学完本章,你应该能:
- 画出 shell tool 的
parse -> collect -> ask -> run -> result主链路。 - 解释为什么命令审批不能只看整段字符串,也不能把静态扫描当成沙箱。
- 追踪一次测试命令如何解析、申请权限、启动进程并回填输出。
- 区分模型调用 shell tool 与用户直接运行 shell 的两条 session 路径。
- 说明正常退出、非零退出、解析失败、拒绝、超时、取消和输出截断分别怎样结束。
- 为自己的 mini agent 设计最小可用的命令执行边界。
1. 一句话讲明白
Section titled “1. 一句话讲明白”OpenCode 不把 shell 当作一个 exec(command),而把它当作一份需要执行前扫描与授权、执行中可观察与可取消、执行后可回填与限流的操作单。
本章的中心问题是:
一段 shell 文本既能读文件、删目录、启动子进程,也可能永远不退出;OpenCode 如何在不真正执行它的前提下先建立边界,又如何在启动后收住资源和输出?
v1.18.16 相比旧基线改变了什么
Section titled “v1.18.16 相比旧基线改变了什么”- shell parser 现在同时处理 Bash、PowerShell 与 cmd 的路径参数、环境变量和文件操作别名,权限扫描不再只围绕 POSIX 命令。
- 工具提示词被拆到
tool/shell/prompt.ts,按平台生成参数说明和命令组合规则;执行逻辑仍留在shell.ts。 - 进程执行改用 Effect 的
ChildProcessSpawner,但安全边界仍是parse -> collect -> ask -> run,静态扫描依旧不是 OS sandbox。
解析结果进入权限询问
packages/opencode/src/tool/shell.ts:257-310
ask 同时处理 shell 命令 permission 与外部目录 read permission。
257const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) {处理命令执行。258 const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command)))开始解析命令参数。259 if (!tree) throw new Error("Failed to parse command")处理命令执行。260 return tree返回给上一层。261})262263const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, input: { command: string }) {处理命令执行。264 if (scan.dirs.size > 0) {按条件进入分支。265 const directories = Array.from(scan.dirs)266 const globs = directories.map((dir) => {267 if (process.platform === "win32") return FSUtil.normalizePathPattern(path.join(dir, "*"))按条件进入分支。268 return path.join(dir, "*")返回给上一层。269 })270 yield* ctx.ask({执行前先走权限。271 permission: "external_directory",272 patterns: globs,273 always: globs,274 metadata: {275 command: input.command,处理命令执行。276 directories,277 patterns: globs,278 },279 })280 }281282 if (scan.patterns.size === 0) return按条件进入分支。283 yield* ctx.ask({执行前先走权限。284 permission: ShellID.ToolID,285 patterns: Array.from(scan.patterns),286 always: Array.from(scan.always),287 metadata: {288 command: input.command,处理命令执行。289 },290 })291})292293function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {处理命令执行。294 if (process.platform === "win32" && Shell.ps(shell)) {处理命令执行。295 return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {处理命令执行。296 cwd,297 env,298 stdin: "ignore",299 detached: false,300 })301 }302303 return ChildProcess.make(command, [], {处理命令执行。304 shell,处理命令执行。305 cwd,306 env,307 stdin: "ignore",308 detached: process.platform !== "win32",309 })310}
收集路径影响并启动进程
packages/opencode/src/tool/shell.ts:378-470
collect 建立审批 pattern,run 才进入真实执行。
378 const collect = Effect.fn("ShellTool.collect")(function* (Effect 异步工作流。379 root: Node,380 cwd: string,381 ps: boolean,382 shell: string,处理命令执行。383 instance: InstanceContext,384 ) {385 const scan: Scan = {386 dirs: new Set<string>(),387 patterns: new Set<string>(),388 always: new Set<string>(),389 }390 const shellKind = ShellID.toKind(Shell.name(shell))处理命令执行。391392 for (const node of commands(root)) {处理命令执行。393 const command = parts(node)处理命令执行。394 const tokens = command.map((item) => item.text)处理命令执行。395 const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0]处理命令执行。396397 if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) {处理命令执行。398 for (const arg of pathArgs(command, ps, shellKind === "cmd")) {处理命令执行。399 const resolved = yield* argPath(arg, cwd, ps, shell)处理命令执行。400 yield* Effect.logInfo("resolved path", { arg, resolved })Effect 异步工作流。401 if (!resolved || containsPath(resolved, instance)) continue按条件进入分支。402 const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)读写本地文件。403 scan.dirs.add(dir)404 }405 }406407 if (tokens.length && (!cmd || !CWD.has(cmd))) {按条件进入分支。408 scan.patterns.add(source(node))409 scan.always.add(BashArity.prefix(tokens).join(" ") + " *")处理命令执行。410 }411 }412413 return scan返回给上一层。414 })415416 const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {处理命令执行。417 const extra = yield* plugin.trigger(调用插件扩展点。418 "shell.env",处理命令执行。419 { cwd, sessionID: ctx.sessionID, callID: ctx.callID },420 { env: {} },421 )422 return {返回给上一层。423 ...process.env,424 ...extra.env,425 }426 })427428 const run = Effect.fn("ShellTool.run")(function* (Effect 异步工作流。429 input: {430 shell: string处理命令执行。431 command: string处理命令执行。432 cwd: string433 env: NodeJS.ProcessEnv434 timeout: number435 },436 ctx: Tool.Context,437 ) {438 const limits = yield* trunc.limits()等待 Effect 结果。439 const keep = limits.maxBytes * 2440 let full = ""441 let last = ""442 const list: Chunk[] = []443 let used = 0444 let file = ""445 let sink: ReturnType<typeof createWriteStream> | undefined446 let cut = false447 let expired = false448 let aborted = false449450 const closeSink = Effect.fnUntraced(function* () {Effect 异步工作流。451 const stream = sink452 if (!stream) return按条件进入分支。453 sink = undefined454 if (stream.destroyed || stream.closed) return按条件进入分支。455 yield* Effect.promise(Effect 异步工作流。456 () =>457 new Promise<void>((resolve) => {458 let settled = false459 const done = () => {460 if (settled) return按条件进入分支。461 settled = true462 stream.off("close", done)463 stream.off("error", done)464 stream.off("finish", done)465 resolve()466 }467 stream.once("close", done)468 stream.once("error", done)469 stream.once("finish", done)470 stream.end(done)
2. 先看全图:两条入口,一种会话结果
Section titled “2. 先看全图:两条入口,一种会话结果”入口 A:模型 tool call SessionTools -> ShellTool.execute | v parse AST -> collect -> ask | v spawn -> stream -> finish | v tool result 回到 loop
入口 B:用户直接 shell SessionPrompt.shell -> SessionRunState.startShell -> shellImpl 构造 synthetic 消息/ToolPart -> spawn -> stream -> completed ToolPart两条路径都把命令和输出留在 session history,但安全语义不同:
- 模型 tool call 走
ShellTool的 AST 扫描与ctx.ask。 - 用户直接 shell 代表用户主动发起,由
SessionPrompt.shellImpl记录和执行;这段实现没有调用ShellTool.collect/ask。
不要因为最后都显示成 shell ToolPart,就假设二者经过完全相同的授权流程。
3. 最小机制:先做计划,再启动进程
Section titled “3. 最小机制:先做计划,再启动进程”抹去 OpenCode 的产品细节,一个 Agent shell runner 至少需要:
1async function runShell(input, ctx) {定义一段可复用逻辑。2 const plan = scan(input.command, input.cwd)处理命令执行。3 await ctx.ask(plan.permissions)进入权限审批。45 const child = spawnNonInteractive(input.command, input.cwd, input.env)处理命令执行。6 const exit = await race(child.exit, ctx.abort, timeout(input.timeout))78 if (exit.kind !== "exit") await kill(child)按条件进入分支。9 return limitAndDescribeOutput(child.output, exit)返回给上一层。10}
这条骨架里有三个独立问题:
- 执行什么:命令 AST 与 permission pattern。
- 会碰哪里:工作目录和文件参数是否越过项目边界。
- 如何结束:exit、abort、timeout 三者谁先发生。
OpenCode 的复杂度主要来自跨平台解析、流式输出和会话集成;最小安全内核仍是这三问。
4. 为什么先 parse,而不是 split(" ")
Section titled “4. 为什么先 parse,而不是 split(" ")”Shell 文本不是空格分隔的参数数组。下面几段都包含会误导简单 split 的结构:
1printf '%s\n' "a b"2cat src/a.ts | rg "TODO"3cd /tmp && rm -r demo4echo "$HOME"
OpenCode 懒加载 tree-sitter 的 Bash 与 PowerShell grammar,并按当前 shell 选择 parser。第一次使用时才加载 WASM,解析树在 Effect scope 结束时删除。见 packages/opencode/src/tool/shell.ts、packages/opencode/src/tool/shell.ts、packages/opencode/src/tool/shell.ts。
Java 类比是“parser + try-with-resources”,而不是 Runtime.exec(String)。类比的边界是:tree-sitter 只提供语法树,它不执行 shell 的全部动态语义。
5. 一条具体源码旅程:模型请求运行测试
Section titled “5. 一条具体源码旅程:模型请求运行测试”假设模型发出典型 tool call:
1{2 "command": "pnpm test",3 "description": "运行项目测试",4 "workdir": "/workspace/project"5}
5.1 选择 shell、cwd 和 timeout
Section titled “5.1 选择 shell、cwd 和 timeout”工具从配置选择可接受的 shell,渲染与平台匹配的 prompt/schema。执行时:
workdir存在就相对 instance directory 解析,否则使用 instance directory。- 负数 timeout 直接报错。
- 未提供 timeout 时,默认来自 runtime flag,否则为两分钟。
路径:packages/opencode/src/tool/shell.ts、packages/opencode/src/tool/shell.ts。
注意源码只拒绝 < 0,所以 timeout: 0 在当前实现中是合法输入,并会几乎立即进入 timeout 竞争。不要把错误文案里的“positive”扩大解释成严格大于零的运行时校验。
5.2 parse 产出 AST,collect 产出审批计划
Section titled “5.2 parse 产出 AST,collect 产出审批计划”collect 遍历所有 command node,把结果写入三个集合:
1type Scan = {定义数据结构约束。2 dirs: Set<string>3 patterns: Set<string>4 always: Set<string>5}
路径:packages/opencode/src/tool/shell.ts
对于 pnpm test:
- 命令不是
cd/chdir/...,所以原始 command source 加入patterns。 BashArity.prefix(tokens)生成更适合“总是允许”的前缀 pattern,再加*放入always。- 它不属于内置文件命令集合,因此不会从参数里推导外部目录。
这三点是根据 collect 控制流对该典型输入的推演,不是运行日志。主实现位于 packages/opencode/src/tool/shell.ts。
5.3 两类权限按顺序询问
Section titled “5.3 两类权限按顺序询问”ask 先处理外部目录,再处理 shell pattern:
1if (scan.dirs.size > 0) {按条件进入分支。2 yield* ctx.ask({ permission: "external_directory", patterns: globs, always: globs })进入权限审批。3}4if (scan.patterns.size > 0) {按条件进入分支。5 yield* ctx.ask({ permission: ShellID.ToolID, patterns, always })进入权限审批。6}
路径:packages/opencode/src/tool/shell.ts
若 workdir 本身位于 instance 外,执行入口也会把它加入 scan.dirs,见 packages/opencode/src/tool/shell.ts。因此“在哪里运行”和“命令文本会访问哪里”都进入外部目录边界。
只要任一 ask 被 deny 或 reject,run 就不会启动子进程。权限状态机在第 09 章展开。
5.4 插件在每次调用前补充环境变量
Section titled “5.4 插件在每次调用前补充环境变量”真正运行前触发 shell.env hook:
1const extra = yield* plugin.trigger(调用插件扩展点。2 "shell.env",处理命令执行。3 { cwd, sessionID: ctx.sessionID, callID: ctx.callID },4 { env: {} },5)6return { ...process.env, ...extra.env }返回给上一层。
路径:packages/opencode/src/tool/shell.ts
后展开的 extra.env 会覆盖同名进程环境变量。这是扩展点,也是一条信任边界:命令实际看到的环境不一定只来自宿主 process.env。
5.5 进程以非交互方式启动
Section titled “5.5 进程以非交互方式启动”PowerShell 与普通 shell 的命令构造不同,但二者都设置 stdin: "ignore"。POSIX 路径使用 detached process,Windows PowerShell 不 detached。见 packages/opencode/src/tool/shell.ts。
因此 shell tool 适合测试、构建、查询等非交互命令,不适合等待密码、确认提示或全屏 TUI。一个“看起来卡住”的命令,可能不是计算慢,而是在等待永远不会到来的 stdin。
5.6 输出一边流入 UI,一边受容量限制
Section titled “5.6 输出一边流入 UI,一边受容量限制”子进程的合并输出 handle.all 被解码成文本流。每个 chunk 到来时,工具会:
- 维护有限的尾部 chunk 列表。
- 更新
last预览。 - 通过
ctx.metadata把进行中输出写入 ToolPart。 - 完整输出超过阈值后,把内容转存到截断文件,并继续追加。
路径:packages/opencode/src/tool/shell.ts、packages/opencode/src/tool/shell.ts。
这同时服务两个消费者:人需要看到进展,模型上下文又不能被无限日志淹没。
5.7 exit、abort、timeout 竞争决定停止原因
Section titled “5.7 exit、abort、timeout 竞争决定停止原因”1const exit = yield* Effect.raceAll([Effect 异步工作流。2 handle.exitCode.pipe(/* kind: exit */),3 abort.pipe(/* kind: abort */),4 timeout.pipe(/* kind: timeout */),5])
路径:packages/opencode/src/tool/shell.ts
abort 或 timeout 获胜时,会 kill handle,并设置三秒后的强制终止。正常 exit 则保留真实 exit code。最终 metadata 中包含 exit、truncated,必要时还有 outputPath;文本 output 会附上超时或用户取消说明。见 packages/opencode/src/tool/shell.ts。
这里要避免一个常见误读:**非零 exit code 不会在 run 中自动抛成工具异常。**它作为 metadata.exit 返回,让 Agent 根据输出决定下一步。源码中的 code 类型就是 number | null,见 packages/opencode/src/tool/shell.ts。
6. 外部路径扫描:有价值,但不是沙箱
Section titled “6. 外部路径扫描:有价值,但不是沙箱”OpenCode 维护一组可能接收文件参数的命令,如 rm/cp/mv/cat 以及对应 PowerShell、cmd 命令。pathArgs 过滤 flags,再由 argPath 处理引号、~、部分环境变量、glob 前缀和 Windows path。见 packages/opencode/src/tool/shell.ts、packages/opencode/src/tool/shell.ts、packages/opencode/src/tool/shell.ts。
若静态得到的路径在 instance 外,collect 把其目录加入 dirs。例如对 cat /tmp/report.txt,按当前控制流会尝试申请 /tmp/* 的外部目录权限。
但动态表达式会被保守地跳过:$()、${}、反引号、某些变量或从首字符开始的 glob 无法在执行前可靠解析。见 packages/opencode/src/tool/shell.ts、packages/opencode/src/tool/shell.ts。
证据边界必须明确:
- 这段扫描能提高审批信息质量。
- 它不是 OS sandbox、容器隔离或 syscall 拦截。
- 源码没有证明所有 shell 语义和间接文件访问都能被提前发现。
所以不要写成“OpenCode 能保证命令绝不访问未授权路径”。更准确的说法是:它对已识别的文件命令和静态路径增加一层 preflight permission gate。
7. 直接 Shell:用户动作也要进入会话账本
Section titled “7. 直接 Shell:用户动作也要进入会话账本”SessionPrompt.shellImpl 处理用户直接发起的命令。它先创建:
- synthetic user text:
The following tool was executed by the user; - assistant message;
- 状态为
running的 shell ToolPart。
路径:packages/opencode/src/session/prompt.ts。
随后它使用首选 shell 启动命令,流式更新 ToolPart metadata,结束后把 part 改为 completed。取消会在 output 中加入说明。见 packages/opencode/src/session/prompt.ts。
同一 session 的运行协调交给 SessionRunState.startShell。内部按 sessionID 复用 runner;runner busy 会转成 Session.BusyError。见 packages/opencode/src/session/run-state.ts、packages/opencode/src/session/run-state.ts。
这解决的是会话状态竞争,不等于系统全局只允许一个进程。不同 session 可以有各自 runner。
8. 失败路径:命令在哪个检查点停下
Section titled “8. 失败路径:命令在哪个检查点停下”| 情况 | 停止点 | 子进程是否启动 | 结果特征 |
|---|---|---|---|
| shell/parser 无法解析 | parse | 否 | tool error |
| timeout 为负数 | execute 参数校验 | 否 | 明确 error |
| 外部目录或 shell permission deny/reject | ask | 否 | permission error |
| spawn 失败 | spawner.spawn | 启动失败 | Effect defect/tool failure |
| 正常退出,code = 0 | exit race | 是 | metadata.exit = 0 |
| 正常退出,code != 0 | exit race | 是 | 返回输出与非零 code,不自动抛错 |
| 用户取消 | abort race | 是 | kill,exit = null,附取消说明 |
| 超时 | timeout race | 是 | kill,exit = null,附超时说明 |
| 输出过长 | streaming/finalize | 是 | 只回传尾部,完整输出保存到 outputPath |
| 命令等待交互输入 | 运行中 | 是 | 因 stdin: ignore 无法回答,通常直到退出/取消/超时 |
输出截断不是执行失败,非零退出也不等于工具基础设施失败。Agent 必须同时看 output、exit 和停止说明。
9. OpenCode 的选择:三个关键取舍
Section titled “9. OpenCode 的选择:三个关键取舍”选择一:用 AST 提升审批精度
Section titled “选择一:用 AST 提升审批精度”比正则或空格切分更能识别管道、多命令和平台语法;代价是需要 WASM parser、语言差异处理,并且仍无法预知动态运行结果。
选择二:保留完整输出文件,只把尾部送入上下文
Section titled “选择二:保留完整输出文件,只把尾部送入上下文”这保留了调试证据,又避免日志挤爆 token;代价是模型若确实需要早期输出,必须继续读取 outputPath。
选择三:取消和超时属于正常停止模型
Section titled “选择三:取消和超时属于正常停止模型”三种结束原因通过 discriminated kind 统一竞争,降低“忘记杀进程”的概率;代价是调用者要理解 exit: null 可能有多种原因,不能只检查一个数字。
10. 可以带走的方法
Section titled “10. 可以带走的方法”方法一:授权对象应来自结构化计划
Section titled “方法一:授权对象应来自结构化计划”不要让审批 UI 只显示“运行 shell”。至少给出命令 pattern、cwd、静态可见的外部路径。验证问题:批准人能否知道能力、对象和范围?
方法二:把进程生命周期建模成竞争
Section titled “方法二:把进程生命周期建模成竞争”正常退出、取消、超时并列,谁先发生就负责收尾。验证问题:每一种终止原因都会释放进程、流和临时文件句柄吗?
方法三:将“可观察输出”与“模型上下文”分层
Section titled “方法三:将“可观察输出”与“模型上下文”分层”UI 可以看持续预览,完整日志可以落盘,模型只拿受限尾部。验证问题:日志增长是否会无限放大内存、持久化空间或 token 成本?
11. 费曼复述与练习
Section titled “11. 费曼复述与练习”不看源码,用 90 秒解释:
collect的dirs / patterns / always分别服务谁?- 为什么 tree-sitter 扫描不能被称作沙箱?
exit = null可能表示哪两种停止原因?- 模型 shell tool 与用户直接 shell 的授权路径有什么差异?
再做一组递进练习:
- 定位题:找出子进程真正 spawn 之前的所有 yield 点。
- 推演题:按源码推演
cd /tmp && cat a.txt会产生哪些 command pattern 和外部目录候选。 - 故障题:设计一个结果类型,区分 spawn failure、non-zero exit、abort 和 timeout。
- 实现题:为 mini runner 增加 200 行尾部窗口与完整输出文件。
- 边界题:列出两种静态扫描无法可靠知道的间接文件访问方式。
12. 最后复盘:命令跑完,怎样判断代码真的变好?
Section titled “12. 最后复盘:命令跑完,怎样判断代码真的变好?”Shell tool 的主干可以压缩成一行:
解析命令 -> 生成审批计划 -> 获得授权 -> 非交互执行 -> 流式观察 -> 受控停止 -> 限流回填它让 Agent 的“手”更可控,却没有直接理解代码语义。测试命令可能太慢,grep 只能看到字符串,编译命令又常常覆盖整个项目。下一章要解决的就是:能否像 IDE 一样,在每次编辑后得到与具体文件、位置和语言相关的快速反馈?