# exec 与 QuickJS 沙箱

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

- 作者：David（道雾轩）
- 专栏：MiMo Code 源码解读（https://daiw.org/manual/mimo-code.md）
- 最后更新：2026-09-17
- 原文：https://daiw.org/manual/mimo-code/exec-sandbox
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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

上游 v1.14.19 没有 `tool/tool-script.ts`、`tool/gpt.ts`、`tool/view-image.ts` 这几个文件。OpenCode 后来有了自己的 Code Mode，做法完全不同：`execute` 工具配一个基于 Acorn 的树遍历解释器，主要用来编排 MCP 工具，见[联网工具与 Code Mode](https://daiw.org/manual/opencode/web-and-codemode)。仓库里的设计文档 `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` 决定：

```ts
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`），提示词与工具集的路由规则见[模型、提供方与提示词路由](https://daiw.org/manual/mimo-code/providers)。

### 谁在顶层，谁在脚本里

`ToolRegistry.available` 先按工具集增删（`registry.ts:386`）：

```ts
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` 是上游已有的工具，补丁格式与校验流程见[文件工具](https://daiw.org/manual/opencode/file-tools)。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`）：

```js
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));
    }),
});
```

5. **收尾**：返回值在 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`）：

```ts
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`）：

```ts
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`）。

下一篇：[内置技能包与技能检索](https://daiw.org/manual/mimo-code/skills)——进入第 6 部分，看随二进制分发的技能包怎么解压、怎么被检索和自动加载。
