# Agent 为什么能“缺啥装啥”？从 0 实现一个 Runtime / Binary Manager

> 让一个找不到 rg 的搜索 Tool，自动下载、校验、安装并继续执行。

![从声明需求到继续执行：本篇只跑一条 Runtime Manager 主线](https://herblab.online/qwen-imgs/runtime-manager-01-guide-v1.png)<!-- display-width:500 -->

这是一篇独立拓展教程。我们会做出一个最小 Runtime / Binary Manager，也就是“程序自己的工具安装管理员”。

**预计用时：** 完整跟做约 60～90 分钟；只下载完成版跑通约 15 分钟，均不含网络等待。**核心成果：** 第一次运行自动准备 ripgrep 并完成搜索，第二次运行直接复用。**成功标志：** 终端最后能找到两行包含 MCP 的文字，而且第二次没有再次下载和解压。

本文 Demo 只实现 **Windows 10/11 x64**。它不会修改系统 PATH，不需要管理员权限，也不会分析任何商业 Agent 的内部源码。你只是在一个自己的小项目里，把“缺少命令”这件事从报错变成闭环。

如果暂时只想理解思路，可以先看每节开头和结果；真正动手时，再回来复制代码。别被文件数量吓到，它们只是把一份工作拆给了几位专职小同事。

只想先确认闭环是否值得做，可以下载 [Runtime Manager Demo v2](https://aiarchblog-6hz4s01hv.maozi.io/articles/runtime-binary-manager-from-zero/files/runtime-binary-manager-demo-v2.zip)，按照压缩包里的 README 跑一遍；确认有兴趣后，再回到正文逐个文件搭建。

## 1. 先看终点：我们到底要做什么？

最后的使用体验很简单：搜索 Tool 声明自己需要 `ripgrep >=14`，Runtime Manager 负责把这个要求变成一个满足版本要求的 `rg.exe` 路径。

严格来说，这个 Demo 管理的是一个可执行文件，所以叫 **Binary Manager** 更准确；**Runtime Manager** 是更宽的说法，还可以管理 Python、Node.js 等完整运行环境。本文沿用 “Runtime / Binary Manager”，但真正动手实现的是最小 Binary Manager。

```text
Tool 被调用
↓
系统里有满足版本要求的 rg？有就直接用
↓ 没有，或版本太旧
项目的 .runtime 里有？有就复用
↓ 没有
解析版本 → 下载 → SHA256 校验 → 安装
↓
把 rg.exe 路径交回原来的 Tool
↓
继续搜索，不要求用户重来一次
```

这一篇最重要的不是 ripgrep，而是职责分开：

- Tool 负责说“我需要什么，以及拿到以后怎么用”；
- Runtime Manager 负责“检测、准备并返回可执行文件”；
- Manifest 负责记录“哪个平台该下载哪个文件”。

先记住这三句，后面的代码就不容易迷路。

## 2. 准备环境：这次只需要 Node.js

主线需要：

- **Windows 10/11 x64**：本篇安装器使用 PowerShell 的 `Expand-Archive`；
- **Node.js 22 或更高版本**：负责运行 TypeScript、下载文件和启动子进程；
- **PowerShell**：Windows 自带的终端；
- **任意代码编辑器**：VS Code、CodeBuddy 或其他编辑器都可以，本文不依赖某一款 AI IDE。

不需要提前安装 ripgrep，不需要 Git，也不需要管理员权限。运行前请确认浏览器能打开 [ripgrep 14.1.1 官方 Release](https://github.com/BurntSushi/ripgrep/releases/tag/14.1.1)；公司网络无法访问 GitHub 时，下载步骤会失败，请先遵守公司的网络与软件安装规定。

Node.js 请从[官方下载页](https://nodejs.org/en/download)获取，并选择 LTS 版本。已经能执行下面命令时，不用重装：

```powershell
node -v
npm -v
node -e "console.log(process.platform, process.arch)"
```

你应该看到类似结果：

```text
v22.23.1
10.9.8
win32 x64
```

版本号可以更新，但最后一行必须是 `win32 x64`。如果是别的平台，请先把本文当作原理教程，不要照抄安装部分。

## 3. 创建项目，先把空房间搭好

在 PowerShell 中执行：

```powershell
mkdir my-runtime-manager
cd my-runtime-manager
npm init -y
npm install -D typescript@7.0.2 tsx@4.23.12 @types/node@26.2.0
mkdir src
mkdir src\runtime
mkdir demo
```

再创建这些空文件：

```powershell
New-Item -ItemType File -Force -Path .\src\index.ts
New-Item -ItemType File -Force -Path .\src\runtime\detector.ts
New-Item -ItemType File -Force -Path .\src\runtime\manifest.ts
New-Item -ItemType File -Force -Path .\src\runtime\downloader.ts
New-Item -ItemType File -Force -Path .\src\runtime\verifier.ts
New-Item -ItemType File -Force -Path .\src\runtime\installer.ts
New-Item -ItemType File -Force -Path .\src\runtime\manager.ts
New-Item -ItemType File -Force -Path .\demo\mcp-intro.md
New-Item -ItemType File -Force -Path .\runtime-manifest.json
New-Item -ItemType File -Force -Path .\tsconfig.json
```

目录应该变成：

```text
my-runtime-manager/
├─ demo/
│  └─ mcp-intro.md
├─ src/
│  ├─ runtime/
│  │  ├─ detector.ts
│  │  ├─ downloader.ts
│  │  ├─ installer.ts
│  │  ├─ manager.ts
│  │  ├─ manifest.ts
│  │  └─ verifier.ts
│  └─ index.ts
├─ package.json
├─ runtime-manifest.json
└─ tsconfig.json
```

把下面配置写入 `tsconfig.json`：

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "types": ["node"],
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src/**/*.ts"]
}
```

测试文件 `demo/mcp-intro.md` 写入：

```markdown
# MCP 学习笔记

MCP 可以让 AI 应用发现并调用外部工具。

这个项目会实现一个 search_workspace Tool，用来搜索工作区里的文本。

后面我们还会搜索 registerTool 这个关键词。
```

**到这里的成功标志：** 文件结构齐全，`node_modules` 已出现，安装命令没有以红色错误结束。

## 4. 先写一个注定失败的搜索 Tool

ripgrep 是一个速度很快的文本搜索命令，真正执行时使用 `rg`。先问问电脑有没有它：

```powershell
rg --version
```

如果 PowerShell 报“无法将 rg 识别为 cmdlet”，正好符合实验条件：

![PowerShell 无法识别 rg，说明系统当前没有可直接调用的 ripgrep](https://herblab.online/qwen-imgs/runtime-manager-02-missing-rg-v1.png)<!-- display-width:560 -->

如果你的电脑已经能返回版本号，不要卸载。后面我们会用一个只影响当前终端的开关，仍然可以体验托管版本。

先把 `src/index.ts` 写成最原始的版本：

```ts
import { spawn } from "node:child_process";

function searchWorkspace(query: string): void {
  console.log(`[Tool] 正在搜索：${query}`);

  const child = spawn(
    "rg",
    [
      "--line-number",
      "--no-heading",
      query,
      "./demo",
    ],
    {
      stdio: "inherit",
    },
  );

  child.on("error", (error) => {
    console.error("[Tool] 无法启动 ripgrep");
    console.error(error);
  });
}

searchWorkspace("MCP");
```

运行：

```powershell
npx tsx src/index.ts
```

系统没有 `rg` 时，会得到 `spawn rg ENOENT`：

![最初的 search_workspace 直接 spawn rg，最终得到 ENOENT](https://herblab.online/qwen-imgs/runtime-manager-03-tool-enoent-v1.png)<!-- display-width:560 -->

代码知道要搜索，但真正的执行能力并不存在。这就是本篇的起点：**有 Tool，不等于 Tool 运行得起来。**

## 5. 第一次升级：让 Tool 声明“我需要什么”

最省事的写法，是直接把下载逻辑塞进 `searchWorkspace()`。但下一个 Tool 需要 Python，再下一个需要 FFmpeg 时，每个 Tool 都会长出一套安装器，场面很快从“自动化”滑向“自动添乱”。

更合适的做法，是让 Tool 只声明要求：

```ts
type RuntimeRequirement = {
  type: string;
  version: string;
};

const searchWorkspaceTool = {
  name: "search_workspace",
  runtime: {
    type: "ripgrep",
    version: ">=14",
  } satisfies RuntimeRequirement,
};
```

`ripgrep >=14` 只是“需求”，还不是下载地址。接下来，我们会依次回答三件事：本机有没有、应该下载哪个、下载后怎么放心执行。

## 6. Detector：先找系统，再找自己的托管目录

把下面代码写入 `src/runtime/detector.ts`：

```ts
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";

export function detectSystemBinary(command: string): string | null {
  const locator = process.platform === "win32"
    ? "where.exe"
    : "which";

  const result = spawnSync(
    locator,
    [command],
    {
      encoding: "utf8",
      windowsHide: true,
    },
  );

  if (result.status !== 0) {
    return null;
  }

  const firstPath = result.stdout
    .trim()
    .split(/\r?\n/)[0];

  return firstPath || null;
}

export function readRipgrepVersion(
  binaryPath: string,
): string | null {
  const result = spawnSync(
    binaryPath,
    ["--version"],
    {
      encoding: "utf8",
      windowsHide: true,
    },
  );

  if (result.status !== 0) {
    return null;
  }

  const match = result.stdout.match(
    /^ripgrep\s+(\d+\.\d+\.\d+)/m,
  );

  return match?.[1] ?? null;
}

export function detectManagedBinary(
  version: string,
): string | null {
  const executable =
    process.platform === "win32"
      ? "rg.exe"
      : "rg";

  const binaryPath = resolve(
    ".runtime",
    "ripgrep",
    "versions",
    version,
    executable,
  );

  if (!existsSync(binaryPath)) {
    return null;
  }

  return binaryPath;
}
```

这里有两种 Binary：

- **System Binary**：用户自己已经安装，能被系统 PATH 找到；
- **Managed Binary**：只放在当前项目的 `.runtime/`，由我们的代码管理。

找到 System Binary 后，`readRipgrepVersion()` 还会执行一次 `rg --version`。只有版本满足 Tool 的要求，Manager 才会使用它；否则继续准备托管版本。

检测顺序先尊重符合要求的系统版本，再看自己是否准备过。两处都没有时，日志会清楚停在这里：

![Detector 先检查系统，再检查项目托管目录，两处都没有时明确停止](https://herblab.online/qwen-imgs/runtime-manager-04-system-managed-missing-v1.png)<!-- display-width:600 -->

这一步还没有解决缺失问题，但已经把“直到 spawn 才突然爆炸”变成“执行前知道少了哪一层”。

## 7. Manifest：把模糊要求变成具体下载文件

Tool 只说 `>=14`，下载器却必须知道版本、操作系统、CPU 架构、URL 和校验值。把这些确定事实放进 `runtime-manifest.json`：

```json
{
  "ripgrep": {
    "14.1.1": {
      "win32-x64": {
        "asset": "ripgrep-14.1.1-x86_64-pc-windows-msvc.zip",
        "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-pc-windows-msvc.zip",
        "sha256": "d0f534024c42afd6cb4d38907c25cd2b249b79bbe6cc1dbee8e3e37c2b6e25a1"
      }
    }
  }
}
```

这个 URL 指向 ripgrep 官方 GitHub Release。SHA256 已在 2026-08-21 的真实下载文件上复验通过。

再把解析逻辑写入 `src/runtime/manifest.ts`：

```ts
import { readFileSync } from "node:fs";
import { resolve } from "node:path";

type Artifact = {
  asset: string;
  url: string;
  sha256: string;
};

type RuntimeManifest = Record<
  string,
  Record<string, Record<string, Artifact>>
>;

export type ResolvedArtifact = Artifact & {
  version: string;
  platformKey: string;
};

export function satisfiesRequirement(
  version: string,
  requirement: string,
): boolean {
  const match = requirement.match(/^>=(\d+)$/);

  if (!match) {
    throw new Error(
      `Demo 暂时只支持形如 >=14 的版本要求：${requirement}`,
    );
  }

  const requiredMajor = Number(match[1]);
  const actualMajor = Number(
    version.split(".")[0],
  );

  return actualMajor >= requiredMajor;
}

export function resolveArtifact(
  runtimeType: string,
  requirement: string,
): ResolvedArtifact {
  const manifestPath = resolve(
    "runtime-manifest.json",
  );

  const manifest = JSON.parse(
    readFileSync(manifestPath, "utf8"),
  ) as RuntimeManifest;

  const runtime = manifest[runtimeType];

  if (!runtime) {
    throw new Error(
      `Manifest 中没有 Runtime：${runtimeType}`,
    );
  }

  const versions = Object.keys(runtime)
    .filter((version) =>
      satisfiesRequirement(version, requirement),
    )
    .sort((a, b) =>
      b.localeCompare(a, undefined, { numeric: true }),
    );

  const version = versions[0];

  if (!version) {
    throw new Error(
      `没有版本满足要求：${runtimeType} ${requirement}`,
    );
  }

  const platformKey =
    `${process.platform}-${process.arch}`;

  const artifact =
    runtime[version][platformKey];

  if (!artifact) {
    throw new Error(
      `当前平台没有可用 Artifact：${platformKey}`,
    );
  }

  return {
    version,
    platformKey,
    ...artifact,
  };
}
```

这份 Demo 的版本判断故意很小，只支持 `>=数字`，例如 `>=14`。成熟实现应使用完整语义化版本库，但第一次实验先别把自己写成 npm 的远房表亲。

## 8. 下载与校验：能拿回来，还要确认拿对了

把下载代码写入 `src/runtime/downloader.ts`：

```ts
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";

import type { ResolvedArtifact } from "./manifest";

export async function downloadArtifact(
  artifact: ResolvedArtifact,
): Promise<string> {
  const downloadDir = resolve(
    ".runtime",
    "downloads",
  );

  await mkdir(downloadDir, {
    recursive: true,
  });

  const archivePath = resolve(
    downloadDir,
    artifact.asset,
  );

  console.log(
    `[Runtime] 正在下载：${artifact.asset}`,
  );

  const response = await fetch(artifact.url);

  if (!response.ok) {
    throw new Error(
      `下载失败：HTTP ${response.status} ${response.statusText}`,
    );
  }

  const data = Buffer.from(
    await response.arrayBuffer(),
  );

  await writeFile(
    archivePath,
    data,
  );

  console.log(
    `[Runtime] 下载完成：${archivePath}`,
  );

  return archivePath;
}
```

它只做四件事：建下载目录、请求 URL、检查 HTTP 状态、写入 ZIP。安全判断不应该偷偷混在下载函数里，所以再写一个独立校验器。

把下面代码写入 `src/runtime/verifier.ts`：

```ts
import { createReadStream } from "node:fs";
import { createHash } from "node:crypto";

export async function verifySha256(
  filePath: string,
  expectedSha256: string,
): Promise<void> {
  const hash = createHash("sha256");

  await new Promise<void>((resolve, reject) => {
    const stream = createReadStream(filePath);

    stream.on("data", (chunk) => {
      hash.update(chunk);
    });

    stream.on("error", reject);
    stream.on("end", resolve);
  });

  const actualSha256 = hash.digest("hex");

  console.log(
    `[Runtime] 实际 SHA256：${actualSha256}`,
  );

  if (
    actualSha256.toLowerCase() !==
    expectedSha256.toLowerCase()
  ) {
    throw new Error(
      `SHA256 校验失败\n期望：${expectedSha256}\n实际：${actualSha256}`,
    );
  }

  console.log("[Runtime] SHA256 校验通过");
}
```

SHA256 在这里解决的是**完整性**：实际文件必须和 Manifest 登记的那一份一致。它不自动等于“来源绝对可信”；真正的生产系统还会保护 Manifest、限制来源，并尽可能验证发布者签名。

真实实验中，期望值和实际值一致：

![实际 SHA256 与 Manifest 中的期望值一致，校验通过](https://herblab.online/qwen-imgs/runtime-manager-06-sha256-pass-v1.png)<!-- display-width:560 -->

到这里，ZIP 已经“下载成功 + 校验通过”，但它仍然不能直接当命令执行。下一步才是安装。

## 9. Installer：解压，再把 rg.exe 放进项目托管目录

把下面代码写入 `src/runtime/installer.ts`：

```ts
import { spawnSync } from "node:child_process";
import {
  copyFile,
  mkdir,
  readdir,
  rm,
} from "node:fs/promises";
import {
  join,
  resolve,
} from "node:path";

import type {
  ResolvedArtifact,
} from "./manifest";

async function findFile(
  directory: string,
  fileName: string,
): Promise<string | null> {
  const entries = await readdir(
    directory,
    {
      withFileTypes: true,
    },
  );

  for (const entry of entries) {
    const fullPath = join(
      directory,
      entry.name,
    );

    if (
      entry.isFile() &&
      entry.name.toLowerCase() ===
        fileName.toLowerCase()
    ) {
      return fullPath;
    }

    if (entry.isDirectory()) {
      const found = await findFile(
        fullPath,
        fileName,
      );

      if (found) {
        return found;
      }
    }
  }

  return null;
}

function quotePowerShell(
  value: string,
): string {
  return `'${value.replace(/'/g, "''")}'`;
}

export async function installArtifact(
  archivePath: string,
  artifact: ResolvedArtifact,
): Promise<string> {
  if (process.platform !== "win32") {
    throw new Error(
      "这个 Demo 的安装步骤目前只实现了 Windows",
    );
  }

  const stagingDir = resolve(
    ".runtime",
    "staging",
    `ripgrep-${artifact.version}`,
  );

  const installDir = resolve(
    ".runtime",
    "ripgrep",
    "versions",
    artifact.version,
  );

  console.log(
    `[Runtime] 正在解压：${artifact.asset}`,
  );

  await rm(
    stagingDir,
    {
      recursive: true,
      force: true,
    },
  );

  await mkdir(
    stagingDir,
    {
      recursive: true,
    },
  );

  const command =
    `Expand-Archive ` +
    `-LiteralPath ${quotePowerShell(archivePath)} ` +
    `-DestinationPath ${quotePowerShell(stagingDir)} ` +
    `-Force`;

  const result = spawnSync(
    "powershell.exe",
    [
      "-NoProfile",
      "-NonInteractive",
      "-Command",
      command,
    ],
    {
      stdio: "inherit",
      windowsHide: true,
    },
  );

  if (result.status !== 0) {
    throw new Error(
      "解压 ripgrep 失败",
    );
  }

  console.log(
    "[Runtime] 解压完成",
  );

  const extractedBinary =
    await findFile(
      stagingDir,
      "rg.exe",
    );

  if (!extractedBinary) {
    throw new Error(
      "解压完成，但没有找到 rg.exe",
    );
  }

  await mkdir(
    installDir,
    {
      recursive: true,
    },
  );

  const installedBinary = join(
    installDir,
    "rg.exe",
  );

  console.log(
    `[Runtime] 正在安装到：${installedBinary}`,
  );

  await copyFile(
    extractedBinary,
    installedBinary,
  );

  await rm(
    stagingDir,
    {
      recursive: true,
      force: true,
    },
  );

  console.log(
    "[Runtime] 安装完成",
  );

  return installedBinary;
}
```

它先解压到 `.runtime/staging`，找到 `rg.exe` 后，再复制到稳定位置：

```text
.runtime/
├─ downloads/
│  └─ ripgrep-14.1.1-x86_64-pc-windows-msvc.zip
├─ ripgrep/
│  └─ versions/
│     └─ 14.1.1/
│        └─ rg.exe
└─ staging/
```

安装完成后，托管的二进制文件能独立报告版本：

![项目托管目录里的 rg.exe 能独立返回 ripgrep 14.1.1](https://herblab.online/qwen-imgs/runtime-manager-07-managed-version-v1.png)<!-- display-width:420 -->

注意：我们没有把它写进系统 PATH。Runtime Manager 最后会直接返回绝对路径，由 Tool 使用这个路径启动进程。

## 10. Manager：把五个零件串成 ensureBinary()

前面有 Detector、Manifest、Downloader、Verifier 和 Installer。现在把它们排成真正的执行顺序。

![一次 Tool 调用依次经过声明、检测、解析、准备和执行](https://herblab.online/qwen-imgs/runtime-manager-05-runtime-flow-v2.png)<!-- display-width:500 -->

将下面代码写入 `src/runtime/manager.ts`：

```ts
import {
  detectManagedBinary,
  detectSystemBinary,
  readRipgrepVersion,
} from "./detector";

import {
  resolveArtifact,
  satisfiesRequirement,
} from "./manifest";

import {
  downloadArtifact,
} from "./downloader";

import {
  verifySha256,
} from "./verifier";

import {
  installArtifact,
} from "./installer";

export type RuntimeRequirement = {
  type: string;
  version: string;
};

export type BinaryInfo = {
  type: string;
  path: string;
  source: "system" | "managed";
  version?: string;
};

function getCommandName(
  runtimeType: string,
): string {
  if (runtimeType === "ripgrep") {
    return "rg";
  }

  throw new Error(
    `暂不支持的 Runtime：${runtimeType}`,
  );
}

export async function ensureBinary(
  requirement: RuntimeRequirement,
): Promise<BinaryInfo> {
  const command = getCommandName(
    requirement.type,
  );

  console.log(
    `[Runtime] 正在检测系统中的 ${requirement.type}...`,
  );

  const systemBinary =
    process.env.RUNTIME_FORCE_MANAGED === "1"
      ? null
      : detectSystemBinary(command);

  if (systemBinary) {
    const systemVersion =
      readRipgrepVersion(systemBinary);

    if (
      systemVersion &&
      satisfiesRequirement(
        systemVersion,
        requirement.version,
      )
    ) {
      console.log(
        `[Runtime] 找到可用的系统 ${requirement.type} ${systemVersion}：${systemBinary}`,
      );

      return {
        type: requirement.type,
        path: systemBinary,
        source: "system",
        version: systemVersion,
      };
    }

    console.log(
      `[Runtime] 系统 ${requirement.type} 版本 ${systemVersion ?? "无法识别"} 不满足 ${requirement.version}，改用托管版本`,
    );
  } else {
    console.log(
      `[Runtime] 未找到系统 ${requirement.type}`,
    );
  }

  console.log(
    "[Runtime] 正在解析 Runtime Artifact...",
  );

  const artifact = resolveArtifact(
    requirement.type,
    requirement.version,
  );

  console.log(
    `[Runtime] 已选择：${artifact.version} / ${artifact.platformKey}`,
  );

  console.log(
    `[Runtime] 正在检查托管的 ${requirement.type}...`,
  );

  const managedBinary =
    detectManagedBinary(
      artifact.version,
    );

  if (managedBinary) {
    console.log(
      `[Runtime] 找到托管 ${requirement.type}：${managedBinary}`,
    );

    console.log(
      "[Runtime] 直接复用已有 Managed Binary",
    );

    return {
      type: requirement.type,
      path: managedBinary,
      source: "managed",
      version: artifact.version,
    };
  }

  console.log(
    `[Runtime] 未找到托管 ${requirement.type}`,
  );

  const archivePath =
    await downloadArtifact(
      artifact,
    );

  console.log(
    "[Runtime] 正在验证 SHA256...",
  );

  await verifySha256(
    archivePath,
    artifact.sha256,
  );

  const installedBinary =
    await installArtifact(
      archivePath,
      artifact,
    );

  console.log(
    "[Runtime] Runtime 已准备完成",
  );

  return {
    type: requirement.type,
    path: installedBinary,
    source: "managed",
    version: artifact.version,
  };
}
```

Manager 现在不会把“找到命令”误当成“满足要求”：系统版本合格才直接使用，版本太旧或无法识别时会继续走托管分支。

`RUNTIME_FORCE_MANAGED=1` 只是教学开关。电脑已经有合格的 `rg` 时，它会让这一次实验跳过系统版本，从而真实走一遍下载与安装；它不会修改 Windows 环境变量的永久配置。

## 11. 把 Tool 改成最终版本

最后，将 `src/index.ts` 替换为：

```ts
import { spawn } from "node:child_process";

import {
  ensureBinary,
  type RuntimeRequirement,
} from "./runtime/manager";

const searchWorkspaceTool = {
  name: "search_workspace",

  runtime: {
    type: "ripgrep",
    version: ">=14",
  } satisfies RuntimeRequirement,
};

async function searchWorkspace(
  query: string,
): Promise<void> {
  console.log(
    `[Tool] ${searchWorkspaceTool.name}`,
  );

  console.log(
    `[Tool] 运行时要求：${searchWorkspaceTool.runtime.type} ${searchWorkspaceTool.runtime.version}`,
  );

  const binary = await ensureBinary(
    searchWorkspaceTool.runtime,
  );

  console.log(
    `[Tool] Runtime 已就绪：${binary.path}`,
  );

  console.log(
    `[Tool] 正在搜索：${query}`,
  );

  await new Promise<void>(
    (resolve, reject) => {
      const child = spawn(
        binary.path,
        [
          "--line-number",
          "--no-heading",
          query,
          "./demo",
        ],
        {
          stdio: "inherit",
        },
      );

      child.on("error", reject);

      child.on("close", (code) => {
        if (code === 0) {
          resolve();
          return;
        }

        reject(
          new Error(
            `ripgrep 退出码：${code}`,
          ),
        );
      });
    },
  );
}

searchWorkspace("MCP").catch(
  (error: unknown) => {
    const message =
      error instanceof Error
        ? error.message
        : String(error);

    console.error(
      `[Tool] 当前无法执行：${message}`,
    );

    process.exitCode = 1;
  },
);
```

Tool 现在只剩两类责任：声明 `ripgrep >=14`，以及拿到 `binary.path` 后执行搜索。下载细节已经从业务代码里消失了。

## 12. 第一次完整运行：缺少就自动准备

先做 TypeScript 检查：

```powershell
npx tsc --noEmit
```

没有输出且回到提示符，就代表类型检查通过。

为了让已经安装 `rg` 的电脑也能复现托管分支，在当前 PowerShell 会话设置教学开关：

```powershell
$env:RUNTIME_FORCE_MANAGED = "1"
npx tsx src/index.ts
```

第一次运行会依次看到：

```text
[Tool] search_workspace
[Tool] 运行时要求：ripgrep >=14
[Runtime] 未找到系统 ripgrep
[Runtime] 已选择：14.1.1 / win32-x64
[Runtime] 未找到托管 ripgrep
[Runtime] 正在下载：ripgrep-14.1.1-x86_64-pc-windows-msvc.zip
[Runtime] SHA256 校验通过
[Runtime] 解压完成
[Runtime] 安装完成
[Runtime] Runtime 已准备完成
[Tool] 正在搜索：MCP
./demo\mcp-intro.md:1:# MCP 学习笔记
./demo\mcp-intro.md:3:MCP 可以让 AI 应用发现并调用外部工具。
```

**核心成功标志：** 最后出现两条搜索结果。安装不是一个需要用户重新开始的前置任务；同一次 Tool 调用会在准备完成后继续走到搜索。

如果卡在下载，先在浏览器打开 Manifest 中的 GitHub Release URL；如果报 SHA256 不一致，删除当前项目下刚下载的 ZIP，确认 Manifest 没有被改过，再重试。不要为了“先跑起来”直接删掉校验步骤。

如果复制时想核对差异，开头提供的 Demo v2 就是这套代码的完整成品。

## 13. 第二次运行：不重复安装，直接复用

不要改任何代码，再运行一次：

```powershell
npx tsx src/index.ts
```

这次 Runtime Manager 会找到 `.runtime/ripgrep/versions/14.1.1/rg.exe`，直接复用已有 Managed Binary：

![第二次调用找到托管 ripgrep，不再下载和解压，直接继续搜索](https://herblab.online/qwen-imgs/runtime-manager-08-managed-reuse-v1.png)<!-- display-width:600 -->

两次运行的差别很清楚：

| 调用 | 当前状态 | 实际行为 |
| --- | --- | --- |
| 第一次 | System 没有 / Managed 没有 | Resolve → Download → Verify → Install → Execute |
| 第二次 | System 没有 / Managed 已有 | Resolve → Reuse → Execute |

Runtime Manager 不是“每次都帮你重装”，而是“确保运行环境存在”。已经存在时，最正确的动作就是少折腾。

实验完成后清掉当前终端的教学开关：

```powershell
Remove-Item Env:RUNTIME_FORCE_MANAGED
```

以后不设置它，代码会优先使用系统中已经存在的 `rg`。

## 14. 为什么这比直接改 PATH 更舒服？

本篇没有把 `rg.exe` 安装到 Program Files，也没有永久修改系统 PATH。Tool 拿到的是一个具体文件路径，然后执行：

```ts
spawn(binary.path, args);
```

这样做有几个直接好处：

- 不需要管理员权限；
- 不会影响其他项目正在使用的 `rg`；
- 一个项目可以明确管理自己的版本；
- 删除当前项目的 `.runtime/` 就能清理托管环境；
- 失败更容易定位在检测、解析、下载、校验、安装或执行中的某一层。

代价也很真实：每个项目可能重复占用磁盘，Manifest 和缓存策略需要维护，多平台安装逻辑也要自己负责。它不是万能答案，只是在 Agent Tool 需要稳定、可控依赖时非常有用的一种边界。

## 15. 放回 Agent 世界：它不是 Hook，也不是 Tool 本身

Runtime Manager 经常发生在 Tool 执行之前，但“执行得早”不等于“它就是 Hook”。

- **Tool**：一个具体动作，例如搜索工作区；
- **Skill**：告诉 Agent 一类任务应该怎么做；
- **MCP**：让客户端用统一协议发现和调用外部能力；
- **Hook**：在开始、执行前、执行后等时机触发逻辑；
- **Runtime / Binary Manager**：确保 Tool 依赖的真实执行环境存在。

它们可以这样合作：

```text
用户提出任务
↓
Agent 选择 Skill / Tool
↓
MCP 或 Plugin 暴露能力
↓
Runtime Manager.ensure()
↓
Node / Python / FFmpeg / ripgrep 等真正执行
↓
结果返回 Agent
```

所以本篇不是在证明“某个 Agent 产品内部一定这样实现”，而是在用独立 Demo 回答一个工程问题：**当 Tool 的依赖不存在时，我们可以怎样把失败变成可控的准备流程？**

## 16. 这个最小 Demo 还缺什么？

现在的版本已经能演示主链，但离成熟系统还有距离：

- 只支持 Windows x64 和 ZIP；
- 只支持 ripgrep；
- 版本表达式只认 `>=主版本号`；
- 没有下载超时、重试和镜像回退；
- 没有并发安装锁，两个进程可能同时准备同一版本；
- 没有原子安装、回滚、损坏自愈和定期清理；
- 只校验 SHA256，还没有发布者签名或更完整的供应链校验；
- Manifest 是本地文件，生产环境还要限制谁能修改它。

这不是“做得不够像生产”，而是刻意把第一版压到可以看懂。先跑通 Declare → Detect → Resolve → Provision → Execute，再逐个添加可靠性，学习成本会舒服很多。

## 17. 常见问题

### 我的电脑已经有 rg，为什么没有下载？

默认逻辑会优先使用满足 `>=14` 的 System Binary；低于 14 或无法识别版本时，会自动改用托管版本。想让合格的系统版本也走一次下载流程，请在当前 PowerShell 里设置 `$env:RUNTIME_FORCE_MANAGED = "1"`，实验结束后用 `Remove-Item Env:RUNTIME_FORCE_MANAGED` 清掉。它不会永久修改系统 PATH。

### 为什么报“当前平台没有可用 Artifact”？

先运行 `node -e "console.log(process.platform, process.arch)"`。本文 Manifest 只有 `win32-x64`；macOS、Linux 或 arm64 需要补对应下载文件和安装逻辑。

### 为什么报 SHA256 校验失败？

当前 ZIP 内容与 Manifest 记录不一致。先停止安装，不要继续执行；删除当前项目 `.runtime/downloads` 里的对应 ZIP，核对官方 Release URL 和 SHA256 后再试。

### 为什么 `runtime-manifest.json` 明明存在，却提示找不到？

请确认 PowerShell 当前路径就是 `my-runtime-manager` 项目根目录。Demo 使用相对路径读取 Manifest，从别的目录启动会找错位置。

### 可以把 ripgrep 换成 Python 或 FFmpeg 吗？

可以，但不只是换 URL。你还要定义版本解析、各平台 Artifact、校验值、解压或安装方式、可执行文件位置和健康检查。框架相同，安装细节会因 Runtime 而变。

## 18. 做完以后，我们真正学会了什么？

![声明、校验、项目级托管与复用：最小 Runtime Manager 闭环完成](https://herblab.online/qwen-imgs/runtime-manager-09-summary-v1.png)<!-- display-width:500 -->

这次完成了三件事：

1. 让 Tool 明确声明自己的运行时要求；
2. 把下载、SHA256 校验和安装放进独立的 Runtime Manager；
3. 第一次自动准备，第二次直接复用，并继续完成原来的搜索任务。

从架构上看，变化只有一句话：**Tool 知道要做什么；Runtime Manager 确保它此刻做得起来。**

如果还想继续，可以把 Manifest 增加一个 FFmpeg Artifact，但先别急着做全平台。只多支持一个 Binary，并把“下载失败”或“文件损坏”处理好，就已经是很扎实的下一步。

## 参考资料

- [Node.js 官方下载](https://nodejs.org/en/download)
- [Node.js child_process.spawn 文档](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options)
- [Node.js crypto.createHash 文档](https://nodejs.org/api/crypto.html#cryptocreatehashalgorithm-options)
- [Node.js fetch 文档](https://nodejs.org/api/globals.html#fetch)
- [ripgrep 14.1.1 官方 Release](https://github.com/BurntSushi/ripgrep/releases/tag/14.1.1)
