feat: 完善标准 Skill 包底座
- 标准化 SKILL.md、资源模型、校验规则与安全限额 - 支持流式内容存储和单、多 Skill ZIP 双向编解码
This commit is contained in:
@@ -24,6 +24,7 @@ Easy-Agents 是一个轻量、可扩展的 Java AI 应用开发框架,覆盖
|
||||
- `easy-agents-search-engine`:检索引擎实现。
|
||||
- `easy-agents-tool`:工具调用能力。
|
||||
- `easy-agents-mcp`:MCP 集成。
|
||||
- `easy-agents-skill`:标准 Agent Skills 包模型、安全校验、资源存储与 ZIP 双向编解码。
|
||||
- `easy-agents-flow`:流程编排核心引擎。
|
||||
- `easy-agents-support`:Flow 与 Easy-Agents 适配模块。
|
||||
- `easy-agents-spring-boot-starter`:Spring Boot 自动配置支持。
|
||||
|
||||
72
easy-agents-skill/README.md
Normal file
72
easy-agents-skill/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Easy-Agents Skill
|
||||
|
||||
`easy-agents-skill` 提供标准 Agent Skills 包的领域模型、安全校验、通用资源存储,以及 ZIP 双向编解码能力。模块只负责 Skill 定义和包处理,不执行 `scripts/`,也不绑定具体智能体 Runtime。
|
||||
|
||||
## 标准包结构
|
||||
|
||||
Codec 支持根目录单 Skill、单目录 Skill 和多目录 Skill 三种输入布局。标准输出使用 `name/SKILL.md`,并保留以下可移植资源:
|
||||
|
||||
- `references/`
|
||||
- `scripts/`
|
||||
- `assets/`
|
||||
- `examples/`
|
||||
- 其他安全相对路径资源
|
||||
|
||||
`SKILL.md` 使用 YAML frontmatter 与 Markdown 正文。未知字段、嵌套 Map/List、布尔值和数字会保留;语义校验通过结构化 issue 返回路径、行列、错误码与修复建议。
|
||||
|
||||
## 推荐调用方式
|
||||
|
||||
无参 `ZipSkillPackageCodec` 使用实例级临时文件存储,适合一次性导入导出。它拥有临时目录,必须关闭:
|
||||
|
||||
```java
|
||||
try (ZipSkillPackageCodec codec = new ZipSkillPackageCodec()) {
|
||||
SkillPackageReadResult result = codec.decode(
|
||||
inputStream,
|
||||
SkillPackageReadOptions.defaults());
|
||||
SkillPackage skillPackage = result.getSkillPackage();
|
||||
}
|
||||
```
|
||||
|
||||
生产系统需要让二进制资源跨请求存活时,应注入持久化的 `SkillContentStore`。注入存储的生命周期由调用方负责,关闭 Codec 不会关闭外部存储:
|
||||
|
||||
```java
|
||||
ZipSkillPackageCodec codec = new ZipSkillPackageCodec(contentStore);
|
||||
SkillPackageReadResult result = codec.decode(inputStream, readOptions);
|
||||
codec.encode(result.getSkillPackage(), outputStream, writeOptions);
|
||||
```
|
||||
|
||||
成功解码的二进制资源通过 `contentRef` 引用已提交内容。业务侧丢弃包或删除资源时,应按持久化策略调用 `release`;复制引用时调用 `retain`。`REPORT_ONLY` 模式会回滚暂存内容,只用于检查诊断,不应持久化其资源引用。
|
||||
|
||||
## 校验上下文
|
||||
|
||||
校验通过 `SkillValidationMode` 区分两个明确上下文:
|
||||
|
||||
- `DRAFT_IMPORT`:ZIP 导入和兼容预检使用;历史下划线名称保留为 warning,允许先进入草稿修复。
|
||||
- `STANDARD`:正式新建、发布校验和标准 ZIP 导出使用;下划线名称等互操作问题作为 error。
|
||||
|
||||
`SkillFactory.createStrict`、`SkillFactory.createWithResourcesStrict`、`DefaultSkillValidator.validate` 与 `ZipSkillPackageCodec.encode` 执行 `STANDARD` 校验。为保持旧调用兼容,`SkillFactory.create` 仍可构建导入草稿,原有 `validateReport(skill)` 与 `validateReport(skill, limits)` 继续使用 `DRAFT_IMPORT`;新调用方需要显式上下文时使用三参数 `validateReport`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
默认 Codec 对读写两端执行统一限制:
|
||||
|
||||
- 严格 UTF-8 文本和 ZIP entry 名称
|
||||
- Zip Slip、符号链接、路径大小写/Unicode 冲突与层级冲突防护
|
||||
- entry 数量、路径长度/深度、单文件、总解压大小、压缩包大小和压缩比限制
|
||||
- CRC、声明大小与实际流量复核
|
||||
- 安全 YAML 构造、重复 key、alias、深度和 code point 限制
|
||||
- stage / commit / rollback,失败时清理暂存内容
|
||||
|
||||
限额通过 `SkillPackageLimits` 配置,并由 `SkillPackageReadOptions`、`SkillPackageWriteOptions` 传入单次操作。
|
||||
|
||||
## 从旧接口迁移
|
||||
|
||||
`importZip(InputStream)` 为兼容入口,现已废弃。新调用方应使用 `decode`,以获得:
|
||||
|
||||
- 包布局 `SkillPackageLayout`
|
||||
- 标准化 `SkillPackage`
|
||||
- 包哈希
|
||||
- 聚合校验报告
|
||||
- `STRICT` 或 `REPORT_ONLY` 读取模式
|
||||
|
||||
写出统一使用 `encode`。自定义校验器是附加业务校验,不能替代 Codec 内置的标准安全校验。
|
||||
@@ -18,6 +18,14 @@
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
|
||||
@@ -1,20 +1,59 @@
|
||||
package com.easyagents.skill.codec;
|
||||
|
||||
import com.easyagents.skill.exception.SkillPackageException;
|
||||
import com.easyagents.skill.model.Skill;
|
||||
import com.easyagents.skill.model.SkillPackage;
|
||||
import com.easyagents.skill.model.SkillPackageLayout;
|
||||
import com.easyagents.skill.validation.SkillValidationReport;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Skill 包导入接口。
|
||||
* Skill 包双向流式编解码接口。
|
||||
*/
|
||||
public interface SkillPackageCodec {
|
||||
|
||||
/**
|
||||
* 从 zip 输入流导入 Skill。
|
||||
* 从 ZIP 输入流导入 Skill。
|
||||
*
|
||||
* @param inputStream zip 输入流
|
||||
* @param inputStream ZIP 输入流
|
||||
* @return Skill 列表
|
||||
* @throws SkillPackageException ZIP 结构、内容或安全校验失败
|
||||
* @deprecated 请使用 {@link #decode(InputStream, SkillPackageReadOptions)} 获取包形态、hash 和诊断。
|
||||
*/
|
||||
@Deprecated
|
||||
List<Skill> importZip(InputStream inputStream);
|
||||
|
||||
/**
|
||||
* 解码 Skill ZIP。
|
||||
*
|
||||
* <p>兼容默认实现委托旧导入接口;正式 Codec 应覆盖。</p>
|
||||
*
|
||||
* @param inputStream ZIP 输入流
|
||||
* @param options 读取选项
|
||||
* @return 解码结果
|
||||
* @throws SkillPackageException ZIP 结构、内容、安全校验或资源存储失败
|
||||
*/
|
||||
default SkillPackageReadResult decode(InputStream inputStream, SkillPackageReadOptions options) {
|
||||
List<Skill> skills = importZip(inputStream);
|
||||
SkillPackageLayout layout = skills.size() > 1
|
||||
? SkillPackageLayout.MULTI_DIRECTORY : SkillPackageLayout.SINGLE_DIRECTORY;
|
||||
return new SkillPackageReadResult(new SkillPackage(layout, skills), new SkillValidationReport(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Skill 包编码为标准 ZIP。
|
||||
*
|
||||
* @param skillPackage Skill 包
|
||||
* @param outputStream 输出流,不由本方法关闭
|
||||
* @param options 写出选项
|
||||
* @return 编码结果
|
||||
* @throws SkillPackageException Skill 包不合法、资源不可读或 ZIP 写出失败
|
||||
*/
|
||||
default SkillPackageWriteResult encode(SkillPackage skillPackage, OutputStream outputStream,
|
||||
SkillPackageWriteOptions options) {
|
||||
throw new SkillPackageException("This SkillPackageCodec does not support encoding.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.easyagents.skill.codec;
|
||||
|
||||
/**
|
||||
* Skill 包读取后的内容处理模式。
|
||||
*/
|
||||
public enum SkillPackageReadMode {
|
||||
|
||||
/**
|
||||
* 校验通过后提交二进制内容;校验失败时抛出异常并回滚暂存内容。
|
||||
*/
|
||||
COMMIT_ON_VALID,
|
||||
|
||||
/**
|
||||
* 返回可解析包的完整校验报告并回滚全部暂存内容,不提交二进制内容。
|
||||
* 返回资源中的 contentRef 仅表示内容哈希身份,不保证读取结果返回后仍可打开。
|
||||
*/
|
||||
REPORT_ONLY
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.easyagents.skill.codec;
|
||||
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
|
||||
/**
|
||||
* Skill 包读取选项。
|
||||
*/
|
||||
public final class SkillPackageReadOptions {
|
||||
|
||||
private final SkillPackageLimits limits;
|
||||
private final SkillPackageReadMode mode;
|
||||
|
||||
/**
|
||||
* 使用指定安全限额创建读取选项。
|
||||
*
|
||||
* @param limits 安全限额
|
||||
*/
|
||||
public SkillPackageReadOptions(SkillPackageLimits limits) {
|
||||
this(limits, SkillPackageReadMode.COMMIT_ON_VALID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定安全限额和内容处理模式创建读取选项。
|
||||
*
|
||||
* @param limits 安全限额
|
||||
* @param mode 内容处理模式
|
||||
*/
|
||||
public SkillPackageReadOptions(SkillPackageLimits limits, SkillPackageReadMode mode) {
|
||||
this.limits = limits == null ? SkillPackageLimits.defaults() : limits;
|
||||
this.mode = mode == null ? SkillPackageReadMode.COMMIT_ON_VALID : mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认读取选项。
|
||||
*
|
||||
* @return 默认读取选项
|
||||
*/
|
||||
public static SkillPackageReadOptions defaults() {
|
||||
return new SkillPackageReadOptions(SkillPackageLimits.defaults());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建只返回校验报告且不提交内容的读取选项。
|
||||
* 二进制资源的 contentRef 仅表示内容哈希身份,不承诺可通过内容存储打开。
|
||||
*
|
||||
* @return 只读预检选项
|
||||
*/
|
||||
public static SkillPackageReadOptions reportOnly() {
|
||||
return new SkillPackageReadOptions(SkillPackageLimits.defaults(), SkillPackageReadMode.REPORT_ONLY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取安全限额。
|
||||
*
|
||||
* @return 安全限额
|
||||
*/
|
||||
public SkillPackageLimits getLimits() {
|
||||
return limits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容处理模式。
|
||||
*
|
||||
* @return 内容处理模式
|
||||
*/
|
||||
public SkillPackageReadMode getMode() {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.easyagents.skill.codec;
|
||||
|
||||
import com.easyagents.skill.model.SkillPackage;
|
||||
import com.easyagents.skill.validation.SkillValidationReport;
|
||||
|
||||
/**
|
||||
* Skill 包解码结果。
|
||||
*/
|
||||
public final class SkillPackageReadResult {
|
||||
|
||||
private final SkillPackage skillPackage;
|
||||
private final SkillValidationReport validationReport;
|
||||
private final String packageHash;
|
||||
|
||||
/**
|
||||
* 创建解码结果。
|
||||
*
|
||||
* @param skillPackage Skill 包
|
||||
* @param validationReport 校验报告
|
||||
* @param packageHash 输入 ZIP SHA-256
|
||||
*/
|
||||
public SkillPackageReadResult(SkillPackage skillPackage, SkillValidationReport validationReport,
|
||||
String packageHash) {
|
||||
this.skillPackage = skillPackage;
|
||||
this.validationReport = validationReport;
|
||||
this.packageHash = packageHash;
|
||||
}
|
||||
|
||||
/** @return Skill 包 */
|
||||
public SkillPackage getSkillPackage() {
|
||||
return skillPackage;
|
||||
}
|
||||
|
||||
/** @return 结构化校验报告 */
|
||||
public SkillValidationReport getValidationReport() {
|
||||
return validationReport;
|
||||
}
|
||||
|
||||
/** @return 输入 ZIP SHA-256 */
|
||||
public String getPackageHash() {
|
||||
return packageHash;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.easyagents.skill.codec;
|
||||
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
|
||||
/**
|
||||
* Skill 包写出选项。
|
||||
*/
|
||||
public final class SkillPackageWriteOptions {
|
||||
|
||||
private final SkillPackageLimits limits;
|
||||
|
||||
/**
|
||||
* 使用指定安全限额创建写出选项。
|
||||
*
|
||||
* @param limits 安全限额
|
||||
*/
|
||||
public SkillPackageWriteOptions(SkillPackageLimits limits) {
|
||||
this.limits = limits == null ? SkillPackageLimits.defaults() : limits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认写出选项。
|
||||
*
|
||||
* @return 默认写出选项
|
||||
*/
|
||||
public static SkillPackageWriteOptions defaults() {
|
||||
return new SkillPackageWriteOptions(SkillPackageLimits.defaults());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取安全限额。
|
||||
*
|
||||
* @return 安全限额
|
||||
*/
|
||||
public SkillPackageLimits getLimits() {
|
||||
return limits;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.easyagents.skill.codec;
|
||||
|
||||
import com.easyagents.skill.model.SkillPackageLayout;
|
||||
|
||||
/**
|
||||
* Skill 包编码结果。
|
||||
*/
|
||||
public final class SkillPackageWriteResult {
|
||||
|
||||
private final String packageHash;
|
||||
private final long size;
|
||||
private final int entryCount;
|
||||
private final SkillPackageLayout layout;
|
||||
|
||||
/**
|
||||
* 创建编码结果。
|
||||
*
|
||||
* @param packageHash 输出 ZIP SHA-256
|
||||
* @param size 输出字节数
|
||||
* @param entryCount 输出 entry 数
|
||||
*/
|
||||
public SkillPackageWriteResult(String packageHash, long size, int entryCount) {
|
||||
this(packageHash, size, entryCount, SkillPackageLayout.SINGLE_DIRECTORY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带包形态的编码结果。
|
||||
*
|
||||
* @param packageHash 输出 ZIP SHA-256
|
||||
* @param size 输出字节数
|
||||
* @param entryCount 输出 entry 数
|
||||
* @param layout 输出包形态
|
||||
*/
|
||||
public SkillPackageWriteResult(String packageHash, long size, int entryCount,
|
||||
SkillPackageLayout layout) {
|
||||
this.packageHash = packageHash;
|
||||
this.size = size;
|
||||
this.entryCount = entryCount;
|
||||
this.layout = layout == null ? SkillPackageLayout.SINGLE_DIRECTORY : layout;
|
||||
}
|
||||
|
||||
/** @return 输出 ZIP SHA-256 */
|
||||
public String getPackageHash() {
|
||||
return packageHash;
|
||||
}
|
||||
|
||||
/** @return 输出字节数 */
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
/** @return 输出 entry 数 */
|
||||
public int getEntryCount() {
|
||||
return entryCount;
|
||||
}
|
||||
|
||||
/** @return 输出包形态 */
|
||||
public SkillPackageLayout getLayout() {
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,23 @@
|
||||
package com.easyagents.skill.exception;
|
||||
|
||||
import com.easyagents.skill.validation.SkillValidationReport;
|
||||
|
||||
/**
|
||||
* Skill 包导入导出异常。
|
||||
*/
|
||||
public class SkillPackageException extends SkillException {
|
||||
|
||||
private final String code;
|
||||
private final String path;
|
||||
private final SkillValidationReport report;
|
||||
|
||||
/**
|
||||
* 创建 Skill 包异常。
|
||||
*
|
||||
* @param message 异常信息
|
||||
*/
|
||||
public SkillPackageException(String message) {
|
||||
super(message);
|
||||
this("SKILL_PACKAGE_FAILED", null, message, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,6 +27,62 @@ public class SkillPackageException extends SkillException {
|
||||
* @param cause 原始异常
|
||||
*/
|
||||
public SkillPackageException(String message, Throwable cause) {
|
||||
this("SKILL_PACKAGE_FAILED", null, message, cause, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可定位的 Skill 包异常。
|
||||
*
|
||||
* @param code 稳定错误码
|
||||
* @param path 包内路径
|
||||
* @param message 异常信息
|
||||
*/
|
||||
public SkillPackageException(String code, String path, String message) {
|
||||
this(code, path, message, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含根因的可定位 Skill 包异常。
|
||||
*
|
||||
* @param code 稳定错误码
|
||||
* @param path 包内路径
|
||||
* @param message 异常信息
|
||||
* @param cause 原始异常
|
||||
*/
|
||||
public SkillPackageException(String code, String path, String message, Throwable cause) {
|
||||
this(code, path, message, cause, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含结构化校验报告的 Skill 包异常。
|
||||
*
|
||||
* @param message 异常信息
|
||||
* @param report 结构化校验报告
|
||||
*/
|
||||
public SkillPackageException(String message, SkillValidationReport report) {
|
||||
this("SKILL_PACKAGE_INVALID", null, message, null, report);
|
||||
}
|
||||
|
||||
private SkillPackageException(String code, String path, String message,
|
||||
Throwable cause, SkillValidationReport report) {
|
||||
super(message, cause);
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
this.report = report;
|
||||
}
|
||||
|
||||
/** @return 稳定错误码 */
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/** @return 包内路径 */
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
/** @return 结构化校验报告 */
|
||||
public SkillValidationReport getReport() {
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
package com.easyagents.skill.exception;
|
||||
|
||||
import com.easyagents.skill.validation.SkillValidationReport;
|
||||
|
||||
/**
|
||||
* Skill 校验失败异常。
|
||||
*/
|
||||
public class SkillValidationException extends SkillException {
|
||||
|
||||
private final String code;
|
||||
private final String path;
|
||||
private final Integer line;
|
||||
private final Integer column;
|
||||
private final SkillValidationReport report;
|
||||
|
||||
/**
|
||||
* 创建 Skill 校验异常。
|
||||
*
|
||||
* @param message 异常信息
|
||||
*/
|
||||
public SkillValidationException(String message) {
|
||||
super(message);
|
||||
this("SKILL_VALIDATION_FAILED", null, null, null, message, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,6 +29,77 @@ public class SkillValidationException extends SkillException {
|
||||
* @param cause 原始异常
|
||||
*/
|
||||
public SkillValidationException(String message, Throwable cause) {
|
||||
this("SKILL_VALIDATION_FAILED", null, null, null, message, cause, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含结构化报告的 Skill 校验异常。
|
||||
*
|
||||
* @param message 异常信息
|
||||
* @param report 结构化校验报告
|
||||
*/
|
||||
public SkillValidationException(String message, SkillValidationReport report) {
|
||||
this("SKILL_VALIDATION_FAILED", null, null, null, message, null, report);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可定位的 Skill 校验异常。
|
||||
*
|
||||
* @param code 稳定错误码
|
||||
* @param path 文件路径
|
||||
* @param line 一基行号
|
||||
* @param column 一基列号
|
||||
* @param message 异常信息
|
||||
* @param cause 原始异常
|
||||
*/
|
||||
public SkillValidationException(String code, String path, Integer line, Integer column,
|
||||
String message, Throwable cause) {
|
||||
this(code, path, line, column, message, cause, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建同时携带定位、根因和完整报告的 Skill 校验异常。
|
||||
*
|
||||
* @param code 稳定错误码
|
||||
* @param path 文件路径
|
||||
* @param line 一基行号
|
||||
* @param column 一基列号
|
||||
* @param message 异常信息
|
||||
* @param cause 原始异常
|
||||
* @param report 完整结构化校验报告
|
||||
*/
|
||||
public SkillValidationException(String code, String path, Integer line, Integer column,
|
||||
String message, Throwable cause, SkillValidationReport report) {
|
||||
super(message, cause);
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
this.line = line;
|
||||
this.column = column;
|
||||
this.report = report;
|
||||
}
|
||||
|
||||
/** @return 稳定错误码 */
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/** @return 文件路径 */
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
/** @return 一基行号 */
|
||||
public Integer getLine() {
|
||||
return line;
|
||||
}
|
||||
|
||||
/** @return 一基列号 */
|
||||
public Integer getColumn() {
|
||||
return column;
|
||||
}
|
||||
|
||||
/** @return 结构化校验报告 */
|
||||
public SkillValidationReport getReport() {
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.easyagents.skill.factory;
|
||||
|
||||
import com.easyagents.skill.model.*;
|
||||
import com.easyagents.skill.util.SkillFrontmatter;
|
||||
import com.easyagents.skill.util.SkillResources;
|
||||
import com.easyagents.skill.validation.defaults.DefaultSkillValidator;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -25,6 +27,49 @@ public final class SkillFactory {
|
||||
return create(id, skillContent, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 SKILL.md 内容创建并严格校验正式标准 Skill。
|
||||
*
|
||||
* @param id Skill ID
|
||||
* @param skillContent SKILL.md 原始内容
|
||||
* @return 通过正式标准校验的 Skill 聚合
|
||||
*/
|
||||
public static Skill createStrict(String id, String skillContent) {
|
||||
Skill skill = create(id, skillContent);
|
||||
new DefaultSkillValidator().validate(skill);
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 SKILL.md 内容和通用资源创建 Skill。
|
||||
*
|
||||
* @param id 仓储 ID,可为空
|
||||
* @param skillContent SKILL.md 原始内容
|
||||
* @param resources 通用资源列表
|
||||
* @return Skill 聚合
|
||||
*/
|
||||
public static Skill createWithResources(String id, String skillContent, List<SkillResource> resources) {
|
||||
Skill skill = create(id, skillContent);
|
||||
skill.setResources(resources);
|
||||
SkillResources.refreshLegacyViews(skill);
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 SKILL.md 和通用资源创建并严格校验正式标准 Skill。
|
||||
*
|
||||
* @param id 仓储 ID,可为空
|
||||
* @param skillContent SKILL.md 原始内容
|
||||
* @param resources 通用资源列表
|
||||
* @return 通过正式标准校验的 Skill 聚合
|
||||
*/
|
||||
public static Skill createWithResourcesStrict(String id, String skillContent,
|
||||
List<SkillResource> resources) {
|
||||
Skill skill = createWithResources(id, skillContent, resources);
|
||||
new DefaultSkillValidator().validate(skill);
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 SKILL.md 内容和资源列表创建 Skill。
|
||||
*
|
||||
@@ -37,16 +82,29 @@ public final class SkillFactory {
|
||||
*/
|
||||
public static Skill create(String id, String skillContent, List<SkillReference> references,
|
||||
List<SkillScript> scripts, List<SkillAsset> assets) {
|
||||
Map<String, Object> values = SkillFrontmatter.parse(skillContent);
|
||||
SkillDocument document = SkillFrontmatter.parseDocument(skillContent);
|
||||
Map<String, Object> values = document.getFrontmatter().getValues();
|
||||
String name = requiredScalar(values, "name");
|
||||
String description = requiredScalar(values, "description");
|
||||
Skill skill = new Skill();
|
||||
skill.setId(id);
|
||||
skill.setName(values.get("name").toString());
|
||||
skill.setDescription(values.get("description").toString());
|
||||
skill.setName(name);
|
||||
skill.setDescription(description);
|
||||
skill.setMetadata(new SkillMetadata(values));
|
||||
skill.setSkillContent(skillContent);
|
||||
skill.setDocument(document);
|
||||
skill.setReferences(references);
|
||||
skill.setScripts(scripts);
|
||||
skill.setAssets(assets);
|
||||
skill.setResources(SkillResources.canonicalResources(skill));
|
||||
return skill;
|
||||
}
|
||||
|
||||
private static String requiredScalar(Map<String, Object> values, String key) {
|
||||
Object value = values.get(key);
|
||||
if (!(value instanceof String text) || text.isBlank()) {
|
||||
throw new com.easyagents.skill.exception.SkillValidationException(
|
||||
"SKILL.md frontmatter " + key + " must be a non-blank string.");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import com.easyagents.skill.util.SkillResources;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -12,10 +14,14 @@ public class Skill implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String id;
|
||||
private String packageRoot;
|
||||
private String name;
|
||||
private String description;
|
||||
private SkillMetadata metadata = new SkillMetadata();
|
||||
private String skillContent;
|
||||
private SkillDocument document;
|
||||
private List<SkillResource> resources = new ArrayList<>();
|
||||
private boolean resourcesInitialized;
|
||||
private List<SkillReference> references = new ArrayList<>();
|
||||
private List<SkillScript> scripts = new ArrayList<>();
|
||||
private List<SkillAsset> assets = new ArrayList<>();
|
||||
@@ -38,6 +44,24 @@ public class Skill implements Serializable {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取导入包中的顶层目录名;该值不是仓储 ID。
|
||||
*
|
||||
* @return 包目录名
|
||||
*/
|
||||
public String getPackageRoot() {
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置导入包中的顶层目录名。
|
||||
*
|
||||
* @param packageRoot 包目录名
|
||||
*/
|
||||
public void setPackageRoot(String packageRoot) {
|
||||
this.packageRoot = packageRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取名称。
|
||||
*
|
||||
@@ -98,7 +122,7 @@ public class Skill implements Serializable {
|
||||
* @return SKILL.md 原始内容
|
||||
*/
|
||||
public String getSkillContent() {
|
||||
return skillContent;
|
||||
return document == null ? skillContent : document.render();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +132,69 @@ public class Skill implements Serializable {
|
||||
*/
|
||||
public void setSkillContent(String skillContent) {
|
||||
this.skillContent = skillContent;
|
||||
this.document = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取解析后的 SKILL.md 文档。
|
||||
*
|
||||
* @return 解析后的文档
|
||||
*/
|
||||
public SkillDocument getDocument() {
|
||||
return document;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置解析后的 SKILL.md 文档。
|
||||
*
|
||||
* @param document 解析后的文档
|
||||
*/
|
||||
public void setDocument(SkillDocument document) {
|
||||
this.document = document;
|
||||
this.skillContent = document == null ? null : document.render();
|
||||
if (document != null) {
|
||||
java.util.Map<String, Object> values = document.getFrontmatter().getValues();
|
||||
this.metadata = new SkillMetadata(values);
|
||||
this.name = values.get("name") instanceof String text ? text : null;
|
||||
this.description = values.get("description") instanceof String text ? text : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通用资源列表。
|
||||
*
|
||||
* @return 通用资源列表
|
||||
*/
|
||||
public List<SkillResource> getResources() {
|
||||
if (!resourcesInitialized) {
|
||||
resources = resources == null || resources.isEmpty()
|
||||
? new ArrayList<>(SkillResources.fromLegacyViews(this))
|
||||
: new ArrayList<>(resources);
|
||||
resourcesInitialized = true;
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置通用资源列表。
|
||||
*
|
||||
* @param resources 通用资源列表
|
||||
*/
|
||||
public void setResources(List<SkillResource> resources) {
|
||||
this.resources = resources == null ? new ArrayList<>() : new ArrayList<>(resources);
|
||||
this.resourcesInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断正式通用资源列表是否已被显式初始化。
|
||||
*
|
||||
* <p>该标记用于区分“尚未迁移的旧资源视图”和“调用方明确设置的空资源列表”,
|
||||
* 避免删除最后一个正式资源后又从旧兼容视图恢复该资源。</p>
|
||||
*
|
||||
* @return 已显式设置通用资源列表时为 true
|
||||
*/
|
||||
public boolean isResourcesInitialized() {
|
||||
return resourcesInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.easyagents.skill.model;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Skill 静态资产。
|
||||
* Skill 静态资产兼容视图。
|
||||
*
|
||||
* @deprecated 请使用 {@link SkillResource}。
|
||||
*/
|
||||
@Deprecated
|
||||
public class SkillAsset implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import com.easyagents.skill.util.SkillFrontmatter;
|
||||
import com.easyagents.skill.validation.SkillValidationIssue;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 解析后的 SKILL.md 文档,保留原文并支持确定性重建。
|
||||
*/
|
||||
public class SkillDocument implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String rawContent;
|
||||
private SkillMetadata frontmatter = new SkillMetadata();
|
||||
private String markdownBody = "";
|
||||
private Map<String, SkillSourceLocation> frontmatterLocations = new LinkedHashMap<>();
|
||||
private List<SkillValidationIssue> diagnostics = new ArrayList<>();
|
||||
private boolean modified;
|
||||
|
||||
/**
|
||||
* 创建空文档。
|
||||
*/
|
||||
public SkillDocument() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建已解析文档。
|
||||
*
|
||||
* @param rawContent 原始完整内容
|
||||
* @param frontmatter frontmatter 有序对象
|
||||
* @param markdownBody Markdown 正文
|
||||
*/
|
||||
public SkillDocument(String rawContent, Map<String, Object> frontmatter, String markdownBody) {
|
||||
this(rawContent, frontmatter, markdownBody, Map.of(), List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含源码定位和解析诊断的已解析文档。
|
||||
*
|
||||
* @param rawContent 原始完整内容
|
||||
* @param frontmatter frontmatter 有序对象
|
||||
* @param markdownBody Markdown 正文
|
||||
* @param frontmatterLocations 顶层 frontmatter 字段源码位置
|
||||
* @param diagnostics 解析阶段产生的结构化诊断
|
||||
*/
|
||||
public SkillDocument(String rawContent, Map<String, Object> frontmatter, String markdownBody,
|
||||
Map<String, SkillSourceLocation> frontmatterLocations,
|
||||
List<SkillValidationIssue> diagnostics) {
|
||||
this.rawContent = rawContent;
|
||||
this.frontmatter = new SkillMetadata(frontmatter);
|
||||
this.markdownBody = markdownBody == null ? "" : markdownBody;
|
||||
this.frontmatterLocations = frontmatterLocations == null
|
||||
? new LinkedHashMap<>() : new LinkedHashMap<>(frontmatterLocations);
|
||||
this.diagnostics = diagnostics == null ? new ArrayList<>() : new ArrayList<>(diagnostics);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始完整内容。
|
||||
*
|
||||
* @return 原始内容
|
||||
*/
|
||||
public String getRawContent() {
|
||||
return rawContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置原始完整内容。
|
||||
*
|
||||
* @param rawContent 原始内容
|
||||
*/
|
||||
public void setRawContent(String rawContent) {
|
||||
this.rawContent = rawContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 frontmatter 防御性副本;修改文档请使用 {@link #putFrontmatter(String, Object)}、
|
||||
* {@link #removeFrontmatter(String)} 或 {@link #setFrontmatter(SkillMetadata)}。
|
||||
*
|
||||
* @return frontmatter 有序对象副本
|
||||
*/
|
||||
public SkillMetadata getFrontmatter() {
|
||||
return new SkillMetadata(frontmatter.getValues());
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 frontmatter 并标记文档已修改。
|
||||
*
|
||||
* @param frontmatter frontmatter 有序对象
|
||||
*/
|
||||
public void setFrontmatter(SkillMetadata frontmatter) {
|
||||
this.frontmatter = frontmatter == null
|
||||
? new SkillMetadata() : new SkillMetadata(frontmatter.getValues());
|
||||
this.frontmatterLocations.clear();
|
||||
this.modified = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入一个 frontmatter 字段并标记文档已修改。
|
||||
*
|
||||
* @param key 字段名
|
||||
* @param value 字段值
|
||||
*/
|
||||
public void putFrontmatter(String key, Object value) {
|
||||
frontmatter.put(key, value);
|
||||
frontmatterLocations.remove(key);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一个 frontmatter 字段并标记文档已修改。
|
||||
*
|
||||
* @param key 字段名
|
||||
* @return 被删除值的防御性副本
|
||||
*/
|
||||
public Object removeFrontmatter(String key) {
|
||||
Object removed = frontmatter.remove(key);
|
||||
frontmatterLocations.remove(key);
|
||||
modified = true;
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取顶层 frontmatter 字段的源码位置。
|
||||
*
|
||||
* @param key frontmatter 字段名
|
||||
* @return 源码位置;字段不存在或已被结构化修改时为空
|
||||
*/
|
||||
public SkillSourceLocation getFrontmatterLocation(String key) {
|
||||
return key == null ? null : frontmatterLocations.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取顶层 frontmatter 字段源码位置的不可变副本。
|
||||
*
|
||||
* @return 字段与源码位置映射
|
||||
*/
|
||||
public Map<String, SkillSourceLocation> getFrontmatterLocations() {
|
||||
return Collections.unmodifiableMap(new LinkedHashMap<>(frontmatterLocations));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取解析阶段结构化诊断的不可变副本。
|
||||
*
|
||||
* @return 解析诊断
|
||||
*/
|
||||
public List<SkillValidationIssue> getDiagnostics() {
|
||||
return Collections.unmodifiableList(new ArrayList<>(diagnostics));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Markdown 正文。
|
||||
*
|
||||
* @return Markdown 正文
|
||||
*/
|
||||
public String getMarkdownBody() {
|
||||
return markdownBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Markdown 正文并标记文档已修改。
|
||||
*
|
||||
* @param markdownBody Markdown 正文
|
||||
*/
|
||||
public void setMarkdownBody(String markdownBody) {
|
||||
this.markdownBody = markdownBody == null ? "" : markdownBody;
|
||||
this.modified = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断结构化内容是否被修改。
|
||||
*
|
||||
* @return 已修改时为 true
|
||||
*/
|
||||
public boolean isModified() {
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置修改标记。
|
||||
*
|
||||
* @param modified 修改标记
|
||||
*/
|
||||
public void setModified(boolean modified) {
|
||||
this.modified = modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染完整 SKILL.md;未修改时优先返回原文。
|
||||
*
|
||||
* @return 完整 SKILL.md
|
||||
*/
|
||||
public String render() {
|
||||
if (!modified && rawContent != null) {
|
||||
return rawContent;
|
||||
}
|
||||
return SkillFrontmatter.serialize(frontmatter.getValues(), markdownBody);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -29,12 +36,12 @@ public class SkillMetadata implements Serializable {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据键值。
|
||||
* 获取元数据键值的递归防御性副本。
|
||||
*
|
||||
* @return 元数据键值
|
||||
* @return 元数据键值副本
|
||||
*/
|
||||
public Map<String, Object> getValues() {
|
||||
return values;
|
||||
return deepCopyMap(values);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +50,7 @@ public class SkillMetadata implements Serializable {
|
||||
* @param values 元数据键值
|
||||
*/
|
||||
public void setValues(Map<String, Object> values) {
|
||||
this.values = values == null ? new LinkedHashMap<>() : new LinkedHashMap<>(values);
|
||||
this.values = values == null ? new LinkedHashMap<>() : deepCopyMap(values);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,9 +60,10 @@ public class SkillMetadata implements Serializable {
|
||||
* @param value 元数据值
|
||||
*/
|
||||
public void put(String key, Object value) {
|
||||
if (key != null && !key.isBlank()) {
|
||||
values.put(key, value);
|
||||
if (key == null || key.isBlank()) {
|
||||
throw new SkillValidationException("Skill metadata key must be a non-blank string.");
|
||||
}
|
||||
values.put(key, deepCopyValue(value));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,7 +73,17 @@ public class SkillMetadata implements Serializable {
|
||||
* @return 元数据值
|
||||
*/
|
||||
public Object get(String key) {
|
||||
return values.get(key);
|
||||
return deepCopyValue(values.get(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一个元数据键。
|
||||
*
|
||||
* @param key 元数据键
|
||||
* @return 被删除值的防御性副本
|
||||
*/
|
||||
public Object remove(String key) {
|
||||
return deepCopyValue(values.remove(key));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,4 +94,37 @@ public class SkillMetadata implements Serializable {
|
||||
public boolean isEmpty() {
|
||||
return values.isEmpty();
|
||||
}
|
||||
|
||||
private static LinkedHashMap<String, Object> deepCopyMap(Map<?, ?> source) {
|
||||
LinkedHashMap<String, Object> target = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : source.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key) || key.isBlank()) {
|
||||
throw new SkillValidationException("Skill metadata keys must be non-blank strings.");
|
||||
}
|
||||
target.put(key, deepCopyValue(entry.getValue()));
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static Object deepCopyValue(Object value) {
|
||||
if (value == null || value instanceof String || value instanceof Boolean
|
||||
|| value instanceof Byte || value instanceof Short || value instanceof Integer
|
||||
|| value instanceof Long || value instanceof Float || value instanceof Double
|
||||
|| value instanceof BigInteger || value instanceof BigDecimal || value instanceof Character) {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Date date) {
|
||||
return new Date(date.getTime());
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
return deepCopyMap(map);
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
List<Object> copy = new ArrayList<>(list.size());
|
||||
list.forEach(item -> copy.add(deepCopyValue(item)));
|
||||
return copy;
|
||||
}
|
||||
throw new SkillValidationException("Unsupported Skill metadata value type: "
|
||||
+ value.getClass().getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 可移植 Skill 包聚合。
|
||||
*/
|
||||
public class SkillPackage implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private SkillPackageLayout layout = SkillPackageLayout.SINGLE_DIRECTORY;
|
||||
private List<Skill> skills = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 创建空 Skill 包。
|
||||
*/
|
||||
public SkillPackage() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Skill 包。
|
||||
*
|
||||
* @param layout 包结构形态
|
||||
* @param skills Skill 列表
|
||||
*/
|
||||
public SkillPackage(SkillPackageLayout layout, List<Skill> skills) {
|
||||
setLayout(layout);
|
||||
setSkills(skills);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取包结构形态。
|
||||
*
|
||||
* @return 包结构形态
|
||||
*/
|
||||
public SkillPackageLayout getLayout() {
|
||||
return layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置包结构形态。
|
||||
*
|
||||
* @param layout 包结构形态
|
||||
*/
|
||||
public void setLayout(SkillPackageLayout layout) {
|
||||
this.layout = layout == null ? SkillPackageLayout.SINGLE_DIRECTORY : layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill 列表。
|
||||
*
|
||||
* @return Skill 列表
|
||||
*/
|
||||
public List<Skill> getSkills() {
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Skill 列表。
|
||||
*
|
||||
* @param skills Skill 列表
|
||||
*/
|
||||
public void setSkills(List<Skill> skills) {
|
||||
this.skills = skills == null ? new ArrayList<>() : new ArrayList<>(skills);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
/**
|
||||
* Skill ZIP 的结构形态。
|
||||
*/
|
||||
public enum SkillPackageLayout {
|
||||
|
||||
/** 根目录直接包含 SKILL.md 的单 Skill 包。 */
|
||||
ROOT_SKILL,
|
||||
|
||||
/** 一个顶层目录包装的单 Skill 包。 */
|
||||
SINGLE_DIRECTORY,
|
||||
|
||||
/** 多个顶层 Skill 目录组成的批量包。 */
|
||||
MULTI_DIRECTORY
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Skill 包读取安全限额;调用方只能在底层硬上限内收紧或调整。
|
||||
*/
|
||||
public final class SkillPackageLimits implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final int HARD_MAX_ENTRY_COUNT = 10_000;
|
||||
private static final long HARD_MAX_TEXT_FILE_BYTES = 8L * 1024 * 1024;
|
||||
private static final long HARD_MAX_BINARY_FILE_BYTES = 200L * 1024 * 1024;
|
||||
private static final long HARD_MAX_TOTAL_BYTES = 512L * 1024 * 1024;
|
||||
private static final long HARD_MAX_COMPRESSED_BYTES = 512L * 1024 * 1024;
|
||||
private static final int HARD_MAX_PATH_LENGTH = 1_024;
|
||||
private static final int HARD_MAX_PATH_DEPTH = 32;
|
||||
private static final double HARD_MAX_COMPRESSION_RATIO = 200D;
|
||||
private static final long HARD_MAX_FRONTMATTER_BYTES = 256L * 1024;
|
||||
private static final int HARD_MAX_YAML_ALIASES = 50;
|
||||
private static final int HARD_MAX_YAML_DEPTH = 20;
|
||||
private static final int HARD_MAX_YAML_CODE_POINTS = 262_144;
|
||||
|
||||
private final int maxEntryCount;
|
||||
private final long maxTextFileBytes;
|
||||
private final long maxBinaryFileBytes;
|
||||
private final long maxTotalUncompressedBytes;
|
||||
private final long maxCompressedPackageBytes;
|
||||
private final int maxPathLength;
|
||||
private final int maxPathDepth;
|
||||
private final double maxCompressionRatio;
|
||||
private final long maxFrontmatterBytes;
|
||||
private final int maxYamlAliases;
|
||||
private final int maxYamlDepth;
|
||||
private final int maxYamlCodePoints;
|
||||
|
||||
private SkillPackageLimits(Builder builder) {
|
||||
maxEntryCount = positive(builder.maxEntryCount, HARD_MAX_ENTRY_COUNT, "maxEntryCount");
|
||||
maxTextFileBytes = positive(builder.maxTextFileBytes, HARD_MAX_TEXT_FILE_BYTES, "maxTextFileBytes");
|
||||
maxBinaryFileBytes = positive(builder.maxBinaryFileBytes, HARD_MAX_BINARY_FILE_BYTES,
|
||||
"maxBinaryFileBytes");
|
||||
maxTotalUncompressedBytes = positive(builder.maxTotalUncompressedBytes, HARD_MAX_TOTAL_BYTES,
|
||||
"maxTotalUncompressedBytes");
|
||||
maxCompressedPackageBytes = positive(builder.maxCompressedPackageBytes, HARD_MAX_COMPRESSED_BYTES,
|
||||
"maxCompressedPackageBytes");
|
||||
maxPathLength = positive(builder.maxPathLength, HARD_MAX_PATH_LENGTH, "maxPathLength");
|
||||
maxPathDepth = positive(builder.maxPathDepth, HARD_MAX_PATH_DEPTH, "maxPathDepth");
|
||||
maxCompressionRatio = positive(builder.maxCompressionRatio, HARD_MAX_COMPRESSION_RATIO,
|
||||
"maxCompressionRatio");
|
||||
maxFrontmatterBytes = positive(builder.maxFrontmatterBytes, HARD_MAX_FRONTMATTER_BYTES,
|
||||
"maxFrontmatterBytes");
|
||||
maxYamlAliases = nonNegative(builder.maxYamlAliases, HARD_MAX_YAML_ALIASES, "maxYamlAliases");
|
||||
maxYamlDepth = positive(builder.maxYamlDepth, HARD_MAX_YAML_DEPTH, "maxYamlDepth");
|
||||
maxYamlCodePoints = positive(builder.maxYamlCodePoints, HARD_MAX_YAML_CODE_POINTS,
|
||||
"maxYamlCodePoints");
|
||||
if (maxTextFileBytes > maxTotalUncompressedBytes || maxBinaryFileBytes > maxTotalUncompressedBytes) {
|
||||
throw new SkillValidationException("Single-file limits cannot exceed the total uncompressed limit.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认限额。
|
||||
*
|
||||
* @return 默认限额
|
||||
*/
|
||||
public static SkillPackageLimits defaults() {
|
||||
return builder().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建限额构建器。
|
||||
*
|
||||
* @return 限额构建器
|
||||
*/
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/** @return 最大 ZIP entry 数 */
|
||||
public int getMaxEntryCount() {
|
||||
return maxEntryCount;
|
||||
}
|
||||
|
||||
/** @return 单文本文件最大字节数 */
|
||||
public long getMaxTextFileBytes() {
|
||||
return maxTextFileBytes;
|
||||
}
|
||||
|
||||
/** @return 单二进制文件最大字节数 */
|
||||
public long getMaxBinaryFileBytes() {
|
||||
return maxBinaryFileBytes;
|
||||
}
|
||||
|
||||
/** @return 包总未压缩最大字节数 */
|
||||
public long getMaxTotalUncompressedBytes() {
|
||||
return maxTotalUncompressedBytes;
|
||||
}
|
||||
|
||||
/** @return 输入 ZIP 最大压缩字节数 */
|
||||
public long getMaxCompressedPackageBytes() {
|
||||
return maxCompressedPackageBytes;
|
||||
}
|
||||
|
||||
/** @return 单路径最大字符数 */
|
||||
public int getMaxPathLength() {
|
||||
return maxPathLength;
|
||||
}
|
||||
|
||||
/** @return 最大目录深度 */
|
||||
public int getMaxPathDepth() {
|
||||
return maxPathDepth;
|
||||
}
|
||||
|
||||
/** @return 单 entry 最大压缩比 */
|
||||
public double getMaxCompressionRatio() {
|
||||
return maxCompressionRatio;
|
||||
}
|
||||
|
||||
/** @return frontmatter 最大字节数 */
|
||||
public long getMaxFrontmatterBytes() {
|
||||
return maxFrontmatterBytes;
|
||||
}
|
||||
|
||||
/** @return YAML collection 最大 alias 数 */
|
||||
public int getMaxYamlAliases() {
|
||||
return maxYamlAliases;
|
||||
}
|
||||
|
||||
/** @return YAML 最大嵌套深度 */
|
||||
public int getMaxYamlDepth() {
|
||||
return maxYamlDepth;
|
||||
}
|
||||
|
||||
/** @return YAML 最大 Unicode code point 数 */
|
||||
public int getMaxYamlCodePoints() {
|
||||
return maxYamlCodePoints;
|
||||
}
|
||||
|
||||
private static int positive(int value, int hardMax, String name) {
|
||||
if (value <= 0 || value > hardMax) {
|
||||
throw new SkillValidationException(name + " must be between 1 and " + hardMax + ".");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static int nonNegative(int value, int hardMax, String name) {
|
||||
if (value < 0 || value > hardMax) {
|
||||
throw new SkillValidationException(name + " must be between 0 and " + hardMax + ".");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static long positive(long value, long hardMax, String name) {
|
||||
if (value <= 0 || value > hardMax) {
|
||||
throw new SkillValidationException(name + " must be between 1 and " + hardMax + ".");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static double positive(double value, double hardMax, String name) {
|
||||
if (!Double.isFinite(value) || value <= 0D || value > hardMax) {
|
||||
throw new SkillValidationException(name + " must be between 0 and " + hardMax + ".");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 包限额构建器。
|
||||
*/
|
||||
public static final class Builder {
|
||||
|
||||
private int maxEntryCount = 2_000;
|
||||
private long maxTextFileBytes = 2L * 1024 * 1024;
|
||||
private long maxBinaryFileBytes = 50L * 1024 * 1024;
|
||||
private long maxTotalUncompressedBytes = 200L * 1024 * 1024;
|
||||
private long maxCompressedPackageBytes = 100L * 1024 * 1024;
|
||||
private int maxPathLength = 512;
|
||||
private int maxPathDepth = 16;
|
||||
private double maxCompressionRatio = 100D;
|
||||
private long maxFrontmatterBytes = 64L * 1024;
|
||||
private int maxYamlAliases = 10;
|
||||
private int maxYamlDepth = 10;
|
||||
private int maxYamlCodePoints = 65_536;
|
||||
|
||||
private Builder() {
|
||||
}
|
||||
|
||||
/** @param value 最大 entry 数 @return 当前构建器 */
|
||||
public Builder maxEntryCount(int value) {
|
||||
maxEntryCount = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param value 单文本文件最大字节数 @return 当前构建器 */
|
||||
public Builder maxTextFileBytes(long value) {
|
||||
maxTextFileBytes = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param value 单二进制文件最大字节数 @return 当前构建器 */
|
||||
public Builder maxBinaryFileBytes(long value) {
|
||||
maxBinaryFileBytes = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param value 包总未压缩最大字节数 @return 当前构建器 */
|
||||
public Builder maxTotalUncompressedBytes(long value) {
|
||||
maxTotalUncompressedBytes = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param value 输入 ZIP 最大压缩字节数 @return 当前构建器 */
|
||||
public Builder maxCompressedPackageBytes(long value) {
|
||||
maxCompressedPackageBytes = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param value 单路径最大字符数 @return 当前构建器 */
|
||||
public Builder maxPathLength(int value) {
|
||||
maxPathLength = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param value 最大目录深度 @return 当前构建器 */
|
||||
public Builder maxPathDepth(int value) {
|
||||
maxPathDepth = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param value 单 entry 最大压缩比 @return 当前构建器 */
|
||||
public Builder maxCompressionRatio(double value) {
|
||||
maxCompressionRatio = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param value frontmatter 最大字节数 @return 当前构建器 */
|
||||
public Builder maxFrontmatterBytes(long value) {
|
||||
maxFrontmatterBytes = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param value YAML alias 最大数量 @return 当前构建器 */
|
||||
public Builder maxYamlAliases(int value) {
|
||||
maxYamlAliases = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param value YAML 最大嵌套深度 @return 当前构建器 */
|
||||
public Builder maxYamlDepth(int value) {
|
||||
maxYamlDepth = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param value YAML 最大 code point 数 @return 当前构建器 */
|
||||
public Builder maxYamlCodePoints(int value) {
|
||||
maxYamlCodePoints = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建并校验限额。
|
||||
*
|
||||
* @return 不可变限额
|
||||
*/
|
||||
public SkillPackageLimits build() {
|
||||
return new SkillPackageLimits(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ package com.easyagents.skill.model;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Skill Markdown 参考文档。
|
||||
* Skill Markdown 参考文档兼容视图。
|
||||
*
|
||||
* @deprecated 请使用 {@link SkillResource}。
|
||||
*/
|
||||
@Deprecated
|
||||
public class SkillReference implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Skill 根目录下的通用安全资源。
|
||||
*/
|
||||
public class SkillResource implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String path;
|
||||
private SkillResourceKind kind = SkillResourceKind.OTHER;
|
||||
private String mediaType;
|
||||
private String textContent;
|
||||
private String contentRef;
|
||||
private String contentHash;
|
||||
private long size;
|
||||
private SkillMetadata metadata = new SkillMetadata();
|
||||
|
||||
/**
|
||||
* 获取 Skill 根目录相对路径。
|
||||
*
|
||||
* @return 资源路径
|
||||
*/
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Skill 根目录相对路径。
|
||||
*
|
||||
* @param path 资源路径
|
||||
*/
|
||||
public void setPath(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源语义类型。
|
||||
*
|
||||
* @return 资源语义类型
|
||||
*/
|
||||
public SkillResourceKind getKind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置资源语义类型。
|
||||
*
|
||||
* @param kind 资源语义类型
|
||||
*/
|
||||
public void setKind(SkillResourceKind kind) {
|
||||
this.kind = kind == null ? SkillResourceKind.OTHER : kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取媒体类型。
|
||||
*
|
||||
* @return 媒体类型
|
||||
*/
|
||||
public String getMediaType() {
|
||||
return mediaType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置媒体类型。
|
||||
*
|
||||
* @param mediaType 媒体类型
|
||||
*/
|
||||
public void setMediaType(String mediaType) {
|
||||
this.mediaType = mediaType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取严格 UTF-8 文本内容。
|
||||
*
|
||||
* @return 文本内容,二进制资源返回 null
|
||||
*/
|
||||
public String getTextContent() {
|
||||
return textContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置严格 UTF-8 文本内容。
|
||||
*
|
||||
* @param textContent 文本内容
|
||||
*/
|
||||
public void setTextContent(String textContent) {
|
||||
this.textContent = textContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取二进制内容引用。
|
||||
*
|
||||
* @return 内容引用,文本资源通常返回 null
|
||||
*/
|
||||
public String getContentRef() {
|
||||
return contentRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置二进制内容引用。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
*/
|
||||
public void setContentRef(String contentRef) {
|
||||
this.contentRef = contentRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容 SHA-256。
|
||||
*
|
||||
* @return 十六进制 SHA-256
|
||||
*/
|
||||
public String getContentHash() {
|
||||
return contentHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置内容 SHA-256。
|
||||
*
|
||||
* @param contentHash 十六进制 SHA-256
|
||||
*/
|
||||
public void setContentHash(String contentHash) {
|
||||
this.contentHash = contentHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始字节大小。
|
||||
*
|
||||
* @return 字节大小
|
||||
*/
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置原始字节大小。
|
||||
*
|
||||
* @param size 字节大小
|
||||
*/
|
||||
public void setSize(long size) {
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源扩展元数据。
|
||||
*
|
||||
* @return 扩展元数据
|
||||
*/
|
||||
public SkillMetadata getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置资源扩展元数据。
|
||||
*
|
||||
* @param metadata 扩展元数据
|
||||
*/
|
||||
public void setMetadata(SkillMetadata metadata) {
|
||||
this.metadata = metadata == null ? new SkillMetadata() : metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断资源是否以内联文本保存。
|
||||
*
|
||||
* @return 包含文本内容时为 true
|
||||
*/
|
||||
public boolean isText() {
|
||||
return textContent != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
/**
|
||||
* Skill 资源语义类型。
|
||||
*/
|
||||
public enum SkillResourceKind {
|
||||
|
||||
/** 参考资料。 */
|
||||
REFERENCE,
|
||||
|
||||
/** 脚本源码,仅保存且不执行。 */
|
||||
SCRIPT,
|
||||
|
||||
/** 静态资产。 */
|
||||
ASSET,
|
||||
|
||||
/** 使用示例。 */
|
||||
EXAMPLE,
|
||||
|
||||
/** 其他安全资源。 */
|
||||
OTHER
|
||||
}
|
||||
@@ -3,8 +3,11 @@ package com.easyagents.skill.model;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Skill 脚本源码。
|
||||
* Skill 脚本源码兼容视图。
|
||||
*
|
||||
* @deprecated 请使用 {@link SkillResource}。
|
||||
*/
|
||||
@Deprecated
|
||||
public class SkillScript implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* SKILL.md 中的一基源码位置。
|
||||
*/
|
||||
public final class SkillSourceLocation implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final int line;
|
||||
private final int column;
|
||||
|
||||
/**
|
||||
* 创建源码位置。
|
||||
*
|
||||
* @param line 一基行号
|
||||
* @param column 一基列号
|
||||
*/
|
||||
public SkillSourceLocation(int line, int column) {
|
||||
if (line < 1 || column < 1) {
|
||||
throw new IllegalArgumentException("Skill source location must be one-based.");
|
||||
}
|
||||
this.line = line;
|
||||
this.column = column;
|
||||
}
|
||||
|
||||
/** @return 一基行号 */
|
||||
public int getLine() {
|
||||
return line;
|
||||
}
|
||||
|
||||
/** @return 一基列号 */
|
||||
public int getColumn() {
|
||||
return column;
|
||||
}
|
||||
}
|
||||
@@ -92,16 +92,54 @@ public class InMemorySkillRepository implements SkillRepository {
|
||||
private static Skill copySkill(Skill source) {
|
||||
Skill target = new Skill();
|
||||
target.setId(source.getId());
|
||||
target.setPackageRoot(source.getPackageRoot());
|
||||
target.setName(source.getName());
|
||||
target.setDescription(source.getDescription());
|
||||
target.setMetadata(copyMetadata(source.getMetadata()));
|
||||
SkillDocument copiedDocument = copyDocument(source.getDocument());
|
||||
if (copiedDocument == null) {
|
||||
target.setSkillContent(source.getSkillContent());
|
||||
} else {
|
||||
target.setDocument(copiedDocument);
|
||||
}
|
||||
target.setResources(copyResources(source.getResources()));
|
||||
target.setReferences(copyReferences(source.getReferences()));
|
||||
target.setScripts(copyScripts(source.getScripts()));
|
||||
target.setAssets(copyAssets(source.getAssets()));
|
||||
return target;
|
||||
}
|
||||
|
||||
private static SkillDocument copyDocument(SkillDocument source) {
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
SkillDocument target = new SkillDocument(source.getRawContent(),
|
||||
source.getFrontmatter().getValues(), source.getMarkdownBody(),
|
||||
source.getFrontmatterLocations(), source.getDiagnostics());
|
||||
target.setModified(source.isModified());
|
||||
return target;
|
||||
}
|
||||
|
||||
private static List<SkillResource> copyResources(List<SkillResource> sources) {
|
||||
List<SkillResource> targets = new ArrayList<>();
|
||||
if (sources == null) {
|
||||
return targets;
|
||||
}
|
||||
for (SkillResource source : sources) {
|
||||
SkillResource target = new SkillResource();
|
||||
target.setPath(source.getPath());
|
||||
target.setKind(source.getKind());
|
||||
target.setMediaType(source.getMediaType());
|
||||
target.setTextContent(source.getTextContent());
|
||||
target.setContentRef(source.getContentRef());
|
||||
target.setContentHash(source.getContentHash());
|
||||
target.setSize(source.getSize());
|
||||
target.setMetadata(copyMetadata(source.getMetadata()));
|
||||
targets.add(target);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static List<SkillReference> copyReferences(List<SkillReference> sources) {
|
||||
List<SkillReference> targets = new ArrayList<>();
|
||||
if (sources == null) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.easyagents.skill.store;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 尚未提交的 Skill 二进制内容阶段结果。
|
||||
*/
|
||||
public final class SkillContentStage {
|
||||
|
||||
private final String stageId;
|
||||
private final String contentRef;
|
||||
private final String contentHash;
|
||||
private final long size;
|
||||
private final boolean alreadyCommitted;
|
||||
private final Path compatibilityPath;
|
||||
|
||||
/**
|
||||
* 创建内容阶段结果。
|
||||
*
|
||||
* @param stageId 暂存标识
|
||||
* @param contentRef 最终内容引用
|
||||
* @param contentHash SHA-256
|
||||
* @param size 字节大小
|
||||
* @param alreadyCommitted 是否由兼容实现提前写入正式存储
|
||||
*/
|
||||
public SkillContentStage(String stageId, String contentRef, String contentHash,
|
||||
long size, boolean alreadyCommitted) {
|
||||
this(stageId, contentRef, contentHash, size, alreadyCommitted, null);
|
||||
}
|
||||
|
||||
private SkillContentStage(String stageId, String contentRef, String contentHash,
|
||||
long size, boolean alreadyCommitted, Path compatibilityPath) {
|
||||
this.stageId = Objects.requireNonNull(stageId, "stageId");
|
||||
this.contentRef = Objects.requireNonNull(contentRef, "contentRef");
|
||||
this.contentHash = Objects.requireNonNull(contentHash, "contentHash");
|
||||
this.size = size;
|
||||
this.alreadyCommitted = alreadyCommitted;
|
||||
this.compatibilityPath = compatibilityPath;
|
||||
}
|
||||
|
||||
/** @return 暂存标识 */
|
||||
public String getStageId() {
|
||||
return stageId;
|
||||
}
|
||||
|
||||
/** @return 最终内容引用 */
|
||||
public String getContentRef() {
|
||||
return contentRef;
|
||||
}
|
||||
|
||||
/** @return SHA-256 */
|
||||
public String getContentHash() {
|
||||
return contentHash;
|
||||
}
|
||||
|
||||
/** @return 字节大小 */
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
/** @return 已提前提交时为 true */
|
||||
public boolean isAlreadyCommitted() {
|
||||
return alreadyCommitted;
|
||||
}
|
||||
|
||||
static SkillContentStage compatibility(Path path, String contentHash, long size) {
|
||||
String contentRef = "sha256:" + contentHash;
|
||||
return new SkillContentStage(contentRef, contentRef, contentHash,
|
||||
size, false, path);
|
||||
}
|
||||
|
||||
Path compatibilityPath() {
|
||||
return compatibilityPath;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
package com.easyagents.skill.store;
|
||||
|
||||
import com.easyagents.skill.exception.SkillException;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* Skill 二进制内容存储接口。
|
||||
* Skill 二进制内容流式存储与引用生命周期接口。
|
||||
*/
|
||||
public interface SkillContentStore {
|
||||
|
||||
@@ -15,6 +25,123 @@ public interface SkillContentStore {
|
||||
*/
|
||||
String put(byte[] bytes);
|
||||
|
||||
/**
|
||||
* 流式保存内容并返回内容引用。
|
||||
*
|
||||
* <p>兼容默认实现仅缓存单个文件;正式持久化实现应覆盖该方法以直接流式写入。</p>
|
||||
*
|
||||
* @param inputStream 内容流,不由本方法关闭
|
||||
* @param maxBytes 最大允许字节数
|
||||
* @return 内容引用
|
||||
*/
|
||||
default String put(InputStream inputStream, long maxBytes) {
|
||||
return put(readBounded(inputStream, maxBytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂存内容,供完成全包校验后统一提交。
|
||||
*
|
||||
* <p>为兼容旧实现,默认实现会立即写入;正式实现应覆盖并提供真实暂存区。</p>
|
||||
*
|
||||
* @param inputStream 内容流,不由本方法关闭
|
||||
* @param maxBytes 最大允许字节数
|
||||
* @return 暂存结果
|
||||
*/
|
||||
default SkillContentStage stage(InputStream inputStream, long maxBytes) {
|
||||
if (inputStream == null || maxBytes < 0) {
|
||||
throw new SkillException("Valid Skill content stream and limit are required.");
|
||||
}
|
||||
Path temporaryFile = null;
|
||||
try {
|
||||
temporaryFile = Files.createTempFile("easy-agents-skill-content-", ".stage");
|
||||
MessageDigest digest = SkillHashes.newSha256Digest();
|
||||
long total = 0;
|
||||
try (DigestOutputStream output = new DigestOutputStream(
|
||||
Files.newOutputStream(temporaryFile, StandardOpenOption.TRUNCATE_EXISTING), digest)) {
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) >= 0) {
|
||||
if (read == 0) {
|
||||
continue;
|
||||
}
|
||||
if (total > maxBytes - read) {
|
||||
throw new SkillException("Skill content exceeds " + maxBytes + " bytes.");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
total += read;
|
||||
}
|
||||
}
|
||||
return SkillContentStage.compatibility(
|
||||
temporaryFile, SkillHashes.toHex(digest.digest()), total);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
deleteTemporaryFile(temporaryFile);
|
||||
if (e instanceof SkillException skillException) {
|
||||
throw skillException;
|
||||
}
|
||||
throw new SkillException("Failed to stage Skill content stream.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交暂存内容。
|
||||
*
|
||||
* @param stage 暂存结果
|
||||
* @return 最终内容引用
|
||||
*/
|
||||
default String commit(SkillContentStage stage) {
|
||||
if (stage == null) {
|
||||
throw new SkillException("Skill content stage is required.");
|
||||
}
|
||||
Path compatibilityPath = stage.compatibilityPath();
|
||||
if (compatibilityPath != null) {
|
||||
try (InputStream input = Files.newInputStream(compatibilityPath)) {
|
||||
String contentRef = put(input, stage.getSize());
|
||||
if (!stage.getContentRef().equals(contentRef)) {
|
||||
release(contentRef);
|
||||
throw new SkillException("Skill content store returned a non-hash content reference.");
|
||||
}
|
||||
return contentRef;
|
||||
} catch (IOException e) {
|
||||
throw new SkillException("Failed to commit staged Skill content.", e);
|
||||
} finally {
|
||||
deleteTemporaryFile(compatibilityPath);
|
||||
}
|
||||
}
|
||||
return stage.getContentRef();
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚尚未提交的内容。
|
||||
*
|
||||
* @param stage 暂存结果
|
||||
*/
|
||||
default void rollback(SkillContentStage stage) {
|
||||
if (stage != null) {
|
||||
deleteTemporaryFile(stage.compatibilityPath());
|
||||
if (stage.isAlreadyCommitted()) {
|
||||
release(stage.getContentRef());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加正式内容引用计数。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
*/
|
||||
default void retain(String contentRef) {
|
||||
// 旧实现没有引用计数,保留兼容空操作。
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放正式内容引用;引用归零后实现可以删除物理内容。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
*/
|
||||
default void release(String contentRef) {
|
||||
// 旧实现没有引用计数,保留兼容空操作。
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开内容流。
|
||||
*
|
||||
@@ -38,4 +165,43 @@ public interface SkillContentStore {
|
||||
* @return 存在时为 true
|
||||
*/
|
||||
boolean exists(String contentRef);
|
||||
|
||||
private static byte[] readBounded(InputStream inputStream, long maxBytes) {
|
||||
if (inputStream == null) {
|
||||
throw new SkillException("Skill content input stream is required.");
|
||||
}
|
||||
if (maxBytes < 0) {
|
||||
throw new SkillException("Skill content max bytes cannot be negative.");
|
||||
}
|
||||
try {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(maxBytes, 8_192));
|
||||
byte[] buffer = new byte[8_192];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) >= 0) {
|
||||
if (read == 0) {
|
||||
continue;
|
||||
}
|
||||
total += read;
|
||||
if (total > maxBytes) {
|
||||
throw new SkillException("Skill content exceeds " + maxBytes + " bytes.");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new SkillException("Failed to read Skill content stream.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteTemporaryFile(Path path) {
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
// 临时文件清理由操作系统兜底,调用方异常语义保持不变。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
package com.easyagents.skill.store.file;
|
||||
|
||||
import com.easyagents.skill.exception.SkillException;
|
||||
import com.easyagents.skill.store.SkillContentStage;
|
||||
import com.easyagents.skill.store.SkillContentStore;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.ref.Cleaner;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 基于进程临时目录的流式 Skill 内容存储。
|
||||
*
|
||||
* <p>内容先写入隔离暂存文件,提交后按 SHA-256 去重,并由引用计数控制物理文件生命周期。
|
||||
* 调用方应在不再使用内容引用和已打开的输入流后调用 {@link #close()}。未显式关闭的实例会由
|
||||
* JVM Cleaner 尝试兜底清理,但 Cleaner 不应替代正常的生命周期管理。</p>
|
||||
*/
|
||||
public final class TemporaryFileSkillContentStore implements SkillContentStore, AutoCloseable {
|
||||
|
||||
private static final String DIRECTORY_PREFIX = "easy-agents-skill-content-";
|
||||
private static final int COPY_BUFFER_SIZE = 16 * 1024;
|
||||
private static final Cleaner CLEANER = Cleaner.create();
|
||||
|
||||
private final Object lifecycleMonitor = new Object();
|
||||
private final Path storageDirectory;
|
||||
private final Path stagedDirectory;
|
||||
private final Path contentDirectory;
|
||||
private final Map<String, StagedContent> stagedContents = new HashMap<>();
|
||||
private final Map<String, StoredContent> contents = new HashMap<>();
|
||||
private final Cleaner.Cleanable cleanable;
|
||||
private volatile boolean closed;
|
||||
|
||||
/**
|
||||
* 在系统临时目录中创建独立内容存储。
|
||||
*/
|
||||
public TemporaryFileSkillContentStore() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定父目录中创建独立内容存储。
|
||||
*
|
||||
* <p>该实例只拥有新建的子目录,关闭时不会删除传入的父目录。</p>
|
||||
*
|
||||
* @param parentDirectory 临时存储父目录;为 null 时使用系统临时目录
|
||||
*/
|
||||
public TemporaryFileSkillContentStore(Path parentDirectory) {
|
||||
this.storageDirectory = createStorageDirectory(parentDirectory);
|
||||
this.stagedDirectory = storageDirectory.resolve("staged");
|
||||
this.contentDirectory = storageDirectory.resolve("content");
|
||||
try {
|
||||
Files.createDirectory(stagedDirectory);
|
||||
Files.createDirectory(contentDirectory);
|
||||
} catch (IOException exception) {
|
||||
deleteDirectoryQuietly(storageDirectory);
|
||||
throw new SkillException("Failed to initialize temporary Skill content store.", exception);
|
||||
}
|
||||
this.cleanable = CLEANER.register(this, new DirectoryCleanup(storageDirectory));
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式保存字节内容并持有一个正式引用。
|
||||
*
|
||||
* @param bytes 内容字节;null 按空内容处理
|
||||
* @return SHA-256 内容引用
|
||||
*/
|
||||
@Override
|
||||
public String put(byte[] bytes) {
|
||||
byte[] safeBytes = bytes == null ? new byte[0] : bytes;
|
||||
return put(new ByteArrayInputStream(safeBytes), safeBytes.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式保存内容并持有一个正式引用。
|
||||
*
|
||||
* @param inputStream 内容流,不由本方法关闭
|
||||
* @param maxBytes 最大允许字节数
|
||||
* @return SHA-256 内容引用
|
||||
*/
|
||||
@Override
|
||||
public String put(InputStream inputStream, long maxBytes) {
|
||||
SkillContentStage stage = stage(inputStream, maxBytes);
|
||||
try {
|
||||
return commit(stage);
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
rollback(stage);
|
||||
} catch (RuntimeException cleanupError) {
|
||||
exception.addSuppressed(cleanupError);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将内容流式写入独立暂存文件并同步计算 SHA-256。
|
||||
*
|
||||
* @param inputStream 内容流,不由本方法关闭
|
||||
* @param maxBytes 最大允许字节数
|
||||
* @return 暂存结果
|
||||
*/
|
||||
@Override
|
||||
public SkillContentStage stage(InputStream inputStream, long maxBytes) {
|
||||
if (inputStream == null || maxBytes < 0) {
|
||||
throw new SkillException("Valid Skill content stream and limit are required.");
|
||||
}
|
||||
String stageId = UUID.randomUUID().toString();
|
||||
Path stagedPath = stagedDirectory.resolve(stageId + ".stage");
|
||||
ensureOpen();
|
||||
try {
|
||||
MessageDigest digest = SkillHashes.newSha256Digest();
|
||||
long size = 0;
|
||||
try (DigestOutputStream output = new DigestOutputStream(
|
||||
Files.newOutputStream(stagedPath, StandardOpenOption.CREATE_NEW,
|
||||
StandardOpenOption.WRITE), digest)) {
|
||||
byte[] buffer = new byte[COPY_BUFFER_SIZE];
|
||||
int length;
|
||||
while ((length = inputStream.read(buffer)) >= 0) {
|
||||
if (length == 0) {
|
||||
continue;
|
||||
}
|
||||
if (size > maxBytes - length) {
|
||||
throw new SkillException("Skill content exceeds " + maxBytes + " bytes.");
|
||||
}
|
||||
output.write(buffer, 0, length);
|
||||
size += length;
|
||||
}
|
||||
}
|
||||
String contentHash = SkillHashes.toHex(digest.digest());
|
||||
String contentRef = "sha256:" + contentHash;
|
||||
StagedContent stagedContent = new StagedContent(
|
||||
stagedPath, contentRef, contentHash, size);
|
||||
synchronized (lifecycleMonitor) {
|
||||
ensureOpenLocked();
|
||||
stagedContents.put(stageId, stagedContent);
|
||||
}
|
||||
return new SkillContentStage(stageId, contentRef, contentHash, size, false);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
deleteFileQuietly(stagedPath);
|
||||
if (exception instanceof SkillException skillException) {
|
||||
throw skillException;
|
||||
}
|
||||
throw new SkillException("Failed to stage temporary Skill content.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子地提交暂存内容并增加一份正式引用。
|
||||
*
|
||||
* @param stage 暂存结果
|
||||
* @return SHA-256 内容引用
|
||||
*/
|
||||
@Override
|
||||
public String commit(SkillContentStage stage) {
|
||||
if (stage == null) {
|
||||
throw new SkillException("Skill content stage is required.");
|
||||
}
|
||||
synchronized (lifecycleMonitor) {
|
||||
ensureOpenLocked();
|
||||
StagedContent stagedContent = stagedContents.get(stage.getStageId());
|
||||
validateStage(stage, stagedContent);
|
||||
validateStagedFile(stagedContent);
|
||||
|
||||
StoredContent existing = contents.get(stagedContent.contentRef);
|
||||
if (existing != null) {
|
||||
validateDuplicateContent(stagedContent, existing);
|
||||
if (existing.references == Long.MAX_VALUE) {
|
||||
throw new SkillException("Skill content reference count overflow: "
|
||||
+ stagedContent.contentRef);
|
||||
}
|
||||
deleteFile(stagedContent.path, "Failed to delete committed Skill stage.");
|
||||
stagedContents.remove(stage.getStageId());
|
||||
existing.references++;
|
||||
existing.pendingDeletion = false;
|
||||
return stagedContent.contentRef;
|
||||
}
|
||||
|
||||
Path targetPath = contentDirectory.resolve(stagedContent.contentHash + ".content");
|
||||
if (Files.exists(targetPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new SkillException("Unexpected Skill content file already exists: "
|
||||
+ stagedContent.contentRef);
|
||||
}
|
||||
moveAtomically(stagedContent.path, targetPath);
|
||||
contents.put(stagedContent.contentRef,
|
||||
new StoredContent(targetPath, stagedContent.size));
|
||||
stagedContents.remove(stage.getStageId());
|
||||
return stagedContent.contentRef;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除尚未提交的暂存内容。
|
||||
*
|
||||
* @param stage 暂存结果
|
||||
*/
|
||||
@Override
|
||||
public void rollback(SkillContentStage stage) {
|
||||
if (stage == null) {
|
||||
return;
|
||||
}
|
||||
synchronized (lifecycleMonitor) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
StagedContent stagedContent = stagedContents.get(stage.getStageId());
|
||||
if (stagedContent == null) {
|
||||
return;
|
||||
}
|
||||
validateStage(stage, stagedContent);
|
||||
deleteFile(stagedContent.path, "Failed to rollback staged Skill content.");
|
||||
stagedContents.remove(stage.getStageId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加一份正式内容引用。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
*/
|
||||
@Override
|
||||
public void retain(String contentRef) {
|
||||
synchronized (lifecycleMonitor) {
|
||||
ensureOpenLocked();
|
||||
StoredContent content = contents.get(contentRef);
|
||||
if (content == null || content.references <= 0) {
|
||||
throw new SkillException("Skill content does not exist: " + contentRef);
|
||||
}
|
||||
if (content.references == Long.MAX_VALUE) {
|
||||
throw new SkillException("Skill content reference count overflow: " + contentRef);
|
||||
}
|
||||
content.references++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放一份正式内容引用,最后一个引用释放后删除物理文件。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
*/
|
||||
@Override
|
||||
public void release(String contentRef) {
|
||||
if (contentRef == null || contentRef.isBlank()) {
|
||||
return;
|
||||
}
|
||||
synchronized (lifecycleMonitor) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
StoredContent content = contents.get(contentRef);
|
||||
if (content == null) {
|
||||
return;
|
||||
}
|
||||
if (content.references > 0) {
|
||||
content.references--;
|
||||
}
|
||||
if (content.references == 0) {
|
||||
content.pendingDeletion = true;
|
||||
deleteReleasedContent(contentRef, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开正式内容的文件流。
|
||||
*
|
||||
* <p>返回流会持有读取租约,最后一个正式引用释放时会等待已打开的流关闭后再删除文件。</p>
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
* @return 内容输入流,调用方负责关闭
|
||||
*/
|
||||
@Override
|
||||
public InputStream open(String contentRef) {
|
||||
synchronized (lifecycleMonitor) {
|
||||
ensureOpenLocked();
|
||||
StoredContent content = contents.get(contentRef);
|
||||
if (content == null || content.references <= 0) {
|
||||
throw new SkillException("Skill content does not exist: " + contentRef);
|
||||
}
|
||||
if (content.readers == Integer.MAX_VALUE) {
|
||||
throw new SkillException("Skill content reader count overflow: " + contentRef);
|
||||
}
|
||||
try {
|
||||
InputStream input = Files.newInputStream(content.path, StandardOpenOption.READ);
|
||||
content.readers++;
|
||||
return new LeasedInputStream(input, contentRef, content);
|
||||
} catch (IOException exception) {
|
||||
throw new SkillException("Failed to open temporary Skill content: " + contentRef,
|
||||
exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取正式内容的全部字节。
|
||||
*
|
||||
* <p>该兼容方法会按接口约定返回一个字节数组;流式调用方应优先使用 {@link #open(String)}。</p>
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
* @return 内容字节
|
||||
*/
|
||||
@Override
|
||||
public byte[] readAllBytes(String contentRef) {
|
||||
try (InputStream input = open(contentRef)) {
|
||||
return input.readAllBytes();
|
||||
} catch (IOException exception) {
|
||||
throw new SkillException("Failed to read temporary Skill content: " + contentRef,
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断正式内容是否仍有有效引用。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
* @return 存在且至少持有一个引用时为 true
|
||||
*/
|
||||
@Override
|
||||
public boolean exists(String contentRef) {
|
||||
synchronized (lifecycleMonitor) {
|
||||
if (closed) {
|
||||
return false;
|
||||
}
|
||||
StoredContent content = contents.get(contentRef);
|
||||
return content != null && content.references > 0
|
||||
&& Files.isRegularFile(content.path, LinkOption.NOFOLLOW_LINKS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理全部暂存和正式内容并关闭存储。
|
||||
*
|
||||
* <p>调用前应先关闭通过 {@link #open(String)} 获取的流。该方法可重复调用。</p>
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized (lifecycleMonitor) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
stagedContents.clear();
|
||||
contents.clear();
|
||||
}
|
||||
try {
|
||||
deleteDirectory(storageDirectory);
|
||||
} catch (IOException exception) {
|
||||
throw new SkillException("Failed to clean temporary Skill content store.", exception);
|
||||
} finally {
|
||||
cleanable.clean();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回该实例拥有的临时目录,供同包生命周期测试使用。
|
||||
*
|
||||
* @return 实例临时目录
|
||||
*/
|
||||
Path storageDirectory() {
|
||||
return storageDirectory;
|
||||
}
|
||||
|
||||
private void ensureOpen() {
|
||||
synchronized (lifecycleMonitor) {
|
||||
ensureOpenLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureOpenLocked() {
|
||||
if (closed) {
|
||||
throw new SkillException("Temporary Skill content store is closed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateStage(SkillContentStage stage, StagedContent content) {
|
||||
if (content == null) {
|
||||
throw new SkillException("Skill content stage does not exist: " + stage.getStageId());
|
||||
}
|
||||
if (!content.contentRef.equals(stage.getContentRef())
|
||||
|| !content.contentHash.equals(stage.getContentHash())
|
||||
|| content.size != stage.getSize()) {
|
||||
throw new SkillException("Skill content stage metadata does not match: "
|
||||
+ stage.getStageId());
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateStagedFile(StagedContent content) {
|
||||
try {
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
content.path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
if (!attributes.isRegularFile() || attributes.size() != content.size) {
|
||||
throw new SkillException("Staged Skill content file changed before commit: "
|
||||
+ content.contentRef);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new SkillException("Failed to inspect staged Skill content: "
|
||||
+ content.contentRef, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateDuplicateContent(StagedContent stagedContent,
|
||||
StoredContent existing) {
|
||||
if (existing.size != stagedContent.size) {
|
||||
throw new SkillException("SHA-256 collision detected for Skill content: "
|
||||
+ stagedContent.contentRef);
|
||||
}
|
||||
try {
|
||||
if (Files.mismatch(stagedContent.path, existing.path) != -1) {
|
||||
throw new SkillException("SHA-256 collision detected for Skill content: "
|
||||
+ stagedContent.contentRef);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new SkillException("Failed to compare duplicate Skill content: "
|
||||
+ stagedContent.contentRef, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void moveAtomically(Path source, Path target) {
|
||||
try {
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
try {
|
||||
Files.move(source, target);
|
||||
} catch (IOException fallbackException) {
|
||||
fallbackException.addSuppressed(exception);
|
||||
throw new SkillException("Failed to commit staged Skill content.",
|
||||
fallbackException);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new SkillException("Failed to commit staged Skill content.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteReleasedContent(String contentRef, StoredContent content) {
|
||||
if (content.readers > 0) {
|
||||
return;
|
||||
}
|
||||
deleteFile(content.path, "Failed to delete released Skill content.");
|
||||
contents.remove(contentRef, content);
|
||||
}
|
||||
|
||||
private void releaseReader(String contentRef, StoredContent content) throws IOException {
|
||||
synchronized (lifecycleMonitor) {
|
||||
if (content.readers > 0) {
|
||||
content.readers--;
|
||||
}
|
||||
if (closed || !content.pendingDeletion || content.readers > 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
deleteReleasedContent(contentRef, content);
|
||||
} catch (SkillException exception) {
|
||||
throw new IOException("Failed to delete released Skill content.", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Path createStorageDirectory(Path parentDirectory) {
|
||||
Path directory = null;
|
||||
try {
|
||||
directory = parentDirectory == null
|
||||
? Files.createTempDirectory(DIRECTORY_PREFIX)
|
||||
: Files.createTempDirectory(parentDirectory, DIRECTORY_PREFIX);
|
||||
restrictOwnerAccess(directory);
|
||||
return directory;
|
||||
} catch (IOException exception) {
|
||||
deleteDirectoryQuietly(directory);
|
||||
throw new SkillException("Failed to create temporary Skill content store.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void restrictOwnerAccess(Path directory) throws IOException {
|
||||
try {
|
||||
Files.setPosixFilePermissions(directory, EnumSet.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE));
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
// 非 POSIX 文件系统使用平台默认的临时目录访问控制。
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteFile(Path path, String message) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException exception) {
|
||||
throw new SkillException(message, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteFileQuietly(Path path) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
// 后续 close 或 Cleaner 会再次清理实例目录。
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteDirectory(Path directory) throws IOException {
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
Files.walkFileTree(directory, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes)
|
||||
throws IOException {
|
||||
Files.deleteIfExists(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path current, IOException exception)
|
||||
throws IOException {
|
||||
if (exception != null) {
|
||||
throw exception;
|
||||
}
|
||||
Files.deleteIfExists(current);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void deleteDirectoryQuietly(Path directory) {
|
||||
if (directory == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
deleteDirectory(directory);
|
||||
} catch (IOException ignored) {
|
||||
// Cleaner 是兜底路径,无法向已回收实例传播清理失败。
|
||||
}
|
||||
}
|
||||
|
||||
private final class LeasedInputStream extends FilterInputStream {
|
||||
|
||||
private final String contentRef;
|
||||
private final StoredContent content;
|
||||
private boolean streamClosed;
|
||||
|
||||
private LeasedInputStream(InputStream input, String contentRef, StoredContent content) {
|
||||
super(input);
|
||||
this.contentRef = contentRef;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (streamClosed) {
|
||||
return;
|
||||
}
|
||||
streamClosed = true;
|
||||
IOException closeFailure = null;
|
||||
try {
|
||||
super.close();
|
||||
} catch (IOException exception) {
|
||||
closeFailure = exception;
|
||||
}
|
||||
try {
|
||||
releaseReader(contentRef, content);
|
||||
} catch (IOException cleanupFailure) {
|
||||
if (closeFailure == null) {
|
||||
closeFailure = cleanupFailure;
|
||||
} else {
|
||||
closeFailure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
if (closeFailure != null) {
|
||||
throw closeFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StagedContent {
|
||||
|
||||
private final Path path;
|
||||
private final String contentRef;
|
||||
private final String contentHash;
|
||||
private final long size;
|
||||
|
||||
private StagedContent(Path path, String contentRef, String contentHash, long size) {
|
||||
this.path = path;
|
||||
this.contentRef = contentRef;
|
||||
this.contentHash = contentHash;
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StoredContent {
|
||||
|
||||
private final Path path;
|
||||
private final long size;
|
||||
private long references = 1;
|
||||
private int readers;
|
||||
private boolean pendingDeletion;
|
||||
|
||||
private StoredContent(Path path, long size) {
|
||||
this.path = path;
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DirectoryCleanup implements Runnable {
|
||||
|
||||
private final Path directory;
|
||||
|
||||
private DirectoryCleanup(Path directory) {
|
||||
this.directory = directory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
deleteDirectoryQuietly(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,30 @@
|
||||
package com.easyagents.skill.store.memory;
|
||||
|
||||
import com.easyagents.skill.exception.SkillException;
|
||||
import com.easyagents.skill.store.SkillContentStage;
|
||||
import com.easyagents.skill.store.SkillContentStore;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 基于内存的 Skill 二进制内容存储。
|
||||
* 基于内存的 Skill 内容存储,支持真实暂存与引用计数,适用于测试和轻量场景。
|
||||
*/
|
||||
public class InMemorySkillContentStore implements SkillContentStore {
|
||||
|
||||
private final ConcurrentMap<String, byte[]> contents = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, StoredContent> contents = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, byte[]> stagedContents = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 保存内容并返回内容引用。
|
||||
* 保存内容并持有一个引用。
|
||||
*
|
||||
* @param bytes 内容字节
|
||||
* @return 内容引用
|
||||
@@ -27,10 +33,109 @@ public class InMemorySkillContentStore implements SkillContentStore {
|
||||
public String put(byte[] bytes) {
|
||||
byte[] safeBytes = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length);
|
||||
String contentRef = SkillHashes.sha256Ref(safeBytes);
|
||||
contents.putIfAbsent(contentRef, safeBytes);
|
||||
contents.compute(contentRef, (key, current) -> {
|
||||
if (current == null) {
|
||||
return new StoredContent(safeBytes);
|
||||
}
|
||||
if (!Arrays.equals(current.bytes, safeBytes)) {
|
||||
throw new SkillException("SHA-256 collision detected for Skill content: " + contentRef);
|
||||
}
|
||||
current.references.incrementAndGet();
|
||||
return current;
|
||||
});
|
||||
return contentRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式保存内容并持有一个引用。
|
||||
*
|
||||
* @param inputStream 内容流
|
||||
* @param maxBytes 最大允许字节数
|
||||
* @return 内容引用
|
||||
*/
|
||||
@Override
|
||||
public String put(InputStream inputStream, long maxBytes) {
|
||||
return put(readBounded(inputStream, maxBytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将内容写入独立暂存区。
|
||||
*
|
||||
* @param inputStream 内容流
|
||||
* @param maxBytes 最大允许字节数
|
||||
* @return 暂存结果
|
||||
*/
|
||||
@Override
|
||||
public SkillContentStage stage(InputStream inputStream, long maxBytes) {
|
||||
byte[] bytes = readBounded(inputStream, maxBytes);
|
||||
String hash = SkillHashes.sha256Hex(bytes);
|
||||
String stageId = UUID.randomUUID().toString();
|
||||
stagedContents.put(stageId, bytes);
|
||||
return new SkillContentStage(stageId, "sha256:" + hash, hash, bytes.length, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子地将暂存内容转为正式引用。
|
||||
*
|
||||
* @param stage 暂存结果
|
||||
* @return 正式内容引用
|
||||
*/
|
||||
@Override
|
||||
public String commit(SkillContentStage stage) {
|
||||
if (stage == null) {
|
||||
throw new SkillException("Skill content stage is required.");
|
||||
}
|
||||
byte[] bytes = stagedContents.remove(stage.getStageId());
|
||||
if (bytes == null) {
|
||||
throw new SkillException("Skill content stage does not exist: " + stage.getStageId());
|
||||
}
|
||||
String contentRef = put(bytes);
|
||||
if (!contentRef.equals(stage.getContentRef())) {
|
||||
release(contentRef);
|
||||
throw new SkillException("Skill staged content hash changed before commit.");
|
||||
}
|
||||
return contentRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除暂存内容。
|
||||
*
|
||||
* @param stage 暂存结果
|
||||
*/
|
||||
@Override
|
||||
public void rollback(SkillContentStage stage) {
|
||||
if (stage != null) {
|
||||
stagedContents.remove(stage.getStageId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加正式内容引用计数。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
*/
|
||||
@Override
|
||||
public void retain(String contentRef) {
|
||||
contents.compute(contentRef, (key, content) -> {
|
||||
if (content == null) {
|
||||
throw new SkillException("Skill content does not exist: " + contentRef);
|
||||
}
|
||||
content.references.incrementAndGet();
|
||||
return content;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放正式内容引用并在归零时删除内容。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
*/
|
||||
@Override
|
||||
public void release(String contentRef) {
|
||||
contents.computeIfPresent(contentRef, (key, content) ->
|
||||
content.references.decrementAndGet() <= 0 ? null : content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开内容流。
|
||||
*
|
||||
@@ -46,15 +151,15 @@ public class InMemorySkillContentStore implements SkillContentStore {
|
||||
* 读取全部内容。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
* @return 内容字节
|
||||
* @return 内容副本
|
||||
*/
|
||||
@Override
|
||||
public byte[] readAllBytes(String contentRef) {
|
||||
byte[] bytes = contents.get(contentRef);
|
||||
if (bytes == null) {
|
||||
StoredContent content = contents.get(contentRef);
|
||||
if (content == null) {
|
||||
throw new SkillException("Skill content does not exist: " + contentRef);
|
||||
}
|
||||
return Arrays.copyOf(bytes, bytes.length);
|
||||
return Arrays.copyOf(content.bytes, content.bytes.length);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,4 +172,39 @@ public class InMemorySkillContentStore implements SkillContentStore {
|
||||
public boolean exists(String contentRef) {
|
||||
return contents.containsKey(contentRef);
|
||||
}
|
||||
|
||||
private static byte[] readBounded(InputStream inputStream, long maxBytes) {
|
||||
if (inputStream == null || maxBytes < 0) {
|
||||
throw new SkillException("Valid Skill content stream and limit are required.");
|
||||
}
|
||||
try {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(maxBytes, 8_192));
|
||||
byte[] buffer = new byte[8_192];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) >= 0) {
|
||||
if (read == 0) {
|
||||
continue;
|
||||
}
|
||||
total += read;
|
||||
if (total > maxBytes) {
|
||||
throw new SkillException("Skill content exceeds " + maxBytes + " bytes.");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new SkillException("Failed to read Skill content stream.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StoredContent {
|
||||
|
||||
private final byte[] bytes;
|
||||
private final AtomicInteger references = new AtomicInteger(1);
|
||||
|
||||
private StoredContent(byte[] bytes) {
|
||||
this.bytes = Arrays.copyOf(bytes, bytes.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,293 @@
|
||||
package com.easyagents.skill.util;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
import com.easyagents.skill.model.SkillDocument;
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
import com.easyagents.skill.model.SkillSourceLocation;
|
||||
import org.yaml.snakeyaml.DumperOptions;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
import org.yaml.snakeyaml.error.MarkedYAMLException;
|
||||
import org.yaml.snakeyaml.error.YAMLException;
|
||||
import org.yaml.snakeyaml.nodes.MappingNode;
|
||||
import org.yaml.snakeyaml.nodes.Node;
|
||||
import org.yaml.snakeyaml.nodes.NodeTuple;
|
||||
import org.yaml.snakeyaml.nodes.ScalarNode;
|
||||
import org.yaml.snakeyaml.nodes.Tag;
|
||||
import org.yaml.snakeyaml.representer.Representer;
|
||||
|
||||
import java.io.StringReader;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TimeZone;
|
||||
|
||||
/**
|
||||
* SKILL.md frontmatter 解析工具。
|
||||
* SKILL.md 安全 frontmatter 解析与确定性序列化工具。
|
||||
*/
|
||||
public final class SkillFrontmatter {
|
||||
|
||||
private static final String DOCUMENT_PATH = "SKILL.md";
|
||||
private static final Set<Tag> ALLOWED_TAGS = Set.of(
|
||||
Tag.MAP, Tag.SEQ, Tag.STR, Tag.BOOL, Tag.NULL,
|
||||
Tag.INT, Tag.FLOAT, Tag.TIMESTAMP
|
||||
);
|
||||
|
||||
private SkillFrontmatter() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 SKILL.md 开头的 frontmatter。
|
||||
* 解析 SKILL.md frontmatter,并兼容原有必填字段校验行为。
|
||||
*
|
||||
* @param content SKILL.md 原始内容
|
||||
* @return frontmatter 键值
|
||||
* @return frontmatter 有序键值
|
||||
* @throws SkillValidationException 文档或必填字段不合法
|
||||
*/
|
||||
public static Map<String, Object> parse(String content) {
|
||||
if (content == null || content.isBlank()) {
|
||||
throw new SkillValidationException("SKILL.md content is required.");
|
||||
}
|
||||
String[] lines = content.split("\\R", -1);
|
||||
if (lines.length == 0 || !"---".equals(lines[0])) {
|
||||
throw new SkillValidationException("SKILL.md must start with frontmatter.");
|
||||
}
|
||||
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
boolean closed = false;
|
||||
for (int i = 1; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
if ("---".equals(line)) {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if (line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (Character.isWhitespace(line.charAt(0))) {
|
||||
throw new SkillValidationException("Nested frontmatter is not supported.");
|
||||
}
|
||||
parseScalarLine(line, values);
|
||||
}
|
||||
if (!closed) {
|
||||
throw new SkillValidationException("SKILL.md frontmatter is not closed.");
|
||||
}
|
||||
if (isBlank(values.get("name"))) {
|
||||
throw new SkillValidationException("SKILL.md frontmatter name is required.");
|
||||
}
|
||||
if (isBlank(values.get("description"))) {
|
||||
throw new SkillValidationException("SKILL.md frontmatter description is required.");
|
||||
}
|
||||
SkillDocument document = parseDocument(content, SkillPackageLimits.defaults());
|
||||
Map<String, Object> values = document.getFrontmatter().getValues();
|
||||
requireCoreField(values, "name");
|
||||
requireCoreField(values, "description");
|
||||
return values;
|
||||
}
|
||||
|
||||
private static void parseScalarLine(String line, Map<String, Object> values) {
|
||||
int colonIndex = line.indexOf(':');
|
||||
if (colonIndex <= 0) {
|
||||
throw new SkillValidationException("Only single-line key: value frontmatter is supported.");
|
||||
}
|
||||
String key = line.substring(0, colonIndex).trim();
|
||||
String value = stripQuotes(line.substring(colonIndex + 1).trim());
|
||||
if (key.isBlank()) {
|
||||
throw new SkillValidationException("Frontmatter key cannot be blank.");
|
||||
}
|
||||
if (value.isBlank()) {
|
||||
throw new SkillValidationException("Frontmatter value cannot be blank: " + key);
|
||||
}
|
||||
values.put(key, value);
|
||||
/**
|
||||
* 使用默认安全限额解析完整 SKILL.md。
|
||||
*
|
||||
* @param content SKILL.md 原始内容
|
||||
* @return 解析后的文档
|
||||
* @throws SkillValidationException 文档语法不合法
|
||||
*/
|
||||
public static SkillDocument parseDocument(String content) {
|
||||
return parseDocument(content, SkillPackageLimits.defaults());
|
||||
}
|
||||
|
||||
private static String stripQuotes(String value) {
|
||||
if (value.length() >= 2) {
|
||||
boolean doubleQuoted = value.startsWith("\"") && value.endsWith("\"");
|
||||
boolean singleQuoted = value.startsWith("'") && value.endsWith("'");
|
||||
if (doubleQuoted || singleQuoted) {
|
||||
return value.substring(1, value.length() - 1);
|
||||
/**
|
||||
* 使用指定安全限额解析完整 SKILL.md。
|
||||
*
|
||||
* @param content SKILL.md 原始内容
|
||||
* @param limits 安全限额
|
||||
* @return 解析后的文档
|
||||
* @throws SkillValidationException 文档语法、类型或限额不合法
|
||||
*/
|
||||
public static SkillDocument parseDocument(String content, SkillPackageLimits limits) {
|
||||
if (content == null || content.isBlank()) {
|
||||
throw error("SKILL_CONTENT_REQUIRED", 1, 1, "SKILL.md content is required.", null);
|
||||
}
|
||||
try {
|
||||
SkillUtf8.byteLength(content);
|
||||
} catch (CharacterCodingException e) {
|
||||
throw error("INVALID_UTF8", 1, 1,
|
||||
"SKILL.md must be losslessly encodable as UTF-8.", e);
|
||||
}
|
||||
SkillPackageLimits effectiveLimits = limits == null ? SkillPackageLimits.defaults() : limits;
|
||||
FrontmatterSections sections = split(content);
|
||||
long frontmatterBytes;
|
||||
try {
|
||||
frontmatterBytes = SkillUtf8.byteLength(sections.yaml);
|
||||
} catch (CharacterCodingException e) {
|
||||
throw error("INVALID_UTF8", 2, 1,
|
||||
"SKILL.md frontmatter must be losslessly encodable as UTF-8.", e);
|
||||
}
|
||||
if (frontmatterBytes > effectiveLimits.getMaxFrontmatterBytes()) {
|
||||
throw error("FRONTMATTER_TOO_LARGE", 1, 1,
|
||||
"SKILL.md frontmatter exceeds " + effectiveLimits.getMaxFrontmatterBytes() + " bytes.", null);
|
||||
}
|
||||
|
||||
LoaderOptions loaderOptions = new LoaderOptions();
|
||||
loaderOptions.setAllowDuplicateKeys(false);
|
||||
loaderOptions.setWarnOnDuplicateKeys(false);
|
||||
loaderOptions.setAllowRecursiveKeys(false);
|
||||
loaderOptions.setMaxAliasesForCollections(effectiveLimits.getMaxYamlAliases());
|
||||
loaderOptions.setNestingDepthLimit(effectiveLimits.getMaxYamlDepth());
|
||||
loaderOptions.setCodePointLimit(effectiveLimits.getMaxYamlCodePoints());
|
||||
loaderOptions.setTagInspector(tag -> isStandardTag(tag));
|
||||
|
||||
try {
|
||||
Yaml yaml = new Yaml(new SafeConstructor(loaderOptions));
|
||||
Node rootNode = yaml.compose(new StringReader(sections.yaml));
|
||||
Object loaded = yaml.load(sections.yaml);
|
||||
if (!(loaded instanceof Map<?, ?> map)) {
|
||||
throw error("FRONTMATTER_ROOT_NOT_MAP", 2, 1,
|
||||
"SKILL.md frontmatter root must be a YAML mapping.", null);
|
||||
}
|
||||
LinkedHashMap<String, Object> values = copyStringMap(map, 0, effectiveLimits.getMaxYamlDepth());
|
||||
return new SkillDocument(content, values, sections.body,
|
||||
collectTopLevelLocations(rootNode), List.of());
|
||||
} catch (SkillValidationException e) {
|
||||
throw e;
|
||||
} catch (MarkedYAMLException e) {
|
||||
int line = e.getProblemMark() == null ? 2 : e.getProblemMark().getLine() + 2;
|
||||
int column = e.getProblemMark() == null ? 1 : e.getProblemMark().getColumn() + 1;
|
||||
throw error("INVALID_FRONTMATTER_YAML", line, column,
|
||||
"Invalid SKILL.md frontmatter: " + safeProblem(e), e);
|
||||
} catch (YAMLException e) {
|
||||
throw error("INVALID_FRONTMATTER_YAML", 2, 1,
|
||||
"Invalid SKILL.md frontmatter: " + safeProblem(e), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将有序 frontmatter 与 Markdown 正文确定性序列化为 SKILL.md。
|
||||
*
|
||||
* @param values frontmatter 有序键值
|
||||
* @param markdownBody Markdown 正文
|
||||
* @return 完整 SKILL.md
|
||||
* @throws SkillValidationException frontmatter 包含不安全类型
|
||||
*/
|
||||
public static String serialize(Map<String, Object> values, String markdownBody) {
|
||||
LinkedHashMap<String, Object> safeValues = copyStringMap(
|
||||
values == null ? Map.of() : values, 0, SkillPackageLimits.defaults().getMaxYamlDepth());
|
||||
DumperOptions dumperOptions = new DumperOptions();
|
||||
dumperOptions.setAllowUnicode(true);
|
||||
dumperOptions.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
|
||||
dumperOptions.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN);
|
||||
dumperOptions.setIndent(2);
|
||||
dumperOptions.setIndicatorIndent(0);
|
||||
dumperOptions.setPrettyFlow(true);
|
||||
dumperOptions.setSplitLines(false);
|
||||
dumperOptions.setWidth(4_096);
|
||||
dumperOptions.setLineBreak(DumperOptions.LineBreak.UNIX);
|
||||
dumperOptions.setDereferenceAliases(true);
|
||||
|
||||
LoaderOptions loaderOptions = new LoaderOptions();
|
||||
loaderOptions.setAllowDuplicateKeys(false);
|
||||
Representer representer = new Representer(dumperOptions);
|
||||
representer.setTimeZone(TimeZone.getTimeZone("UTC"));
|
||||
Yaml yaml = new Yaml(new SafeConstructor(loaderOptions), representer, dumperOptions, loaderOptions);
|
||||
String yamlContent = yaml.dump(safeValues);
|
||||
String body = markdownBody == null ? "" : markdownBody;
|
||||
return "---\n" + yamlContent + "---\n" + body;
|
||||
}
|
||||
|
||||
private static FrontmatterSections split(String content) {
|
||||
int yamlStart;
|
||||
if (content.startsWith("---\n")) {
|
||||
yamlStart = 4;
|
||||
} else if (content.startsWith("---\r\n")) {
|
||||
yamlStart = 5;
|
||||
} else {
|
||||
throw error("FRONTMATTER_START_REQUIRED", 1, 1,
|
||||
"SKILL.md must start with a frontmatter delimiter.", null);
|
||||
}
|
||||
|
||||
int lineStart = yamlStart;
|
||||
while (lineStart <= content.length()) {
|
||||
int lineEnd = content.indexOf('\n', lineStart);
|
||||
int contentEnd = lineEnd < 0 ? content.length() : lineEnd;
|
||||
String line = content.substring(lineStart, contentEnd);
|
||||
if (line.endsWith("\r")) {
|
||||
line = line.substring(0, line.length() - 1);
|
||||
}
|
||||
if ("---".equals(line)) {
|
||||
int bodyStart = lineEnd < 0 ? content.length() : lineEnd + 1;
|
||||
return new FrontmatterSections(content.substring(yamlStart, lineStart),
|
||||
content.substring(bodyStart));
|
||||
}
|
||||
if (lineEnd < 0) {
|
||||
break;
|
||||
}
|
||||
lineStart = lineEnd + 1;
|
||||
}
|
||||
throw error("FRONTMATTER_NOT_CLOSED", 1, 1,
|
||||
"SKILL.md frontmatter is not closed.", null);
|
||||
}
|
||||
|
||||
private static LinkedHashMap<String, Object> copyStringMap(Map<?, ?> source, int depth, int maxDepth) {
|
||||
if (depth > maxDepth) {
|
||||
throw error("YAML_DEPTH_LIMIT", 2, 1,
|
||||
"SKILL.md frontmatter exceeds the YAML nesting depth limit.", null);
|
||||
}
|
||||
LinkedHashMap<String, Object> target = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : source.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key) || key.isBlank()) {
|
||||
throw error("INVALID_FRONTMATTER_KEY", 2, 1,
|
||||
"SKILL.md frontmatter keys must be non-blank strings.", null);
|
||||
}
|
||||
target.put(key, copyValue(entry.getValue(), depth + 1, maxDepth));
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static Object copyValue(Object value, int depth, int maxDepth) {
|
||||
if (value == null || value instanceof String || value instanceof Boolean || value instanceof Number) {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Date date) {
|
||||
return new Date(date.getTime());
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
return copyStringMap(map, depth, maxDepth);
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
if (depth > maxDepth) {
|
||||
throw error("YAML_DEPTH_LIMIT", 2, 1,
|
||||
"SKILL.md frontmatter exceeds the YAML nesting depth limit.", null);
|
||||
}
|
||||
List<Object> target = new ArrayList<>(list.size());
|
||||
for (Object item : list) {
|
||||
target.add(copyValue(item, depth + 1, maxDepth));
|
||||
}
|
||||
return target;
|
||||
}
|
||||
throw error("UNSUPPORTED_FRONTMATTER_TYPE", 2, 1,
|
||||
"Unsupported SKILL.md frontmatter value type: " + value.getClass().getName(), null);
|
||||
}
|
||||
|
||||
private static boolean isBlank(Object value) {
|
||||
return value == null || value.toString().isBlank();
|
||||
private static boolean isStandardTag(Tag tag) {
|
||||
return tag != null && ALLOWED_TAGS.contains(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集顶层 frontmatter key 在完整 SKILL.md 中的一基位置。
|
||||
*
|
||||
* @param rootNode YAML 根节点
|
||||
* @return 按源码顺序排列的字段位置
|
||||
*/
|
||||
private static Map<String, SkillSourceLocation> collectTopLevelLocations(Node rootNode) {
|
||||
LinkedHashMap<String, SkillSourceLocation> locations = new LinkedHashMap<>();
|
||||
if (!(rootNode instanceof MappingNode mappingNode)) {
|
||||
return locations;
|
||||
}
|
||||
for (NodeTuple tuple : mappingNode.getValue()) {
|
||||
if (!(tuple.getKeyNode() instanceof ScalarNode scalarNode)
|
||||
|| scalarNode.getStartMark() == null) {
|
||||
continue;
|
||||
}
|
||||
locations.putIfAbsent(scalarNode.getValue(), new SkillSourceLocation(
|
||||
scalarNode.getStartMark().getLine() + 2,
|
||||
scalarNode.getStartMark().getColumn() + 1));
|
||||
}
|
||||
return locations;
|
||||
}
|
||||
|
||||
private static void requireCoreField(Map<String, Object> values, String key) {
|
||||
Object value = values.get(key);
|
||||
if (value == null || value.toString().isBlank()) {
|
||||
throw error("MISSING_" + key.toUpperCase(), 2, 1,
|
||||
"SKILL.md frontmatter " + key + " is required.", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeProblem(Exception exception) {
|
||||
String message = exception.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
return exception.getClass().getSimpleName();
|
||||
}
|
||||
int lineBreak = message.indexOf('\n');
|
||||
return lineBreak < 0 ? message : message.substring(0, lineBreak);
|
||||
}
|
||||
|
||||
private static SkillValidationException error(String code, Integer line, Integer column,
|
||||
String message, Throwable cause) {
|
||||
return new SkillValidationException(code, DOCUMENT_PATH, line, column, message, cause);
|
||||
}
|
||||
|
||||
private record FrontmatterSections(String yaml, String body) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,16 +32,34 @@ public final class SkillHashes {
|
||||
* @return 十六进制 hash
|
||||
*/
|
||||
public static String sha256Hex(byte[] bytes) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance(SHA_256);
|
||||
byte[] hashed = digest.digest(bytes == null ? new byte[0] : bytes);
|
||||
StringBuilder builder = new StringBuilder(hashed.length * 2);
|
||||
for (byte item : hashed) {
|
||||
builder.append(String.format("%02x", item));
|
||||
return toHex(newSha256Digest().digest(bytes == null ? new byte[0] : bytes));
|
||||
}
|
||||
return builder.toString();
|
||||
|
||||
/**
|
||||
* 创建新的 SHA-256 摘要器。
|
||||
*
|
||||
* @return SHA-256 摘要器
|
||||
*/
|
||||
public static MessageDigest newSha256Digest() {
|
||||
try {
|
||||
return MessageDigest.getInstance(SHA_256);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new SkillException("SHA-256 algorithm is unavailable.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字节转为小写十六进制文本。
|
||||
*
|
||||
* @param bytes 原始字节
|
||||
* @return 小写十六进制文本
|
||||
*/
|
||||
public static String toHex(byte[] bytes) {
|
||||
StringBuilder builder = new StringBuilder(bytes.length * 2);
|
||||
for (byte item : bytes) {
|
||||
builder.append(Character.forDigit((item >>> 4) & 0x0F, 16));
|
||||
builder.append(Character.forDigit(item & 0x0F, 16));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.easyagents.skill.util;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
|
||||
import java.text.Normalizer;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,11 @@ public final class SkillPaths {
|
||||
*/
|
||||
public static final String ASSETS_DIR = "assets";
|
||||
|
||||
/**
|
||||
* example 顶级目录。
|
||||
*/
|
||||
public static final String EXAMPLES_DIR = "examples";
|
||||
|
||||
private SkillPaths() {
|
||||
}
|
||||
|
||||
@@ -42,17 +48,17 @@ public final class SkillPaths {
|
||||
if (path == null) {
|
||||
throw new SkillValidationException("Skill path is required.");
|
||||
}
|
||||
String normalized = path.replace('\\', '/').trim();
|
||||
while (normalized.startsWith("./")) {
|
||||
normalized = normalized.substring(2);
|
||||
if (containsControlCharacter(path)) {
|
||||
throw new SkillValidationException("Skill path contains a control character.");
|
||||
}
|
||||
String normalized = Normalizer.normalize(path.replace('\\', '/'), Normalizer.Form.NFC);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new SkillValidationException("Skill path cannot be empty.");
|
||||
}
|
||||
if (normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*")) {
|
||||
throw new SkillValidationException("Absolute skill path is not allowed: " + path);
|
||||
}
|
||||
String[] segments = normalized.split("/");
|
||||
String[] segments = normalized.split("/", -1);
|
||||
for (String segment : segments) {
|
||||
if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
|
||||
throw new SkillValidationException("Unsafe skill path is not allowed: " + path);
|
||||
@@ -60,10 +66,33 @@ public final class SkillPaths {
|
||||
if (segment.startsWith(".")) {
|
||||
throw new SkillValidationException("Hidden skill path is not allowed: " + path);
|
||||
}
|
||||
if (!segment.equals(segment.strip())) {
|
||||
throw new SkillValidationException("Skill path segments cannot have surrounding whitespace: " + path);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成大小写不敏感的路径冲突键。
|
||||
*
|
||||
* @param path 逻辑路径
|
||||
* @return 路径冲突键
|
||||
*/
|
||||
public static String collisionKey(String path) {
|
||||
return normalize(path).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路径段数量。
|
||||
*
|
||||
* @param path 逻辑路径
|
||||
* @return 路径深度
|
||||
*/
|
||||
public static int depth(String path) {
|
||||
return normalize(path).split("/", -1).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一级目录或文件名。
|
||||
*
|
||||
@@ -115,4 +144,15 @@ public final class SkillPaths {
|
||||
|| normalized.endsWith("/.DS_Store")
|
||||
|| ".DS_Store".equals(normalized);
|
||||
}
|
||||
|
||||
private static boolean containsControlCharacter(String path) {
|
||||
for (int index = 0; index < path.length(); index++) {
|
||||
char character = path.charAt(index);
|
||||
if (character <= 0x1F || character == 0x7F
|
||||
|| Character.getType(character) == Character.FORMAT) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.easyagents.skill.util;
|
||||
|
||||
import com.easyagents.skill.model.Skill;
|
||||
import com.easyagents.skill.model.SkillAsset;
|
||||
import com.easyagents.skill.model.SkillMetadata;
|
||||
import com.easyagents.skill.model.SkillReference;
|
||||
import com.easyagents.skill.model.SkillResource;
|
||||
import com.easyagents.skill.model.SkillResourceKind;
|
||||
import com.easyagents.skill.model.SkillScript;
|
||||
import com.easyagents.skill.model.SkillScriptLanguage;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 通用 Skill 资源与旧资源视图之间的兼容适配工具。
|
||||
*/
|
||||
public final class SkillResources {
|
||||
|
||||
private static final Set<String> TEXT_EXTENSIONS = Set.of(
|
||||
".md", ".markdown", ".txt", ".json", ".yaml", ".yml", ".xml", ".csv", ".tsv",
|
||||
".htm", ".html", ".css", ".properties", ".toml", ".ini", ".sql", ".java", ".kt", ".kts",
|
||||
".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".sh", ".bash", ".zsh",
|
||||
".rb", ".go", ".rs", ".c", ".h", ".cpp", ".hpp", ".gradle", ".groovy"
|
||||
);
|
||||
|
||||
private SkillResources() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据顶层目录识别资源语义类型。
|
||||
*
|
||||
* @param path Skill 根目录相对路径
|
||||
* @return 资源语义类型
|
||||
*/
|
||||
public static SkillResourceKind classify(String path) {
|
||||
String topDirectory = SkillPaths.firstSegment(path);
|
||||
return switch (topDirectory) {
|
||||
case SkillPaths.REFERENCES_DIR -> SkillResourceKind.REFERENCE;
|
||||
case SkillPaths.SCRIPTS_DIR -> SkillResourceKind.SCRIPT;
|
||||
case SkillPaths.ASSETS_DIR -> SkillResourceKind.ASSET;
|
||||
case SkillPaths.EXAMPLES_DIR -> SkillResourceKind.EXAMPLE;
|
||||
default -> SkillResourceKind.OTHER;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断资源是否应按严格 UTF-8 文本处理。
|
||||
*
|
||||
* @param path 资源路径
|
||||
* @param kind 资源语义类型
|
||||
* @param mediaType 媒体类型
|
||||
* @return 文本资源时为 true
|
||||
*/
|
||||
public static boolean isText(String path, SkillResourceKind kind, String mediaType) {
|
||||
if (kind == SkillResourceKind.SCRIPT) {
|
||||
return true;
|
||||
}
|
||||
if (kind == SkillResourceKind.ASSET) {
|
||||
return false;
|
||||
}
|
||||
if (mediaType != null && (mediaType.startsWith("text/")
|
||||
|| mediaType.contains("json") || mediaType.contains("yaml")
|
||||
|| mediaType.contains("xml") || mediaType.contains("javascript"))) {
|
||||
return true;
|
||||
}
|
||||
String lowerPath = path.toLowerCase(Locale.ROOT);
|
||||
return TEXT_EXTENSIONS.stream().anyMatch(lowerPath::endsWith);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill 的正式通用资源;旧模型会被按需转换。
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @return 通用资源副本
|
||||
*/
|
||||
public static List<SkillResource> canonicalResources(Skill skill) {
|
||||
if (skill == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return new ArrayList<>(skill.getResources());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将尚未迁移的旧 references、scripts、assets 视图转换为正式通用资源。
|
||||
*
|
||||
* <p>该方法只读取旧视图,不读取或修改正式资源列表,由 {@link Skill#getResources()}
|
||||
* 在第一次正式访问时完成一次性迁移。</p>
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @return 从旧视图转换得到的通用资源副本
|
||||
*/
|
||||
public static List<SkillResource> fromLegacyViews(Skill skill) {
|
||||
List<SkillResource> resources = new ArrayList<>();
|
||||
if (skill == null) {
|
||||
return resources;
|
||||
}
|
||||
if (skill.getReferences() != null) {
|
||||
for (SkillReference reference : skill.getReferences()) {
|
||||
SkillResource resource = base(reference.getPath(), SkillResourceKind.REFERENCE,
|
||||
"text/markdown", reference.getContentHash(), reference.getSize(), reference.getMetadata());
|
||||
resource.setTextContent(reference.getContent());
|
||||
resources.add(resource);
|
||||
}
|
||||
}
|
||||
if (skill.getScripts() != null) {
|
||||
for (SkillScript script : skill.getScripts()) {
|
||||
SkillResource resource = base(script.getPath(), SkillResourceKind.SCRIPT,
|
||||
"text/plain", script.getContentHash(), script.getSize(), script.getMetadata());
|
||||
resource.setTextContent(script.getContent());
|
||||
resources.add(resource);
|
||||
}
|
||||
}
|
||||
if (skill.getAssets() != null) {
|
||||
for (SkillAsset asset : skill.getAssets()) {
|
||||
SkillResource resource = base(asset.getPath(), SkillResourceKind.ASSET,
|
||||
asset.getMediaType(), asset.getContentHash(), asset.getSize(), asset.getMetadata());
|
||||
resource.setContentRef(asset.getContentRef());
|
||||
resources.add(resource);
|
||||
}
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据通用资源刷新旧 references、scripts、assets 兼容视图。
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
*/
|
||||
public static void refreshLegacyViews(Skill skill) {
|
||||
List<SkillReference> references = new ArrayList<>();
|
||||
List<SkillScript> scripts = new ArrayList<>();
|
||||
List<SkillAsset> assets = new ArrayList<>();
|
||||
for (SkillResource resource : skill.getResources()) {
|
||||
if (resource.getKind() == SkillResourceKind.REFERENCE && resource.isText()) {
|
||||
SkillReference reference = new SkillReference();
|
||||
reference.setPath(resource.getPath());
|
||||
reference.setName(SkillPaths.fileName(resource.getPath()));
|
||||
reference.setContent(resource.getTextContent());
|
||||
reference.setContentHash(resource.getContentHash());
|
||||
reference.setSize(resource.getSize());
|
||||
reference.setMetadata(copy(resource.getMetadata()));
|
||||
references.add(reference);
|
||||
} else if (resource.getKind() == SkillResourceKind.SCRIPT && resource.isText()) {
|
||||
SkillScript script = new SkillScript();
|
||||
script.setPath(resource.getPath());
|
||||
script.setLanguage(SkillScriptLanguage.fromPath(resource.getPath()));
|
||||
script.setContent(resource.getTextContent());
|
||||
script.setContentHash(resource.getContentHash());
|
||||
script.setSize(resource.getSize());
|
||||
script.setMetadata(copy(resource.getMetadata()));
|
||||
scripts.add(script);
|
||||
} else if (resource.getKind() == SkillResourceKind.ASSET) {
|
||||
SkillAsset asset = new SkillAsset();
|
||||
asset.setPath(resource.getPath());
|
||||
asset.setName(SkillPaths.fileName(resource.getPath()));
|
||||
asset.setMediaType(resource.getMediaType());
|
||||
asset.setContentRef(resource.getContentRef());
|
||||
asset.setContentHash(resource.getContentHash());
|
||||
asset.setSize(resource.getSize());
|
||||
asset.setMetadata(copy(resource.getMetadata()));
|
||||
assets.add(asset);
|
||||
}
|
||||
}
|
||||
skill.setReferences(references);
|
||||
skill.setScripts(scripts);
|
||||
skill.setAssets(assets);
|
||||
}
|
||||
|
||||
private static SkillResource base(String path, SkillResourceKind kind, String mediaType,
|
||||
String hash, long size, SkillMetadata metadata) {
|
||||
SkillResource resource = new SkillResource();
|
||||
resource.setPath(path);
|
||||
resource.setKind(kind);
|
||||
resource.setMediaType(mediaType);
|
||||
resource.setContentHash(hash);
|
||||
resource.setSize(size);
|
||||
resource.setMetadata(copy(metadata));
|
||||
return resource;
|
||||
}
|
||||
|
||||
private static SkillMetadata copy(SkillMetadata metadata) {
|
||||
return metadata == null ? new SkillMetadata() : new SkillMetadata(metadata.getValues());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.easyagents.skill.util;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.CharBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CharsetEncoder;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Skill 文本的严格 UTF-8 编码工具。
|
||||
*/
|
||||
public final class SkillUtf8 {
|
||||
|
||||
private SkillUtf8() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格编码文本,拒绝孤立 surrogate 等非法 UTF-16 输入。
|
||||
*
|
||||
* @param text 待编码文本
|
||||
* @return UTF-8 字节
|
||||
* @throws CharacterCodingException 文本不能无损编码为 UTF-8
|
||||
*/
|
||||
public static byte[] encode(String text) throws CharacterCodingException {
|
||||
ByteBuffer encoded = newEncoder().encode(CharBuffer.wrap(
|
||||
Objects.requireNonNull(text, "text")));
|
||||
byte[] bytes = new byte[encoded.remaining()];
|
||||
encoded.get(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格计算文本的 UTF-8 字节数。
|
||||
*
|
||||
* @param text 待计算文本
|
||||
* @return UTF-8 字节数
|
||||
* @throws CharacterCodingException 文本不能无损编码为 UTF-8
|
||||
*/
|
||||
public static long byteLength(String text) throws CharacterCodingException {
|
||||
return newEncoder().encode(CharBuffer.wrap(
|
||||
Objects.requireNonNull(text, "text"))).remaining();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建拒绝替换非法输入的 UTF-8 编码器。
|
||||
*
|
||||
* @return 严格 UTF-8 编码器
|
||||
*/
|
||||
private static CharsetEncoder newEncoder() {
|
||||
return StandardCharsets.UTF_8.newEncoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.easyagents.skill.validation;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 可定位的 Skill 校验问题。
|
||||
*/
|
||||
public final class SkillValidationIssue implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String code;
|
||||
private final SkillValidationSeverity severity;
|
||||
private final String path;
|
||||
private final Integer line;
|
||||
private final Integer column;
|
||||
private final String message;
|
||||
private final String suggestion;
|
||||
|
||||
/**
|
||||
* 创建 Skill 校验问题。
|
||||
*
|
||||
* @param code 稳定问题码
|
||||
* @param severity 严重级别
|
||||
* @param path 文件路径
|
||||
* @param line 一基行号
|
||||
* @param column 一基列号
|
||||
* @param message 问题说明
|
||||
* @param suggestion 修复建议
|
||||
*/
|
||||
public SkillValidationIssue(String code, SkillValidationSeverity severity, String path,
|
||||
Integer line, Integer column, String message, String suggestion) {
|
||||
this.code = Objects.requireNonNull(code, "code");
|
||||
this.severity = Objects.requireNonNull(severity, "severity");
|
||||
this.path = path;
|
||||
this.line = line;
|
||||
this.column = column;
|
||||
this.message = Objects.requireNonNull(message, "message");
|
||||
this.suggestion = suggestion;
|
||||
}
|
||||
|
||||
/** @return 稳定问题码 */
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/** @return 严重级别 */
|
||||
public SkillValidationSeverity getSeverity() {
|
||||
return severity;
|
||||
}
|
||||
|
||||
/** @return 文件路径 */
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
/** @return 一基行号 */
|
||||
public Integer getLine() {
|
||||
return line;
|
||||
}
|
||||
|
||||
/** @return 一基列号 */
|
||||
public Integer getColumn() {
|
||||
return column;
|
||||
}
|
||||
|
||||
/** @return 问题说明 */
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
/** @return 修复建议 */
|
||||
public String getSuggestion() {
|
||||
return suggestion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.easyagents.skill.validation;
|
||||
|
||||
/**
|
||||
* Skill 标准校验模式。
|
||||
*/
|
||||
public enum SkillValidationMode {
|
||||
|
||||
/**
|
||||
* 兼容历史包进入草稿;可修复的兼容问题以 warning 返回。
|
||||
*/
|
||||
DRAFT_IMPORT,
|
||||
|
||||
/**
|
||||
* 正式新建、发布与标准导出;所有标准互操作约束均严格执行。
|
||||
*/
|
||||
STANDARD
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.easyagents.skill.validation;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Skill 结构化校验报告。
|
||||
*/
|
||||
public final class SkillValidationReport implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final List<SkillValidationIssue> issues = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 添加校验问题。
|
||||
*
|
||||
* @param issue 校验问题
|
||||
* @return 当前报告
|
||||
*/
|
||||
public SkillValidationReport add(SkillValidationIssue issue) {
|
||||
if (issue != null) {
|
||||
issues.add(issue);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并其他报告。
|
||||
*
|
||||
* @param report 其他报告
|
||||
* @return 当前报告
|
||||
*/
|
||||
public SkillValidationReport merge(SkillValidationReport report) {
|
||||
if (report != null) {
|
||||
issues.addAll(report.issues);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取不可变问题列表。
|
||||
*
|
||||
* @return 问题列表
|
||||
*/
|
||||
public List<SkillValidationIssue> getIssues() {
|
||||
return Collections.unmodifiableList(issues);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否存在错误。
|
||||
*
|
||||
* @return 存在 ERROR 时为 true
|
||||
*/
|
||||
public boolean hasErrors() {
|
||||
return issues.stream().anyMatch(issue -> issue.getSeverity() == SkillValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断报告是否为空。
|
||||
*
|
||||
* @return 没有问题时为 true
|
||||
*/
|
||||
public boolean isEmpty() {
|
||||
return issues.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误存在时抛出包含完整报告的异常。
|
||||
*
|
||||
* @throws SkillValidationException 校验报告包含错误
|
||||
*/
|
||||
public void throwIfInvalid() {
|
||||
if (hasErrors()) {
|
||||
SkillValidationIssue first = issues.stream()
|
||||
.filter(issue -> issue.getSeverity() == SkillValidationSeverity.ERROR)
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
throw new SkillValidationException(first.getCode(), first.getPath(), first.getLine(),
|
||||
first.getColumn(), first.getMessage(), null, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.easyagents.skill.validation;
|
||||
|
||||
/**
|
||||
* Skill 校验问题严重级别。
|
||||
*/
|
||||
public enum SkillValidationSeverity {
|
||||
|
||||
/** 阻止导入确认、标准导出或发布。 */
|
||||
ERROR,
|
||||
|
||||
/** 允许进入草稿但需要用户处理。 */
|
||||
WARNING,
|
||||
|
||||
/** 不影响操作的信息提示。 */
|
||||
INFO
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.easyagents.skill.validation;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
import com.easyagents.skill.model.Skill;
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
|
||||
/**
|
||||
* Skill 校验接口。
|
||||
@@ -13,4 +15,51 @@ public interface SkillValidator {
|
||||
* @param skill Skill 聚合
|
||||
*/
|
||||
void validate(Skill skill);
|
||||
|
||||
/**
|
||||
* 聚合校验 Skill 并返回结构化报告。
|
||||
*
|
||||
* <p>兼容默认实现会把旧命令式异常转换为单个错误;新实现应覆盖以返回全部问题。</p>
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @return 结构化校验报告
|
||||
*/
|
||||
default SkillValidationReport validateReport(Skill skill) {
|
||||
SkillValidationReport report = new SkillValidationReport();
|
||||
try {
|
||||
validate(skill);
|
||||
} catch (SkillValidationException e) {
|
||||
report.add(new SkillValidationIssue(e.getCode(), SkillValidationSeverity.ERROR, e.getPath(),
|
||||
e.getLine(), e.getColumn(), e.getMessage(), null));
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前 Codec 操作的安全限额聚合校验 Skill。
|
||||
*
|
||||
* <p>兼容实现默认委托给原有校验入口;需要检查包限额的实现应覆盖本方法。</p>
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @param limits 当前读写操作的安全限额
|
||||
* @return 结构化校验报告
|
||||
*/
|
||||
default SkillValidationReport validateReport(Skill skill, SkillPackageLimits limits) {
|
||||
return validateReport(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定标准模式和当前 Codec 安全限额聚合校验 Skill。
|
||||
*
|
||||
* <p>兼容实现默认忽略模式;需要区分草稿导入与正式标准约束的实现应覆盖本方法。</p>
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @param limits 当前读写操作的安全限额
|
||||
* @param mode 标准校验模式
|
||||
* @return 结构化校验报告
|
||||
*/
|
||||
default SkillValidationReport validateReport(Skill skill, SkillPackageLimits limits,
|
||||
SkillValidationMode mode) {
|
||||
return validateReport(skill, limits);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,156 +1,528 @@
|
||||
package com.easyagents.skill.validation.defaults;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
import com.easyagents.skill.model.*;
|
||||
import com.easyagents.skill.model.Skill;
|
||||
import com.easyagents.skill.model.SkillDocument;
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
import com.easyagents.skill.model.SkillResource;
|
||||
import com.easyagents.skill.model.SkillResourceKind;
|
||||
import com.easyagents.skill.model.SkillScriptLanguage;
|
||||
import com.easyagents.skill.model.SkillSourceLocation;
|
||||
import com.easyagents.skill.util.SkillFrontmatter;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
import com.easyagents.skill.util.SkillPaths;
|
||||
import com.easyagents.skill.util.SkillResources;
|
||||
import com.easyagents.skill.util.SkillUtf8;
|
||||
import com.easyagents.skill.validation.SkillValidationIssue;
|
||||
import com.easyagents.skill.validation.SkillValidationMode;
|
||||
import com.easyagents.skill.validation.SkillValidationReport;
|
||||
import com.easyagents.skill.validation.SkillValidationSeverity;
|
||||
import com.easyagents.skill.validation.SkillValidator;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 默认 Skill 聚合校验器。
|
||||
* 默认 Skill 聚合结构化校验器。
|
||||
*/
|
||||
public class DefaultSkillValidator implements SkillValidator {
|
||||
|
||||
private static final Pattern CANONICAL_NAME = Pattern.compile("[a-z0-9]+(?:-[a-z0-9]+)*");
|
||||
private static final Pattern LEGACY_UNDERSCORE_NAME = Pattern.compile("[a-z0-9]+(?:_[a-z0-9]+)+");
|
||||
private static final Pattern SHA_256 = Pattern.compile("[a-f0-9]{64}");
|
||||
|
||||
private final SkillPackageLimits limits;
|
||||
|
||||
/**
|
||||
* 校验 Skill 聚合。
|
||||
* 使用默认安全限额创建校验器。
|
||||
*/
|
||||
public DefaultSkillValidator() {
|
||||
this(SkillPackageLimits.defaults());
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定安全限额创建校验器。
|
||||
*
|
||||
* @param limits 安全限额
|
||||
*/
|
||||
public DefaultSkillValidator(SkillPackageLimits limits) {
|
||||
this.limits = limits == null ? SkillPackageLimits.defaults() : limits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Skill 聚合,存在错误时抛出包含完整报告的异常。
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @throws SkillValidationException 校验失败
|
||||
*/
|
||||
@Override
|
||||
public void validate(Skill skill) {
|
||||
validateReport(skill, limits, SkillValidationMode.STANDARD).throwIfInvalid();
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合校验 Skill。
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @return 结构化校验报告
|
||||
*/
|
||||
@Override
|
||||
public SkillValidationReport validateReport(Skill skill) {
|
||||
return validateReport(skill, limits);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前 Codec 操作的安全限额聚合校验 Skill。
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @param operationLimits 当前读写操作的安全限额
|
||||
* @return 结构化校验报告
|
||||
*/
|
||||
@Override
|
||||
public SkillValidationReport validateReport(Skill skill, SkillPackageLimits operationLimits) {
|
||||
return validateReport(skill, operationLimits, SkillValidationMode.DRAFT_IMPORT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定标准模式和当前操作限额聚合校验 Skill。
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @param operationLimits 当前读写操作的安全限额
|
||||
* @param mode 标准校验模式
|
||||
* @return 结构化校验报告
|
||||
*/
|
||||
@Override
|
||||
public SkillValidationReport validateReport(Skill skill, SkillPackageLimits operationLimits,
|
||||
SkillValidationMode mode) {
|
||||
SkillPackageLimits effectiveLimits = operationLimits == null ? limits : operationLimits;
|
||||
SkillValidationMode effectiveMode = mode == null
|
||||
? SkillValidationMode.DRAFT_IMPORT : mode;
|
||||
SkillValidationReport report = new SkillValidationReport();
|
||||
if (skill == null) {
|
||||
throw new SkillValidationException("Skill is required.");
|
||||
}
|
||||
requireText(skill.getId(), "Skill id is required.");
|
||||
requireText(skill.getName(), "Skill name is required.");
|
||||
requireText(skill.getDescription(), "Skill description is required.");
|
||||
requireText(skill.getSkillContent(), "SKILL.md content is required.");
|
||||
validateSkillFrontmatter(skill);
|
||||
|
||||
Set<String> paths = new HashSet<>();
|
||||
paths.add(SkillPaths.SKILL_FILE);
|
||||
validateReferences(skill.getReferences(), paths);
|
||||
validateScripts(skill.getScripts(), paths);
|
||||
validateAssets(skill.getAssets(), paths);
|
||||
return report.add(error("SKILL_REQUIRED", null, "Skill is required.", null));
|
||||
}
|
||||
|
||||
private static void validateReferences(List<SkillReference> references, Set<String> paths) {
|
||||
if (references == null) {
|
||||
SkillDocument document = parseDocument(skill, report, effectiveLimits);
|
||||
validateName(skill, document, report, effectiveMode);
|
||||
validateDescription(skill, document, report);
|
||||
if (document != null) {
|
||||
validateDocument(skill, document, report);
|
||||
}
|
||||
List<SkillResource> resources = SkillResources.canonicalResources(skill);
|
||||
validateAggregateLimits(skill, resources, report, effectiveLimits);
|
||||
validateResources(skill.getName(), resources, report, effectiveLimits);
|
||||
return report;
|
||||
}
|
||||
|
||||
private static void validateName(Skill skill, SkillDocument document,
|
||||
SkillValidationReport report, SkillValidationMode mode) {
|
||||
SkillSourceLocation location = sourceLocation(document, "name");
|
||||
String name = skill.getName();
|
||||
if (isBlank(name)) {
|
||||
report.add(errorAt("NAME_REQUIRED", SkillPaths.SKILL_FILE, location, "Skill name is required.",
|
||||
"Set frontmatter name to a portable Skill name."));
|
||||
return;
|
||||
}
|
||||
for (SkillReference reference : references) {
|
||||
if (reference == null) {
|
||||
throw new SkillValidationException("Skill reference cannot be null.");
|
||||
if (name.length() > 64) {
|
||||
report.add(errorAt("NAME_TOO_LONG", SkillPaths.SKILL_FILE, location,
|
||||
"Skill name cannot exceed 64 characters.", "Shorten the frontmatter name."));
|
||||
} else if (LEGACY_UNDERSCORE_NAME.matcher(name).matches()) {
|
||||
SkillValidationSeverity severity = mode == SkillValidationMode.STANDARD
|
||||
? SkillValidationSeverity.ERROR : SkillValidationSeverity.WARNING;
|
||||
report.add(new SkillValidationIssue("NON_CANONICAL_NAME", severity,
|
||||
SkillPaths.SKILL_FILE, line(location), column(location),
|
||||
mode == SkillValidationMode.STANDARD
|
||||
? "Standard Skill name must use hyphens instead of underscores: " + name
|
||||
: "Legacy underscore Skill name is accepted only for draft import: " + name,
|
||||
"Replace underscores with single hyphens before publishing or standard export."));
|
||||
} else if (!CANONICAL_NAME.matcher(name).matches()) {
|
||||
report.add(errorAt("INVALID_NAME", SkillPaths.SKILL_FILE, location,
|
||||
"Skill name must use lowercase letters, numbers, and single hyphens.",
|
||||
"Use a name such as data-analysis."));
|
||||
}
|
||||
String path = validateFilePath(reference.getPath(), SkillPaths.REFERENCES_DIR, paths);
|
||||
if (!SkillPaths.hasExtension(path, ".md")) {
|
||||
throw new SkillValidationException("Skill reference must be a markdown file: " + path);
|
||||
}
|
||||
requireContent(reference.getContent(), "Skill reference content is required: " + path);
|
||||
validateTextHash(path, reference.getContent(), reference.getContentHash());
|
||||
validateSize(reference.getSize(), path);
|
||||
if (!isBlank(skill.getPackageRoot()) && !name.equals(skill.getPackageRoot())) {
|
||||
report.add(errorAt("ROOT_NAME_MISMATCH", SkillPaths.SKILL_FILE, location,
|
||||
"Skill package directory must match frontmatter name.",
|
||||
"Rename the package directory or update frontmatter name."));
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateScripts(List<SkillScript> scripts, Set<String> paths) {
|
||||
if (scripts == null) {
|
||||
return;
|
||||
}
|
||||
for (SkillScript script : scripts) {
|
||||
if (script == null) {
|
||||
throw new SkillValidationException("Skill script cannot be null.");
|
||||
}
|
||||
String path = validateFilePath(script.getPath(), SkillPaths.SCRIPTS_DIR, paths);
|
||||
if (SkillScriptLanguage.fromPath(path) == SkillScriptLanguage.UNKNOWN) {
|
||||
throw new SkillValidationException("Unsupported skill script extension: " + path);
|
||||
}
|
||||
if (script.getLanguage() == SkillScriptLanguage.UNKNOWN
|
||||
|| script.getLanguage() != SkillScriptLanguage.fromPath(path)) {
|
||||
throw new SkillValidationException("Skill script language does not match path: " + path);
|
||||
}
|
||||
requireContent(script.getContent(), "Skill script content is required: " + path);
|
||||
validateTextHash(path, script.getContent(), script.getContentHash());
|
||||
validateSize(script.getSize(), path);
|
||||
private static void validateDescription(Skill skill, SkillDocument document,
|
||||
SkillValidationReport report) {
|
||||
SkillSourceLocation location = sourceLocation(document, "description");
|
||||
if (isBlank(skill.getDescription())) {
|
||||
report.add(errorAt("DESCRIPTION_REQUIRED", SkillPaths.SKILL_FILE, location,
|
||||
"Skill description is required.", "Describe both the capability and when to use it."));
|
||||
} else if (skill.getDescription().length() > 1_024) {
|
||||
report.add(errorAt("DESCRIPTION_TOO_LONG", SkillPaths.SKILL_FILE, location,
|
||||
"Skill description cannot exceed 1024 characters.", "Shorten the description."));
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateAssets(List<SkillAsset> assets, Set<String> paths) {
|
||||
if (assets == null) {
|
||||
return;
|
||||
private static SkillDocument parseDocument(Skill skill, SkillValidationReport report,
|
||||
SkillPackageLimits limits) {
|
||||
if (isBlank(skill.getSkillContent())) {
|
||||
report.add(error("SKILL_CONTENT_REQUIRED", SkillPaths.SKILL_FILE,
|
||||
"SKILL.md content is required.", null));
|
||||
return null;
|
||||
}
|
||||
for (SkillAsset asset : assets) {
|
||||
if (asset == null) {
|
||||
throw new SkillValidationException("Skill asset cannot be null.");
|
||||
}
|
||||
String path = validateFilePath(asset.getPath(), SkillPaths.ASSETS_DIR, paths);
|
||||
requireText(asset.getName(), "Skill asset name is required: " + path);
|
||||
requireText(asset.getMediaType(), "Skill asset media type is required: " + path);
|
||||
requireText(asset.getContentRef(), "Skill asset content ref is required: " + path);
|
||||
requireText(asset.getContentHash(), "Skill asset content hash is required: " + path);
|
||||
if (!asset.getContentRef().equals("sha256:" + asset.getContentHash())) {
|
||||
throw new SkillValidationException("Skill asset content ref does not match hash: " + path);
|
||||
}
|
||||
validateSize(asset.getSize(), path);
|
||||
try {
|
||||
return SkillFrontmatter.parseDocument(skill.getSkillContent(), limits);
|
||||
} catch (SkillValidationException e) {
|
||||
report.add(new SkillValidationIssue(e.getCode(), SkillValidationSeverity.ERROR, e.getPath(),
|
||||
e.getLine(), e.getColumn(), e.getMessage(), "Fix the YAML frontmatter and retry."));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateSkillFrontmatter(Skill skill) {
|
||||
Map<String, Object> values = SkillFrontmatter.parse(skill.getSkillContent());
|
||||
if (!skill.getName().equals(values.get("name").toString())) {
|
||||
throw new SkillValidationException("Skill name must match SKILL.md frontmatter.");
|
||||
}
|
||||
if (!skill.getDescription().equals(values.get("description").toString())) {
|
||||
throw new SkillValidationException("Skill description must match SKILL.md frontmatter.");
|
||||
private static void validateDocument(Skill skill, SkillDocument document, SkillValidationReport report) {
|
||||
Map<String, Object> values = document.getFrontmatter().getValues();
|
||||
validateCoreString(document, values, "name", skill.getName(), report);
|
||||
validateCoreString(document, values, "description", skill.getDescription(), report);
|
||||
validateOptionalString(document, values, "license", null, report);
|
||||
Object compatibility = values.get("compatibility");
|
||||
if (compatibility != null && (!(compatibility instanceof String text)
|
||||
|| text.isBlank() || text.length() > 500)) {
|
||||
report.add(errorAt("INVALID_COMPATIBILITY", SkillPaths.SKILL_FILE,
|
||||
sourceLocation(document, "compatibility"),
|
||||
"Frontmatter compatibility must be a non-blank string of at most 500 characters.", null));
|
||||
}
|
||||
validateOptionalString(document, values, "allowed-tools", "INVALID_ALLOWED_TOOLS", report);
|
||||
validateMetadataField(document, values.get("metadata"), report);
|
||||
if (skill.getMetadata() == null || !skill.getMetadata().getValues().equals(values)) {
|
||||
throw new SkillValidationException("Skill metadata must match SKILL.md frontmatter.");
|
||||
report.add(errorAt("METADATA_MISMATCH", SkillPaths.SKILL_FILE,
|
||||
sourceLocation(document, "name"),
|
||||
"Skill metadata must match SKILL.md frontmatter.",
|
||||
"Reparse SKILL.md before saving the aggregate."));
|
||||
}
|
||||
if (document.getMarkdownBody().length() > 30_000) {
|
||||
report.add(new SkillValidationIssue("LONG_SKILL_BODY", SkillValidationSeverity.WARNING,
|
||||
SkillPaths.SKILL_FILE, null, null,
|
||||
"SKILL.md body is large and may reduce progressive-disclosure efficiency.",
|
||||
"Move detailed material into references/."));
|
||||
}
|
||||
}
|
||||
|
||||
private static String validateFilePath(String path, String expectedTopDir, Set<String> paths) {
|
||||
String normalized = SkillPaths.normalize(path);
|
||||
if (!expectedTopDir.equals(SkillPaths.firstSegment(normalized))) {
|
||||
throw new SkillValidationException("Skill file must be under " + expectedTopDir + "/: " + normalized);
|
||||
}
|
||||
if (normalized.indexOf('/') < 0 || normalized.endsWith("/")) {
|
||||
throw new SkillValidationException("Skill file path must include file name: " + normalized);
|
||||
}
|
||||
if (!paths.add(normalized)) {
|
||||
throw new SkillValidationException("Duplicate skill file path: " + normalized);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static void validateTextHash(String path, String content, String contentHash) {
|
||||
requireText(contentHash, "Skill file content hash is required: " + path);
|
||||
String actualHash = SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8));
|
||||
if (!actualHash.equals(contentHash)) {
|
||||
throw new SkillValidationException("Skill file content hash does not match: " + path);
|
||||
private static void validateCoreString(SkillDocument document, Map<String, Object> values,
|
||||
String key, String expected,
|
||||
SkillValidationReport report) {
|
||||
Object value = values.get(key);
|
||||
if (!(value instanceof String text) || text.isBlank()) {
|
||||
report.add(errorAt(key.toUpperCase(Locale.ROOT) + "_REQUIRED", SkillPaths.SKILL_FILE,
|
||||
sourceLocation(document, key),
|
||||
"SKILL.md frontmatter " + key + " must be a non-blank string.", null));
|
||||
} else if (!text.equals(expected)) {
|
||||
report.add(errorAt(key.toUpperCase(Locale.ROOT) + "_MISMATCH", SkillPaths.SKILL_FILE,
|
||||
sourceLocation(document, key),
|
||||
"Skill " + key + " must match SKILL.md frontmatter.", null));
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateSize(long size, String path) {
|
||||
if (size < 0) {
|
||||
throw new SkillValidationException("Skill file size cannot be negative: " + path);
|
||||
private static void validateOptionalString(SkillDocument document, Map<String, Object> values,
|
||||
String key, String code,
|
||||
SkillValidationReport report) {
|
||||
Object value = values.get(key);
|
||||
if (value != null && (!(value instanceof String text) || text.isBlank())) {
|
||||
report.add(errorAt(code == null ? "INVALID_" + key.toUpperCase(Locale.ROOT) : code,
|
||||
SkillPaths.SKILL_FILE, sourceLocation(document, key),
|
||||
"Frontmatter " + key + " must be a non-blank string when provided.", null));
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String message) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new SkillValidationException(message);
|
||||
private static void validateMetadataField(SkillDocument document, Object metadata,
|
||||
SkillValidationReport report) {
|
||||
if (metadata == null) {
|
||||
return;
|
||||
}
|
||||
if (!(metadata instanceof Map<?, ?> map)) {
|
||||
report.add(errorAt("INVALID_METADATA", SkillPaths.SKILL_FILE,
|
||||
sourceLocation(document, "metadata"),
|
||||
"Frontmatter metadata must be a mapping.", null));
|
||||
return;
|
||||
}
|
||||
if (map.values().stream().anyMatch(value -> !(value instanceof String))) {
|
||||
SkillSourceLocation location = sourceLocation(document, "metadata");
|
||||
report.add(new SkillValidationIssue("NON_STANDARD_METADATA",
|
||||
SkillValidationSeverity.WARNING, SkillPaths.SKILL_FILE,
|
||||
line(location), column(location),
|
||||
"Nested or non-string metadata is preserved for AgentScope compatibility.",
|
||||
"Use string values only for strict Agent Skills interoperability."));
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireContent(String value, String message) {
|
||||
if (value == null) {
|
||||
throw new SkillValidationException(message);
|
||||
private static void validateResources(String skillName, List<SkillResource> resources,
|
||||
SkillValidationReport report,
|
||||
SkillPackageLimits limits) {
|
||||
Set<String> exactPaths = new HashSet<>();
|
||||
Map<String, String> collisionPaths = new HashMap<>();
|
||||
Map<String, String> descendantPaths = new HashMap<>();
|
||||
exactPaths.add(SkillPaths.SKILL_FILE);
|
||||
collisionPaths.put(SkillPaths.collisionKey(SkillPaths.SKILL_FILE), SkillPaths.SKILL_FILE);
|
||||
for (SkillResource resource : resources) {
|
||||
if (resource == null) {
|
||||
report.add(error("NULL_RESOURCE", null, "Skill resource cannot be null.", null));
|
||||
continue;
|
||||
}
|
||||
if (isBlank(resource.getPath())) {
|
||||
report.add(error("RESOURCE_PATH_REQUIRED", null,
|
||||
"Skill resource path is required.",
|
||||
"Set a safe relative path inside the Skill package."));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
SkillUtf8.encode(resource.getPath());
|
||||
} catch (CharacterCodingException e) {
|
||||
report.add(error("INVALID_UTF8_PATH", resource.getPath(),
|
||||
"Skill resource path must be losslessly encodable as UTF-8.",
|
||||
"Remove malformed UTF-16 surrogate code units from the path."));
|
||||
continue;
|
||||
}
|
||||
String path;
|
||||
try {
|
||||
path = SkillPaths.normalize(resource.getPath());
|
||||
} catch (SkillValidationException e) {
|
||||
report.add(error("UNSAFE_RESOURCE_PATH", resource.getPath(), e.getMessage(), null));
|
||||
continue;
|
||||
}
|
||||
String portablePath = isBlank(skillName) ? path : skillName + "/" + path;
|
||||
if (portablePath.length() > limits.getMaxPathLength()) {
|
||||
report.add(error("PATH_LENGTH_LIMIT", path,
|
||||
"Skill resource path exceeds the configured length limit.", null));
|
||||
}
|
||||
if (SkillPaths.depth(portablePath) > limits.getMaxPathDepth()) {
|
||||
report.add(error("PATH_DEPTH_LIMIT", path,
|
||||
"Skill resource path exceeds the configured depth limit.", null));
|
||||
}
|
||||
if (!path.equals(resource.getPath())) {
|
||||
report.add(error("NON_NORMALIZED_PATH", path,
|
||||
"Skill resource path must already be normalized.", null));
|
||||
}
|
||||
if (!exactPaths.add(path)) {
|
||||
report.add(error("DUPLICATE_RESOURCE_PATH", path,
|
||||
"Duplicate Skill resource path: " + path, null));
|
||||
continue;
|
||||
}
|
||||
String collisionKey = SkillPaths.collisionKey(path);
|
||||
String conflicting = collisionPaths.get(collisionKey);
|
||||
if (conflicting != null) {
|
||||
report.add(error("RESOURCE_PATH_COLLISION", path,
|
||||
"Skill resource path conflicts with " + conflicting + ".", null));
|
||||
continue;
|
||||
}
|
||||
String hierarchyConflict = findHierarchyConflict(collisionKey, collisionPaths, descendantPaths);
|
||||
if (hierarchyConflict != null) {
|
||||
report.add(error("RESOURCE_PATH_HIERARCHY_CONFLICT", path,
|
||||
"Skill resource path conflicts with file " + hierarchyConflict
|
||||
+ " in the same path hierarchy.", null));
|
||||
continue;
|
||||
}
|
||||
collisionPaths.put(collisionKey, path);
|
||||
indexDescendantPath(collisionKey, path, descendantPaths);
|
||||
SkillResourceKind expectedKind = SkillResources.classify(path);
|
||||
if (resource.getKind() != expectedKind) {
|
||||
report.add(error("RESOURCE_KIND_MISMATCH", path,
|
||||
"Skill resource kind does not match its path.", null));
|
||||
}
|
||||
validateResourceContent(resource, path, report, limits);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateResourceContent(SkillResource resource, String path,
|
||||
SkillValidationReport report, SkillPackageLimits limits) {
|
||||
boolean expectedText = SkillResources.isText(path, resource.getKind(), resource.getMediaType());
|
||||
if (expectedText != resource.isText()) {
|
||||
String code = resource.getKind() == SkillResourceKind.SCRIPT
|
||||
? "SCRIPT_TEXT_REQUIRED" : "RESOURCE_CONTENT_MODE_MISMATCH";
|
||||
report.add(error(code, path,
|
||||
expectedText
|
||||
? "Resource path and media type require strict UTF-8 text content."
|
||||
: "Resource path and media type require a binary content reference.",
|
||||
"Store the resource using the canonical text or binary representation."));
|
||||
}
|
||||
if (resource.getKind() == SkillResourceKind.SCRIPT && resource.isText() && resource.getSize() == 0) {
|
||||
report.add(new SkillValidationIssue("EMPTY_SCRIPT", SkillValidationSeverity.WARNING,
|
||||
path, null, null, "Script resource is empty.",
|
||||
"Add script source or remove the unused file."));
|
||||
}
|
||||
if (resource.getKind() == SkillResourceKind.SCRIPT
|
||||
&& SkillScriptLanguage.fromPath(path) == SkillScriptLanguage.UNKNOWN) {
|
||||
report.add(new SkillValidationIssue("SCRIPT_LANGUAGE_UNRECOGNIZED",
|
||||
SkillValidationSeverity.WARNING, path, null, null,
|
||||
"Script language is not recognized from its extension.",
|
||||
"Use .py, .js, or .sh for first-class editing and syntax highlighting."));
|
||||
}
|
||||
if (isBlank(resource.getMediaType())) {
|
||||
report.add(error("MEDIA_TYPE_REQUIRED", path, "Skill resource media type is required.", null));
|
||||
}
|
||||
if (resource.getSize() < 0) {
|
||||
report.add(error("NEGATIVE_RESOURCE_SIZE", path,
|
||||
"Skill resource size cannot be negative.", null));
|
||||
}
|
||||
byte[] textBytes = null;
|
||||
if (resource.isText()) {
|
||||
try {
|
||||
textBytes = SkillUtf8.encode(resource.getTextContent());
|
||||
} catch (CharacterCodingException e) {
|
||||
report.add(error("INVALID_UTF8", path,
|
||||
"Text Skill resource must be losslessly encodable as UTF-8.",
|
||||
"Remove malformed UTF-16 surrogate code units."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isBlank(resource.getContentHash()) || !SHA_256.matcher(resource.getContentHash()).matches()) {
|
||||
report.add(error("INVALID_RESOURCE_HASH", path,
|
||||
"Skill resource SHA-256 is required and must be lowercase hexadecimal.", null));
|
||||
return;
|
||||
}
|
||||
if (resource.isText()) {
|
||||
if (resource.getSize() > limits.getMaxTextFileBytes()) {
|
||||
report.add(error("TEXT_FILE_SIZE_LIMIT", path,
|
||||
"Text Skill resource exceeds the configured size limit.", null));
|
||||
}
|
||||
if (resource.getContentRef() != null) {
|
||||
report.add(error("AMBIGUOUS_RESOURCE_CONTENT", path,
|
||||
"Text resources cannot also contain a binary content reference.", null));
|
||||
}
|
||||
if (resource.getSize() != textBytes.length) {
|
||||
report.add(error("RESOURCE_SIZE_MISMATCH", path,
|
||||
"Skill resource size does not match UTF-8 content.", null));
|
||||
}
|
||||
if (!resource.getContentHash().equals(SkillHashes.sha256Hex(textBytes))) {
|
||||
report.add(error("RESOURCE_HASH_MISMATCH", path,
|
||||
"Skill resource hash does not match text content.", null));
|
||||
}
|
||||
} else {
|
||||
if (resource.getSize() > limits.getMaxBinaryFileBytes()) {
|
||||
report.add(error("BINARY_FILE_SIZE_LIMIT", path,
|
||||
"Binary Skill resource exceeds the configured size limit.", null));
|
||||
}
|
||||
if (isBlank(resource.getContentRef())) {
|
||||
report.add(error("CONTENT_REF_REQUIRED", path,
|
||||
"Binary Skill resource content reference is required.", null));
|
||||
} else if (!resource.getContentRef().equals("sha256:" + resource.getContentHash())) {
|
||||
report.add(error("CONTENT_REF_MISMATCH", path,
|
||||
"Binary Skill resource content reference must match its SHA-256.", null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateAggregateLimits(Skill skill, List<SkillResource> resources,
|
||||
SkillValidationReport report, SkillPackageLimits limits) {
|
||||
if (!isBlank(skill.getName())) {
|
||||
String skillPath = skill.getName() + "/" + SkillPaths.SKILL_FILE;
|
||||
if (skillPath.length() > limits.getMaxPathLength()) {
|
||||
report.add(error("PATH_LENGTH_LIMIT", skillPath,
|
||||
"Skill entry path exceeds the configured length limit.", null));
|
||||
}
|
||||
if (SkillPaths.depth(skillPath) > limits.getMaxPathDepth()) {
|
||||
report.add(error("PATH_DEPTH_LIMIT", skillPath,
|
||||
"Skill entry path exceeds the configured depth limit.", null));
|
||||
}
|
||||
}
|
||||
if (resources.size() + 1 > limits.getMaxEntryCount()) {
|
||||
report.add(error("ENTRY_COUNT_LIMIT", null,
|
||||
"Skill contains too many files.", null));
|
||||
}
|
||||
long total = 0;
|
||||
if (skill.getSkillContent() != null) {
|
||||
try {
|
||||
total = SkillUtf8.byteLength(skill.getSkillContent());
|
||||
} catch (CharacterCodingException ignored) {
|
||||
// parseDocument 已返回可定位的 INVALID_UTF8;非法文本不参与后续大小计算。
|
||||
}
|
||||
}
|
||||
if (total > limits.getMaxTextFileBytes()) {
|
||||
report.add(error("TEXT_FILE_SIZE_LIMIT", SkillPaths.SKILL_FILE,
|
||||
"SKILL.md exceeds the configured size limit.", null));
|
||||
}
|
||||
for (SkillResource resource : resources) {
|
||||
if (resource == null || resource.getSize() < 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
total = Math.addExact(total, resource.getSize());
|
||||
} catch (ArithmeticException e) {
|
||||
total = Long.MAX_VALUE;
|
||||
}
|
||||
if (total > limits.getMaxTotalUncompressedBytes()) {
|
||||
report.add(error("TOTAL_SIZE_LIMIT", resource.getPath(),
|
||||
"Skill resources exceed the configured total size limit.", null));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找当前文件路径与已索引文件之间的祖先或后代冲突。
|
||||
*
|
||||
* @param pathKey 当前路径冲突键
|
||||
* @param filePaths 已索引的文件路径
|
||||
* @param descendantPaths 已索引路径对应的首个后代文件
|
||||
* @return 冲突文件路径,不存在时返回 null
|
||||
*/
|
||||
private static String findHierarchyConflict(String pathKey, Map<String, String> filePaths,
|
||||
Map<String, String> descendantPaths) {
|
||||
String descendant = descendantPaths.get(pathKey);
|
||||
if (descendant != null) {
|
||||
return descendant;
|
||||
}
|
||||
int separator = pathKey.indexOf('/');
|
||||
while (separator >= 0) {
|
||||
String ancestor = filePaths.get(pathKey.substring(0, separator));
|
||||
if (ancestor != null) {
|
||||
return ancestor;
|
||||
}
|
||||
separator = pathKey.indexOf('/', separator + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为文件路径的每个祖先建立后代索引,以常量时间识别反向插入冲突。
|
||||
*
|
||||
* @param pathKey 文件路径冲突键
|
||||
* @param path 原始文件路径
|
||||
* @param descendantPaths 后代索引
|
||||
*/
|
||||
private static void indexDescendantPath(String pathKey, String path,
|
||||
Map<String, String> descendantPaths) {
|
||||
int separator = pathKey.indexOf('/');
|
||||
while (separator >= 0) {
|
||||
descendantPaths.putIfAbsent(pathKey.substring(0, separator), path);
|
||||
separator = pathKey.indexOf('/', separator + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static SkillValidationIssue error(String code, String path, String message, String suggestion) {
|
||||
return new SkillValidationIssue(code, SkillValidationSeverity.ERROR, path, null, null,
|
||||
message, suggestion);
|
||||
}
|
||||
|
||||
private static SkillValidationIssue errorAt(String code, String path, SkillSourceLocation location,
|
||||
String message, String suggestion) {
|
||||
return new SkillValidationIssue(code, SkillValidationSeverity.ERROR, path,
|
||||
line(location), column(location), message, suggestion);
|
||||
}
|
||||
|
||||
private static SkillSourceLocation sourceLocation(SkillDocument document, String key) {
|
||||
return document == null ? null : document.getFrontmatterLocation(key);
|
||||
}
|
||||
|
||||
private static Integer line(SkillSourceLocation location) {
|
||||
return location == null ? null : location.getLine();
|
||||
}
|
||||
|
||||
private static Integer column(SkillSourceLocation location) {
|
||||
return location == null ? null : location.getColumn();
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SkillMetadata 防御性复制与键约束测试。
|
||||
*/
|
||||
public class SkillMetadataTest {
|
||||
|
||||
/**
|
||||
* 嵌套 Map/List 在读写边界上执行防御性复制。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void deeplyCopiesNestedValues() {
|
||||
LinkedHashMap<String, Object> nested = new LinkedHashMap<>();
|
||||
nested.put("items", new java.util.ArrayList<>(List.of("a")));
|
||||
SkillMetadata metadata = new SkillMetadata(Map.of("nested", nested));
|
||||
|
||||
Map<String, Object> read = metadata.getValues();
|
||||
((Map<String, Object>) read.get("nested")).put("changed", true);
|
||||
|
||||
Assert.assertNull(((Map<?, ?>) metadata.get("nested")).get("changed"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 空白键写入会显式失败。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectBlankKey() {
|
||||
new SkillMetadata().put(" ", "value");
|
||||
}
|
||||
|
||||
/**
|
||||
* 嵌套非字符串键会显式失败且不会静默转换。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
public void rejectNestedNonStringKey() {
|
||||
Map nested = new LinkedHashMap();
|
||||
nested.put(1, "number");
|
||||
new SkillMetadata(Map.of("nested", nested));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* SkillPackageLimits 硬上限测试。
|
||||
*/
|
||||
public class SkillPackageLimitsTest {
|
||||
|
||||
/**
|
||||
* 调用方不能关闭或突破底层 entry 硬上限。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectEntryCountAboveHardLimit() {
|
||||
SkillPackageLimits.builder().maxEntryCount(10_001).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 单文件限制不能大于总未压缩限制。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectSingleFileLimitAboveTotalLimit() {
|
||||
SkillPackageLimits.builder()
|
||||
.maxTextFileBytes(200)
|
||||
.maxBinaryFileBytes(200)
|
||||
.maxTotalUncompressedBytes(100)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.easyagents.skill.repository.memory;
|
||||
import com.easyagents.skill.model.Skill;
|
||||
import com.easyagents.skill.model.SkillDescriptor;
|
||||
import com.easyagents.skill.model.SkillReference;
|
||||
import com.easyagents.skill.util.SkillResources;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
@@ -66,6 +67,40 @@ public class InMemorySkillRepositoryTest {
|
||||
Assert.assertEquals("# A", reloaded.getReferences().get(0).getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* 仓储复制旧资源对象时应先迁移正式资源,不能因空 canonical 列表丢失 reference。
|
||||
*/
|
||||
@Test
|
||||
public void repositoryCopyMigratesLegacyOnlyResources() {
|
||||
InMemorySkillRepository repository = new InMemorySkillRepository();
|
||||
Skill legacy = skill();
|
||||
Assert.assertFalse(legacy.isResourcesInitialized());
|
||||
|
||||
repository.save(legacy);
|
||||
Skill loaded = repository.get("skill-a").orElseThrow();
|
||||
|
||||
Assert.assertTrue(loaded.isResourcesInitialized());
|
||||
Assert.assertEquals(1, SkillResources.canonicalResources(loaded).size());
|
||||
Assert.assertEquals("references/a.md", loaded.getResources().get(0).getPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* 显式清空正式资源列表后,仓储往返不得从旧兼容视图恢复已删除资源。
|
||||
*/
|
||||
@Test
|
||||
public void repositoryCopyPreservesExplicitlyEmptyCanonicalResources() {
|
||||
InMemorySkillRepository repository = new InMemorySkillRepository();
|
||||
Skill skill = skill();
|
||||
Assert.assertEquals(1, skill.getResources().size());
|
||||
skill.getResources().clear();
|
||||
|
||||
repository.save(skill);
|
||||
Skill loaded = repository.get("skill-a").orElseThrow();
|
||||
|
||||
Assert.assertTrue(loaded.isResourcesInitialized());
|
||||
Assert.assertTrue(SkillResources.canonicalResources(loaded).isEmpty());
|
||||
}
|
||||
|
||||
private static Skill skill() {
|
||||
Skill skill = new Skill();
|
||||
skill.setId("skill-a");
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package com.easyagents.skill.store.file;
|
||||
|
||||
import com.easyagents.skill.codec.ZipSkillPackageCodec;
|
||||
import com.easyagents.skill.exception.SkillException;
|
||||
import com.easyagents.skill.store.SkillContentStage;
|
||||
import com.easyagents.skill.store.SkillContentStore;
|
||||
import com.easyagents.skill.store.memory.InMemorySkillContentStore;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* {@link TemporaryFileSkillContentStore} 流式存储与生命周期测试。
|
||||
*/
|
||||
public class TemporaryFileSkillContentStoreTest {
|
||||
|
||||
/**
|
||||
* 暂存、提交、回滚、去重与引用计数均由磁盘文件驱动。
|
||||
*
|
||||
* @throws Exception 文件操作失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void stageCommitRollbackAndReferenceCountingAreFileBacked() throws Exception {
|
||||
Path parent = Files.createTempDirectory("skill-content-store-test-");
|
||||
TemporaryFileSkillContentStore store = new TemporaryFileSkillContentStore(parent);
|
||||
Path storageDirectory = store.storageDirectory();
|
||||
byte[] content = "file-backed-content".getBytes(StandardCharsets.UTF_8);
|
||||
try {
|
||||
SkillContentStage rolledBack = store.stage(
|
||||
new ByteArrayInputStream(content), content.length);
|
||||
Assert.assertFalse(store.exists(rolledBack.getContentRef()));
|
||||
Assert.assertEquals(1, countRegularFiles(storageDirectory.resolve("staged")));
|
||||
|
||||
store.rollback(rolledBack);
|
||||
Assert.assertEquals(0, countRegularFiles(storageDirectory.resolve("staged")));
|
||||
|
||||
SkillContentStage firstStage = store.stage(
|
||||
new ByteArrayInputStream(content), content.length);
|
||||
SkillContentStage secondStage = store.stage(
|
||||
new ByteArrayInputStream(content), content.length);
|
||||
String firstRef = store.commit(firstStage);
|
||||
String secondRef = store.commit(secondStage);
|
||||
|
||||
Assert.assertEquals(firstRef, secondRef);
|
||||
Assert.assertTrue(store.exists(firstRef));
|
||||
Assert.assertEquals(0, countRegularFiles(storageDirectory.resolve("staged")));
|
||||
Assert.assertEquals(1, countRegularFiles(storageDirectory.resolve("content")));
|
||||
|
||||
store.release(firstRef);
|
||||
Assert.assertTrue(store.exists(secondRef));
|
||||
store.retain(secondRef);
|
||||
store.release(secondRef);
|
||||
Assert.assertTrue(store.exists(secondRef));
|
||||
store.release(secondRef);
|
||||
|
||||
Assert.assertFalse(store.exists(secondRef));
|
||||
Assert.assertEquals(0, countRegularFiles(storageDirectory.resolve("content")));
|
||||
} finally {
|
||||
store.close();
|
||||
Assert.assertFalse(Files.exists(storageDirectory));
|
||||
Assert.assertTrue(Files.exists(parent));
|
||||
Files.deleteIfExists(parent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 已打开的流持有读取租约,最后一个正式引用释放后等待流关闭再删除文件。
|
||||
*
|
||||
* @throws Exception 文件读取失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void openStreamDefersPhysicalDeletionUntilReaderCloses() throws Exception {
|
||||
Path parent = Files.createTempDirectory("skill-content-reader-test-");
|
||||
try (TemporaryFileSkillContentStore store = new TemporaryFileSkillContentStore(parent)) {
|
||||
Path contentDirectory = store.storageDirectory().resolve("content");
|
||||
byte[] content = "leased-content".getBytes(StandardCharsets.UTF_8);
|
||||
String contentRef = store.put(content);
|
||||
InputStream input = store.open(contentRef);
|
||||
|
||||
store.release(contentRef);
|
||||
Assert.assertFalse(store.exists(contentRef));
|
||||
Assert.assertEquals(1, countRegularFiles(contentDirectory));
|
||||
Assert.assertArrayEquals(content, input.readAllBytes());
|
||||
|
||||
input.close();
|
||||
Assert.assertEquals(0, countRegularFiles(contentDirectory));
|
||||
|
||||
String pendingRef = store.put(content);
|
||||
InputStream pendingReader = store.open(pendingRef);
|
||||
store.release(pendingRef);
|
||||
String revivedRef = store.put(content);
|
||||
Assert.assertEquals(pendingRef, revivedRef);
|
||||
|
||||
pendingReader.close();
|
||||
Assert.assertTrue(store.exists(revivedRef));
|
||||
Assert.assertEquals(1, countRegularFiles(contentDirectory));
|
||||
store.release(revivedRef);
|
||||
Assert.assertEquals(0, countRegularFiles(contentDirectory));
|
||||
} finally {
|
||||
Files.deleteIfExists(parent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 大内容按流写入并受字节上限约束,存储实现及内部状态不持有 byte[] 字段。
|
||||
*
|
||||
* @throws Exception 文件操作失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void largeContentIsStreamedWithoutByteArrayState() throws Exception {
|
||||
assertNoByteArrayFields(TemporaryFileSkillContentStore.class);
|
||||
long contentSize = 2L * 1024 * 1024 + 37;
|
||||
Path parent = Files.createTempDirectory("skill-content-stream-test-");
|
||||
try (TemporaryFileSkillContentStore store = new TemporaryFileSkillContentStore(parent)) {
|
||||
SkillContentStage stage = store.stage(new RepeatingInputStream(contentSize), contentSize);
|
||||
Assert.assertEquals(contentSize, stage.getSize());
|
||||
String contentRef = store.commit(stage);
|
||||
|
||||
MessageDigest digest = SkillHashes.newSha256Digest();
|
||||
long copied = 0;
|
||||
try (InputStream input = store.open(contentRef)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int length;
|
||||
while ((length = input.read(buffer)) >= 0) {
|
||||
if (length == 0) {
|
||||
continue;
|
||||
}
|
||||
digest.update(buffer, 0, length);
|
||||
copied += length;
|
||||
}
|
||||
}
|
||||
Assert.assertEquals(contentSize, copied);
|
||||
Assert.assertEquals(stage.getContentHash(), SkillHashes.toHex(digest.digest()));
|
||||
|
||||
assertLimitExceeded(store, contentSize - 1);
|
||||
Assert.assertEquals(0,
|
||||
countRegularFiles(store.storageDirectory().resolve("staged")));
|
||||
store.release(contentRef);
|
||||
} finally {
|
||||
Files.deleteIfExists(parent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无参 ZIP Codec 使用自有临时文件 Store,注入 Store 仍由调用方管理。
|
||||
*
|
||||
* @throws Exception 反射或文件操作失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void defaultCodecOwnsTemporaryStoreWhileInjectedStoreRemainsExternal()
|
||||
throws Exception {
|
||||
Field contentStoreField = ZipSkillPackageCodec.class.getDeclaredField("contentStore");
|
||||
contentStoreField.setAccessible(true);
|
||||
|
||||
ZipSkillPackageCodec defaultCodec = new ZipSkillPackageCodec();
|
||||
SkillContentStore defaultStore = (SkillContentStore) contentStoreField.get(defaultCodec);
|
||||
Assert.assertTrue(defaultStore instanceof TemporaryFileSkillContentStore);
|
||||
Assert.assertFalse(defaultStore instanceof InMemorySkillContentStore);
|
||||
Path defaultDirectory = ((TemporaryFileSkillContentStore) defaultStore).storageDirectory();
|
||||
defaultCodec.close();
|
||||
Assert.assertFalse(Files.exists(defaultDirectory));
|
||||
|
||||
Path parent = Files.createTempDirectory("skill-codec-external-store-test-");
|
||||
TemporaryFileSkillContentStore injectedStore = new TemporaryFileSkillContentStore(parent);
|
||||
try {
|
||||
ZipSkillPackageCodec injectedCodec = new ZipSkillPackageCodec(injectedStore);
|
||||
injectedCodec.close();
|
||||
String contentRef = injectedStore.put("still-open".getBytes(StandardCharsets.UTF_8));
|
||||
Assert.assertTrue(injectedStore.exists(contentRef));
|
||||
injectedStore.release(contentRef);
|
||||
} finally {
|
||||
injectedStore.close();
|
||||
Files.deleteIfExists(parent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显式关闭后清理暂存文件并拒绝新的写入。
|
||||
*
|
||||
* @throws Exception 文件操作失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void closeCleansStagesAndRejectsFurtherOperations() throws Exception {
|
||||
Path parent = Files.createTempDirectory("skill-content-close-test-");
|
||||
TemporaryFileSkillContentStore store = new TemporaryFileSkillContentStore(parent);
|
||||
Path storageDirectory = store.storageDirectory();
|
||||
store.stage(new ByteArrayInputStream(new byte[]{1, 2, 3}), 3);
|
||||
|
||||
store.close();
|
||||
store.close();
|
||||
|
||||
Assert.assertFalse(Files.exists(storageDirectory));
|
||||
try {
|
||||
store.stage(new ByteArrayInputStream(new byte[0]), 0);
|
||||
Assert.fail("Closed store should reject new stages.");
|
||||
} catch (SkillException expected) {
|
||||
Assert.assertTrue(expected.getMessage().contains("closed"));
|
||||
} finally {
|
||||
Files.deleteIfExists(parent);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertLimitExceeded(TemporaryFileSkillContentStore store, long maxBytes) {
|
||||
try {
|
||||
store.stage(new RepeatingInputStream(maxBytes + 1), maxBytes);
|
||||
Assert.fail("Content above the limit should be rejected.");
|
||||
} catch (SkillException expected) {
|
||||
Assert.assertTrue(expected.getMessage().contains("exceeds"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertNoByteArrayFields(Class<?> rootType) {
|
||||
List<Class<?>> types = new ArrayList<>();
|
||||
types.add(rootType);
|
||||
for (int index = 0; index < types.size(); index++) {
|
||||
Class<?> type = types.get(index);
|
||||
for (Field field : type.getDeclaredFields()) {
|
||||
Assert.assertNotEquals(type.getName() + " must not retain byte[] field "
|
||||
+ field.getName(), byte[].class, field.getType());
|
||||
}
|
||||
for (Class<?> nestedType : type.getDeclaredClasses()) {
|
||||
types.add(nestedType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long countRegularFiles(Path directory) throws IOException {
|
||||
try (Stream<Path> paths = Files.list(directory)) {
|
||||
return paths.filter(Files::isRegularFile).count();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RepeatingInputStream extends InputStream {
|
||||
|
||||
private final long length;
|
||||
private long position;
|
||||
|
||||
private RepeatingInputStream(long length) {
|
||||
this.length = length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() {
|
||||
if (position >= length) {
|
||||
return -1;
|
||||
}
|
||||
return (int) (position++ & 0xFF);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] bytes, int offset, int requestedLength) {
|
||||
if (position >= length) {
|
||||
return -1;
|
||||
}
|
||||
int actualLength = (int) Math.min(requestedLength, length - position);
|
||||
for (int index = 0; index < actualLength; index++) {
|
||||
bytes[offset + index] = (byte) ((position + index) & 0xFF);
|
||||
}
|
||||
position += actualLength;
|
||||
return actualLength;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.easyagents.skill.store.memory;
|
||||
|
||||
import com.easyagents.skill.store.SkillContentStage;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
@@ -43,4 +44,40 @@ public class InMemorySkillContentStoreTest {
|
||||
Assert.assertArrayEquals(bytes, inputStream.readAllBytes());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂存内容在 commit 前不可见,rollback 后不残留。
|
||||
*/
|
||||
@Test
|
||||
public void stageCommitAndRollback() {
|
||||
InMemorySkillContentStore store = new InMemorySkillContentStore();
|
||||
byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
SkillContentStage rolledBack = store.stage(new java.io.ByteArrayInputStream(bytes), bytes.length);
|
||||
Assert.assertFalse(store.exists(rolledBack.getContentRef()));
|
||||
store.rollback(rolledBack);
|
||||
Assert.assertFalse(store.exists(rolledBack.getContentRef()));
|
||||
|
||||
SkillContentStage committed = store.stage(new java.io.ByteArrayInputStream(bytes), bytes.length);
|
||||
String contentRef = store.commit(committed);
|
||||
Assert.assertTrue(store.exists(contentRef));
|
||||
Assert.assertArrayEquals(bytes, store.readAllBytes(contentRef));
|
||||
}
|
||||
|
||||
/**
|
||||
* 相同内容按引用计数释放,归零后删除。
|
||||
*/
|
||||
@Test
|
||||
public void releaseDeletesOnlyAfterReferenceCountReachesZero() {
|
||||
InMemorySkillContentStore store = new InMemorySkillContentStore();
|
||||
byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
String first = store.put(bytes);
|
||||
String second = store.put(bytes);
|
||||
store.release(first);
|
||||
|
||||
Assert.assertTrue(store.exists(second));
|
||||
store.release(second);
|
||||
Assert.assertFalse(store.exists(second));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.easyagents.skill.util;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
import com.easyagents.skill.model.SkillDocument;
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SkillFrontmatter 安全解析与 round-trip 测试。
|
||||
*/
|
||||
public class SkillFrontmatterTest {
|
||||
|
||||
/**
|
||||
* 嵌套结构、未知字段和插入顺序在序列化后保持语义。
|
||||
*/
|
||||
@Test
|
||||
public void nestedMetadataRoundTrips() {
|
||||
String content = "---\nname: nested-skill\ndescription: Handles nested values\n"
|
||||
+ "enabled: true\ncount: 3\nnullable: null\n"
|
||||
+ "metadata:\n owner: team-a\n tags:\n - one\n - two\n"
|
||||
+ "---\n# Nested\n";
|
||||
|
||||
SkillDocument first = SkillFrontmatter.parseDocument(content);
|
||||
String serialized = SkillFrontmatter.serialize(first.getFrontmatter().getValues(), first.getMarkdownBody());
|
||||
SkillDocument second = SkillFrontmatter.parseDocument(serialized);
|
||||
|
||||
Assert.assertEquals(first.getFrontmatter().getValues(), second.getFrontmatter().getValues());
|
||||
Assert.assertEquals(first.getMarkdownBody(), second.getMarkdownBody());
|
||||
Assert.assertEquals(List.of("name", "description", "enabled", "count", "nullable", "metadata"),
|
||||
second.getFrontmatter().getValues().keySet().stream().toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 未编辑文档原样返回,结构化编辑后重新生成合法文档。
|
||||
*/
|
||||
@Test
|
||||
public void preserveRawUntilEdited() {
|
||||
String content = "---\r\nname: raw-skill\r\ndescription: Keep source\r\n---\r\n# Raw\r\n";
|
||||
SkillDocument document = SkillFrontmatter.parseDocument(content);
|
||||
|
||||
Assert.assertEquals(content, document.render());
|
||||
|
||||
document.putFrontmatter("metadata", Map.of("level", 2));
|
||||
Assert.assertNotEquals(content, document.render());
|
||||
SkillDocument parsed = SkillFrontmatter.parseDocument(document.render());
|
||||
Assert.assertEquals("# Raw\r\n", parsed.getMarkdownBody());
|
||||
Assert.assertTrue(parsed.getFrontmatter().get("metadata") instanceof Map);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层字段保留完整 SKILL.md 中的一基源码位置,结构化修改后移除失效位置。
|
||||
*/
|
||||
@Test
|
||||
public void retainTopLevelSourceLocationsUntilFieldEdited() {
|
||||
String content = "---\nname: located-skill\ndescription: Keep positions\n"
|
||||
+ "metadata:\n owner: team-a\n---\n# Located\n";
|
||||
|
||||
SkillDocument document = SkillFrontmatter.parseDocument(content);
|
||||
|
||||
Assert.assertEquals(2, document.getFrontmatterLocation("name").getLine());
|
||||
Assert.assertEquals(1, document.getFrontmatterLocation("name").getColumn());
|
||||
Assert.assertEquals(3, document.getFrontmatterLocation("description").getLine());
|
||||
Assert.assertEquals(1, document.getFrontmatterLocation("description").getColumn());
|
||||
Assert.assertEquals(4, document.getFrontmatterLocation("metadata").getLine());
|
||||
Assert.assertEquals(1, document.getFrontmatterLocation("metadata").getColumn());
|
||||
Assert.assertTrue(document.getDiagnostics().isEmpty());
|
||||
|
||||
document.putFrontmatter("description", "Updated description");
|
||||
|
||||
Assert.assertNull(document.getFrontmatterLocation("description"));
|
||||
Assert.assertNotNull(document.getFrontmatterLocation("name"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复 key 被安全构造器拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void rejectDuplicateKeys() {
|
||||
assertCode("INVALID_FRONTMATTER_YAML",
|
||||
"---\nname: duplicate-skill\nname: other\ndescription: Duplicate\n---\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义危险 tag 被拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void rejectCustomTag() {
|
||||
assertCode("INVALID_FRONTMATTER_YAML",
|
||||
"---\nname: tagged-skill\ndescription: Tagged\nvalue: !java/object test\n---\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* yaml.org 前缀下不在白名单中的 core tag 也被拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void rejectDisallowedYamlOrgTag() {
|
||||
assertCode("UNSUPPORTED_FRONTMATTER_TYPE",
|
||||
"---\nname: binary-skill\ndescription: Binary tag\nvalue: !!binary YWJj\n---\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 未知 yaml.org 全局 tag 被 TagInspector 拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void rejectUnknownYamlOrgTag() {
|
||||
assertCode("INVALID_FRONTMATTER_YAML",
|
||||
"---\nname: unknown-skill\ndescription: Unknown tag\nvalue: !!unknown test\n---\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 非 Map 顶层 YAML 被拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void rejectNonMapRoot() {
|
||||
assertCode("FRONTMATTER_ROOT_NOT_MAP", "---\n- one\n- two\n---\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 嵌套 Map 的非字符串 key 被拒绝且不做静默字符串化。
|
||||
*/
|
||||
@Test
|
||||
public void rejectNestedNonStringKey() {
|
||||
assertCode("INVALID_FRONTMATTER_KEY",
|
||||
"---\nname: key-skill\ndescription: Invalid key\nmetadata:\n 1: value\n---\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 可配置 frontmatter 字节上限生效。
|
||||
*/
|
||||
@Test
|
||||
public void rejectOversizedFrontmatter() {
|
||||
SkillPackageLimits limits = SkillPackageLimits.builder().maxFrontmatterBytes(64).build();
|
||||
try {
|
||||
SkillFrontmatter.parseDocument("---\nname: large-skill\ndescription: " + "x".repeat(80)
|
||||
+ "\n---\n", limits);
|
||||
Assert.fail("Oversized frontmatter should fail.");
|
||||
} catch (SkillValidationException e) {
|
||||
Assert.assertEquals("FRONTMATTER_TOO_LARGE", e.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* YAML collection alias 上限生效。
|
||||
*/
|
||||
@Test
|
||||
public void rejectAliasLimit() {
|
||||
String content = "---\nname: alias-skill\ndescription: Alias limit\n"
|
||||
+ "base: &base\n - one\naliases:\n - *base\n - *base\n---\n";
|
||||
assertCode("INVALID_FRONTMATTER_YAML", content,
|
||||
SkillPackageLimits.builder().maxYamlAliases(1).build());
|
||||
}
|
||||
|
||||
/**
|
||||
* YAML 嵌套深度上限生效。
|
||||
*/
|
||||
@Test
|
||||
public void rejectDepthLimit() {
|
||||
String content = "---\nname: depth-skill\ndescription: Depth limit\n"
|
||||
+ "metadata:\n one:\n two:\n three: value\n---\n";
|
||||
assertCode("INVALID_FRONTMATTER_YAML", content,
|
||||
SkillPackageLimits.builder().maxYamlDepth(3).build());
|
||||
}
|
||||
|
||||
/**
|
||||
* YAML code point 上限生效。
|
||||
*/
|
||||
@Test
|
||||
public void rejectCodePointLimit() {
|
||||
String content = "---\nname: codepoint-skill\ndescription: " + "x".repeat(100) + "\n---\n";
|
||||
assertCode("INVALID_FRONTMATTER_YAML", content,
|
||||
SkillPackageLimits.builder().maxYamlCodePoints(64).build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 未闭合 frontmatter 返回稳定错误码。
|
||||
*/
|
||||
@Test
|
||||
public void rejectUnclosedFrontmatter() {
|
||||
assertCode("FRONTMATTER_NOT_CLOSED",
|
||||
"---\nname: unclosed-skill\ndescription: Unclosed\n");
|
||||
}
|
||||
|
||||
private static void assertCode(String code, String content) {
|
||||
assertCode(code, content, SkillPackageLimits.defaults());
|
||||
}
|
||||
|
||||
private static void assertCode(String code, String content, SkillPackageLimits limits) {
|
||||
try {
|
||||
SkillFrontmatter.parseDocument(content, limits);
|
||||
Assert.fail("Invalid frontmatter should fail.");
|
||||
} catch (SkillValidationException e) {
|
||||
Assert.assertEquals(code, e.getCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.easyagents.skill.util;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* SkillPaths 安全规范化测试。
|
||||
*/
|
||||
public class SkillPathsTest {
|
||||
|
||||
/**
|
||||
* 点路径段不能被静默剥离。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectLeadingDotSegment() {
|
||||
SkillPaths.normalize("./references/a.md");
|
||||
}
|
||||
|
||||
/**
|
||||
* 双向文本控制字符被拒绝。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectBidiFormatCharacter() {
|
||||
SkillPaths.normalize("references/a\u202Etxt.md");
|
||||
}
|
||||
|
||||
/**
|
||||
* Unicode 路径统一为 NFC。
|
||||
*/
|
||||
@Test
|
||||
public void normalizeUnicodeToNfc() {
|
||||
Assert.assertEquals("references/\u00e9.md", SkillPaths.normalize("references/e\u0301.md"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.easyagents.skill.util;
|
||||
|
||||
import com.easyagents.skill.model.SkillResourceKind;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* {@link SkillResources} 规范存储表示测试。
|
||||
*/
|
||||
public class SkillResourcesTest {
|
||||
|
||||
/**
|
||||
* HTML 的短扩展名与长扩展名都应作为严格 UTF-8 文本处理。
|
||||
*/
|
||||
@Test
|
||||
public void recognizeBothHtmlExtensionsAsText() {
|
||||
Assert.assertTrue(SkillResources.isText(
|
||||
"references/page.htm", SkillResourceKind.REFERENCE, "application/octet-stream"));
|
||||
Assert.assertTrue(SkillResources.isText(
|
||||
"references/page.html", SkillResourceKind.REFERENCE, "application/octet-stream"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.easyagents.skill.validation;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* {@link SkillValidationReport} 命令式异常契约测试。
|
||||
*/
|
||||
public class SkillValidationReportTest {
|
||||
|
||||
/**
|
||||
* 验证异常 getter 暴露首个错误定位,同时保留包含全部问题的报告。
|
||||
*/
|
||||
@Test
|
||||
public void throwIfInvalidKeepsFirstErrorLocationAndCompleteReport() {
|
||||
SkillValidationReport report = new SkillValidationReport()
|
||||
.add(new SkillValidationIssue("DRAFT_WARNING", SkillValidationSeverity.WARNING,
|
||||
"draft/SKILL.md", 1, 1, "草稿警告", null))
|
||||
.add(new SkillValidationIssue("FIRST_ERROR", SkillValidationSeverity.ERROR,
|
||||
"demo/SKILL.md", 4, 7, "首个错误", "修复首个错误"))
|
||||
.add(new SkillValidationIssue("SECOND_ERROR", SkillValidationSeverity.ERROR,
|
||||
"demo/scripts/run.sh", 2, 3, "第二个错误", null));
|
||||
|
||||
try {
|
||||
report.throwIfInvalid();
|
||||
Assert.fail("应抛出 SkillValidationException");
|
||||
} catch (SkillValidationException exception) {
|
||||
Assert.assertEquals("FIRST_ERROR", exception.getCode());
|
||||
Assert.assertEquals("demo/SKILL.md", exception.getPath());
|
||||
Assert.assertEquals(Integer.valueOf(4), exception.getLine());
|
||||
Assert.assertEquals(Integer.valueOf(7), exception.getColumn());
|
||||
Assert.assertSame(report, exception.getReport());
|
||||
Assert.assertEquals(3, exception.getReport().getIssues().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,15 @@ package com.easyagents.skill.validation.defaults;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
import com.easyagents.skill.factory.SkillFactory;
|
||||
import com.easyagents.skill.model.*;
|
||||
import com.easyagents.skill.model.Skill;
|
||||
import com.easyagents.skill.model.SkillResource;
|
||||
import com.easyagents.skill.model.SkillResourceKind;
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
import com.easyagents.skill.validation.SkillValidationReport;
|
||||
import com.easyagents.skill.validation.SkillValidationMode;
|
||||
import com.easyagents.skill.validation.SkillValidationSeverity;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -16,178 +23,385 @@ public class DefaultSkillValidatorTest {
|
||||
private final DefaultSkillValidator validator = new DefaultSkillValidator();
|
||||
|
||||
/**
|
||||
* 缺失 SKILL.md 内容时失败。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectMissingSkillContent() {
|
||||
Skill skill = validSkill();
|
||||
skill.setSkillContent(null);
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缺失名称时失败。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectMissingName() {
|
||||
Skill skill = validSkill();
|
||||
skill.setName("");
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缺失描述时失败。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectMissingDescription() {
|
||||
Skill skill = validSkill();
|
||||
skill.setDescription("");
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段名称必须与 SKILL.md frontmatter 一致。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectNameMismatchWithFrontmatter() {
|
||||
Skill skill = validSkill();
|
||||
skill.setName("Changed");
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据必须与 SKILL.md frontmatter 一致。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectMetadataMismatchWithFrontmatter() {
|
||||
Skill skill = validSkill();
|
||||
skill.getMetadata().put("extra", "x");
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝嵌套 frontmatter。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectNestedFrontmatter() {
|
||||
Skill skill = validSkill();
|
||||
skill.setSkillContent("---\nname: Skill A\ndescription: Desc A\nconfig:\n mode: strict\n---\n# Skill A\n");
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绝对路径失败。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectAbsolutePath() {
|
||||
Skill skill = validSkill();
|
||||
SkillReference reference = reference("/references/a.md", "# A");
|
||||
skill.getReferences().add(reference);
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* ../ 路径失败。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectParentPath() {
|
||||
Skill skill = validSkill();
|
||||
skill.getReferences().add(reference("references/../a.md", "# A"));
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复路径失败。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectDuplicatePath() {
|
||||
Skill skill = validSkill();
|
||||
skill.getReferences().add(reference("references/a.md", "# A"));
|
||||
skill.getReferences().add(reference("references/a.md", "# B"));
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 不因为文件大小较大而失败。
|
||||
*/
|
||||
@Test
|
||||
public void allowLargeRecordedSize() {
|
||||
Skill skill = validSkill();
|
||||
SkillReference reference = reference("references/a.md", "# A");
|
||||
reference.setSize(Long.MAX_VALUE);
|
||||
skill.getReferences().add(reference);
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 允许空内容文件,只校验 hash 和 size 记录。
|
||||
*/
|
||||
@Test
|
||||
public void allowEmptyReferenceAndScriptContent() {
|
||||
Skill skill = validSkill();
|
||||
skill.getReferences().add(reference("references/empty.md", ""));
|
||||
skill.getScripts().add(script("scripts/empty.sh", ""));
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验通过完整 Skill。
|
||||
* 完整标准 Skill 校验通过。
|
||||
*/
|
||||
@Test
|
||||
public void validateCompleteSkill() {
|
||||
Skill skill = validSkill();
|
||||
skill.getReferences().add(reference("references/a.md", "# A"));
|
||||
skill.getScripts().add(script("scripts/run.sh", "echo ok"));
|
||||
skill.getAssets().add(asset("assets/a.bin", "abc"));
|
||||
Skill skill = validSkill("skill-a");
|
||||
skill.setResources(java.util.List.of(
|
||||
textResource("references/a.md", SkillResourceKind.REFERENCE, "# A"),
|
||||
textResource("scripts/run.sh", SkillResourceKind.SCRIPT, "echo ok"),
|
||||
binaryResource("assets/a.bin", "abc")
|
||||
));
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
private static Skill validSkill() {
|
||||
return SkillFactory.create("skill-a", "---\nname: Skill A\ndescription: Desc A\n---\n# Skill A\n");
|
||||
/**
|
||||
* 嵌套 Map、List、布尔和数字 frontmatter 可校验。
|
||||
*/
|
||||
@Test
|
||||
public void allowNestedFrontmatter() {
|
||||
String content = "---\nname: nested-skill\ndescription: Handles nested metadata\n"
|
||||
+ "metadata:\n enabled: true\n retries: 3\n tags:\n - alpha\n - beta\n"
|
||||
+ "---\n# Nested\n";
|
||||
Skill skill = SkillFactory.create("repository-id", content);
|
||||
|
||||
validator.validate(skill);
|
||||
|
||||
Assert.assertTrue(skill.getMetadata().get("metadata") instanceof java.util.Map);
|
||||
}
|
||||
|
||||
private static SkillReference reference(String path, String content) {
|
||||
SkillReference reference = new SkillReference();
|
||||
reference.setPath(path);
|
||||
reference.setName("a.md");
|
||||
reference.setContent(content);
|
||||
reference.setContentHash(SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)));
|
||||
reference.setSize(content.getBytes(StandardCharsets.UTF_8).length);
|
||||
return reference;
|
||||
/**
|
||||
* 结构化文档重新应用到聚合时同步 name、description 和 metadata。
|
||||
*/
|
||||
@Test
|
||||
public void applyEditedDocumentToAggregate() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
com.easyagents.skill.model.SkillDocument document = skill.getDocument();
|
||||
document.putFrontmatter("description", "Updated description for the Skill");
|
||||
|
||||
skill.setDocument(document);
|
||||
|
||||
Assert.assertEquals("Updated description for the Skill", skill.getDescription());
|
||||
Assert.assertEquals("Updated description for the Skill", skill.getMetadata().get("description"));
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
private static SkillScript script(String path, String content) {
|
||||
SkillScript script = new SkillScript();
|
||||
script.setPath(path);
|
||||
script.setLanguage(SkillScriptLanguage.fromPath(path));
|
||||
script.setContent(content);
|
||||
script.setContentHash(SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)));
|
||||
script.setSize(content.getBytes(StandardCharsets.UTF_8).length);
|
||||
return script;
|
||||
/**
|
||||
* 历史下划线名称只产生 warning。
|
||||
*/
|
||||
@Test
|
||||
public void legacyUnderscoreNameProducesWarning() {
|
||||
Skill skill = validSkill("legacy_skill");
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertFalse(report.hasErrors());
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"NON_CANONICAL_NAME".equals(issue.getCode())
|
||||
&& issue.getSeverity() == SkillValidationSeverity.WARNING));
|
||||
}
|
||||
|
||||
private static SkillAsset asset(String path, String content) {
|
||||
/**
|
||||
* 正式标准模式拒绝仅为草稿导入兼容的下划线名称。
|
||||
*/
|
||||
@Test
|
||||
public void standardModeRejectsLegacyUnderscoreName() {
|
||||
Skill skill = validSkill("legacy_skill");
|
||||
|
||||
SkillValidationReport report = validator.validateReport(
|
||||
skill, SkillPackageLimits.defaults(), SkillValidationMode.STANDARD);
|
||||
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"NON_CANONICAL_NAME".equals(issue.getCode())
|
||||
&& issue.getSeverity() == SkillValidationSeverity.ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格工厂入口执行正式标准名称校验。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void strictFactoryRejectsLegacyUnderscoreName() {
|
||||
SkillFactory.createStrict("repository-id", skillMd("legacy_skill"));
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentScope 嵌套 metadata 保真并标记标准兼容 warning。
|
||||
*/
|
||||
@Test
|
||||
public void nestedMetadataProducesCompatibilityWarning() {
|
||||
Skill skill = SkillFactory.create("id", "---\nname: metadata-skill\n"
|
||||
+ "description: Nested metadata compatibility\nmetadata:\n provider:\n enabled: true\n"
|
||||
+ "---\n# Metadata\n");
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertFalse(report.hasErrors());
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"NON_STANDARD_METADATA".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准可选字符串字段拒绝空值或非字符串。
|
||||
*/
|
||||
@Test
|
||||
public void rejectInvalidOptionalStandardFields() {
|
||||
Skill skill = SkillFactory.create("id", "---\nname: optional-skill\n"
|
||||
+ "description: Invalid optional fields\nlicense: []\ncompatibility: ''\nallowed-tools:\n - Read\n"
|
||||
+ "---\n# Optional\n");
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"INVALID_LICENSE".equals(issue.getCode())
|
||||
&& Integer.valueOf(4).equals(issue.getLine())
|
||||
&& Integer.valueOf(1).equals(issue.getColumn())));
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"INVALID_COMPATIBILITY".equals(issue.getCode())
|
||||
&& Integer.valueOf(5).equals(issue.getLine())
|
||||
&& Integer.valueOf(1).equals(issue.getColumn())));
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"INVALID_ALLOWED_TOOLS".equals(issue.getCode())
|
||||
&& Integer.valueOf(6).equals(issue.getLine())
|
||||
&& Integer.valueOf(1).equals(issue.getColumn())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 非规范名称返回结构化错误。
|
||||
*/
|
||||
@Test
|
||||
public void rejectInvalidName() {
|
||||
Skill skill = validSkill("Invalid Name");
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertTrue(report.hasErrors());
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> "INVALID_NAME".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 名称与目录不一致时失败。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectRootNameMismatch() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
skill.setPackageRoot("other-skill");
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 大小写冲突路径会被拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void rejectCaseConflictingResourcePaths() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
skill.setResources(java.util.List.of(
|
||||
textResource("references/A.md", SkillResourceKind.REFERENCE, "A"),
|
||||
textResource("references/a.md", SkillResourceKind.REFERENCE, "B")
|
||||
));
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"RESOURCE_PATH_COLLISION".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件资源不能同时作为另一个资源的祖先路径。
|
||||
*/
|
||||
@Test
|
||||
public void rejectFileAndDescendantResourcePaths() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
skill.setResources(java.util.List.of(
|
||||
textResource("references/a.md", SkillResourceKind.REFERENCE, "descendant"),
|
||||
textResource("references", SkillResourceKind.OTHER, "file")
|
||||
));
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"RESOURCE_PATH_HIERARCHY_CONFLICT".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径穿越会被拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void rejectParentPath() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
skill.setResources(java.util.List.of(
|
||||
textResource("references/../a.md", SkillResourceKind.REFERENCE, "A")));
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"UNSAFE_RESOURCE_PATH".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 缺失资源路径必须形成结构化问题,并继续聚合其他资源错误。
|
||||
*/
|
||||
@Test
|
||||
public void aggregateMissingPathWithOtherResourceErrors() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
SkillResource missingPath = textResource(
|
||||
"references/missing.md", SkillResourceKind.REFERENCE, "missing");
|
||||
missingPath.setPath(null);
|
||||
skill.setResources(java.util.List.of(
|
||||
missingPath,
|
||||
textResource("references/../escape.md", SkillResourceKind.REFERENCE, "escape")));
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"RESOURCE_PATH_REQUIRED".equals(issue.getCode()) && issue.getPath() == null));
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"UNSAFE_RESOURCE_PATH".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本资源 hash 和 size 必须与 UTF-8 内容一致。
|
||||
*/
|
||||
@Test
|
||||
public void rejectTextHashAndSizeMismatch() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
SkillResource resource = textResource("references/a.md", SkillResourceKind.REFERENCE, "A");
|
||||
resource.setContentHash("0".repeat(64));
|
||||
resource.setSize(99);
|
||||
skill.setResources(java.util.List.of(resource));
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"RESOURCE_HASH_MISMATCH".equals(issue.getCode())));
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"RESOURCE_SIZE_MISMATCH".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* scripts 目录只接受严格 UTF-8 文本表示,二进制引用必须被拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void rejectBinaryScriptResource() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
SkillResource script = binaryResource("scripts/run.sh", "echo unsafe");
|
||||
script.setKind(SkillResourceKind.SCRIPT);
|
||||
skill.setResources(java.util.List.of(script));
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"SCRIPT_TEXT_REQUIRED".equals(issue.getCode())
|
||||
&& issue.getSeverity() == SkillValidationSeverity.ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源的文本或二进制表示必须与统一路径和媒体类型判定一致。
|
||||
*/
|
||||
@Test
|
||||
public void rejectNonCanonicalResourceContentMode() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
SkillResource textAsset = textResource("assets/readme.txt", SkillResourceKind.ASSET, "text asset");
|
||||
skill.setResources(java.util.List.of(textAsset));
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"RESOURCE_CONTENT_MODE_MISMATCH".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 空脚本允许保存在草稿中,但应返回可见 warning。
|
||||
*/
|
||||
@Test
|
||||
public void warnForEmptyScriptResource() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
skill.setResources(java.util.List.of(
|
||||
textResource("scripts/run.py", SkillResourceKind.SCRIPT, "")));
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertFalse(report.hasErrors());
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"EMPTY_SCRIPT".equals(issue.getCode())
|
||||
&& issue.getSeverity() == SkillValidationSeverity.WARNING));
|
||||
}
|
||||
|
||||
/**
|
||||
* 未识别脚本语言需要提示,但不能破坏外部标准 Skill 包的资源保真。
|
||||
*/
|
||||
@Test
|
||||
public void warnForUnrecognizedScriptLanguageExtension() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
skill.setResources(java.util.List.of(
|
||||
textResource("scripts/run.txt", SkillResourceKind.SCRIPT, "echo ok")));
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertFalse(report.hasErrors());
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"SCRIPT_LANGUAGE_UNRECOGNIZED".equals(issue.getCode())
|
||||
&& issue.getSeverity() == SkillValidationSeverity.WARNING));
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构化校验器执行资源单文件安全限额。
|
||||
*/
|
||||
@Test
|
||||
public void reportConfiguredResourceLimit() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
skill.setResources(java.util.List.of(
|
||||
textResource("references/a.md", SkillResourceKind.REFERENCE, "x".repeat(300))));
|
||||
DefaultSkillValidator limitedValidator = new DefaultSkillValidator(
|
||||
SkillPackageLimits.builder().maxTextFileBytes(128).build());
|
||||
|
||||
SkillValidationReport report = limitedValidator.validateReport(skill);
|
||||
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"TEXT_FILE_SIZE_LIMIT".equals(issue.getCode())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次调用传入的限额优先于校验器构造时的默认限额。
|
||||
*/
|
||||
@Test
|
||||
public void operationLimitsOverrideValidatorDefaults() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
String path = "references/" + "a".repeat(520) + ".md";
|
||||
skill.setResources(java.util.List.of(
|
||||
textResource(path, SkillResourceKind.REFERENCE, "A")));
|
||||
SkillPackageLimits operationLimits = SkillPackageLimits.builder()
|
||||
.maxPathLength(600)
|
||||
.build();
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill, operationLimits);
|
||||
|
||||
Assert.assertFalse(report.hasErrors());
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据与 SKILL.md 不一致时失败。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectMetadataMismatch() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
skill.getMetadata().put("extra", "value");
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
private static Skill validSkill(String name) {
|
||||
return SkillFactory.create("repository-id", skillMd(name));
|
||||
}
|
||||
|
||||
private static String skillMd(String name) {
|
||||
return "---\nname: " + name + "\ndescription: Use this Skill for validator tests\n---\n# Skill\n";
|
||||
}
|
||||
|
||||
private static SkillResource textResource(String path, SkillResourceKind kind, String content) {
|
||||
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
|
||||
SkillResource resource = new SkillResource();
|
||||
resource.setPath(path);
|
||||
resource.setKind(kind);
|
||||
resource.setMediaType("text/markdown");
|
||||
resource.setTextContent(content);
|
||||
resource.setContentHash(SkillHashes.sha256Hex(bytes));
|
||||
resource.setSize(bytes.length);
|
||||
return resource;
|
||||
}
|
||||
|
||||
private static SkillResource binaryResource(String path, String content) {
|
||||
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
|
||||
String hash = SkillHashes.sha256Hex(bytes);
|
||||
SkillAsset asset = new SkillAsset();
|
||||
asset.setPath(path);
|
||||
asset.setName("a.bin");
|
||||
asset.setMediaType("application/octet-stream");
|
||||
asset.setContentRef("sha256:" + hash);
|
||||
asset.setContentHash(hash);
|
||||
asset.setSize(bytes.length);
|
||||
return asset;
|
||||
SkillResource resource = new SkillResource();
|
||||
resource.setPath(path);
|
||||
resource.setKind(SkillResourceKind.ASSET);
|
||||
resource.setMediaType("application/octet-stream");
|
||||
resource.setContentRef("sha256:" + hash);
|
||||
resource.setContentHash(hash);
|
||||
resource.setSize(bytes.length);
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
|
||||
14
pom.xml
14
pom.xml
@@ -46,6 +46,8 @@
|
||||
<kotlin.version>1.8.22</kotlin.version>
|
||||
<opentelemetry.version>1.51.0</opentelemetry.version>
|
||||
<agentscope.version>1.0.12</agentscope.version>
|
||||
<snakeyaml.version>2.6</snakeyaml.version>
|
||||
<commons-compress.version>1.28.0</commons-compress.version>
|
||||
</properties>
|
||||
|
||||
|
||||
@@ -114,6 +116,18 @@
|
||||
<version>${junit.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
<version>${snakeyaml.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
<version>${commons-compress.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!--easy-agents dependency management-->
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
|
||||
Reference in New Issue
Block a user