Solon v4.1.1

harness - 挂载机制详解

</> markdown
2026年10月8日 上午9:16:35

挂载(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,且不能包含 /、\ 或空白字符
typeFILES挂载类型:SKILLS / AGENTS / FILES
source/内容来源(MountSource)。未设置时 build() 直接抛 IllegalArgumentException
descriptionnull挂载描述,会出现在引导词的挂载清单里
primaryfalse主挂载标记。仅是一个标记,不提供删除/覆盖保护
enabledtrue是否启用。禁用的挂载不参与目录扫描,逻辑路径解析时也会被拒绝
writeablefalse是否允许写入(挂载默认只读)。这是授权配置,与来源本身的技术能力是两件事
visibletrue是否在展示层出现。它只影响引导词的挂载清单,不影响启用、解析与运行时使用

注意: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、挂载在运行时的呈现

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

  1. 引导词挂载清单:启用且可见、且类型不为 AGENTS 的挂载会写进 <mount_list>,并声明 shell 可达性:
shell含义
"true"本地来源,可用逻辑路径 cd/执行(附 env 占位符)
"materialized"可物化的虚拟来源,首次引用时物化到用户级缓存后执行(附 scheme、env)
"false"纯虚拟来源,只能走文件工具
  1. 沙盒白名单:启用 OS 级沙盒时,本地挂载根目录进入文件系统白名单(AGENTS 类型除外)。

  2. 工具路径解析: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");             // 移除,并清理该挂载关联的运行时定义

从旧版本迁移时,按下表替换即可(这是一次不保留旧接口的结构性迁移):

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

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

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

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