harness - 挂载机制详解
挂载(Mount)是 Harness 的“外部资源接入层”。它让技能、子代理、普通文件不再只能来自工作区目录,而可以来自全局共享库、插件目录、Classpath/Jar,甚至未来来自数据库或对象存储等完全虚拟的来源。
自 4.1.1 起,挂载被拆成两个对象(旧类型 MountDir、SkillDir 已删除):
Mount:挂载的元数据与策略(别名、类型、启用/可写/可见、来源引用)。MountSource:内容来源,负责真正的读、写、查、删、移与能力声明。
两者分开之后,文件工具与技能/子代理目录都不再假设“资源一定是一个本地 Path”。
1、快速上手
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 字段参考
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 个布尔位描述来源能力:
readable, writable, searchable, editable, deletable, movable,
watchable, shellAccessible, localPathAccessible, materializable
4、内置实现与能力对照
4.1 FileMountSource(本地文件系统)
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)
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
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) 展开为绝对路径:
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。挂载内容被外部修改时不会自动感知,需要显式刷新:
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、挂载在运行时的呈现
挂载不是“配了就完事”,它会以三种方式影响运行态:
- 引导词挂载清单:启用且可见、且类型不为
AGENTS的挂载会写进<mount_list>,并声明 shell 可达性:
shell | 含义 |
|---|---|
"true" | 本地来源,可用逻辑路径 cd/执行(附 env 占位符) |
"materialized" | 可物化的虚拟来源,首次引用时物化到用户级缓存后执行(附 scheme、env) |
"false" | 纯虚拟来源,只能走文件工具 |
-
沙盒白名单:启用 OS 级沙盒时,本地挂载根目录进入文件系统白名单(
AGENTS类型除外)。 -
工具路径解析:
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:
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、运行时动态管理与迁移
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 | 来源无关,不暴露真实本地路径 |
正确的调用链应始终保持来源无关:
逻辑路径
-> MountManager.resolveResource
-> ResolvedResource
-> MountSource 操作
这样未来新增数据库、远程对象存储等来源时,不需要再次改造 SkillTalent、AgentCatalog 与 Terminal 的核心逻辑。