Shell / 命令执行
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
07-shell-execution - 章节摘要:沿一次测试命令,理解 shell tool 的 parse、collect、ask、run 管线,以及外部路径、输出截断、取消和超时边界。
- 教程版本:
eec0843c - 源码基线:
eec0843ce42298080569ca31a6455bc3f699d213 - 章节元数据:/versions/eec0843c/data/chapters.json
- 源码映射:/versions/eec0843c/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
源码基线:
eec0843ce422。本章用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 如何在不真正执行它的前提下先建立边界,又如何在启动后收住资源和输出?
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:260-264、packages/opencode/src/tool/shell.ts:307-332、packages/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}
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:334-343、packages/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 })
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: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。
5.5 进程以非交互方式启动
Section titled “5.5 进程以非交互方式启动”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 到来时,工具会:
- 维护有限的尾部 chunk 列表。
- 更新
last预览。 - 通过
ctx.metadata把进行中输出写入 ToolPart。 - 完整输出超过阈值后,把内容转存到截断文件,并继续追加。
路径:packages/opencode/src/tool/shell.ts:435-477、packages/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 中包含 exit、truncated,必要时还有 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-67、packages/opencode/src/tool/shell.ts:130-220、packages/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-188、packages/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-68、packages/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 无法解析 | 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 一样,在每次编辑后得到与具体文件、位置和语言相关的快速反馈?