# Shell / 命令执行

> 源码基线：`eec0843ce422`。本章用 `pnpm test` 和带路径的命令做教学追踪；除非明确说明，它们都是**典型调用**，不是本章真实执行记录。

## 0. 本章学习目标

学完本章，你应该能：

- 画出 shell tool 的 `parse -> collect -> ask -> run -> result` 主链路。
- 解释为什么命令审批不能只看整段字符串，也不能把静态扫描当成沙箱。
- 追踪一次测试命令如何解析、申请权限、启动进程并回填输出。
- 区分模型调用 shell tool 与用户直接运行 shell 的两条 session 路径。
- 说明正常退出、非零退出、解析失败、拒绝、超时、取消和输出截断分别怎样结束。
- 为自己的 mini agent 设计最小可用的命令执行边界。

## 1. 一句话讲明白

OpenCode 不把 shell 当作一个 `exec(command)`，而把它当作一份需要**执行前扫描与授权、执行中可观察与可取消、执行后可回填与限流**的操作单。

本章的中心问题是：

> 一段 shell 文本既能读文件、删目录、启动子进程，也可能永远不退出；OpenCode 如何在不真正执行它的前提下先建立边界，又如何在启动后收住资源和输出？

## 2. 先看全图：两条入口，一种会话结果

```text
入口 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. 最小机制：先做计划，再启动进程

抹去 OpenCode 的产品细节，一个 Agent shell runner 至少需要：

```ts
async function runShell(input, ctx) {
  const plan = scan(input.command, input.cwd)
  await ctx.ask(plan.permissions)

  const child = spawnNonInteractive(input.command, input.cwd, input.env)
  const exit = await race(child.exit, ctx.abort, timeout(input.timeout))

  if (exit.kind !== "exit") await kill(child)
  return limitAndDescribeOutput(child.output, exit)
}
```

这条骨架里有三个独立问题：

1. **执行什么**：命令 AST 与 permission pattern。
2. **会碰哪里**：工作目录和文件参数是否越过项目边界。
3. **如何结束**：exit、abort、timeout 三者谁先发生。

OpenCode 的复杂度主要来自跨平台解析、流式输出和会话集成；最小安全内核仍是这三问。

## 4. 为什么先 parse，而不是 `split(" ")`

Shell 文本不是空格分隔的参数数组。下面几段都包含会误导简单 split 的结构：

```sh
printf '%s\n' "a b"
cat src/a.ts | rg "TODO"
cd /tmp && rm -r demo
echo "$HOME"
```

OpenCode 懒加载 tree-sitter 的 Bash 与 PowerShell grammar，并按当前 shell 选择 parser。第一次使用时才加载 WASM，解析树在 Effect scope 结束时删除。见 `packages/opencode/src/tool/shell.ts:260-264`、`packages/opencode/src/tool/shell.ts:307-332`、`packages/opencode/src/tool/shell.ts:621-630`。

Java 类比是“parser + try-with-resources”，而不是 `Runtime.exec(String)`。类比的边界是：tree-sitter 只提供语法树，它不执行 shell 的全部动态语义。

## 5. 一条具体源码旅程：模型请求运行测试

假设模型发出典型 tool call：

```json
{
  "command": "pnpm test",
  "description": "运行项目测试",
  "workdir": "/workspace/project"
}
```

### 5.1 选择 shell、cwd 和 timeout

工具从配置选择可接受的 shell，渲染与平台匹配的 prompt/schema。执行时：

- `workdir` 存在就相对 instance directory 解析，否则使用 instance directory。
- 负数 timeout 直接报错。
- 未提供 timeout 时，默认来自 runtime flag，否则为两分钟。

路径：`packages/opencode/src/tool/shell.ts:334-343`、`packages/opencode/src/tool/shell.ts:598-620`。

注意源码只拒绝 `< 0`，所以 `timeout: 0` 在当前实现中是合法输入，并会几乎立即进入 timeout 竞争。不要把错误文案里的“positive”扩大解释成严格大于零的运行时校验。

### 5.2 parse 产出 AST，collect 产出审批计划

`collect` 遍历所有 command node，把结果写入三个集合：

```ts
type Scan = {
  dirs: Set<string>
  patterns: Set<string>
  always: Set<string>
}
```

路径：`packages/opencode/src/tool/shell.ts:69-78`

对于 `pnpm test`：

- 命令不是 `cd/chdir/...`，所以原始 command source 加入 `patterns`。
- `BashArity.prefix(tokens)` 生成更适合“总是允许”的前缀 pattern，再加 ` *` 放入 `always`。
- 它不属于内置文件命令集合，因此不会从参数里推导外部目录。

这三点是根据 `collect` 控制流对该典型输入的推演，不是运行日志。主实现位于 `packages/opencode/src/tool/shell.ts:374-410`。

<!-- source-ref path="packages/opencode/src/tool/shell.ts" lines="374-410" title="collect：从命令 AST 生成审批计划" note="patterns 管当前命令，always 管可记住的前缀，dirs 管已识别的外部路径。" -->

### 5.3 两类权限按顺序询问

`ask` 先处理外部目录，再处理 shell pattern：

```ts
if (scan.dirs.size > 0) {
  yield* ctx.ask({ permission: "external_directory", patterns: globs, always: globs })
}
if (scan.patterns.size > 0) {
  yield* ctx.ask({ permission: ShellID.ToolID, patterns, always })
}
```

路径：`packages/opencode/src/tool/shell.ts:266-287`

若 `workdir` 本身位于 instance 外，执行入口也会把它加入 `scan.dirs`，见 `packages/opencode/src/tool/shell.ts:626-628`。因此“在哪里运行”和“命令文本会访问哪里”都进入外部目录边界。

只要任一 ask 被 deny 或 reject，`run` 就不会启动子进程。权限状态机在第 09 章展开。

### 5.4 插件在每次调用前补充环境变量

真正运行前触发 `shell.env` hook：

```ts
const extra = yield* plugin.trigger(
  "shell.env",
  { cwd, sessionID: ctx.sessionID, callID: ctx.callID },
  { env: {} },
)
return { ...process.env, ...extra.env }
```

路径：`packages/opencode/src/tool/shell.ts:412-422`

后展开的 `extra.env` 会覆盖同名进程环境变量。这是扩展点，也是一条信任边界：命令实际看到的环境不一定只来自宿主 `process.env`。

### 5.5 进程以非交互方式启动

PowerShell 与普通 shell 的命令构造不同，但二者都设置 `stdin: "ignore"`。POSIX 路径使用 detached process，Windows PowerShell 不 detached。见 `packages/opencode/src/tool/shell.ts:289-305`。

因此 shell tool 适合测试、构建、查询等非交互命令，不适合等待密码、确认提示或全屏 TUI。一个“看起来卡住”的命令，可能不是计算慢，而是在等待永远不会到来的 stdin。

### 5.6 输出一边流入 UI，一边受容量限制

子进程的合并输出 `handle.all` 被解码成文本流。每个 chunk 到来时，工具会：

1. 维护有限的尾部 chunk 列表。
2. 更新 `last` 预览。
3. 通过 `ctx.metadata` 把进行中输出写入 ToolPart。
4. 完整输出超过阈值后，把内容转存到截断文件，并继续追加。

路径：`packages/opencode/src/tool/shell.ts:435-477`、`packages/opencode/src/tool/shell.ts:479-531`。

这同时服务两个消费者：人需要看到进展，模型上下文又不能被无限日志淹没。

### 5.7 exit、abort、timeout 竞争决定停止原因

```ts
const exit = yield* Effect.raceAll([
  handle.exitCode.pipe(/* kind: exit */),
  abort.pipe(/* kind: abort */),
  timeout.pipe(/* kind: timeout */),
])
```

路径：`packages/opencode/src/tool/shell.ts:533-557`

abort 或 timeout 获胜时，会 kill handle，并设置三秒后的强制终止。正常 exit 则保留真实 exit code。最终 metadata 中包含 `exit`、`truncated`，必要时还有 `outputPath`；文本 output 会附上超时或用户取消说明。见 `packages/opencode/src/tool/shell.ts:561-595`。

这里要避免一个常见误读：**非零 exit code 不会在 `run` 中自动抛成工具异常。**它作为 `metadata.exit` 返回，让 Agent 根据输出决定下一步。源码中的 `code` 类型就是 `number | null`，见 `packages/opencode/src/tool/shell.ts:479-559`。

## 6. 外部路径扫描：有价值，但不是沙箱

OpenCode 维护一组可能接收文件参数的命令，如 `rm/cp/mv/cat` 以及对应 PowerShell、cmd 命令。`pathArgs` 过滤 flags，再由 `argPath` 处理引号、`~`、部分环境变量、glob 前缀和 Windows path。见 `packages/opencode/src/tool/shell.ts:28-67`、`packages/opencode/src/tool/shell.ts:130-220`、`packages/opencode/src/tool/shell.ts:345-372`。

若静态得到的路径在 instance 外，`collect` 把其目录加入 `dirs`。例如对 `cat /tmp/report.txt`，按当前控制流会尝试申请 `/tmp/*` 的外部目录权限。

但动态表达式会被保守地跳过：`$()`、`${}`、反引号、某些变量或从首字符开始的 glob 无法在执行前可靠解析。见 `packages/opencode/src/tool/shell.ts:177-188`、`packages/opencode/src/tool/shell.ts:365-370`。

证据边界必须明确：

- 这段扫描能提高审批信息质量。
- 它不是 OS sandbox、容器隔离或 syscall 拦截。
- 源码没有证明所有 shell 语义和间接文件访问都能被提前发现。

所以不要写成“OpenCode 能保证命令绝不访问未授权路径”。更准确的说法是：它对已识别的文件命令和静态路径增加一层 preflight permission gate。

## 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:492-559`。

随后它使用首选 shell 启动命令，流式更新 ToolPart metadata，结束后把 part 改为 completed。取消会在 output 中加入说明。见 `packages/opencode/src/session/prompt.ts:571-646`。

同一 session 的运行协调交给 `SessionRunState.startShell`。内部按 sessionID 复用 runner；runner busy 会转成 `Session.BusyError`。见 `packages/opencode/src/session/run-state.ts:34-68`、`packages/opencode/src/session/run-state.ts:95-104`。

这解决的是会话状态竞争，不等于系统全局只允许一个进程。不同 session 可以有各自 runner。

## 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 的选择：三个关键取舍

### 选择一：用 AST 提升审批精度

比正则或空格切分更能识别管道、多命令和平台语法；代价是需要 WASM parser、语言差异处理，并且仍无法预知动态运行结果。

### 选择二：保留完整输出文件，只把尾部送入上下文

这保留了调试证据，又避免日志挤爆 token；代价是模型若确实需要早期输出，必须继续读取 `outputPath`。

### 选择三：取消和超时属于正常停止模型

三种结束原因通过 discriminated `kind` 统一竞争，降低“忘记杀进程”的概率；代价是调用者要理解 `exit: null` 可能有多种原因，不能只检查一个数字。

## 10. 可以带走的方法

### 方法一：授权对象应来自结构化计划

不要让审批 UI 只显示“运行 shell”。至少给出命令 pattern、cwd、静态可见的外部路径。验证问题：**批准人能否知道能力、对象和范围？**

### 方法二：把进程生命周期建模成竞争

正常退出、取消、超时并列，谁先发生就负责收尾。验证问题：**每一种终止原因都会释放进程、流和临时文件句柄吗？**

### 方法三：将“可观察输出”与“模型上下文”分层

UI 可以看持续预览，完整日志可以落盘，模型只拿受限尾部。验证问题：**日志增长是否会无限放大内存、持久化空间或 token 成本？**

## 11. 费曼复述与练习

不看源码，用 90 秒解释：

1. `collect` 的 `dirs / patterns / always` 分别服务谁？
2. 为什么 tree-sitter 扫描不能被称作沙箱？
3. `exit = null` 可能表示哪两种停止原因？
4. 模型 shell tool 与用户直接 shell 的授权路径有什么差异？

再做一组递进练习：

1. **定位题**：找出子进程真正 spawn 之前的所有 yield 点。
2. **推演题**：按源码推演 `cd /tmp && cat a.txt` 会产生哪些 command pattern 和外部目录候选。
3. **故障题**：设计一个结果类型，区分 spawn failure、non-zero exit、abort 和 timeout。
4. **实现题**：为 mini runner 增加 200 行尾部窗口与完整输出文件。
5. **边界题**：列出两种静态扫描无法可靠知道的间接文件访问方式。

## 12. 最后复盘：命令跑完，怎样判断代码真的变好？

Shell tool 的主干可以压缩成一行：

```text
解析命令 -> 生成审批计划 -> 获得授权 -> 非交互执行 -> 流式观察 -> 受控停止 -> 限流回填
```

它让 Agent 的“手”更可控，却没有直接理解代码语义。测试命令可能太慢，grep 只能看到字符串，编译命令又常常覆盖整个项目。下一章要解决的就是：**能否像 IDE 一样，在每次编辑后得到与具体文件、位置和语言相关的快速反馈？**
