exec 与 QuickJS 沙箱

GPT 类模型顶层只剩一个 exec:脚本在 QuickJS 里经 tools 调宿主工具。隔离范围、权限、排除的工具、资源上限与精简工具集。

作者 David更新于 第 24 篇(共 33 篇)

模型每调一次工具就要往返一轮,结果全文进上下文;十个互不相关的读取也得排队问十次。MiMo 给 GPT 类模型换了一种调用方式:顶层只暴露一个 exec 工具,模型提交一段 JavaScript 或 TypeScript,在脚本里 await tools.xxx(...) 调宿主工具,用 Promise.all 并发,把大块中间结果留在脚本里,只返回聚合后的小结果。脚本在 QuickJS 里执行,和 Workflow 运行时共用同一个 evalScript。

上游 v1.14.19 没有 tool/tool-script.ts、tool/gpt.ts、tool/view-image.ts 这几个文件。OpenCode 后来有了自己的 Code Mode,做法完全不同:execute 工具配一个基于 Acorn 的树遍历解释器,主要用来编排 MCP 工具,见联网工具与 Code Mode。仓库里的设计文档 docs/architecture/codex-microkernel-runtime.md 描述了 exec 的思路,但它的工具清单、排除列表和行号都已落后于代码,下文以代码为准并逐处指出。

怎么用

  • 谁会拿到:模型 ID、API 模型 ID 或 family 里含 gpt(gpt-oss 除外)时自动启用。MIMOCODE_CODEX_MODE=false 对所有模型(包括 GPT)关闭;=true 让非 GPT 模型也启用。调用会话接口时还可以在首条消息上带 harness(auto、codex、default),以首条为准,codex 与 default 对非 GPT 模型优先于环境变量(packages/opencode/src/session/prompt.ts:5434)。
  • 非 GPT 模型也想用:设 MIMOCODE_ENABLE_EXEC_TOOL,exec 作为额外工具出现,其余工具不变(packages/opencode/src/tool/registry.ts:374)。
  • 参数:code 是异步函数体;max_tool_calls 默认 50、最多 500;timeout 是毫秒计的活跃计算预算,默认 60000、最多 600000。
  • 脚本里可用:tools.<id>(input) 调内置工具,tools["<目录名>"](input) 调 MCP 工具,只读目录 ALL_TOOLS,临时数据通道 files.readText、files.writeText,以及 console.log。没有 import、require、process、fetch 和定时器(packages/opencode/src/tool/tool-script.txt:37)。
  • Shell:推荐写 tools.exec_command({ cmd, workdir }),它是 bash 的别名;tools.bash(...) 仍然可用。
  • 界面:TUI 把一次 exec 折叠成“exec · N calls · 各工具计数”一行,下面列最近 5 次子调用,点开能看到脚本和每个子调用(packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:2535)。

精简工具集

判定

是否启用由 packages/opencode/src/tool/gpt.ts:11 决定:

function usesCodexMode(harness: HarnessMode | undefined, ...modelIDs: Array<string | undefined>) {
  const mode = Flag.MIMOCODE_CODEX_MODE
  if (mode === false) return false
  if (isGPTModel(...modelIDs)) return true
  return codexHarnessOverride(harness) ?? mode ?? false
}

export function isGPTModel(...values: Array<string | undefined>) {
  const ids = values.flatMap((value) => (value ? [value.toLowerCase()] : []))
  if (ids.some((id) => id.includes("gpt-oss"))) return false
  return ids.some((id) => id.includes("gpt"))
}

设计文档写的规则是“ID 含 gpt-,排除 oss 与 gpt-4”,那其实是上游 v1.14.19 决定是否用 apply_patch 的条件;现在的判定只排除 gpt-oss,gpt-4 系列也算在内。同一个判断还决定系统提示词:启用时用 session/prompt/gpt.txt(packages/opencode/src/session/system.ts:36),提示词与工具集的路由规则见模型、提供方与提示词路由。

谁在顶层,谁在脚本里

ToolRegistry.available 先按工具集增删(registry.ts:386):

if (tool.id === ApplyPatchTool.id || tool.id === ViewImageTool.id) return useGPTTools
if (
  tool.id === EditTool.id ||
  tool.id === MultiEditTool.id ||
  tool.id === WriteTool.id ||
  tool.id === ReadTool.id ||
  tool.id === GrepTool.id ||
  tool.id === GlobTool.id ||
  tool.id === NotebookEditTool.id
)
  return !useGPTTools

得到的列表再分两种用途:registered 把全部授权工具注册进 AI SDK 的工具表,模型万一直接发出某个工具调用也能执行;tools 在 GPT 工具集下只留 GPT_TOP_LEVEL_TOOLS 里的名字(registry.ts:462)。请求里真正下发给模型的 activeTools 用同一个过滤(prompt.ts:1721),MCP 工具也不进顶层。结果是 GPT 模型看到的工具定义只有一个 exec,其余工具都写成 TypeScript 声明附在 exec 的说明后面,由各工具的 zod 参数生成(registry.ts:491、packages/opencode/src/tool/tool-script.ts:159)。

工具非 GPT 模型GPT 工具集
read、write、edit、grep、glob、notebook_edit顶层不提供
apply_patch、view_image不提供在 exec 里调
bash、skill、actor、task、question 等顶层在 exec 里调
MCP 工具顶层,或经 mcp_tool_search 加载在 exec 里按目录名调
exec需 MIMOCODE_ENABLE_EXEC_TOOL唯一的顶层工具

设计文档说 GPT 可见的是 bash、apply_patch、view_image、exec 四个,这与现在的代码不符;GPT_TOP_LEVEL_TOOLS 里的 wait 注释说是为尚未提供的独立工具预留的,注册表里没有它。

对 xiaomi 提供方走内置 openai-compatible 实现的模型,exec 以 Responses API 的 custom tool 形式下发,输入就是脚本原文(packages/opencode/src/provider/provider.ts:1738);模型在函数调用通道里直接塞一段裸字符串时,修复逻辑会把它包成 { code }(packages/opencode/src/util/tool-compat.ts:204)。

apply_patch 与 view_image

apply_patch 是上游已有的工具,补丁格式与校验流程见文件工具。MiMo 的改动集中在几处:参数名从 patchText 改成 patch_text(packages/opencode/src/tool/apply_patch.ts:21);路径相对会话当前目录解析,而不是实例目录(apply_patch.ts:69);越界检查换成 assertWriteAllowed,额外套上系统智能体的写入沙箱与记忆目录守卫(packages/opencode/src/tool/external-directory.ts:109);写入记忆目录的文件不再触发 edit 询问(apply_patch.ts:192)。

view_image 是新增的:先确认当前模型支持图片输入,路径做越界检查并以 read 权限询问,再按文件头嗅探格式,只接受 JPEG、PNG、GIF、WebP,最后把图片作为 data URL 附件返回;detail 参数只写进输出文字和元数据(packages/opencode/src/tool/view-image.ts:34、view-image.ts:71)。

这里有一处需要复核的衔接。GPT 工具集下 view_image 只能在 exec 里调,而 exec 交给脚本的子调用结果只有 title、output、metadata(tool-script.ts:908),附件只记进 sub_parts 元数据供 TUI 显示(tool-script.ts:903),exec 自己的返回值也不带附件。代码里没有找到把这些图片再交给模型的路径。设计文档建议“图片应直接调用 view_image”,可顶层恰恰没有下发它。

一次 exec 怎么跑

  1. 预处理:去掉模型误带的 <parameter name="code"> 这类外壳(tool-script.ts:114),代码超过 128 KiB 直接返回 code_error。
  2. 定出可调用的工具:通过晚绑定的 toolScriptRegistry 拿到与外层同一份按模型、智能体过滤后的列表,去掉排除项;actor 若带工具白名单,再取交集(tool-script.ts:598)。MCP 工具来自本次请求已授权的那份映射,名字与内置工具冲突时内置优先(tool-script.ts:629)。
  3. 转译:把代码包成 globalThis.__main = async () => { ... },用 TypeScript 编译器的 transpileModule 去掉类型;出现 import 或 export 就报错并附上说明(tool-script.ts:669)。注释解释了不用 Bun.Transpiler 的原因:这个模块也打进 Node 版构建。
  4. 执行:在 QuickJS 里依次注入 ALL_TOOLS、guest 预置代码与转译结果,宿主只提供 __callTool、__log、__readText、__writeText 四个函数(tool-script.ts:989)。tools 是一个 Proxy,任何属性名都转成对 __callTool 的调用(tool-script.ts:207):
const tools = new Proxy({}, {
  get: (_t, name) => (args) =>
    __callTool(String(name), args === undefined ? {} : args).catch((e) => {
      throw e instanceof Error ? e : new Error(String(e));
    }),
});
  1. 收尾:返回值在 guest 里先经严格序列化,循环引用、BigInt、会抛错的 getter 直接判失败并给出 $.a.b 形式的路径,NaN、Map、Error 这类有损转换记为警告(tool-script.ts:219);结果包进 <exec status="...">,里面按需有 <return_value>、<warnings>、<logs>、<trace>、<error_message>。状态只有 completed、code_error、timeout、budget_exceeded、cancelled 五种(tool-script.ts:1019)。

QuickJS 隔离了什么

packages/opencode/src/workflow/sandbox.ts:106 的 evalScript 每次新建一个 QuickJS WASM 运行时,用的是把 wasm 内联成 base64 的单文件变体,bun build --compile 出来的二进制无需附带 .wasm(sandbox.ts:7)。guest 是一个裸 JS 引擎:没有 Node、Web 与定时器 API,只有注入的全局;所有值按拷贝进出,对象走 JSON。exec 以 deterministic: false 调用,保留真实的 Date 和 Math.random;Workflow 则会删掉 Date,并把 Math.random 换成定种子的版本(tool-script.ts:1003)。

隔离的只是 exec 脚本本身。脚本产生的一切副作用都经由宿主工具,bash 仍然是真实的 Shell,不是容器沙箱。files 是唯一绕过工具层的通道,读取时也不经过 read 权限询问,但范围很窄:读限于 worktree 与系统临时目录,写只允许临时目录,都按 realpath 判断,单文件不超过 10 MiB,非 UTF-8 内容直接报错(tool-script.ts:955、tool-script.ts:973)。项目文件要改只能走 tools.apply_patch。

权限:什么会被检查,什么不会

逐次调用的询问不会被绕过。 内置工具的子调用直接执行原来的 Tool.Def.execute,传入的上下文沿用外层的 ctx.ask,所以 bash、读写、apply_patch、glob、grep、webfetch、skill、view_image 这些在实现里调用 ctx.ask 的工具,规则照常求值、照常弹窗(tool-script.ts:871)。MCP 子调用走的是 SessionPrompt 包装好的执行函数,里面每次都以工具键询问权限,插件钩子、截断也都在(prompt.ts:1826)。exec_command 与 bash 共用同一份定义,权限与超时行为完全一致(packages/opencode/src/tool/tool-script-ref.ts:43)。

代码里有三点与“完全等价于直接调用”不同,文档没有提:

  • 插件钩子:tool.execute.before、tool.execute.after 只包在外层直接调用的路径上(prompt.ts:1651)。内置工具在 exec 里被调用时不经过这层,只有参数校验与输出截断(packages/opencode/src/tool/tool.ts:119);MCP 子调用则会触发钩子。
  • 靠隐藏生效的 deny:权限里“模式为 * 的 deny 就把工具从列表拿掉”只在组装顶层工具表时执行(packages/opencode/src/session/llm.ts:1024),exec 取的是不看权限的 available 列表。对内部会询问的工具没有影响;对内部不询问权限的工具就不同了。例如 question 在默认规则里是 deny,build、plan、compose 等主模式各自放行,general 等子智能体仍是 deny(packages/opencode/src/agent/agent.ts:121),而它的实现直接调用 Question 服务、不做权限询问(packages/opencode/src/tool/question.ts:47)。代码里没找到在 exec 里拦住这类调用的地方。
  • exec 自身:exec 不做任何询问;只要还有别的工具可用,即使规则对 exec 是 deny,它也会作为网关留在顶层(llm.ts:1025)。

被排除的工具

排除列表和别名定义在一起(tool-script-ref.ts:30):

export const GPT_TOP_LEVEL_TOOLS = new Set(["exec", "wait"])

// Recursive orchestration and internal sentinel tools stay outside scripts.
// Other control-flow tools are intentionally callable through `tools.<id>` so
// the GPT/Codex toolset can expose a single outer `exec` surface.
export const TOOL_SCRIPT_EXCLUDED = new Set([
  "exec",
  "mcp_tool_search",
  "invalid",
  "session",
  "workflow",
])

// Reserved aliases share the target definition and therefore its permission,
// execution, timeout, and truncation behavior.
export const TOOL_SCRIPT_ALIASES = {
  exec_command: "bash",
} as const

真正排除的只有五个:exec 不能递归,session 与 workflow 是递归编排,invalid 是内部哨兵,mcp_tool_search 被 ALL_TOOLS 目录取代(声明里写明可以直接搜索这个目录,tool-script.ts:189)。设计文档说 task、actor、question、skill、cron 也被排除,这已过时:GPT 工具集只有 exec 一个入口,这些控制流工具必须能在脚本里调,工具说明也明确列出了它们(tool-script.txt:49)。唯一的附加限制在 actor 工具里:经 exec 调用时,子智能体只能用 send 与父智能体通信,派生、取消等操作留给主智能体(packages/opencode/src/tool/actor.ts:542)。

资源上限

常量都在文件头(tool-script.ts:24):

const MAX_TOOL_CALLS_DEFAULT = 50
const MAX_TOOL_CALLS_CEILING = 500
const MAX_CONCURRENT = 8
const ACTIVE_DEADLINE_MS_DEFAULT = 60_000
const ACTIVE_DEADLINE_MS_CEILING = 600_000
const WALL_DEADLINE_MS = 30 * 60 * 1000
const MAX_RESULT_BYTES = 256 * 1024
const MAX_LOG_BYTES = 64 * 1024
const MAX_CODE_BYTES = 128 * 1024
const MAX_FILE_BYTES = 10 * 1024 * 1024
const EXEC_PROGRESS_DEBOUNCE_MS = 150
const TRACE_TAIL_ENTRIES = 20
const EXEC_COMMAND_DEFAULT_YIELD_TIME_MS = 10_000
const EXEC_COMMAND_DEFAULT_MAX_OUTPUT_TOKENS = 10_000
资源上限超出时
子调用次数默认 50,max_tool_calls 最多 500第 51 次调用被拒绝,未捕获则 budget_exceeded
并发子调用8排队
活跃计算默认 60 秒,timeout 最多 600 秒timeout
墙钟30 分钟timeout
guest 内存64 MiB,沙箱默认值,exec 未覆盖(sandbox.ts:45)脚本报错
代码128 KiBcode_error
返回值256 KiB(UTF-8 字节)budget_exceeded,提示先聚合再返回
日志64 KiB截断并注明预算耗尽
files 单文件10 MiB报错
exec_command等待默认 10000 毫秒、输出默认 10000 token进程被终止或输出截断

“活跃计算”只计没有宿主调用在途的时间:沙箱用在途调用计数暂停和恢复计时,中断处理器在执行 guest 字节码时比对预算,同时轮询调用方的取消信号(sandbox.ts:114、sandbox.ts:132)。所以长时间等待 Shell 返回的脚本不会因为 60 秒预算被杀,一段 while (true) 则会。

exec_command 的参数名拼错也能被纠正:键名规范化后与某个合法键相同,或与长度不小于 5 的合法键编辑距离为 1,且候选唯一时自动改名(tool-script.ts:81)。

下一篇:内置技能包与技能检索——进入第 6 部分,看随二进制分发的技能包怎么解压、怎么被检索和自动加载。

本页目录