# 文件与 Shell 工具

> 读、写、编辑、搜索与 bash 的实现：pi 管什么、MiniMax 的包装加了哪些守卫与上限，以及一条 Shell 命令的执行路径与子进程环境清洗。

- 作者：David（道雾轩）
- 专栏：MiniMax Code 源码解读（https://daiw.net/manual/minimax-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.net/manual/minimax-code/file-and-shell
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

模型最常调用的六个工具是 read、write、edit、grep、glob、bash。其中四个的核心逻辑来自 pi coding-agent，MiniMax 在外面包了一层：加上各种守卫和输出上限，edit 改用与 Claude Code 相同的参数名，再把 bash 接进后台任务与沙箱；grep 和 glob 则是自己用 ripgrep 写的。

本篇按工具逐个看实现，重点是边界：哪些行为是 pi 原样的，哪些是包装加的，一条 Shell 命令经过哪些模块才真正启动子进程。工具怎样进入清单见[工具体系](https://daiw.net/manual/minimax-code/tool-system)，调用前的放行判断见[权限模式与规则](https://daiw.net/manual/minimax-code/permission)。

## 怎么用

六个工具的参数都定义在 `packages/agent-tools/src/desktop/builtin-defs.ts`：

| 工具 | 参数 | 要点 |
| --- | --- | --- |
| `read` | `path`、`offset`、`limit`、`pages` | 文本带行号，一次最多 2000 行；能读图片、PDF（每次最多 20 页）、Jupyter 笔记本，模型支持时也能读视频（`builtin-defs.ts:17`） |
| `write` | `path`、`content` | 整文件覆盖，自动建父目录（`:55`） |
| `edit` | `file_path`、`old_string`、`new_string`、`replace_all` | 精确替换，`old_string` 必须唯一，除非 `replace_all` 为真（`:91`） |
| `grep` | `pattern`、`path`、`glob`、`output_mode`、`literal` 等 | 默认只列命中的文件；`content` 模式给行，`count` 模式给计数（`:159`） |
| `glob` | `pattern`、`path`、`include_ignored`、`sort` 等 | 默认最多 200 条，`sort="modified"` 按修改时间倒序（`:208`） |
| `bash` | `command`、`timeout`、`run_in_background` | 前台默认 120 秒、上限 300 秒；长命令走后台（`:108`） |

bash 的描述里给 Windows 单独写了一段：优先 PowerShell，不要用 `export`、`/dev/null`、heredoc、`&&` 这类 Bash 写法（`builtin-defs.ts:120`）。

用户自己也能跑命令：在 TUI 输入框里以 `!` 开头，命令直接在工作区执行，输出显示在转录里，并作为 `<user-provided-context>` 附进下一条消息；以 `!!` 开头则只执行、不附上下文（`packages/tui/src/tui/commands/bash-input.ts:6`，`packages/tui/src/tui/controller/product/bash-flow.ts:59`）。输出和上下文各保留最后 64K 字符（`bash-flow.ts:6`）。这类命令不是模型的工具调用，不经过权限闸门，也不进沙箱。

子进程环境变量的清洗强度可用 `MAVIS_BASH_ENV_SANITIZE` 设为 `off`、`scrub` 或 `strict`，见本篇最后一节。

## 分工：pi、agent-tools 与 files

| 位置 | 负责 |
| --- | --- |
| pi coding-agent 的 `src/core/tools` 与 `src/utils/shell.ts` | read、write、edit、bash 的核心：路径解析、BOM 与换行符、模糊匹配、同文件写入排队、输出截断、Shell 选择、进程启动与进程树终止 |
| `packages/agent-tools/src/desktop/local-pi-tools.ts` | 四个包装类：edit 的兼容参数、读取守卫、输出上限、后台与托管前台 bash |
| `packages/agent-tools/src/desktop/local-grep.ts`、`local-glob.ts` | 基于 ripgrep 的搜索，不用 pi 的 grep、find、ls |
| `packages/local-runtime/src/files` | 不是模型工具，是给界面用的文件接口 |

最后一行容易误解，单独说明。`packages/local-runtime/src/files/api.ts` 里的 `routeLocalFileApi`（`api.ts:276`）是一组面向界面的路由：文件树、内容、缩略图、预览、git diff、保存（`api.ts:557`），在公开仓库里没有调用方，从代码看是给桌面端界面准备的。命令行版用到的是它导出的三样东西（`packages/local-runtime/src/index.ts:73`）：读 git 元数据的 `createLocalWorkspaceGitFacade`，以及 `listFileTree` 与 `searchFiles`。后两者经运行时的工作区能力（`packages/local-runtime-v2/src/compat/v1/runtime.ts:694`）供 TUI 的 `@` 路径补全使用（`packages/tui/src/tui/controller/run/active-run-flow.ts:767`）。`searchFiles` 在 git 仓库里取 `git ls-files -co --exclude-standard` 的结果（`api.ts:1136`），不是 git 仓库就遍历目录，再按文件名、词干、前缀、路径段依次打分排序（`api.ts:1114`）。

## read：守卫在前，pi 在后

`LocalReadTool.execute` 的顺序（`packages/agent-tools/src/desktop/local-pi-tools.ts:111`）：

1. **解析路径**：相对路径按工作区解析，`~` 展开到主目录。字面路径不存在时再试几种变体：去掉开头的 `@`、把 `file://` 转成路径、把 AM、PM 前的普通空格换成窄不换行空格、改用 NFD 形式、把直引号换成弯引号（`local-pi-tools.ts:684`）。后三种对付的是 macOS 文件名里常见的字符差异。
2. **设备与特殊文件**：先挡 `/dev/zero` 这类设备路径，再用一次 `stat` 挡住命名管道、套接字和块设备（`:116`、`:122`），它们会让读取永远阻塞。
3. **按类型分流**：视频交给 `readVideoAsToolResult`，PDF 与笔记本各有解析器，都不经过 pi（`:131`、`:149`、`:157`）。
4. **二进制探测**：其余二进制文件直接返回可恢复的错误，免得 pi 把它当 UTF-8 解码成乱码（`:167`）。
5. **交给 pi**：去掉 pi 不认识的 `pages` 参数后调用 pi 的 read（`:172`）。

pi 返回的是原始文本，模型看不到行号。包装层用 `piReadResultToToolResult` 补上与 `cat -n` 类似的行号，格式为右对齐 6 位加 `→`（`packages/agent-tools/src/shared/read-postprocess.ts:61`），单行超过 2000 字符截断（`packages/agent-tools/src/shared/read-contract.ts:8`）；最后按 24KiB 截取开头，末尾附上 `next_offset`，告诉模型从哪一行续读（`local-pi-tools.ts:177`）。

## write 与 edit

**write** 用的是 pi 留出的 `WriteOperations` 注入点：每次调用新建一组捕获用的写操作（`local-pi-tools.ts:247`），它在 pi 的同文件写入队列里执行（`third_party/pi-mono/packages/coding-agent/src/core/tools/file-mutation-queue.ts:32`），顺带记下真实写入的字节数、旧文件内容，并沿用旧文件的 BOM 与 UTF-16LE 编码，结果里附上 diff（`packages/agent-tools/src/shared/write-capture.ts:7`）。

**edit** 的公开参数与 Claude Code 一致，包装层把它翻译成 pi 的 `{ path, edits: [{ oldText, newText }] }`（`local-pi-tools.ts:315`）。匹配规则都在 pi 的 `applyEditsToNormalizedContent` 里（`third_party/pi-mono/packages/coding-agent/src/core/tools/edit-diff.ts:317`）：

- 先精确查找，找不到再做模糊匹配：逐行 NFKC 归一，去行尾空白，弯引号、各种破折号、各种特殊空格折成 ASCII（`edit-diff.ts:32`）；
- 出现多处就报错并列出前几处的行号，多处编辑区间重叠也报错；
- 模糊命中的范围远大于 `oldText` 时拒绝（`edit-diff.ts:281`）。

模糊匹配的范围控制是 MiniMax 的两处补丁（2026-07-04 与 07-09，记在 `third_party/pi-mono/MINIMAX_CHANGES.md:249`、`:265`）：原先一旦走模糊匹配，整个文件都会被归一化后写回，中文全角空格、弯引号会被悄悄改掉；现在归一化只用来定位，替换只动命中的那一段，而且映射回原文的片段必须能重新归一成同一段文字，否则按找不到处理（`edit-diff.ts:193`）。

包装层还加了一次失败重试：模型常把 read 结果里的行号前缀一起粘进 `old_string`，第一次失败后，若每个非空行都带行号前缀，就去掉前缀再试一次，仍失败则报原来的错误（`packages/agent-tools/src/shared/edit-line-number-retry.ts:55`）。

### replace_all 与 `$` 序列

pi 的 edit 只支持“唯一匹配”，`replace_all` 是包装层自己实现的：读出整个文件，数出现次数，替换后拼成一次“整文件替换”交给 pi（`packages/agent-tools/src/shared/replace-all-edit.ts:40`）。按文件头注释的说法，这样换行符恢复、diff 输出和写入排队都还沿用 pi 的实现。

导入后不久的一次修复（#165，提交 f3160cd）改的就是其中的替换函数（`replace-all-edit.ts:94`）：

```ts
function rawStringReplaceAll(haystack: string, needle: string, replacement: string): string {
  if (typeof haystack.replaceAll === 'function') {
    // A callback keeps replacement text literal instead of expanding $ sequences.
    return haystack.replaceAll(needle, () => replacement);
  }
  return haystack.split(needle).join(replacement);
}
```

原来是 `haystack.replaceAll(needle, replacement)`。字符串作第二个参数时，`$&`、`$$`、`$'` 以及美元符号加反引号这几种序列会被解释成替换模式：模型想把 `TOKEN` 换成字面的 `$&`，写进文件的却是 `TOKEN` 本身。改成回调后替换文本原样写入。同一提交加的测试覆盖了这四种序列，也验证了不开 `replace_all` 的单次替换本来就是字面写入（`packages/agent-tools/src/shared/replace-all-edit.test.ts:10`）：pi 用 `substring` 拼接新内容（`edit-diff.ts:385`），不经过 `String.prototype.replace`。

## grep 与 glob

两者都调 ripgrep：优先用 `@vscode/ripgrep` 自带的二进制，找不到再用 PATH 上的 `rg`；单次 30 秒超时，标准输出最多收 8MiB（`packages/agent-tools/src/desktop/local-rg-runner.ts:17`、`:19`、`:26`）。

grep 的参数由 `buildGrepArgs` 拼出（`packages/agent-tools/src/desktop/local-grep.ts:58`）：`--no-config --no-messages --hidden`，跳过 1M 以上的文件，默认最多 100 条（`local-grep.ts:46`）；遵守 `.gitignore`；默认排除 `node_modules`、`dist`、`build`、`.venv` 等依赖和构建目录（`packages/agent-tools/src/shared/rg-scan-policy.ts:14`）；最后追加敏感文件排除，`.env`、`*.pem`、`.ssh`、`.aws` 等一律不搜（`packages/agent-tools/src/shared/rg-sensitive.ts:28`）。敏感规则必须放在用户 `glob` 之后，因为 ripgrep 的 glob 规则后者优先（`local-grep.ts:92`）。glob 工具同样排除敏感路径，`include_ignored` 只在路径足够具体时才放开忽略规则（`packages/agent-tools/src/desktop/local-glob.ts:189`）。

## bash：一条命令的路径

`LocalBashTool.execute`（`local-pi-tools.ts:383`）按下面的分支走：

```mermaid
flowchart TD
  A["LocalBashTool.execute"] --> B{"run_in_background 为真"}
  B -->|是| C["startBackground：explicit_background"]
  B -->|否| D["超时默认 120 秒，上限 300 秒"]
  D --> E{"允许自动转后台"}
  E -->|是| F["runManagedForeground：managed_foreground"]
  F -->|"15 秒内结束"| G["直接返回输出"]
  F -->|"15 秒后仍在跑"| H["转为后台任务，返回 task_id"]
  E -->|否| I["pi 的 bash：direct_foreground"]
  C --> J["后台执行器：pi 的 bash 加沙箱 operations"]
  F --> J
```

- **前台超时**：没给或给了非法值取 120 秒，超过 300 秒截到 300（`local-pi-tools.ts:589`、`:590`）。
- **托管前台**：本地运行时总会接上后台适配器（`packages/local-runtime/src/api/host-turn-tools.ts:90`），只要本回合有内置的 `task_output`（`packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts:491`），普通前台命令其实也作为后台任务启动，只是等它 15 秒（`local-pi-tools.ts:591`）；到时仍没结束就原地转成后台任务，不重启进程，返回 `task_id`（`packages/local-runtime/src/background-task/bash-runner.ts:146`、`:183`）。转过去的命令仍带着前台的超时（`bash-runner.ts:253`）。
- **显式后台**：同样要求本回合有 `task_output`，否则直接报错（`local-pi-tools.ts:389`）。不给超时就没有命令级超时，只有看门狗：取 30 分钟与所请求超时中的较大者（`packages/local-runtime/src/background-task/bash-runner-limits.ts:5`、`:12`）。后台任务的查询、输出与续跑见[子 Agent 与后台任务](https://daiw.net/manual/minimax-code/subagents)。
- **三类调用身份**：`direct_foreground`、`managed_foreground`、`explicit_background`（`packages/agent-tools/src/desktop/types.ts:299`）。除直接前台外都开启 pi 的父进程死亡守护，宿主被强杀时由它回收命令进程组（`packages/local-runtime-v2/src/service/sandbox/deferred-port.ts:75`；补丁见 `MINIMAX_CHANGES.md:273`）。沙箱怎样包住这条命令见[沙箱](https://daiw.net/manual/minimax-code/sandbox)。
- **可恢复删除**：Windows 上权限层把删除改写成 `mavis-trash --`，由宿主执行（`packages/agent-tools/src/desktop/host-trash-executor.ts:63`）；macOS 与 Linux 靠环境里的 `rm` 垫片，见下文。

最终起进程的都是 pi 的 `createLocalBashOperations`（`third_party/pi-mono/packages/coding-agent/src/core/tools/bash.ts:203`）：非 Windows 平台以独立进程组启动（`bash.ts:282`）；中止或超时先给整个进程组发 `SIGTERM`，3 秒后再 `SIGKILL`（`third_party/pi-mono/packages/coding-agent/src/utils/shell.ts:250`），让脚本里的 `trap` 有机会清理，这也是 MiniMax 的补丁（2026-07-07，`MINIMAX_CHANGES.md:257`）。输出由 pi 收集：保留最后 2000 行或 50KB，全文写进 `pi-bash` 前缀的临时文件（`bash.ts:681`）；非零退出码与超时都会变成错误，错误文本分别以 `Command exited with code` 和 `Command timed out after` 开头（`bash.ts:795`、`:787`）。包装层再截到 24KiB，保留头尾，并提示模型去读临时文件或把输出重定向到文件，不要盲目重跑可能有副作用的命令（`local-pi-tools.ts:553`）。

## Shell 选择与 PowerShell

用哪个 Shell 由 pi 的 `getShellConfig` 决定（`shell.ts:114`）：Unix 依次试 `/bin/bash`、PATH 上的 `bash`、`sh`；Windows 依次试 PATH 上的 `pwsh.exe`、`C:\Program Files\PowerShell\7\pwsh.exe`、系统自带的 Windows PowerShell 5.1、Git Bash、PATH 上的 `bash.exe`。PowerShell 统一用 `-NoProfile -NonInteractive -Command` 启动（`shell.ts:54`）。对照上游仓库 v0.79.1 标签下的同一文件，那里的 Windows 分支只找 Git Bash 和 PATH 上的 `bash.exe`，bash 工具也没有任何 PowerShell 代码。也就是说，Windows 上改用 PowerShell 是 vendored 副本里的改动，补丁台账却没有为它单列一条，只有 08-20、08-31 两条记了它上面的包装。

PowerShell 7 原生 UTF-8，不需要额外处理。Windows PowerShell 5.1 则要包一层（`bash.ts:235`）：

```ts
if (shellConfig.type === "powershell") {
	const languageMode = await getWindowsPowerShellLanguageMode(shell, args, cwd, shellEnv);
	if (signal?.aborted) throw new Error("aborted");
	if (languageMode === "ConstrainedLanguage") {
		const transport = wrapConstrainedWindowsPowerShellCommand();
		effectiveCommand = transport.launcher;
		shellEnv[transport.environmentVariable] = command;
	} else {
		effectiveCommand = wrapWindowsPowerShellStdinCommand();
		stdinCommand = command;
	}
}
```

每个 PowerShell 可执行文件第一次用时，先跑一次 `$ExecutionContext.SessionState.LanguageMode` 探测语言模式，5 秒超时，结果按可执行文件缓存（`bash.ts:145`、`:162`）。

- **FullLanguage**：设置控制台编码为 UTF-8，把原命令从标准输入读进来，用 `Parser.ParseInput` 解析、校验 `#requires` 声明，再经 `ScriptBlock.Create` 执行（`bash.ts:420`）。按台账的说法，这层包装来自 2026-08-20 的补丁，避开了 Base64 参数展开、`.ps1` 执行策略检查和崩溃后残留的命令文件（`MINIMAX_CHANGES.md:277`）。
- **ConstrainedLanguage**：企业用 App Control 或 AppLocker 锁定的机器上，上面用到的 .NET API 都被禁止，包装在用户命令之前就失败了。2026-08-31 的补丁改为：原命令放进一个带随机后缀、名为 `MAVIS_POWERSHELL_SOURCE_` 开头的一次性环境变量，启动器先读出它、立刻 `Remove-Item` 删掉，再在同样受限的语言模式里 `Invoke-Expression`（`bash.ts:407`）。台账记录这个补丁尚未提交上游，真实的 App Control 环境也还没有验收（`MINIMAX_CHANGES.md:16`）。

## 子进程环境：两层清洗

bash 子进程的环境变量由 `packages/agent-core/src/bash-subprocess-env.ts:190` 的 `sanitizeBashSubprocessEnv` 处理。它先执行宿主注入的启动前检查，再分两层：

- **A 层，永远开启**：去掉运行时自己的边界变量，比如父会话身份、数据目录、端口一类，防止子进程冒充父会话，或让命令里嵌套的 CLI 绑到父运行时（`bash-subprocess-env.ts:199`）。
- **B 层，按模式**：清洗第三方与模型提供方的凭据。

模式的决定顺序是：显式指定、`MAVIS_BASH_ENV_SANITIZE` 环境变量、CI 标记，最后默认 `off`（`bash-subprocess-env.ts:171`）：

```ts
  let mode: BashEnvSanitizeMode | undefined = overrides?.mode;
  if (mode === undefined) {
    const explicit = envSource.MAVIS_BASH_ENV_SANITIZE?.trim().toLowerCase();
    if (explicit && VALID_MODES.has(explicit)) {
      mode = explicit as BashEnvSanitizeMode;
    } else if (isEnvTruthy(envSource.CI) || isEnvTruthy(envSource.GITHUB_ACTIONS)) {
      mode = 'scrub';
    } else {
      mode = 'off';
    }
  }
```

交互使用时默认不删用户的凭据，理由写在文件头：交互用户依赖环境里的 `gh`、`npm` 凭据（`bash-subprocess-env.ts:16`）。`scrub` 模式按一张精确的黑名单删除，每项连同 GitHub Actions 的 `INPUT_` 前缀别名一起删（`bash-subprocess-env.ts:202`）。黑名单开头是模型提供方的密钥（`bash-subprocess-env.ts:75`）：

```ts
export const BASH_SUBPROCESS_SCRUB: readonly string[] = [
  // Provider / LLM auth — the runtime re-reads these per-request itself
  'MCODE_PROVIDER_API_KEY',
  'ANTHROPIC_API_KEY',
  'ANTHROPIC_AUTH_TOKEN',
  'CLAUDE_CODE_OAUTH_TOKEN',
  'ANTHROPIC_FOUNDRY_API_KEY',
  'ANTHROPIC_CUSTOM_HEADERS',
  'OPENAI_API_KEY',
```

第一项是快照提交（#167）新加的。`MCODE_PROVIDER_API_KEY` 是 `mcode provider add` 读取 BYOK 密钥的默认变量名（`packages/tui/src/cli/provider-command.ts:56`），之前在 CI 这类 `scrub` 模式下，bash 仍能读到它。名单其余部分还有 OTLP 导出头、AWS 与 Azure 等云凭据、GitHub Actions 的 OIDC 令牌；`GH_TOKEN`、`GITHUB_TOKEN`、`NPM_TOKEN` 有意留着（`bash-subprocess-env.ts:73`）。`strict` 模式则按名字模式删除所有像凭据的变量（`bash-subprocess-env.ts:125`），但保留 `PATH`、`HOME` 等系统变量，可用白名单豁免。

清洗之后还有一步：把 `prependPath` 里的目录插到 PATH 最前（`bash-subprocess-env.ts:221`）。本地运行时用它安装可恢复删除的 `rm` 垫片：`<dataDir>/shims/rm` 只做一件事，把参数原样转给同一数据目录下的 `mavis-trash`（`packages/local-runtime/src/infra/ensure-rm-shim.ts:47`、`:67`）。无论 `rm` 出现在命令里、`xargs` 后面还是模型刚写的脚本里，只要经 PATH 解析就会落到垫片上。启动前检查会确认垫片存在且可执行，缺了就当场重建，重建失败则拒绝启动 bash，绝不退回真正的 `/bin/rm`（`ensure-rm-shim.ts:82`）。Windows 不装垫片（`ensure-rm-shim.ts:86`）。

同一套清洗用在三个起进程的地方：前台 `LocalBashTool` 的内联钩子（`local-pi-tools.ts:376`）、后台执行器（`packages/local-runtime-v2/src/service/background-bash/executor.ts:53`）、PiTurnRunner 的后备 bash（`packages/agent-core/src/pi-turn-runner/tools.ts:62`，本地运行时不会走到）。被删掉的变量名（只有名字，不含值）在第一次出现时写进结果的 `envSanitized`，让命令突然缺凭据的用户知道原因（`local-pi-tools.ts:536`）。前面说的 `!` 命令只做 A 层（`packages/tui/src/host/bash-command.ts:20`）。

下一篇：[权限模式与规则](https://daiw.net/manual/minimax-code/permission)——每次工具调用前，谁来决定放行、询问还是拒绝。
