0-1. 学习目标与一句话
Shell 模块把命令执行包装成可扫描、可审批、可取消、可截断、可回填的 tool action。它先用 tree-sitter 分析命令,再通过 ctx.ask 审批,最后执行进程并把输出持续写回 metadata。
来源:packages/opencode/src/tool/shell.ts:266-287、packages/opencode/src/tool/shell.ts:374-410、packages/opencode/src/tool/shell.ts:424-596。
2-5. 它的位置与最小源码路径
源码路径卡片
packages/opencode/src/tool/shell.ts:28-78:风险命令集合和Scan。packages/opencode/src/tool/shell.ts:266-287:把扫描结果转为权限请求。packages/opencode/src/tool/shell.ts:307-332:lazy 初始化 tree-sitter parser。packages/opencode/src/tool/shell.ts:374-410:从 AST 收集外部目录和 shell patterns。packages/opencode/src/tool/shell.ts:424-596:执行、流式输出、截断、超时、取消。packages/opencode/src/session/prompt.ts:492-650:用户直接 shell 命令的 session 记录。packages/opencode/src/session/run-state.ts:10-24:session 级运行状态。
6. 用户输入到 agent 行动的整体链路
model tool-call(shell)
-> ShellTool.execute
-> parse(params.command)
-> collect(rootNode, cwd)
-> ctx.ask(external_directory / shell)
-> ChildProcessSpawner.spawn
-> ctx.metadata(output preview)
-> return { title, metadata, output }
-> processor writes tool result
-> next LLM round
Java 类比
ShellTool 像 ShellCommandService;collect 像执行前的授权分析器;ChildProcessSpawner 像封装过的 ProcessBuilder;SessionRunState 像 per-session lock。
7. 核心源码逐段讲解
命令风险词表
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const FILES = new Set([
...CWD,
"rm",
"cp",
"mv",
"mkdir",
"touch",
"chmod",
"chown",
"cat",
])
type Scan = {
dirs: Set<string>
patterns: Set<string>
always: Set<string>
}
路径:packages/opencode/src/tool/shell.ts:28-78。这些集合告诉扫描器哪些命令可能携带路径参数。
审批
if (scan.dirs.size > 0) {
yield* ctx.ask({
permission: "external_directory",
patterns: globs,
always: globs,
metadata: {},
})
}
if (scan.patterns.size === 0) return
yield* ctx.ask({
permission: ShellID.ToolID,
patterns: Array.from(scan.patterns),
always: Array.from(scan.always),
metadata: {},
})
路径:packages/opencode/src/tool/shell.ts:266-287。外部目录和 shell 命令是两个权限维度。
扫描 AST
for (const node of commands(root)) {
const command = parts(node)
const tokens = command.map((item) => item.text)
const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0]
if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) {
for (const arg of pathArgs(command, ps, shellKind === "cmd")) {
const resolved = yield* argPath(arg, cwd, ps, shell)
if (!resolved || containsPath(resolved, instance)) continue
const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir)
}
}
}
路径:packages/opencode/src/tool/shell.ts:388-400。它不是字符串 split,而是从 tree-sitter AST 抽取命令和参数。
执行与输出回写
const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
last = preview(last + chunk)
return ctx.metadata({
metadata: {
output: last,
description: input.description,
},
})
}),
)
路径:packages/opencode/src/tool/shell.ts:479-530。输出不是最后一次性返回,而是持续更新 tool part。
超时和取消
const exit = yield* Effect.raceAll([
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])
if (exit.kind === "abort") {
aborted = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
路径:packages/opencode/src/tool/shell.ts:542-555。
用户直接执行 shell
const userPart: MessageV2.Part = {
type: "text",
id: PartID.ascending(),
messageID: userMsg.id,
sessionID: input.sessionID,
text: "The following tool was executed by the user",
synthetic: true,
}
路径:packages/opencode/src/session/prompt.ts:520-528。直接 shell 也会写进 session history。
8. 关键 TypeScript 语法复习
Set<string>:类似 JavaHashSet<String>。{ kind: "exit" as const }:字面量类型,类似 sealed subtype tag。params.timeout ?? defaultTimeout:只在 null/undefined 时取默认值。await import("web-tree-sitter"):运行时动态 import。Effect.acquireRelease:类似 Javatry-with-resources。{ ...process.env, ...extra.env }:对象 spread,后者覆盖前者。
9-10. 架构思想与模块协作
- Strategy:
ShellTool是 tool strategy。 - Preflight scanner:
collect先扫描再执行。 - Policy enforcement point:
ctx.ask统一进入权限系统。 - Streaming progress:
ctx.metadata持续回写输出预览。 - Session 协作:tool result 写回 message history,供下一轮 LLM 使用。
- Plugin 协作:
shell.envhook 注入环境变量。
11. mini agent 对应代码
async function runShellTool(input, ctx) {
const pattern = input.command.split(/\s+/).slice(0, 2).join(" ") + " *"
await ctx.ask("shell", [pattern])
const child = spawn(input.command, { cwd: input.cwd, shell: true, signal: ctx.signal })
child.stdout.on("data", chunk => ctx.updateMetadata({ output: String(chunk) }))
const result = await waitWithTimeout(child, input.timeoutMs)
return truncateShellOutput(result)
}
12. 费曼复述区
- 为什么 shell tool 不能直接
exec(command)? dirs、patterns、always分别有什么用?- 用户直接 shell 和模型 tool call shell 的 session 记录有什么差异?
13-15. 练习与自测
- 入门:解释
CWD、FILES、CMD_FILES为什么分开。 - 进阶:读
collect,比较cd /tmp和cat /tmp/a.txt。 - 源码追踪:从
SessionTools.resolve追到ShellTool.execute。 - 小实现:写一个支持 timeout、abort、输出截断的 shell runner。
- 自测:如果模型想执行
rm -rf /tmp/foo,有哪些代码机会阻止它?