跳转到内容

Shell / 命令执行

v1.18.16 源码提交 a3647eb025c7 · 官方 Release
状态已完成
难度较难
预计阅读45 分钟
  • 章节 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
  • packages/opencode/src/tool/shell.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/session/run-state.ts
  • packages/opencode/src/permission/index.ts

源码基线:v1.18.16(提交 a3647eb025c7)。本章用 pnpm test 和带路径的命令做教学追踪;除非明确说明,它们都是典型调用,不是本章真实执行记录。

学完本章,你应该能:

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

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

本章的中心问题是:

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

  • 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 callShellTool 的 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}

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

  1. 执行什么:命令 AST 与 permission pattern。
  2. 会碰哪里:工作目录和文件参数是否越过项目边界。
  3. 如何结束: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.tspackages/opencode/src/tool/shell.tspackages/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}

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

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

路径:packages/opencode/src/tool/shell.tspackages/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

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

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 到来时,工具会:

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

路径:packages/opencode/src/tool/shell.tspackages/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 中包含 exittruncated,必要时还有 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.tspackages/opencode/src/tool/shell.tspackages/opencode/src/tool/shell.ts

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

但动态表达式会被保守地跳过:$()${}、反引号、某些变量或从首字符开始的 glob 无法在执行前可靠解析。见 packages/opencode/src/tool/shell.tspackages/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.tspackages/opencode/src/session/run-state.ts

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

8. 失败路径:命令在哪个检查点停下

Section titled “8. 失败路径:命令在哪个检查点停下”
情况停止点子进程是否启动结果特征
shell/parser 无法解析parsetool error
timeout 为负数execute 参数校验明确 error
外部目录或 shell permission deny/rejectaskpermission error
spawn 失败spawner.spawn启动失败Effect defect/tool failure
正常退出,code = 0exit racemetadata.exit = 0
正常退出,code != 0exit race返回输出与非零 code,不自动抛错
用户取消abort racekill,exit = null,附取消说明
超时timeout racekill,exit = null,附超时说明
输出过长streaming/finalize只回传尾部,完整输出保存到 outputPath
命令等待交互输入运行中stdin: ignore 无法回答,通常直到退出/取消/超时

输出截断不是执行失败,非零退出也不等于工具基础设施失败。Agent 必须同时看 outputexit 和停止说明。

9. OpenCode 的选择:三个关键取舍

Section titled “9. OpenCode 的选择:三个关键取舍”

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

选择二:保留完整输出文件,只把尾部送入上下文

Section titled “选择二:保留完整输出文件,只把尾部送入上下文”

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

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

Section titled “选择三:取消和超时属于正常停止模型”

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

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

Section titled “方法一:授权对象应来自结构化计划”

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

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

Section titled “方法二:把进程生命周期建模成竞争”

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

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

Section titled “方法三:将“可观察输出”与“模型上下文”分层”

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

不看源码,用 90 秒解释:

  1. collectdirs / 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. 最后复盘:命令跑完,怎样判断代码真的变好?

Section titled “12. 最后复盘:命令跑完,怎样判断代码真的变好?”

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

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

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