文件与 Shell 工具

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

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

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

本篇按工具逐个看实现,重点是边界:哪些行为是 pi 原样的,哪些是包装加的,一条 Shell 命令经过哪些模块才真正启动子进程。工具怎样进入清单见工具体系,调用前的放行判断见权限模式与规则。

怎么用

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

工具参数要点
readpath、offset、limit、pages文本带行号,一次最多 2000 行;能读图片、PDF(每次最多 20 页)、Jupyter 笔记本,模型支持时也能读视频(builtin-defs.ts:17)
writepath、content整文件覆盖,自动建父目录(:55)
editfile_path、old_string、new_string、replace_all精确替换,old_string 必须唯一,除非 replace_all 为真(:91)
greppattern、path、glob、output_mode、literal 等默认只列命中的文件;content 模式给行,count 模式给计数(:159)
globpattern、path、include_ignored、sort 等默认最多 200 条,sort="modified" 按修改时间倒序(:208)
bashcommand、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.tsread、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):

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)按下面的分支走:

图表加载中…
  • 前台超时:没给或给了非法值取 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 与后台任务。
  • 三类调用身份: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)。沙箱怎样包住这条命令见沙箱。
  • 可恢复删除: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):

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

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

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

下一篇:权限模式与规则——每次工具调用前,谁来决定放行、询问还是拒绝。

本页目录