跳转到内容

Shell / 命令执行

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度较难
预计阅读45 分钟
  • 章节 ID:07-shell-execution
  • 章节摘要:沿一次测试命令,理解 shell tool 的 parse、collect、ask、run 管线,以及外部路径、输出截断、取消和超时边界。
  • 教程版本:eec0843c
  • 源码基线:eec0843ce42298080569ca31a6455bc3f699d213
  • 章节元数据:/versions/eec0843c/data/chapters.json
  • 源码映射:/versions/eec0843c/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

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

学完本章,你应该能:

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

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

本章的中心问题是:

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

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.ts:260-264packages/opencode/src/tool/shell.ts:307-332packages/opencode/src/tool/shell.ts:621-630

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:260-264
260const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) {处理命令执行。261  const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command)))开始解析命令参数。262  if (!tree) throw new Error("Failed to parse command")处理命令执行。263  return tree返回给上一层。264})
packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:307-332
307const parser = lazy(async () => {308  const { Parser } = await import("web-tree-sitter")按需加载模块。309  const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {按需加载模块。310    with: { type: "wasm" },311  })312  const treePath = resolveWasm(treeWasm)313  await Parser.init({314    locateFile() {315      return treePath返回给上一层。316    },317  })318  const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {按需加载模块。319    with: { type: "wasm" },320  })321  const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, {按需加载模块。322    with: { type: "wasm" },323  })324  const bashPath = resolveWasm(bashWasm)325  const psPath = resolveWasm(psWasm)326  const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])并行等待多个任务。327  const bash = new Parser()328  bash.setLanguage(bashLanguage)329  const ps = new Parser()330  ps.setLanguage(psLanguage)331  return { bash, ps }返回给上一层。332})
packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:621-630
621              yield* Effect.scoped(Effect 异步工作流。622                Effect.gen(function* () {Effect 异步工作流。623                  const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>处理命令执行。624                    Effect.sync(() => tree.delete()),Effect 异步工作流。625                  )626                  const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx)处理命令执行。627                  if (!containsPath(cwd, instanceCtx)) scan.dirs.add(cwd)按条件进入分支。628                  yield* ask(ctx, scan)等待 Effect 结果。629                }),630              )

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.ts:334-343packages/opencode/src/tool/shell.ts:598-620

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:334-343
334export const ShellTool = Tool.define(声明可调用工具。335  ShellID.ToolID,336  Effect.gen(function* () {Effect 异步工作流。337    const config = yield* Config.Service等待 Effect 结果。338    const spawner = yield* ChildProcessSpawner等待 Effect 结果。339    const fs = yield* AppFileSystem.Service读写本地文件。340    const trunc = yield* Truncate.Service等待 Effect 结果。341    const plugin = yield* Plugin.Service调用插件扩展点。342    const flags = yield* RuntimeFlags.Service等待 Effect 结果。343    const defaultTimeout = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:598-620
598    return () =>返回给上一层。599      Effect.gen(function* () {Effect 异步工作流。600        const cfg = yield* config.get()读取运行配置。601        const shell = Shell.acceptable(cfg.shell)处理命令执行。602        const name = Shell.name(shell)处理命令执行。603        const limits = yield* trunc.limits()等待 Effect 结果。604        const prompt = ShellPrompt.render(name, process.platform, limits)605        log.info("shell tool using shell", { shell })处理命令执行。606607        return {返回给上一层。608          description: prompt.description,609          parameters: prompt.parameters,610          execute: (params: Parameters, ctx: Tool.Context) =>611            Effect.gen(function* () {Effect 异步工作流。612              const instanceCtx = yield* InstanceState.context等待 Effect 结果。613              const cwd = params.workdir614                ? yield* resolvePath(params.workdir, instanceCtx.directory, shell)处理命令执行。615                : instanceCtx.directory616              if (params.timeout !== undefined && params.timeout < 0) {按条件进入分支。617                throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)失败时抛出错误。618              }619              const timeout = params.timeout ?? defaultTimeout620              const ps = Shell.ps(shell)处理命令执行。

注意源码只拒绝 < 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:69-78

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:69-78
69type Part = {定义数据结构约束。70  type: string71  text: string72}7374type Scan = {定义数据结构约束。75  dirs: Set<string>76  patterns: Set<string>77  always: Set<string>78}

对于 pnpm test

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

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

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:374-410
374    const collect = Effect.fn("ShellTool.collect")(function* (Effect 异步工作流。375      root: Node,376      cwd: string,377      ps: boolean,378      shell: string,处理命令执行。379      instance: InstanceContext,380    ) {381      const scan: Scan = {382        dirs: new Set<string>(),383        patterns: new Set<string>(),384        always: new Set<string>(),385      }386      const shellKind = ShellID.toKind(Shell.name(shell))处理命令执行。387388      for (const node of commands(root)) {处理命令执行。389        const command = parts(node)处理命令执行。390        const tokens = command.map((item) => item.text)处理命令执行。391        const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0]处理命令执行。392393        if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) {处理命令执行。394          for (const arg of pathArgs(command, ps, shellKind === "cmd")) {处理命令执行。395            const resolved = yield* argPath(arg, cwd, ps, shell)处理命令执行。396            log.info("resolved path", { arg, resolved })397            if (!resolved || containsPath(resolved, instance)) continue按条件进入分支。398            const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)读写本地文件。399            scan.dirs.add(dir)400          }401        }402403        if (tokens.length && (!cmd || !CWD.has(cmd))) {按条件进入分支。404          scan.patterns.add(source(node))405          scan.always.add(BashArity.prefix(tokens).join(" ") + " *")处理命令执行。406        }407      }408409      return scan返回给上一层。410    })
collect:从命令 AST 生成审批计划 packages/opencode/src/tool/shell.ts:374-410

patterns 管当前命令,always 管可记住的前缀,dirs 管已识别的外部路径。

374    const collect = Effect.fn("ShellTool.collect")(function* (Effect 异步工作流。375      root: Node,376      cwd: string,377      ps: boolean,378      shell: string,处理命令执行。379      instance: InstanceContext,380    ) {381      const scan: Scan = {382        dirs: new Set<string>(),383        patterns: new Set<string>(),384        always: new Set<string>(),385      }386      const shellKind = ShellID.toKind(Shell.name(shell))处理命令执行。387388      for (const node of commands(root)) {处理命令执行。389        const command = parts(node)处理命令执行。390        const tokens = command.map((item) => item.text)处理命令执行。391        const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0]处理命令执行。392393        if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) {处理命令执行。394          for (const arg of pathArgs(command, ps, shellKind === "cmd")) {处理命令执行。395            const resolved = yield* argPath(arg, cwd, ps, shell)处理命令执行。396            log.info("resolved path", { arg, resolved })397            if (!resolved || containsPath(resolved, instance)) continue按条件进入分支。398            const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)读写本地文件。399            scan.dirs.add(dir)400          }401        }402403        if (tokens.length && (!cmd || !CWD.has(cmd))) {按条件进入分支。404          scan.patterns.add(source(node))405          scan.always.add(BashArity.prefix(tokens).join(" ") + " *")处理命令执行。406        }407      }408409      return scan返回给上一层。410    })

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:266-287

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:266-287
266const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) {Effect 异步工作流。267  if (scan.dirs.size > 0) {按条件进入分支。268    const globs = Array.from(scan.dirs).map((dir) => {269      if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))读写本地文件。270      return path.join(dir, "*")返回给上一层。271    })272    yield* ctx.ask({执行前先走权限。273      permission: "external_directory",274      patterns: globs,275      always: globs,276      metadata: {},277    })278  }279280  if (scan.patterns.size === 0) return按条件进入分支。281  yield* ctx.ask({执行前先走权限。282    permission: ShellID.ToolID,283    patterns: Array.from(scan.patterns),284    always: Array.from(scan.always),285    metadata: {},286  })287})

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

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:626-628
626                  const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx)处理命令执行。627                  if (!containsPath(cwd, instanceCtx)) scan.dirs.add(cwd)按条件进入分支。628                  yield* ask(ctx, scan)等待 Effect 结果。

只要任一 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:412-422

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:412-422
412    const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {处理命令执行。413      const extra = yield* plugin.trigger(调用插件扩展点。414        "shell.env",处理命令执行。415        { cwd, sessionID: ctx.sessionID, callID: ctx.callID },416        { env: {} },417      )418      return {返回给上一层。419        ...process.env,420        ...extra.env,421      }422    })

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

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

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:289-305
289function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {处理命令执行。290  if (process.platform === "win32" && Shell.ps(shell)) {处理命令执行。291    return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {处理命令执行。292      cwd,293      env,294      stdin: "ignore",295      detached: false,296    })297  }298299  return ChildProcess.make(command, [], {处理命令执行。300    shell,处理命令执行。301    cwd,302    env,303    stdin: "ignore",304    detached: process.platform !== "win32",305  })

因此 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.ts:435-477packages/opencode/src/tool/shell.ts:479-531

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:435-477
435      const limits = yield* trunc.limits()等待 Effect 结果。436      const keep = limits.maxBytes * 2437      let full = ""438      let last = ""439      const list: Chunk[] = []440      let used = 0441      let file = ""442      let sink: ReturnType<typeof createWriteStream> | undefined443      let cut = false444      let expired = false445      let aborted = false446447      const closeSink = Effect.fnUntraced(function* () {Effect 异步工作流。448        const stream = sink449        if (!stream) return按条件进入分支。450        sink = undefined451        if (stream.destroyed || stream.closed) return按条件进入分支。452        yield* Effect.promise(Effect 异步工作流。453          () =>454            new Promise<void>((resolve) => {455              let settled = false456              const done = () => {457                if (settled) return按条件进入分支。458                settled = true459                stream.off("close", done)460                stream.off("error", done)461                stream.off("finish", done)462                resolve()463              }464              stream.once("close", done)465              stream.once("error", done)466              stream.once("finish", done)467              stream.end(done)468            }),469        ).pipe(Effect.catch(() => Effect.void))Effect 异步工作流。470      })471472      yield* ctx.metadata({等待 Effect 结果。473        metadata: {474          output: "",475          description: input.description,476        },477      })
packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:479-531
479      const code: number | null = yield* Effect.scoped(Effect 异步工作流。480        Effect.gen(function* () {Effect 异步工作流。481          yield* Effect.addFinalizer(closeSink)Effect 异步工作流。482          const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))处理命令执行。483484          yield* Effect.forkScoped(Effect 异步工作流。485            Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {486              const size = Buffer.byteLength(chunk, "utf-8")487              list.push({ text: chunk, size })488              used += size489              while (used > keep && list.length > 1) {490                const item = list.shift()491                if (!item) break按条件进入分支。492                used -= item.size493                cut = true494              }495496              last = preview(last + chunk)497498              if (file) {按条件进入分支。499                sink?.write(chunk)500              } else {501                full += chunk502                if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) {按条件进入分支。503                  return trunc.write(full).pipe(返回给上一层。504                    Effect.andThen((next) =>Effect 异步工作流。505                      Effect.sync(() => {Effect 异步工作流。506                        file = next507                        cut = true508                        sink = createWriteStream(next, { flags: "a" })509                        full = ""510                      }),511                    ),512                    Effect.andThen(Effect 异步工作流。513                      ctx.metadata({514                        metadata: {515                          output: last,516                          description: input.description,517                        },518                      }),519                    ),520                  )521                }522              }523524              return ctx.metadata({返回给上一层。525                metadata: {526                  output: last,527                  description: input.description,528                },529              })530            }),531          )

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

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:533-557

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:533-557
533          const abort = Effect.callback<void>((resume) => {Effect 异步工作流。534            if (ctx.abort.aborted) return resume(Effect.void)Effect 异步工作流。535            const handler = () => resume(Effect.void)Effect 异步工作流。536            ctx.abort.addEventListener("abort", handler, { once: true })537            return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))Effect 异步工作流。538          })539540          const timeout = Effect.sleep(`${input.timeout + 100} millis`)Effect 异步工作流。541542          const exit = yield* Effect.raceAll([Effect 异步工作流。543            handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),Effect 异步工作流。544            abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),Effect 异步工作流。545            timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),Effect 异步工作流。546          ])547548          if (exit.kind === "abort") {按条件进入分支。549            aborted = true550            yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)Effect 异步工作流。551          }552          if (exit.kind === "timeout") {按条件进入分支。553            expired = true554            yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)Effect 异步工作流。555          }556557          return exit.kind === "exit" ? exit.code : null返回给上一层。

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

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:561-595
561      const meta: string[] = []562      if (expired) {按条件进入分支。563        meta.push(564          `shell tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`,处理命令执行。565        )566      }567      if (aborted) meta.push("User aborted the command")处理命令执行。568      const raw = list.map((item) => item.text).join("")569      const end = tail(raw, limits.maxLines, limits.maxBytes)570      if (end.cut) cut = true按条件进入分支。571      if (!file && end.cut) {按条件进入分支。572        file = yield* trunc.write(raw)等待 Effect 结果。573      }574575      let output = end.text576      if (!output) output = "(no output)"按条件进入分支。577578      if (cut && file) {按条件进入分支。579        output = `...output truncated...\n\nFull output saved to: ${file}\n\n` + output580      }581582      if (meta.length > 0) {按条件进入分支。583        output += "\n\n<shell_metadata>\n" + meta.join("\n") + "\n</shell_metadata>"处理命令执行。584      }585      return {返回给上一层。586        title: input.description,587        metadata: {588          output: last || preview(output),589          exit: code,590          description: input.description,591          truncated: cut,592          ...(cut && file ? { outputPath: file } : {}),593        },594        output,595      }

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

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:479-559
479      const code: number | null = yield* Effect.scoped(Effect 异步工作流。480        Effect.gen(function* () {Effect 异步工作流。481          yield* Effect.addFinalizer(closeSink)Effect 异步工作流。482          const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))处理命令执行。483484          yield* Effect.forkScoped(Effect 异步工作流。485            Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {486              const size = Buffer.byteLength(chunk, "utf-8")487              list.push({ text: chunk, size })488              used += size489              while (used > keep && list.length > 1) {490                const item = list.shift()491                if (!item) break按条件进入分支。492                used -= item.size493                cut = true494              }495496              last = preview(last + chunk)497498              if (file) {按条件进入分支。499                sink?.write(chunk)500              } else {501                full += chunk502                if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) {按条件进入分支。503                  return trunc.write(full).pipe(返回给上一层。504                    Effect.andThen((next) =>Effect 异步工作流。505                      Effect.sync(() => {Effect 异步工作流。506                        file = next507                        cut = true508                        sink = createWriteStream(next, { flags: "a" })509                        full = ""510                      }),511                    ),512                    Effect.andThen(Effect 异步工作流。513                      ctx.metadata({514                        metadata: {515                          output: last,516                          description: input.description,517                        },518                      }),519                    ),520                  )521                }522              }523524              return ctx.metadata({返回给上一层。525                metadata: {526                  output: last,527                  description: input.description,528                },529              })530            }),531          )532533          const abort = Effect.callback<void>((resume) => {Effect 异步工作流。534            if (ctx.abort.aborted) return resume(Effect.void)Effect 异步工作流。535            const handler = () => resume(Effect.void)Effect 异步工作流。536            ctx.abort.addEventListener("abort", handler, { once: true })537            return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))Effect 异步工作流。538          })539540          const timeout = Effect.sleep(`${input.timeout + 100} millis`)Effect 异步工作流。541542          const exit = yield* Effect.raceAll([Effect 异步工作流。543            handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),Effect 异步工作流。544            abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),Effect 异步工作流。545            timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),Effect 异步工作流。546          ])547548          if (exit.kind === "abort") {按条件进入分支。549            aborted = true550            yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)Effect 异步工作流。551          }552          if (exit.kind === "timeout") {按条件进入分支。553            expired = true554            yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)Effect 异步工作流。555          }556557          return exit.kind === "exit" ? exit.code : null返回给上一层。558        }),559      ).pipe(Effect.orDie)Effect 异步工作流。

6. 外部路径扫描:有价值,但不是沙箱

Section titled “6. 外部路径扫描:有价值,但不是沙箱”

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

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:28-67
28const MAX_METADATA_LENGTH = 30_00029const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])30const FILES = new Set([31  ...CWD,32  "rm",33  "cp",34  "mv",35  "mkdir",36  "touch",37  "chmod",38  "chown",39  "cat",40  // Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir41  // already hit the entries above, and alias normalization should happen in one42  // place later so we do not risk double-prompting.43  "get-content",44  "set-content",45  "add-content",46  "copy-item",47  "move-item",48  "remove-item",49  "new-item",50  "rename-item",51])52const CMD_FILES = new Set([53  "copy",54  "del",55  "dir",56  "erase",57  "md",58  "mkdir",59  "move",60  "rd",61  "ren",62  "rename",63  "rmdir",64  "type",65])66const FLAGS = new Set(["-destination", "-literalpath", "-path"])67const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:130-220
130function unquote(text: string) {定义一段可复用逻辑。131  if (text.length < 2) return text按条件进入分支。132  const first = text[0]133  const last = text[text.length - 1]134  if ((first === '"' || first === "'") && first === last) return text.slice(1, -1)按条件进入分支。135  return text返回给上一层。136}137138function home(text: string) {定义一段可复用逻辑。139  if (text === "~") return os.homedir()按条件进入分支。140  if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2))按条件进入分支。141  return text返回给上一层。142}143144function envValue(key: string) {定义一段可复用逻辑。145  if (process.platform !== "win32") return process.env[key]按条件进入分支。146  const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())147  return name ? process.env[name] : undefined返回给上一层。148}149150function auto(key: string, cwd: string, shell: string) {处理命令执行。151  const name = key.toUpperCase()152  if (name === "HOME") return os.homedir()按条件进入分支。153  if (name === "PWD") return cwd按条件进入分支。154  if (name === "PSHOME") return path.dirname(shell)处理命令执行。155}156157function expand(text: string, cwd: string, shell: string) {处理命令执行。158  const out = unquote(text)159    .replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "")160    .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "")161    .replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "")处理命令执行。162  return home(out)返回给上一层。163}164165function provider(text: string) {选择模型或 provider。166  const match = text.match(/^([A-Za-z]+)::(.*)$/)167  if (match) {按条件进入分支。168    if (match[1].toLowerCase() !== "filesystem") return按条件进入分支。169    return match[2]返回给上一层。170  }171  const prefix = text.match(/^([A-Za-z]+):(.*)$/)172  if (!prefix) return text按条件进入分支。173  if (prefix[1].length === 1) return text按条件进入分支。174  return返回给上一层。175}176177function dynamic(text: string, ps: boolean) {定义一段可复用逻辑。178  if (text.startsWith("(") || text.startsWith("@(")) return true按条件进入分支。179  if (text.includes("$(") || text.includes("${") || text.includes("`")) return true按条件进入分支。180  if (ps) return /\$(?!env:)/i.test(text)按条件进入分支。181  return text.includes("$")返回给上一层。182}183184function prefix(text: string) {定义一段可复用逻辑。185  const match = /[?*[]/.exec(text)186  if (!match) return text按条件进入分支。187  if (match.index === 0) return按条件进入分支。188  return text.slice(0, match.index)返回给上一层。189}190191function pathArgs(list: Part[], ps: boolean, cmd = false) {定义一段可复用逻辑。192  if (!ps) {按条件进入分支。193    return list返回给上一层。194      .slice(1)195      .filter(196        (item) =>197          !item.text.startsWith("-") &&198          !(cmd && item.text.startsWith("/")) &&199          !(list[0]?.text === "chmod" && item.text.startsWith("+")),200      )201      .map((item) => item.text)202  }203204  const out: string[] = []205  let want = false206  for (const item of list.slice(1)) {遍历集合。207    if (want) {按条件进入分支。208      out.push(item.text)209      want = false210      continue211    }212    if (item.type === "command_parameter") {处理命令执行。213      const flag = item.text.toLowerCase()214      if (SWITCHES.has(flag)) continue按条件进入分支。215      want = FLAGS.has(flag)216      continue217    }218    out.push(item.text)219  }220  return out返回给上一层。
packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:345-372
345    const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {处理命令执行。346      const lines = yield* spawner等待 Effect 结果。347        .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]))处理命令执行。348        .pipe(Effect.catch(() => Effect.succeed([] as string[])))Effect 异步工作流。349      const file = lines[0]?.trim()350      if (!file) return按条件进入分支。351      return AppFileSystem.normalizePath(file)读写本地文件。352    })353354    const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {处理命令执行。355      if (process.platform === "win32") {按条件进入分支。356        if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {读写本地文件。357          const file = yield* cygpath(shell, text)处理命令执行。358          if (file) return file按条件进入分支。359        }360        return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text)))读写本地文件。361      }362      return path.resolve(root, text)返回给上一层。363    })364365    const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {处理命令执行。366      const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))处理命令执行。367      const file = text && prefix(text)368      if (!file || dynamic(file, ps)) return按条件进入分支。369      const next = ps ? provider(file) : file选择模型或 provider。370      if (!next) return按条件进入分支。371      return yield* resolvePath(next, cwd, shell)处理命令执行。372    })

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

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

packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:177-188
177function dynamic(text: string, ps: boolean) {定义一段可复用逻辑。178  if (text.startsWith("(") || text.startsWith("@(")) return true按条件进入分支。179  if (text.includes("$(") || text.includes("${") || text.includes("`")) return true按条件进入分支。180  if (ps) return /\$(?!env:)/i.test(text)按条件进入分支。181  return text.includes("$")返回给上一层。182}183184function prefix(text: string) {定义一段可复用逻辑。185  const match = /[?*[]/.exec(text)186  if (!match) return text按条件进入分支。187  if (match.index === 0) return按条件进入分支。188  return text.slice(0, match.index)返回给上一层。
packages/opencode/src/tool/shell.ts packages/opencode/src/tool/shell.ts:365-370
365    const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {处理命令执行。366      const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))处理命令执行。367      const file = text && prefix(text)368      if (!file || dynamic(file, ps)) return按条件进入分支。369      const next = ps ? provider(file) : file选择模型或 provider。370      if (!next) return按条件进入分支。

证据边界必须明确:

  • 这段扫描能提高审批信息质量。
  • 它不是 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:492-559

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:492-559
492    const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, ready?: Latch.Latch) {处理命令执行。493      return yield* Effect.uninterruptibleMask((restore) =>Effect 异步工作流。494        Effect.gen(function* () {Effect 异步工作流。495          const markReady = ready ? ready.open.pipe(Effect.asVoid) : Effect.voidEffect 异步工作流。496          const { msg, part, cwd } = yield* Effect.gen(function* () {Effect 异步工作流。497            const ctx = yield* InstanceState.context等待 Effect 结果。498            const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)Effect 异步工作流。499            if (session.revert) {按条件进入分支。500              yield* revert.cleanup(session)等待 Effect 结果。501            }502            const agent = yield* agents.get(input.agent)等待 Effect 结果。503            if (!agent) {按条件进入分支。504              const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。505              const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""506              const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` })507              yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })广播状态变化。508              throw error失败时抛出错误。509            }510            const model = input.model ?? agent.model ?? (yield* currentModel(input.sessionID))等待 Effect 结果。511            const userMsg: MessageV2.User = {会话消息片段结构。512              id: input.messageID ?? MessageID.ascending(),513              sessionID: input.sessionID,514              time: { created: Date.now() },515              role: "user",516              agent: input.agent,517              model: { providerID: model.providerID, modelID: model.modelID },选择模型或 provider。518            }519            yield* sessions.updateMessage(userMsg)等待 Effect 结果。520            const userPart: MessageV2.Part = {会话消息片段结构。521              type: "text",522              id: PartID.ascending(),523              messageID: userMsg.id,524              sessionID: input.sessionID,525              text: "The following tool was executed by the user",526              synthetic: true,527            }528            yield* sessions.updatePart(userPart)等待 Effect 结果。529530            const msg: MessageV2.Assistant = {会话消息片段结构。531              id: MessageID.ascending(),532              sessionID: input.sessionID,533              parentID: userMsg.id,534              mode: input.agent,535              agent: input.agent,536              cost: 0,537              path: { cwd: ctx.directory, root: ctx.worktree },538              time: { created: Date.now() },539              role: "assistant",540              tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },541              modelID: model.modelID,选择模型或 provider。542              providerID: model.providerID,选择模型或 provider。543            }544            yield* sessions.updateMessage(msg)等待 Effect 结果。545            const started = Date.now()546            const part: MessageV2.ToolPart = {会话消息片段结构。547              type: "tool",548              id: PartID.ascending(),549              messageID: msg.id,550              sessionID: input.sessionID,551              tool: ShellID.ToolID,552              callID: ulid(),553              state: {554                status: "running",555                time: { start: started },556                input: { command: input.command },处理命令执行。557              },558            }559            yield* sessions.updatePart(part)等待 Effect 结果。

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

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:571-646
571          const cfg = yield* config.get()读取运行配置。572          const sh = Shell.preferred(cfg.shell)处理命令执行。573          const args = Shell.args(sh, input.command, cwd)处理命令执行。574          let output = ""575          let aborted = false576577          const finish = Effect.uninterruptible(Effect 异步工作流。578            Effect.gen(function* () {Effect 异步工作流。579              if (aborted) {按条件进入分支。580                output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")处理命令执行。581              }582              const completed = Date.now()583              if (flags.experimentalEventSystem) {按条件进入分支。584                yield* events.publish(SessionEvent.Shell.Ended, {广播状态变化。585                  sessionID: input.sessionID,586                  timestamp: DateTime.makeUnsafe(completed),587                  callID: part.callID,588                  output,589                })590              }591              if (!msg.time.completed) {按条件进入分支。592                msg.time.completed = completed593                yield* sessions.updateMessage(msg)等待 Effect 结果。594              }595              if (part.state.status === "running") {按条件进入分支。596                part.state = {597                  status: "completed",598                  time: { ...part.state.time, end: completed },599                  input: part.state.input,600                  title: "",601                  metadata: { output, description: "" },602                  output,603                }604                yield* sessions.updatePart(part)等待 Effect 结果。605              }606            }),607          )608609          const exit = yield* restore(等待 Effect 结果。610            Effect.gen(function* () {Effect 异步工作流。611              const shellEnv = yield* plugin.trigger(调用插件扩展点。612                "shell.env",处理命令执行。613                { cwd, sessionID: input.sessionID, callID: part.callID },614                { env: {} },615              )616              const cmd = ChildProcess.make(sh, args, {617                cwd,618                extendEnv: true,619                env: { ...shellEnv.env, TERM: "dumb" },处理命令执行。620                stdin: "ignore",621                forceKillAfter: "3 seconds",622              })623              const handle = yield* spawner.spawn(cmd)等待 Effect 结果。624              yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>等待 Effect 结果。625                Effect.gen(function* () {Effect 异步工作流。626                  output += chunk627                  if (part.state.status === "running") {按条件进入分支。628                    part.state.metadata = { output, description: "" }629                    yield* sessions.updatePart(part)等待 Effect 结果。630                  }631                }),632              )633              yield* handle.exitCode等待 Effect 结果。634            }).pipe(Effect.scoped, Effect.orDie),Effect 异步工作流。635          ).pipe(Effect.exit)Effect 异步工作流。636637          if (Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause) && !Cause.hasDies(exit.cause)) {按条件进入分支。638            aborted = true639          }640          yield* finish等待 Effect 结果。641642          if (Exit.isFailure(exit) && !aborted && !Cause.hasInterruptsOnly(exit.cause)) {按条件进入分支。643            return yield* Effect.failCause(exit.cause)Effect 异步工作流。644          }645646          return { info: msg, parts: [part] }返回给上一层。

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

packages/opencode/src/session/run-state.ts packages/opencode/src/session/run-state.ts:34-68
34    const state = yield* InstanceState.make(等待 Effect 结果。35      Effect.fn("SessionRunState.state")(function* () {Effect 异步工作流。36        const scope = yield* Scope.Scope等待 Effect 结果。37        const runners = new Map<SessionID, Runner.Runner<MessageV2.WithParts>>()会话消息片段结构。38        yield* Effect.addFinalizer(Effect 异步工作流。39          Effect.fnUntraced(function* () {Effect 异步工作流。40            yield* Effect.forEach(runners.values(), (runner) => runner.cancel, {Effect 异步工作流。41              concurrency: "unbounded",42              discard: true,43            })44            runners.clear()45          }),46        )47        return { runners, scope }返回给上一层。48      }),49    )5051    const runner = Effect.fn("SessionRunState.runner")(function* (Effect 异步工作流。52      sessionID: SessionID,53      onInterrupt: Effect.Effect<MessageV2.WithParts>,会话消息片段结构。54    ) {55      const data = yield* InstanceState.get(state)等待 Effect 结果。56      const existing = data.runners.get(sessionID)57      if (existing) return existing按条件进入分支。58      const next = Runner.make<MessageV2.WithParts>(data.scope, {会话消息片段结构。59        onIdle: Effect.gen(function* () {Effect 异步工作流。60          data.runners.delete(sessionID)61          yield* status.set(sessionID, { type: "idle" })等待 Effect 结果。62        }),63        onBusy: status.set(sessionID, { type: "busy" }),64        onInterrupt,65      })66      data.runners.set(sessionID, next)67      return next返回给上一层。68    })
packages/opencode/src/session/run-state.ts packages/opencode/src/session/run-state.ts:95-104
95    const startShell = Effect.fn("SessionRunState.startShell")(function* (Effect 异步工作流。96      sessionID: SessionID,97      onInterrupt: Effect.Effect<MessageV2.WithParts>,会话消息片段结构。98      work: Effect.Effect<MessageV2.WithParts>,会话消息片段结构。99      ready?: Latch.Latch,100    ) {101      return yield* (yield* runner(sessionID, onInterrupt))等待 Effect 结果。102        .startShell(work, ready)103        .pipe(Effect.catchTag("RunnerBusy", () => Effect.fail(busyError(sessionID))))Effect 异步工作流。104    })

这解决的是会话状态竞争,不等于系统全局只允许一个进程。不同 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 一样,在每次编辑后得到与具体文件、位置和语言相关的快速反馈?