---
title: "harness - 挂载机制详解"
---

挂载（Mount）是 Harness 的“外部资源接入层”。它让技能、子代理、普通文件不再只能来自工作区目录，而可以来自全局共享库、插件目录、Classpath/Jar，甚至未来来自数据库或对象存储等完全虚拟的来源。

自 4.1.1 起，挂载被拆成**两个对象**（旧类型 `MountDir`、`SkillDir` 已删除）：

* `Mount`：挂载的**元数据与策略**（别名、类型、启用/可写/可见、来源引用）。
* `MountSource`：**内容来源**，负责真正的读、写、查、删、移与能力声明。

两者分开之后，文件工具与技能/子代理目录都不再假设“资源一定是一个本地 `Path`”。

### 1、快速上手

```java
import org.noear.solon.ai.harness.HarnessEngine;
import org.noear.solon.ai.talents.mount.Mount;
import org.noear.solon.ai.talents.mount.MountType;
import org.noear.solon.ai.talents.mount.source.ClasspathMountSource;
import org.noear.solon.ai.talents.mount.source.FileMountSource;

import java.nio.file.Paths;

public class DemoApp {
    public static void main(String[] arg) {
        HarnessEngine engine = HarnessEngine.of("work", ".soloncode/")
                .sessionProvider(sessionProvider)
                // 1. 本地目录：技能库（只读）
                .mountAdd(Mount.builder()
                        .alias("@global-skills")                       // 可以省略 @，注册时会自动补齐
                        .description("全局共享技能")                    // 可选；会出现在引导词的挂载清单里
                        .type(MountType.SKILLS)
                        .source(FileMountSource.of(
                                Paths.get(System.getProperty("user.home"), ".soloncode/skills")))
                        .build())
                // 2. 本地目录：子代理库（可写）
                .mountAdd(Mount.builder()
                        .alias("@team-agents")
                        .type(MountType.AGENTS)
                        .writeable(true)                               // 默认 false（只读）
                        .source(FileMountSource.of("team/agents"))     // 相对工作区的路径
                        .build())
                // 3. Classpath：随应用一起发布的只读资源
                .mountAdd(Mount.builder()
                        .alias("@bundled")
                        .description("随包发布的内置资料")
                        .type(MountType.FILES)
                        .source(ClasspathMountSource.of("bundled-resources"))
                        .build())
                .build();
    }
}
```

挂载完成后，就可以在工具调用中直接用逻辑路径引用：`@global-skills/xxx/SKILL.md`、`@bundled/api/openapi.yaml`。

### 2、Mount 字段参考

```java
Mount.builder()
    .alias("@global-skills")
    .description("全局共享技能")
    .type(MountType.SKILLS)
    .source(FileMountSource.of("..."))
    .primary(true)
    .enabled(true)
    .writeable(false)
    .visible(true)
    .build();
```

| 字段 | 默认值 | 说明 |
|------|--------|------|
| `alias` | / | 虚拟别名。注册时自动补齐 `@`；不得为 `@` 或 `@workspace`，且不能包含 `/`、`\` 或空白字符 |
| `type` | `FILES` | 挂载类型：`SKILLS` / `AGENTS` / `FILES` |
| `source` | / | 内容来源（`MountSource`）。未设置时 `build()` 直接抛 `IllegalArgumentException` |
| `description` | `null` | 挂载描述，会出现在引导词的挂载清单里 |
| `primary` | `false` | 主挂载标记。**仅是一个标记**，不提供删除/覆盖保护 |
| `enabled` | `true` | 是否启用。禁用的挂载不参与目录扫描，逻辑路径解析时也会被拒绝 |
| `writeable` | `false` | 是否允许写入（挂载默认只读）。这是**授权配置**，与来源本身的技术能力是两件事 |
| `visible` | `true` | 是否在展示层出现。它只影响引导词的挂载清单，**不影响**启用、解析与运行时使用 |

> 注意：`writeable` 与 `MountCapabilities` 的 `writable` 是两层含义。前者表示“这个挂载点允许你写”，后者表示“这个来源技术上能不能写”。真正的写入还需要同时通过来源能力与 Terminal 策略检查。

### 3、MountSource 接口契约

`MountSource` 是“可挂载的虚拟文件来源”，继承 `AutoCloseable`：

| 方法 | 作用 |
|------|------|
| `getScheme()` | 来源协议名，如 `file`、`classpath`、`jdbc` |
| `getLocation()` | 来源位置标识，默认返回 `getScheme()` |
| `normalize(path)` | 规范化来源内路径，并**拒绝越界**（`..` 等） |
| `stat(path)` | 取条目元数据；不存在返回 `null` |
| `exists(path)` / `isDirectory(path)` | 便捷判断（默认基于 `stat` 实现） |
| `list(path)` | 列出直接子条目 |
| `find(path, options)` | 按 `FindOptions` 遍历查找（`glob` / `maxDepth` / `maxEntries` / `filesOnly` / `directoriesOnly`） |
| `openRead(path)` | 打开读取流 |
| `openWrite(path, options)` | 按 `WriteOptions` 打开写入流（`append` / `createParents` / `expectedVersion`） |
| `delete(path)` | 删除条目 |
| `move(source, target, options)` | 移动条目（`MoveOptions.replaceExisting()` 决定是否覆盖） |
| `capabilities()` | 声明来源支持哪些操作 |
| `getLocalRoot()` / `getLocalPath(path)` | 本地根目录 / 本地路径；默认返回空 `Optional` |
| `materialize(path, targetDirectory)` | 把内容物化到本地目录；默认返回空 `Optional` |
| `getFingerprint()` | 内容指纹，供物化缓存做失效判定；默认返回位置标识 |
| `close()` | 释放资源；默认空实现 |

其中 **`getLocalRoot()` / `getLocalPath()` / `materialize()` / `getFingerprint()` 的默认返回值**是整个设计的关键：默认实现表示“不提供本地路径、不可物化”，因此文件工具不能假设任何来源都能落到 `Path`。

`MountCapabilities` 用 10 个布尔位描述来源能力：

```text
readable, writable, searchable, editable, deletable, movable,
watchable, shellAccessible, localPathAccessible, materializable
```

### 4、内置实现与能力对照

#### 4.1 FileMountSource（本地文件系统）

```java
FileMountSource source = FileMountSource.of(Paths.get("/data/skills"));
// 或
FileMountSource source = FileMountSource.of("/data/skills");
```

* 构造时把根目录规范化为绝对路径；
* `getScheme()` 返回 `file`，`getLocation()` 返回根目录字符串；
* 能力位：可读、可写、可搜索、可编辑、可删除、可移动、可监听、**shell 可达**、**本地路径可达**；`materializable = false`。

> 本地来源不参与物化：它本身就有本地根目录，物化分支对它是死代码。这是“诚实声明能力”的一个例子——能力位与实现必须一致。

#### 4.2 ClasspathMountSource（Classpath / Jar）

```java
ClasspathMountSource source = ClasspathMountSource.of("bundled-skills");
// 或指定类加载器
ClasspathMountSource source = ClasspathMountSource.of(pluginLoader, "skills");
```

* 资源路径相对 `basePath`，统一使用 `/`，不依赖真实 `Path`；
* `getScheme()` 返回 `classpath`，`getLocation()` 返回 `basePath`；
* 能力位：可读、可搜索、**可物化**；不可写、不可编辑、不可删除、不可移动、不可监听、**shell 不可达**、**本地路径不可达**；
* 支持从 Classpath 目录与 Jar 条目建立索引；当 Jar 中没有目录条目时，可用 `basePath + "index"` 资源文件补充条目；
* 索引会缓存，运行期新增/替换资源后需显式 `refresh()` 丢弃索引；
* `openWrite`、`delete`、`move` 一律抛 `UnsupportedOperationException`。

`basePath` 应使用 classpath 资源路径（如 `bundled-skills`、`META-INF/solon/skills`），不要写 `src/main/resources`、磁盘绝对路径或以 `/` 开头的路径。来源会规范化反斜杠与前导/尾随 `/`，但来源内的 `..` 会被拒绝。

### 5、路径解析规则

用户输入的逻辑路径最终由 `MountManager` 解析为来源与来源内路径。

#### 5.1 逻辑路径形态

| 输入 | 解析结果 |
|------|----------|
| `src/app.java`、`./src/app.java` | 工作区来源（等价于 `@workspace/src/app.java`） |
| `.`、空串 | 工作区根 |
| `@workspace/xxx` | 工作区来源（保留别名，不能注册为普通挂载） |
| `@alias/xxx` | 对应挂载的来源内路径 `xxx` |
| `@alias` | 对应挂载的来源根 |

#### 5.2 两个解析 API

```java
MountManager mountManager = new MountManager("work");

// A. 解析为本地物理路径：只适用于工作区与本地来源，供 bash/LSP 等需要 Path 的场景使用
Path local = mountManager.resolve(Paths.get("work"), "@skills/demo/SKILL.md");

// B. 统一解析为来源 + 来源内路径：非 bash 文件工具应优先使用
ResolvedResource resource = mountManager.resolveResource("@bundled/demo/SKILL.md");
MountSource source = resource.getSource();
String sourcePath = resource.getSourcePath();     // "demo/SKILL.md"
Optional<Path> maybeLocal = resource.getLocalPath();  // 虚拟来源为空
```

`ResolvedResource` 保存了：原始逻辑路径、挂载别名、对应 `Mount`（工作区路径为 `null`）、`MountSource`、来源内路径。

> 推荐用 `ResolvedResource` 一次性完成“拆分逻辑路径 + 取来源”，不要手工 `substring` 切别名。

#### 5.3 `~/` 与 `./` 的展开

`Mount` 本身不承载路径语义。配置里出现 `~/...`、`./...` 时，先由 `MountManager.parseRealPath(String)` 展开为绝对路径：

```java
MountManager mountManager = new MountManager("work");

Path skillsPath = mountManager.parseRealPath("~/.soloncode/skills");  // 用户目录相对
Path localPath  = mountManager.parseRealPath("./team/skills");        // 工作区相对

mountManager.register(Mount.builder()
        .alias("@global-skills")
        .type(MountType.SKILLS)
        .source(FileMountSource.of(skillsPath))
        .build());
```

### 6、安全约束

挂载是把外部资源接进智能体执行面的动作，因此边界检查是强制的：

* **别名规范化**：自动补齐 `@`；`@`、`@workspace`、含 `/`、`\` 或空白的别名一律拒绝。
* **路径防穿越**：`normalize(path)` 必须拒绝 `..`（`FileMountSource` 还会拒绝绝对路径），越界直接抛 `SecurityException`。
* **未知/禁用挂载**：解析 `@xxx` 时，若挂载不存在或已禁用，抛 `SecurityException`（“未知的挂载点 / 挂载点已禁用”），不会退化为普通相对路径。
* **来源能力限制**：凡是不支持本地路径的来源（如 classpath），在 `resolve(...)` 中直接拒绝：“挂载点不支持本地路径”。
* **本地来源边界**：本地来源的路径解析会校验“候选路径是否仍在挂载根内”，并检查已有路径的**符号链接**真实目标是否仍落在根内；工作区不允许绝对路径。
* **挂载根保护**：`delete` 不能删除挂载根本身。
* **写入授权**：逻辑路径默认只读，仅当挂载清单声明 `writeable="true"` 时才允许写入；所有写入同时还要通过来源能力与 Terminal 策略。

### 7、挂载类型与目录发现

类型决定挂载内容如何被“发现”：

| 类型 | 发现约定 |
|------|----------|
| `SKILLS` | 在来源内按 `**/SKILL.md`（兼容 `**/skill.md`）查找标记文件，最大深度 3；技能标识为 `@alias/相对目录`，名称为目录名 |
| `AGENTS` | 扫描来源内的 Markdown 文件，**名称取自 front matter 的 `name`**（缺失则跳过该文件），不支持再用文件名兜底 |
| `FILES` | 不参与技能/子代理发现，仅作为文件资源使用 |

`MountManager` 在注册/移除挂载时会自动刷新两个目录：`SkillCatalog` 与 `AgentCatalog`。挂载内容被**外部**修改时不会自动感知，需要显式刷新：

```java
mountManager.getSkillCatalog().refresh();
mountManager.getAgentCatalog().refresh();
mountManager.refreshByMount("@global-skills");   // 默认实现为全量刷新
```

#### 内置子代理挂载 `@harness-agents`

引擎构造时会自动注册一个只读挂载：

* 别名 `@harness-agents`，类型 `AGENTS`，`primary=true`、`enabled=true`、`writeable=false`；
* 来源是 classpath 的 `META-INF/solon/ai/harness/agents/`（`ClasspathMountSource`）；
* 内含内置子代理定义（`bash`、`explore`、`general`、`git-summary`、`plan`）。

该别名受保护：`mountAdd`、`addMount`、`removeMount` 命中它都会抛 `IllegalArgumentException`——既不能被同名覆盖，也不能被移除。由于类型是 `AGENTS`，它既不进入引导词的挂载清单，也不进入沙盒的文件系统白名单。

### 8、挂载在运行时的呈现

挂载不是“配了就完事”，它会以三种方式影响运行态：

1. **引导词挂载清单**：启用且可见、且类型不为 `AGENTS` 的挂载会写进 `<mount_list>`，并声明 shell 可达性：

| `shell` | 含义 |
|---------|------|
| `"true"` | 本地来源，可用逻辑路径 `cd`/执行（附 `env` 占位符） |
| `"materialized"` | 可物化的虚拟来源，首次引用时物化到用户级缓存后执行（附 `scheme`、`env`） |
| `"false"` | 纯虚拟来源，只能走文件工具 |

2. **沙盒白名单**：启用 OS 级沙盒时，本地挂载根目录进入文件系统白名单（`AGENTS` 类型除外）。

3. **工具路径解析**：`read/write/edit/glob/grep/ls` 通过 `resolveResource` 走来源无关的读写链路；`bash` 只在来源支持本地路径（或可物化）时才能使用该逻辑路径。

### 9、物化执行（materialized）

Classpath/Jar 这类来源没有本地目录，无法直接在 bash 里 `cd`。`MountSource` 通过 `materialize(path, targetDirectory)` 提供“落地执行”能力，由 CLI 侧的物化缓存管理器执行：

* **触发条件**：来源能力位 `materializable = true`（如 `ClasspathMountSource`）。
* **缓存根目录**：`~/.soloncode/cache/mount`（`user.home` 不可用时回退到工作区）。
* **目录结构**：`<cacheRoot>/<alias>/<指纹哈希>`；指纹由 `getFingerprint()` 提供，内容变化后指纹改变，旧目录自然失效。
* **完整性**：目录内写有 `.ready`（完成标记）与 `.manifest`（完整性摘要）；落盘采用唯一 staging 目录 + 原子 rename，读者要么看到完整快照，要么触发重物化。
* **并发**：同一挂载的物化由 JVM 内“按别名分段锁”与缓存目录中的跨进程锁串行化。
* **生命周期**：缓存跨工作区复用，且不随 Talent 生命周期删除。
* **安全**：文件工具层拒绝直接读写该缓存目录（mandatory deny）；启用 OS 沙盒时该目录进入 allowRead 且进入 denyWrite。
* **失败语义**：物化失败会直接报错，**不会**把原始 `@alias` 路径交给 shell。

物化产物是**内容快照**：不回写、不监听，与来源生命周期解耦。POSIX 文件系统下，`.sh`、`.bash`、`.command` 后缀的文件会被补上可执行位；`.py`、`.js` 之类由解释器调用的脚本不需要 `+x`。

### 10、自定义 MountSource

新增一种来源（数据库、对象存储等）只需实现 `MountSource`：

```java
public final class DatabaseMountSource implements MountSource {
    @Override
    public String getScheme() {
        return "db";
    }

    @Override
    public String normalize(String path) {
        // 返回来源内的规范化路径；必须拒绝越界
        return path == null ? "" : path;
    }

    @Override
    public MountEntry stat(String path) throws IOException { /* ... */ }

    @Override
    public List<MountEntry> list(String path) throws IOException { /* ... */ }

    @Override
    public List<MountEntry> find(String path, FindOptions options) throws IOException { /* ... */ }

    @Override
    public InputStream openRead(String path) throws IOException { /* ... */ }

    @Override
    public OutputStream openWrite(String path, WriteOptions options) throws IOException {
        throw new UnsupportedOperationException("read-only");
    }

    @Override
    public void delete(String path) throws IOException {
        throw new UnsupportedOperationException("read-only");
    }

    @Override
    public void move(String source, String target, MoveOptions options) throws IOException {
        throw new UnsupportedOperationException("read-only");
    }

    @Override
    public MountCapabilities capabilities() {
        return new MountCapabilities(true, false, true,
                false, false, false, false, false, false, false);
    }
}
```

实现要点：

* 不支持的操作要**同时**在 `capabilities()` 里声明 false、并让方法明确失败；不要只做其中一半。
* `getFingerprint()` 声明 `materializable = true` 的来源**必须覆写**：指纹要随内容或可靠版本变化，无法确定时应抛异常，**不能**退回位置标识，否则会误命中旧脚本。该方法可能在每次命令翻译时被调用，应按来源体量选择内容摘要或可信版本号。
* 虚拟来源的读取限制应以**实际读取字节数**为准，不要只依赖 `MountEntry.size`。
* 只有真正需要 bash、LSP 或 Path 诊断时才实现 `getLocalRoot`/`getLocalPath`；否则保持默认空实现，让调用方走 `MountSource` 链路。

### 11、运行时动态管理与迁移

```java
engine.addMount(Mount.builder()
        .alias("@team-skills")
        .type(MountType.SKILLS)
        .source(FileMountSource.of("team/skills"))
        .build());                              // 添加/替换（同别名替换，并刷新目录）

engine.hasMount("@team-skills");                // 是否存在
engine.getMount("@team-skills");                // 取挂载对象
engine.refreshMount("@team-skills");            // 清理该挂载的运行时定义，并重新扫描目录
engine.refreshMount(null);                      // 刷新全部（清自定义代理 + 全量刷新技能/子代理目录）
engine.removeMount("@team-skills");             // 移除，并清理该挂载关联的运行时定义
```

从旧版本迁移时，按下表替换即可（这是一次**不保留旧接口**的结构性迁移）：

| 旧模型 | 新模型 | 说明 |
|--------|--------|------|
| `MountDir` | `Mount` + `MountSource` | 元数据/策略与内容来源分离 |
| `SkillDir` | `Mount` + `FileMountSource` | 技能目录就是普通本地挂载 |
| `SkillProvider` / `MountSkillProvider` | `SkillCatalog` / `DefaultSkillCatalog` | 来源适配职责交给 `MountSource`，索引职责交给 Catalog |
| `AgentMd` | `AgentDescriptor` | 表示“已发现的来源描述”，不等于运行时 `AgentDefinition` |
| 直接返回 `Path` 的资源条目 | `MountEntry` / `ResolvedResource` | 来源无关，不暴露真实本地路径 |

正确的调用链应始终保持来源无关：

```text
逻辑路径
  -> MountManager.resolveResource
  -> ResolvedResource
  -> MountSource 操作
```

这样未来新增数据库、远程对象存储等来源时，不需要再次改造 SkillTalent、AgentCatalog 与 Terminal 的核心逻辑。
