Shell 工具

工具 ID 为何仍叫 bash、shell 的选择与说明模板、超时中止与输出截断,以及用 tree-sitter 解析命令、按命令元数生成“总是允许”规则。

作者 David更新于 第 15 篇(共 38 篇)

Shell 工具实现在 packages/opencode/src/tool/shell.ts,说明模板与参数在 tool/shell/ 子目录。先看一个细节:不管底层是 bash、PowerShell 还是 cmd,工具 ID 都叫 bash。

Keep the exposed tool ID and permission key as "bash" for compatibility with existing plugins, users, and saved permissions. Rename with opencode 2.0.

(packages/opencode/src/tool/shell/id.ts:14)

工具 ID 同时是权限键,改名会让用户写好的 bash 权限规则全部失效,只能留到 2.0。

参数与 shell 选择

参数只有三个:command、timeout(毫秒)和 workdir。V1 的 Shell 工具没有“后台运行”参数(子代理的后台模式见下一篇)。

用哪个 shell 取决于配置项 shell,缺省取环境变量 SHELL,都要经过 Shell.acceptable 过滤:

const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
  bash: { login: true, posix: true },
  dash: { login: true, posix: true },
  fish: { deny: true, login: true },
  ksh: { login: true, posix: true },
  nu: { deny: true },
  powershell: { ps: true },
  pwsh: { ps: true },
  sh: { login: true, posix: true },
  zsh: { login: true, posix: true },
}

(packages/core/src/shell.ts:13)

fish 与 nu 这两个非 POSIX 的 shell 被标为 deny,会退回默认值:Windows 上依次尝试 pwsh、powershell、Git Bash 和 COMSPEC(通常是 cmd.exe),macOS 用 /bin/zsh,其他系统找 bash,最后兜底 /bin/sh。

说明按 shell 渲染

shell/prompt.ts 以 shell/shell.txt 为模板,按 shell 种类填入不同段落:bash 版讲引号与 && 串联;PowerShell 7 和 5.1 分开写,因为 5.1 不支持 &&,要写成 cmd1; if ($?) { cmd2 };cmd.exe 版讲 %VAR% 与 if exist。默认超时、截断阈值这些数字在渲染时插入,说明与代码不会漂移。

模板里还有两点值得注意:一是明令文件操作改用专门工具,不要用 cat、sed、echo 重定向;二是告诉模型 opencode 的临时目录已预先批准外部访问,对应 agent 默认规则对 Global.Path.tmp 的放行(见权限系统)。

启动进程

function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
  if (process.platform === "win32" && Shell.ps(shell)) {
    return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
      cwd,
      env,
      stdin: "ignore",
      detached: false,
    })
  }

  return ChildProcess.make(command, [], {
    shell,
    cwd,
    env,
    stdin: "ignore",
    detached: process.platform !== "win32",
  })
}

(packages/opencode/src/tool/shell.ts:293)

stdin: "ignore" 让等待输入的交互式命令立刻读到 EOF,而不是挂到超时;非 Windows 下以 detached 启动,子进程自成一个新的进程组;环境变量是 process.env 叠加插件钩子 shell.env 的返回值,插件可以借此追加变量。

这里用的是普通子进程,没有 TTY。packages/core/src/pty.ts 虽然提供伪终端,但它服务的是客户端里的交互式终端(服务端有对应的 pty 路由),与 Shell 工具无关。

超时、中止与输出

默认超时 2 分钟,可用 OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS 修改。执行时让三件事赛跑:

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 }))),
])

(packages/opencode/src/tool/shell.ts:542)

中止或超时都会 handle.kill({ forceKillAfter: "3 seconds" }),并在输出末尾追加 <shell_metadata>;超时时还明确建议模型:命令若不是在等交互输入,就用更大的 timeout 重试。

输出处理比通用截断讲究:

  • 读取 stdout 与 stderr 的合流,每来一块就用 ctx.metadata 推送最近 30000 字符的预览,TUI 实时滚动;
  • 内存只保留最近约两倍 max_bytes 的数据;累计超过 max_bytes 后,全量输出改为流式追加到截断目录的文件;
  • 结束时取尾部的 max_lines 行或 max_bytes 字节作为输出(报错通常在最后),前面注明 ...output truncated... 和全文路径;
  • 元数据带上 exit 退出码,并显式设置 truncated,通用截断不再重复处理。

用 tree-sitter 解析命令

权限判定是 Shell 工具最有意思的部分。执行前,parser() 懒加载 web-tree-sitter 与 bash、PowerShell 两份 wasm 语法,把命令解析成语法树,collect() 遍历其中每个 command 节点(删减后):

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)
    }
  }

  if (tokens.length && (!cmd || !CWD.has(cmd))) {
    scan.patterns.add(source(node))
    scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
  }
}

(packages/opencode/src/tool/shell.ts:392)

它做两件事:

  1. 找越界路径。对 rm、cp、mv、mkdir、touch、chmod、chown、cat、cd 一族,以及 Remove-Item 等 PowerShell cmdlet(cmd.exe 另有 del、copy 等),逐个解析路径参数:跳过选项,展开 ~(PowerShell 还会展开环境变量),含变量或命令替换等动态片段的参数放弃判断,带通配符的只取通配符之前的部分。落在项目之外的目录记入 scan.dirs;工作目录本身在项目外也算。
  2. 生成权限模式。每条非 cd 类命令的原文进入 patterns,同时按命令元数生成一条 always 模式。

随后先询问 external_directory(如有越界目录),再以 bash 为权限键询问全部命令。git status && rm -rf build 会被拆成两个模式分别求值,bash 下一条 "rm *": "deny" 就能拦下整次调用。

命令元数:总是允许什么

“总是允许”如果存整条命令,参数一变就失效;如果存整个工具,又等于放开一切。permission/arity.ts 取中间粒度,即命令的有效前缀:

export function prefix(tokens: string[]) {
  for (let len = tokens.length; len > 0; len--) {
    const prefix = tokens.slice(0, len).join(" ")
    const arity = ARITY[prefix]
    if (arity !== undefined) return tokens.slice(0, arity)
  }
  if (tokens.length === 0) return []
  return tokens.slice(0, 1)
}

(packages/opencode/src/permission/arity.ts:1)

ARITY 是一张一百三十多条的字典,注释说明它由一段提示词生成,规则是最长前缀优先:git 为 2,npm run 与 docker compose 为 3,cat 为 1。于是:

命令记住的 always 模式
git checkout maingit checkout *
npm run devnpm run dev *
docker compose up -ddocker compose up *
./build.sh --fast./build.sh *(不在字典里,只取第一个词)

用户在授权框里选“总是允许”时存下的正是这些模式;而通配符匹配会让结尾的“空格加星号”变成可选,所以 git checkout * 也覆盖不带参数的 git checkout。

下一篇:子代理、待办与提问——task 如何派生子会话、结果怎么回传,以及 todowrite、question、plan_exit 与 skill。

本页目录