exec 与 QuickJS 沙箱
GPT 类模型顶层只剩一个 exec:脚本在 QuickJS 里经 tools 调宿主工具。隔离范围、权限、排除的工具、资源上限与精简工具集。
模型每调一次工具就要往返一轮,结果全文进上下文;十个互不相关的读取也得排队问十次。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 怎么跑
- 预处理:去掉模型误带的
<parameter name="code">这类外壳(tool-script.ts:114),代码超过 128 KiB 直接返回code_error。 - 定出可调用的工具:通过晚绑定的
toolScriptRegistry拿到与外层同一份按模型、智能体过滤后的列表,去掉排除项;actor 若带工具白名单,再取交集(tool-script.ts:598)。MCP 工具来自本次请求已授权的那份映射,名字与内置工具冲突时内置优先(tool-script.ts:629)。 - 转译:把代码包成
globalThis.__main = async () => { ... },用 TypeScript 编译器的transpileModule去掉类型;出现import或export就报错并附上说明(tool-script.ts:669)。注释解释了不用Bun.Transpiler的原因:这个模块也打进 Node 版构建。 - 执行:在 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));
}),
});- 收尾:返回值在 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 KiB | code_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 部分,看随二进制分发的技能包怎么解压、怎么被检索和自动加载。