mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
Merge pull request #636 from iflytek/codex/builtin-skills-release
Some checks failed
Deploy Docs / build (push) Has been cancelled
Security / Dependency Review (push) Has been cancelled
Security / CodeQL (java-kotlin) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Deploy Docs / Deploy (push) Has been cancelled
Some checks failed
Deploy Docs / build (push) Has been cancelled
Security / Dependency Review (push) Has been cancelled
Security / CodeQL (java-kotlin) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Deploy Docs / Deploy (push) Has been cancelled
feat(bootstrap): verify built-in skill artifacts
This commit is contained in:
commit
fac1110d15
16 changed files with 727 additions and 47 deletions
|
|
@ -58,6 +58,9 @@ WEB_PORT=80
|
|||
# Must stay true when the public site is behind HTTPS.
|
||||
SESSION_COOKIE_SECURE=true
|
||||
|
||||
# Built-in starter skills are installed by default. Set to false to skip initialization.
|
||||
SKILLHUB_BUILTIN_SKILLS_ENABLED=true
|
||||
|
||||
# External object storage. Production should use s3.
|
||||
SKILLHUB_STORAGE_PROVIDER=s3
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,9 @@ SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
|
|||
# Security scanner is enabled by default. Set to false to disable scanning.
|
||||
SKILLHUB_SECURITY_SCANNER_ENABLED=true
|
||||
|
||||
# Built-in starter skills are installed by default. Set to false to skip initialization.
|
||||
SKILLHUB_BUILTIN_SKILLS_ENABLED=true
|
||||
|
||||
# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment.
|
||||
# runtime.sh generates and persists one automatically when this placeholder is still present.
|
||||
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes
|
||||
|
|
|
|||
|
|
@ -20,7 +20,13 @@ make test-builtin-skills
|
|||
|
||||
The build writes deterministic, uncompressed ZIPs and `artifacts.json` to
|
||||
`builtin-skills/dist/`. The artifact index records each ZIP's SHA-256 for the release step; runtime
|
||||
manifest integration is maintained separately from the reviewed source collection.
|
||||
manifest integration is maintained separately from the reviewed source collection. A package is
|
||||
added to the runtime manifest only after its immutable CDN URL is available; the manifest records
|
||||
the matching SHA-256 so the backend can reject changed or incorrectly uploaded bytes before
|
||||
extraction.
|
||||
|
||||
The first batch of 15 packages is pinned in the runtime manifest. A clean deployment initializes
|
||||
these packages alongside the existing built-in Skills in the public `@global` namespace.
|
||||
|
||||
## Share a Skill with the Community
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ services:
|
|||
SKILLHUB_SECURITY_SCANNER_ENABLED: ${SKILLHUB_SECURITY_SCANNER_ENABLED:-true}
|
||||
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
|
||||
SKILLHUB_SECURITY_SCANNER_MODE: upload
|
||||
SKILLHUB_BUILTIN_SKILLS_ENABLED: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:-true}
|
||||
SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false}
|
||||
BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-false}
|
||||
BOOTSTRAP_ADMIN_USER_ID: ${BOOTSTRAP_ADMIN_USER_ID:-docker-admin}
|
||||
|
|
|
|||
|
|
@ -30,11 +30,12 @@ scripts/build-builtin-skills.py
|
|||
server/skillhub-app/src/main/resources/builtin-skills/manifest.json
|
||||
```
|
||||
|
||||
首版 manifest 只需要维护三个字段:
|
||||
manifest 需要维护四个字段:
|
||||
|
||||
- `slug`:Skill 在 `@global` 下的 slug。
|
||||
- `version`:期望同步的 Skill 版本。
|
||||
- `url`:Skill zip 包的云存储 HTTPS 链接。
|
||||
- `sha256`:发布制品的 SHA-256,小写 64 位十六进制字符串。
|
||||
|
||||
## 2. Manifest 配置
|
||||
|
||||
|
|
@ -46,7 +47,8 @@ manifest 文件格式如下:
|
|||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.0.0.zip"
|
||||
"url": "https://bjcdn.openstorage.cn/aicontest/2026-06-11/f8a59af3-30d4-4031-80f6-ebff74b05195.zip",
|
||||
"sha256": "acb591ed0891e735c364b955f5b94b2b9ce567c1d9e347312cebfbfde2d93f57"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -60,17 +62,20 @@ manifest 文件格式如下:
|
|||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.0.0.zip"
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.0.0.zip",
|
||||
"sha256": "<sha256-of-skillhub-hello-1.0.0.zip>"
|
||||
},
|
||||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.1.0",
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.1.0.zip"
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.1.0.zip",
|
||||
"sha256": "<sha256-of-skillhub-hello-1.1.0.zip>"
|
||||
},
|
||||
{
|
||||
"slug": "skillhub-guide",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-guide-1.0.0.zip"
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-guide-1.0.0.zip",
|
||||
"sha256": "<sha256-of-skillhub-guide-1.0.0.zip>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -79,8 +84,9 @@ manifest 文件格式如下:
|
|||
配置要求:
|
||||
|
||||
- `skills` 必须是数组。
|
||||
- 每一项必须同时填写 `slug`、`version`、`url`。
|
||||
- 每一项必须同时填写 `slug`、`version`、`url`、`sha256`。
|
||||
- `slug` 必须符合 SkillHub slug 规则。
|
||||
- `sha256` 必须是小写 64 位十六进制字符串,并与 URL 返回的原始 zip 字节一致。
|
||||
- 同一个 `slug + version` 重复出现时,只处理第一条,后续重复项会被跳过。
|
||||
- manifest 最多处理前 100 条 entries。
|
||||
- 同一个 `slug` 的多个版本建议按从旧到新的顺序排列;运行时按 manifest 文件顺序处理,不做自动版本排序。
|
||||
|
|
@ -155,11 +161,12 @@ skillhub-hello-1.0.0.zip
|
|||
7. 按 manifest 顺序处理每一个 item。
|
||||
8. 下载前先检查 `@global/{slug}` 和目标版本是否已经存在;如果已经确定应跳过,则不发起远程下载。
|
||||
9. 只有需要发布新 Skill 或新版本时,才下载对应 zip 包。
|
||||
10. 解包并校验 Skill 入口 `SKILL.md`。
|
||||
11. 校验 manifest 中的 `slug`、`version` 与包内元数据一致。
|
||||
12. 发布前再次检查是否已存在同名 Skill 或同版本,处理并发启动场景。
|
||||
13. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`。
|
||||
14. 发布完成后,该 Skill 位于 `@global/{slug}`,可见性为 `PUBLIC`。
|
||||
10. 对下载到的原始 zip 字节计算 SHA-256,并与 manifest 的 `sha256` 比较;不一致时停止处理该项。
|
||||
11. 解包并校验 Skill 入口 `SKILL.md`。
|
||||
12. 校验 manifest 中的 `slug`、`version` 与包内元数据一致。
|
||||
13. 发布前再次检查是否已存在同名 Skill 或同版本,处理并发启动场景。
|
||||
14. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`。
|
||||
15. 发布完成后,该 Skill 位于 `@global/{slug}`,可见性为 `PUBLIC`。
|
||||
|
||||
同步逻辑不会直接写数据库 seed 数据。它复用现有发布服务,因此会保留现有的包校验、对象存储写入、版本记录、latest version 更新、事件和搜索索引同步。
|
||||
|
||||
|
|
@ -223,7 +230,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false
|
|||
3. 运行 `make test-builtin-skills`,确认包结构、来源、许可证和确定性构建门禁通过。
|
||||
4. 运行 `make build-builtin-skills`,从 `builtin-skills/dist/artifacts.json` 读取制品哈希。
|
||||
5. 上传 zip 到 `bjcdn.openstorage.cn` 或其子域名下的不可变路径。
|
||||
6. 在 `server/skillhub-app/src/main/resources/builtin-skills/manifest.json` 中新增一项。
|
||||
6. 在 `server/skillhub-app/src/main/resources/builtin-skills/manifest.json` 中新增一项,同时填写制品 URL 和 `artifacts.json` 中对应的 SHA-256。
|
||||
7. 本地或测试环境启动 SkillHub,查看后端日志确认同步结果。
|
||||
8. 在 Web UI 或 API 中确认 `@global/{slug}` 已公开可见。
|
||||
|
||||
|
|
@ -251,10 +258,12 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false
|
|||
|---|---|
|
||||
| manifest not found | 确认 `builtin-skills/manifest.json` 是否被打进 classpath |
|
||||
| publisher account id already exists but is not a system account | `builtin-skill-publisher` 已被普通账号占用;需要人工处理账号冲突后再启用内置同步 |
|
||||
| slug, version, and url are required | 检查 manifest item 是否缺字段或字段不是字符串 |
|
||||
| slug, version, url, and sha256 are required | 检查 manifest item 是否缺字段或字段不是字符串 |
|
||||
| slug is invalid | 检查 slug 是否符合 SkillHub slug 规则 |
|
||||
| sha256 must be 64 lowercase hexadecimal characters | 使用 `builtin-skills/dist/artifacts.json` 中对应制品的 SHA-256 |
|
||||
| URL is not allowed | 检查 URL 是否为 HTTPS、host 是否为 `bjcdn.openstorage.cn` 或其子域名 |
|
||||
| package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 |
|
||||
| package checksum mismatch | 云端对象与 manifest 固定的制品不一致;不要继续解包或发布,检查是否上传错误或对象被覆盖 |
|
||||
| package must contain SKILL.md | 检查 zip 是否存在唯一可识别的 `SKILL.md` 入口 |
|
||||
| manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 |
|
||||
| slug already belongs to another user | 说明 `@global/{slug}` 已被非内置发布者创建或发布,内置同步不会覆盖 |
|
||||
|
|
@ -268,7 +277,8 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false
|
|||
|
||||
- `make test-builtin-skills` 通过,且 15 个回归用例都有对应包。
|
||||
- manifest JSON 格式合法。
|
||||
- 每个 item 都包含 `slug`、`version`、`url`。
|
||||
- 每个 item 都包含 `slug`、`version`、`url`、`sha256`。
|
||||
- 每个 `sha256` 都与 URL 下载到的原始 zip 字节一致。
|
||||
- URL 使用 `https://bjcdn.openstorage.cn/...` 或可信子域名。
|
||||
- zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`。
|
||||
- `SKILL.md name` 归一化后的 slug 与 manifest `slug` 一致。
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ SkillHub 已经支持通过 manifest 将远程 Skill 包同步到 `@global`。
|
|||
2. 完成协议、许可证、安全和可用性检查。
|
||||
3. 将审查后源码、来源和回归用例纳入开源仓库。
|
||||
4. 确定性打包并上传官方云存储。
|
||||
5. 加入 `builtin-skills/manifest.json`。
|
||||
5. 将不可变 URL 和制品 SHA-256 加入
|
||||
`server/skillhub-app/src/main/resources/builtin-skills/manifest.json`。
|
||||
6. 随 SkillHub 默认部署同步到 `@global`。
|
||||
|
||||
目标总量为 30~50 个。当前已有的 `skillhub-hello` 和 `agentguard` 也需要重新检查,通过质量
|
||||
|
|
@ -121,7 +122,7 @@ Catalog 按单个 Skill 的许可证筛选。Anthropic 的 `docx`、`pdf`、`ppt
|
|||
- 跑通批量检查、打包、上传和 manifest 更新流程。
|
||||
- 已从 70 个候选中选定 A05、A07–A15、B06、B07、B10、B11、B24,共 15 个。
|
||||
- 15 个候选均已完成必要适配、来源与许可证登记、生产包校验和静态安全复测。
|
||||
- 待完成不可变制品上传、manifest 哈希登记和干净实例同步验证。
|
||||
- 已完成不可变制品上传和 manifest 哈希登记,待完成干净实例同步验证。
|
||||
- 不要求第一批为了形式上覆盖全部类别而降低门槛;旅行规划等缺少合格候选的场景留到第二批。
|
||||
|
||||
### 第二批:扩充到 30 个
|
||||
|
|
|
|||
|
|
@ -206,7 +206,8 @@ B25 的核心行程生成实测失败,并会把敏感旅行画像写入宿主
|
|||
4. 在声明支持的 Agent 中完成至少一个真实任务,并记录输入、预期结果和实际结果。
|
||||
5. 运行 SkillHub 包校验、安全扫描和人工审查。
|
||||
6. 使用新版本号打包,上传不可变的官方 CDN URL。
|
||||
7. 更新 `builtin-skills/manifest.json`,在干净部署中验证同步、下载和安装。
|
||||
7. 将不可变 URL 和制品 SHA-256 写入
|
||||
`server/skillhub-app/src/main/resources/builtin-skills/manifest.json`,在干净部署中验证同步、下载和安装。
|
||||
|
||||
当前不修改 manifest:候选包尚未完成以上检查,也没有可用的官方不可变 URL。把 GitHub
|
||||
分支下载地址直接写进 manifest,会导致上游内容变化后无法审计和复现。
|
||||
第一批 15 个适配包已经完成内容、安全和确定性构建检查,并已通过官方不可变 CDN URL 和
|
||||
SHA-256 写入运行时 manifest。后续更新必须发布新版本和新制品,不得覆盖当前 URL 对应的字节。
|
||||
|
|
|
|||
|
|
@ -18,6 +18,34 @@ python3 "$BUILDER" --output "$second"
|
|||
|
||||
cmp "$first/artifacts.json" "$second/artifacts.json"
|
||||
|
||||
runtime_manifest="$REPO_ROOT/server/skillhub-app/src/main/resources/builtin-skills/manifest.json"
|
||||
python3 - "$first/artifacts.json" "$runtime_manifest" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
artifacts = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["artifacts"]
|
||||
runtime_items = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))["skills"]
|
||||
runtime_by_coordinate = {}
|
||||
for item in runtime_items:
|
||||
coordinate = (item["slug"], item["version"])
|
||||
assert coordinate not in runtime_by_coordinate, coordinate
|
||||
runtime_by_coordinate[coordinate] = item
|
||||
|
||||
assert len(runtime_items) == 17, len(runtime_items)
|
||||
for artifact in artifacts:
|
||||
coordinate = (artifact["slug"], artifact["version"])
|
||||
assert coordinate in runtime_by_coordinate, coordinate
|
||||
runtime_item = runtime_by_coordinate[coordinate]
|
||||
assert runtime_item["sha256"] == artifact["sha256"], coordinate
|
||||
parsed_url = urlsplit(runtime_item["url"])
|
||||
assert parsed_url.scheme == "https", coordinate
|
||||
assert parsed_url.hostname == "bjcdn.openstorage.cn", coordinate
|
||||
assert not parsed_url.query and not parsed_url.fragment, coordinate
|
||||
assert parsed_url.path.endswith(f'/{artifact["sha256"]}.zip'), coordinate
|
||||
PY
|
||||
|
||||
python3 - "$first" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ POSTGRES_PASSWORD=strong-postgres-password
|
|||
SESSION_COOKIE_SECURE=true
|
||||
BOOTSTRAP_ADMIN_ENABLED=false
|
||||
SKILLHUB_TRUST_FORWARDED_PROTO=false
|
||||
SKILLHUB_BUILTIN_SKILLS_ENABLED=true
|
||||
SKILLHUB_STORAGE_PROVIDER=s3
|
||||
SKILLHUB_STORAGE_S3_ENDPOINT=https://storage.example.com
|
||||
SKILLHUB_STORAGE_S3_BUCKET=skillhub
|
||||
|
|
@ -69,6 +70,11 @@ valid_env="$tmp/valid.env"
|
|||
write_env "$valid_env" "release-download-secret-32-bytes-minimum"
|
||||
"$SCRIPT" "$valid_env" >/dev/null
|
||||
|
||||
disabled_builtin_skills_env="$tmp/disabled-builtin-skills.env"
|
||||
write_env "$disabled_builtin_skills_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "SKILLHUB_BUILTIN_SKILLS_ENABLED=false" >>"$disabled_builtin_skills_env"
|
||||
"$SCRIPT" "$disabled_builtin_skills_env" >/dev/null
|
||||
|
||||
missing_env="$tmp/missing.env"
|
||||
write_env "$missing_env" "" no
|
||||
expect_fail "$missing_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required"
|
||||
|
|
@ -86,6 +92,11 @@ write_env "$invalid_forwarded_proto_env" "release-download-secret-32-bytes-minim
|
|||
printf '%s\n' "SKILLHUB_TRUST_FORWARDED_PROTO=yes" >>"$invalid_forwarded_proto_env"
|
||||
expect_fail "$invalid_forwarded_proto_env" "SKILLHUB_TRUST_FORWARDED_PROTO must be true or false"
|
||||
|
||||
invalid_builtin_skills_env="$tmp/invalid-builtin-skills.env"
|
||||
write_env "$invalid_builtin_skills_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "SKILLHUB_BUILTIN_SKILLS_ENABLED=yes" >>"$invalid_builtin_skills_env"
|
||||
expect_fail "$invalid_builtin_skills_env" "SKILLHUB_BUILTIN_SKILLS_ENABLED must be true or false"
|
||||
|
||||
valid_redis_cluster_env="$tmp/valid-redis-cluster.env"
|
||||
write_env "$valid_redis_cluster_env" "release-download-secret-32-bytes-minimum"
|
||||
cat >>"$valid_redis_cluster_env" <<'EOF'
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ reject_patterns SPRING_MAIL_PASSWORD "TODO_*" "todo_*" "replace*"
|
|||
validate_boolean SESSION_COOKIE_SECURE
|
||||
validate_boolean BOOTSTRAP_ADMIN_ENABLED
|
||||
validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO
|
||||
validate_boolean SKILLHUB_BUILTIN_SKILLS_ENABLED
|
||||
validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE
|
||||
validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET
|
||||
validate_boolean SPRING_DATA_REDIS_SSL_ENABLED
|
||||
|
|
|
|||
|
|
@ -209,7 +209,21 @@ public class BuiltinSkillInitializer {
|
|||
return SyncOutcome.FAILED;
|
||||
}
|
||||
|
||||
SkillPackageArchiveExtractor.ExtractionResult extractionResult = extractor.extract(packageBytes.get());
|
||||
byte[] downloadedBytes = packageBytes.get();
|
||||
String actualSha256 = sha256(downloadedBytes);
|
||||
if (!item.sha256().equals(actualSha256)) {
|
||||
log.warn(
|
||||
"Skipping built-in skill slug={} version={} because package checksum mismatch: "
|
||||
+ "expectedSha256={}, actualSha256={}",
|
||||
item.slug(),
|
||||
item.version(),
|
||||
item.sha256(),
|
||||
actualSha256
|
||||
);
|
||||
return SyncOutcome.FAILED;
|
||||
}
|
||||
|
||||
SkillPackageArchiveExtractor.ExtractionResult extractionResult = extractor.extract(downloadedBytes);
|
||||
List<PackageEntry> entries = extractionResult.entries();
|
||||
SkillMetadata metadata = parseSkillMetadata(entries);
|
||||
String packageSlug = SlugValidator.slugify(metadata.name());
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import java.util.ArrayList;
|
|||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
public class BuiltinSkillManifestLoader {
|
||||
|
|
@ -24,6 +25,7 @@ public class BuiltinSkillManifestLoader {
|
|||
static final int MAX_ITEMS = 100;
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BuiltinSkillManifestLoader.class);
|
||||
private static final Pattern SHA256_PATTERN = Pattern.compile("[0-9a-f]{64}");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ResourceLoader resourceLoader;
|
||||
|
|
@ -72,8 +74,22 @@ public class BuiltinSkillManifestLoader {
|
|||
String slug = text(itemNode, "slug");
|
||||
String version = text(itemNode, "version");
|
||||
String url = text(itemNode, "url");
|
||||
if (!StringUtils.hasText(slug) || !StringUtils.hasText(version) || !StringUtils.hasText(url)) {
|
||||
log.warn("Skipping built-in skill manifest item {} because slug, version, and url are required", index);
|
||||
String sha256 = text(itemNode, "sha256");
|
||||
if (!StringUtils.hasText(slug)
|
||||
|| !StringUtils.hasText(version)
|
||||
|| !StringUtils.hasText(url)
|
||||
|| !StringUtils.hasText(sha256)) {
|
||||
log.warn(
|
||||
"Skipping built-in skill manifest item {} because slug, version, url, and sha256 are required",
|
||||
index
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!SHA256_PATTERN.matcher(sha256).matches()) {
|
||||
log.warn(
|
||||
"Skipping built-in skill manifest item {} because sha256 must be 64 lowercase hexadecimal characters",
|
||||
index
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
|
|
@ -90,7 +106,7 @@ public class BuiltinSkillManifestLoader {
|
|||
continue;
|
||||
}
|
||||
|
||||
items.add(new ManifestItem(slug, version, url));
|
||||
items.add(new ManifestItem(slug, version, url, sha256));
|
||||
}
|
||||
return List.copyOf(items);
|
||||
}
|
||||
|
|
@ -103,6 +119,6 @@ public class BuiltinSkillManifestLoader {
|
|||
return value.asText().trim();
|
||||
}
|
||||
|
||||
public record ManifestItem(String slug, String version, String url) {
|
||||
public record ManifestItem(String slug, String version, String url, String sha256) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,104 @@
|
|||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/aicontest/2026-06-11/f8a59af3-30d4-4031-80f6-ebff74b05195.zip"
|
||||
"url": "https://bjcdn.openstorage.cn/aicontest/2026-06-11/f8a59af3-30d4-4031-80f6-ebff74b05195.zip",
|
||||
"sha256": "acb591ed0891e735c364b955f5b94b2b9ce567c1d9e347312cebfbfde2d93f57"
|
||||
},
|
||||
{
|
||||
"slug": "agentguard",
|
||||
"version": "1.1",
|
||||
"url": "https://bjcdn.openstorage.cn/aicontest/2026-06-12/9d063bc7-223a-4762-adeb-305c268aa29e.zip"
|
||||
"url": "https://bjcdn.openstorage.cn/aicontest/2026-06-12/9d063bc7-223a-4762-adeb-305c268aa29e.zip",
|
||||
"sha256": "77b16d62c1adaf8c1d5ca06b9bff26005bc77da345e6036cc5f1f91bdee1a149"
|
||||
},
|
||||
{
|
||||
"slug": "ai-claim-checker",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/9d8a9ef1-6c63-483a-a54d-b593180572a2/skillhub-builtin-skills/ai-claim-checker/1.0.0/19fd699fc5e67469bb764d5f695894c1a39e49055060152aa6168629ca0f0ea5.zip",
|
||||
"sha256": "19fd699fc5e67469bb764d5f695894c1a39e49055060152aa6168629ca0f0ea5"
|
||||
},
|
||||
{
|
||||
"slug": "daily-standup-journal",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/c6dc807b-5255-4aa9-8bdd-a708b9d159a6/skillhub-builtin-skills/daily-standup-journal/1.0.0/fd365dd3e1a4302044839bf3317c262c4bad650e0a677b7a1ca1d1ad5b8ab1bd.zip",
|
||||
"sha256": "fd365dd3e1a4302044839bf3317c262c4bad650e0a677b7a1ca1d1ad5b8ab1bd"
|
||||
},
|
||||
{
|
||||
"slug": "decision-matrix",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/73347ade-b71d-42e0-ad14-a63ac761d7ab/skillhub-builtin-skills/decision-matrix/1.0.0/1f284d78ebd8530b10d11a801237c11a0b24a08fe1798587df99ade7686b827a.zip",
|
||||
"sha256": "1f284d78ebd8530b10d11a801237c11a0b24a08fe1798587df99ade7686b827a"
|
||||
},
|
||||
{
|
||||
"slug": "diagram-maker",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/3d36219f-d700-4623-b482-39a39691ec22/skillhub-builtin-skills/diagram-maker/1.0.0/4c2fd87851f7a2a6dfca712887f8135f1696e23e8dc24cbb48f82303029eed17.zip",
|
||||
"sha256": "4c2fd87851f7a2a6dfca712887f8135f1696e23e8dc24cbb48f82303029eed17"
|
||||
},
|
||||
{
|
||||
"slug": "documentation-writer",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/e7e7b3ce-4923-4528-b7b4-e89de10802fa/skillhub-builtin-skills/documentation-writer/1.0.0/6a2cff6c0248c9a01c2475f2467606f06312ffc093c4ac0637ad50ba13b421aa.zip",
|
||||
"sha256": "6a2cff6c0248c9a01c2475f2467606f06312ffc093c4ac0637ad50ba13b421aa"
|
||||
},
|
||||
{
|
||||
"slug": "exam-ready",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/2a4ff8f9-7f57-435f-8eb1-69fe33afa99e/skillhub-builtin-skills/exam-ready/1.0.0/aea81a497157a19e4b136f2cfe3ba0764ebb52e520e4a87fa0446e5465f97663.zip",
|
||||
"sha256": "aea81a497157a19e4b136f2cfe3ba0764ebb52e520e4a87fa0446e5465f97663"
|
||||
},
|
||||
{
|
||||
"slug": "frontend-design",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/fae63ded-f222-4583-a723-d6d9529360e0/skillhub-builtin-skills/frontend-design/1.0.0/67b2f34163874e56ba43a0a9ed3d1012d752615a1a80f0c26608a702d9fe394e.zip",
|
||||
"sha256": "67b2f34163874e56ba43a0a9ed3d1012d752615a1a80f0c26608a702d9fe394e"
|
||||
},
|
||||
{
|
||||
"slug": "linkedin-post-formatter",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/4e19c89f-7fa2-44fa-a3bf-90ec959258c0/skillhub-builtin-skills/linkedin-post-formatter/1.0.0/171acf2d0574265b1ececa53759658aeb582aae4c859d4199b1ff322d65f8642.zip",
|
||||
"sha256": "171acf2d0574265b1ececa53759658aeb582aae4c859d4199b1ff322d65f8642"
|
||||
},
|
||||
{
|
||||
"slug": "meeting-note-summarizer",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/6cf347a7-ccc5-4016-b4ca-6263959a1b08/skillhub-builtin-skills/meeting-note-summarizer/1.0.0/5add401203f323bc1cbb350f306d3a5d3f3b16c2ad6578e7cab55d2c196152ca.zip",
|
||||
"sha256": "5add401203f323bc1cbb350f306d3a5d3f3b16c2ad6578e7cab55d2c196152ca"
|
||||
},
|
||||
{
|
||||
"slug": "retrieval-practice-generator",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/ac9086e8-ee32-4baa-bf42-0bc3f5c59558/skillhub-builtin-skills/retrieval-practice-generator/1.0.0/8e1b21f9c02312378da4d5493297d98a2ce40ba03f83a51e24c97ad53f144dc1.zip",
|
||||
"sha256": "8e1b21f9c02312378da4d5493297d98a2ce40ba03f83a51e24c97ad53f144dc1"
|
||||
},
|
||||
{
|
||||
"slug": "storytelling-advisor",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/12e1eaea-cffc-4d67-b452-5383868475d2/skillhub-builtin-skills/storytelling-advisor/1.0.0/c44427e0a45e3a63dc47fb2a58e9b40f9db7a6d29715fc38a2d6162e89cd4be3.zip",
|
||||
"sha256": "c44427e0a45e3a63dc47fb2a58e9b40f9db7a6d29715fc38a2d6162e89cd4be3"
|
||||
},
|
||||
{
|
||||
"slug": "study-strategy-selector",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/ac7017cf-7d83-46e0-a467-7e2d199cbd03/skillhub-builtin-skills/study-strategy-selector/1.0.0/ea5ef41b449b823cd996be18c4f6af2d9944a0571aa7c65091327cc1893657f1.zip",
|
||||
"sha256": "ea5ef41b449b823cd996be18c4f6af2d9944a0571aa7c65091327cc1893657f1"
|
||||
},
|
||||
{
|
||||
"slug": "time-blocking-scheduler",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/9b032189-7bba-407e-adb2-bcda2c0c2765/skillhub-builtin-skills/time-blocking-scheduler/1.0.0/005b0d9afda5bb8f4dd8b8d43b2201e26f2f2792a1d7b57dc7667367d543e76e.zip",
|
||||
"sha256": "005b0d9afda5bb8f4dd8b8d43b2201e26f2f2792a1d7b57dc7667367d543e76e"
|
||||
},
|
||||
{
|
||||
"slug": "video-frames",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/4136bddd-868a-44e3-a16e-16321433733c/skillhub-builtin-skills/video-frames/1.0.0/7b4e9f1fb30b5f8318b061e1c5aeda0249d73746acc548ee4d7ff13b3d999966.zip",
|
||||
"sha256": "7b4e9f1fb30b5f8318b061e1c5aeda0249d73746acc548ee4d7ff13b3d999966"
|
||||
},
|
||||
{
|
||||
"slug": "weather",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-07-31/0170ce8f-ad41-4819-bc50-9b572b1c6510/skillhub-builtin-skills/weather/1.0.0/181e36c0fd6ca5f6cdf224276dd12e0fd203f83456a988a6bcf15cc4f3c20dd6.zip",
|
||||
"sha256": "181e36c0fd6ca5f6cdf224276dd12e0fd203f83456a988a6bcf15cc4f3c20dd6"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,9 +44,9 @@ import org.springframework.context.event.EventListener;
|
|||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.lang.reflect.Method;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
|
@ -58,10 +58,14 @@ class BuiltinSkillInitializerTest {
|
|||
|
||||
private static final String GLOBAL = "global";
|
||||
private static final String PUBLISHER = "builtin-skill-publisher";
|
||||
private static final byte[] PACKAGE_BYTES = "zip".getBytes(StandardCharsets.UTF_8);
|
||||
private static final String PACKAGE_SHA256 =
|
||||
"4a70fe9aa6436e02c2dea340fbd1e352e4ef2d8ce6ca52ad25d4b95471fc8bf2";
|
||||
private static final ManifestItem ITEM = new ManifestItem(
|
||||
"skillhub-hello",
|
||||
"1.0.0",
|
||||
"https://bjcdn.openstorage.cn/skills/skillhub-hello.zip"
|
||||
"https://bjcdn.openstorage.cn/skills/skillhub-hello.zip",
|
||||
PACKAGE_SHA256
|
||||
);
|
||||
|
||||
@Mock private BuiltinSkillManifestLoader manifestLoader;
|
||||
|
|
@ -215,7 +219,8 @@ class BuiltinSkillInitializerTest {
|
|||
ManifestItem malformed = new ManifestItem(
|
||||
"skillhub-hello",
|
||||
"1.0.0",
|
||||
"https://bjcdn.openstorage.cn/skills/%zz.zip"
|
||||
"https://bjcdn.openstorage.cn/skills/%zz.zip",
|
||||
PACKAGE_SHA256
|
||||
);
|
||||
givenManifestAndSystemPublisher(List.of(malformed));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of());
|
||||
|
|
@ -227,6 +232,25 @@ class BuiltinSkillInitializerTest {
|
|||
assertThat(output).doesNotContain("Failed to synchronize built-in skill slug=skillhub-hello");
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsPackageWithMismatchedSha256BeforeExtraction() throws Exception {
|
||||
ManifestItem mismatched = new ManifestItem(
|
||||
"skillhub-hello",
|
||||
"1.0.0",
|
||||
ITEM.url(),
|
||||
"0000000000000000000000000000000000000000000000000000000000000000"
|
||||
);
|
||||
givenManifestAndSystemPublisher(List.of(mismatched));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of());
|
||||
when(downloader.download(URI.create(mismatched.url()))).thenReturn(Optional.of(PACKAGE_BYTES));
|
||||
|
||||
runInitializer();
|
||||
|
||||
verify(downloader).download(URI.create(mismatched.url()));
|
||||
verify(extractor, never()).extract(any());
|
||||
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsWhenManifestSlugDoesNotMatchPackageMetadata() throws Exception {
|
||||
givenExtractedPackage(packageEntries("other-skill", "1.0.0", "same"));
|
||||
|
|
@ -340,14 +364,14 @@ class BuiltinSkillInitializerTest {
|
|||
}
|
||||
|
||||
private void givenExtractedPackage(List<PackageEntry> entries) throws Exception {
|
||||
byte[] bytes = "zip".getBytes(StandardCharsets.UTF_8);
|
||||
when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace));
|
||||
when(manifestLoader.load()).thenReturn(List.of(ITEM));
|
||||
lenient().when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher()));
|
||||
lenient().when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER))
|
||||
.thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER)));
|
||||
when(downloader.download(URI.create(ITEM.url()))).thenReturn(Optional.of(bytes));
|
||||
when(extractor.extract(bytes)).thenReturn(new SkillPackageArchiveExtractor.ExtractionResult(entries, List.of()));
|
||||
when(downloader.download(URI.create(ITEM.url()))).thenReturn(Optional.of(PACKAGE_BYTES));
|
||||
when(extractor.extract(PACKAGE_BYTES))
|
||||
.thenReturn(new SkillPackageArchiveExtractor.ExtractionResult(entries, List.of()));
|
||||
}
|
||||
|
||||
private void givenManifestAndSystemPublisher() {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
|
@ -12,22 +13,53 @@ import java.util.List;
|
|||
|
||||
class BuiltinSkillManifestLoaderTest {
|
||||
|
||||
private static final String SHA256_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private static final String SHA256_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
@Test
|
||||
void loadsManifestItemsInOrder() {
|
||||
BuiltinSkillManifestLoader loader = loaderWith("""
|
||||
{
|
||||
"skills": [
|
||||
{"slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/skillhub-hello.zip"},
|
||||
{"slug": "skillhub-hello", "version": "1.1.0", "url": "https://cdn.bjcdn.openstorage.cn/skillhub-hello.zip"}
|
||||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/skillhub-hello.zip",
|
||||
"sha256": "%s"
|
||||
},
|
||||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.1.0",
|
||||
"url": "https://cdn.bjcdn.openstorage.cn/skillhub-hello.zip",
|
||||
"sha256": "%s"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
""".formatted(SHA256_A, SHA256_B));
|
||||
|
||||
List<BuiltinSkillManifestLoader.ManifestItem> items = loader.load();
|
||||
|
||||
assertThat(items)
|
||||
.extracting(BuiltinSkillManifestLoader.ManifestItem::version)
|
||||
.containsExactly("1.0.0", "1.1.0");
|
||||
assertThat(items)
|
||||
.extracting(BuiltinSkillManifestLoader.ManifestItem::sha256)
|
||||
.containsExactly(SHA256_A, SHA256_B);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadsBundledManifestFromClasspath() {
|
||||
BuiltinSkillManifestLoader loader = new BuiltinSkillManifestLoader(
|
||||
new ObjectMapper(),
|
||||
new DefaultResourceLoader()
|
||||
);
|
||||
|
||||
List<BuiltinSkillManifestLoader.ManifestItem> items = loader.load();
|
||||
|
||||
assertThat(items).hasSize(17);
|
||||
assertThat(items).allSatisfy(item ->
|
||||
assertThat(item.sha256()).matches("[0-9a-f]{64}")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -65,20 +97,61 @@ class BuiltinSkillManifestLoaderTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void skipsItemsWithMissingHumanFieldsAndDuplicateSlugVersion() {
|
||||
void skipsItemsWithMissingRequiredFieldsAndDuplicateSlugVersion() {
|
||||
BuiltinSkillManifestLoader loader = loaderWith("""
|
||||
{
|
||||
"skills": [
|
||||
{"slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/first.zip"},
|
||||
{"slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/second.zip"},
|
||||
{"slug": "InvalidUppercase", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/invalid.zip"},
|
||||
{"slug": "", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/blank.zip"},
|
||||
{"slug": "missing-version", "url": "https://bjcdn.openstorage.cn/missing-version.zip"},
|
||||
{"slug": "missing-url", "version": "1.0.0"},
|
||||
{"slug": "valid-after-invalid", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/valid.zip"}
|
||||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/first.zip",
|
||||
"sha256": "%s"
|
||||
},
|
||||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/second.zip",
|
||||
"sha256": "%s"
|
||||
},
|
||||
{
|
||||
"slug": "InvalidUppercase",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/invalid.zip",
|
||||
"sha256": "%s"
|
||||
},
|
||||
{
|
||||
"slug": "",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/blank.zip",
|
||||
"sha256": "%s"
|
||||
},
|
||||
{
|
||||
"slug": "missing-version",
|
||||
"url": "https://bjcdn.openstorage.cn/missing-version.zip",
|
||||
"sha256": "%s"
|
||||
},
|
||||
{
|
||||
"slug": "missing-url",
|
||||
"version": "1.0.0",
|
||||
"sha256": "%s"
|
||||
},
|
||||
{
|
||||
"slug": "valid-after-invalid",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/valid.zip",
|
||||
"sha256": "%s"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
""".formatted(
|
||||
SHA256_A,
|
||||
SHA256_B,
|
||||
SHA256_A,
|
||||
SHA256_A,
|
||||
SHA256_A,
|
||||
SHA256_A,
|
||||
SHA256_B
|
||||
));
|
||||
|
||||
List<BuiltinSkillManifestLoader.ManifestItem> items = loader.load();
|
||||
|
||||
|
|
@ -90,6 +163,45 @@ class BuiltinSkillManifestLoaderTest {
|
|||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsItemsWithMissingOrInvalidSha256() {
|
||||
BuiltinSkillManifestLoader loader = loaderWith("""
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"slug": "missing-sha",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/missing.zip"
|
||||
},
|
||||
{
|
||||
"slug": "short-sha",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/short.zip",
|
||||
"sha256": "abc123"
|
||||
},
|
||||
{
|
||||
"slug": "uppercase-sha",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/uppercase.zip",
|
||||
"sha256": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
},
|
||||
{
|
||||
"slug": "valid-sha",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/valid.zip",
|
||||
"sha256": "%s"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(SHA256_A));
|
||||
|
||||
List<BuiltinSkillManifestLoader.ManifestItem> items = loader.load();
|
||||
|
||||
assertThat(items)
|
||||
.extracting(BuiltinSkillManifestLoader.ManifestItem::slug)
|
||||
.containsExactly("valid-sha");
|
||||
}
|
||||
|
||||
@Test
|
||||
void capsManifestEntriesAtOneHundredRawEntries() {
|
||||
StringBuilder json = new StringBuilder("{\"skills\":[");
|
||||
|
|
@ -98,12 +210,13 @@ class BuiltinSkillManifestLoaderTest {
|
|||
json.append(',');
|
||||
}
|
||||
if (i == 0) {
|
||||
json.append("{\"slug\":\"\",\"version\":\"1.0.0\",\"url\":\"https://bjcdn.openstorage.cn/blank.zip\"}");
|
||||
json.append("{\"slug\":\"\",\"version\":\"1.0.0\",\"url\":\"https://bjcdn.openstorage.cn/blank.zip\",")
|
||||
.append("\"sha256\":\"").append(SHA256_A).append("\"}");
|
||||
} else {
|
||||
json.append("{\"slug\":\"skill-").append(i)
|
||||
.append("\",\"version\":\"1.0.0\",\"url\":\"https://bjcdn.openstorage.cn/skill-")
|
||||
.append(i)
|
||||
.append(".zip\"}");
|
||||
.append(".zip\",\"sha256\":\"").append(SHA256_A).append("\"}");
|
||||
}
|
||||
}
|
||||
json.append("]}");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,356 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.SkillhubApplication;
|
||||
import com.iflytek.skillhub.TestRedisConfig;
|
||||
import com.iflytek.skillhub.bootstrap.BuiltinSkillManifestLoader.ManifestItem;
|
||||
import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.security.SecurityScanService;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFile;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.search.SearchRebuildService;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = SkillhubApplication.class,
|
||||
properties = {
|
||||
"skillhub.builtin-skills.enabled=false",
|
||||
"skillhub.bootstrap.admin.enabled=false",
|
||||
"logging.level.com.iflytek.skillhub.bootstrap.BuiltinSkillInitializer=INFO"
|
||||
})
|
||||
@ActiveProfiles("test")
|
||||
@Import(TestRedisConfig.class)
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
class BuiltinSkillUpgradeConflictIntegrationTest {
|
||||
|
||||
private static final String GLOBAL = "global";
|
||||
private static final String SYSTEM_PUBLISHER = "builtin-skill-publisher";
|
||||
private static final String USER_PUBLISHER = "existing-weather-owner";
|
||||
private static final Set<String> SUPER_ADMIN = Set.of("SUPER_ADMIN");
|
||||
private static final Set<String> PREEXISTING_SLUGS = Set.of("skillhub-hello", "agentguard", "weather");
|
||||
|
||||
private static final List<SkillCoordinate> RELEASE_SKILLS = List.of(
|
||||
new SkillCoordinate("skillhub-hello", "1.0.0"),
|
||||
new SkillCoordinate("agentguard", "1.1"),
|
||||
new SkillCoordinate("ai-claim-checker", "1.0.0"),
|
||||
new SkillCoordinate("daily-standup-journal", "1.0.0"),
|
||||
new SkillCoordinate("decision-matrix", "1.0.0"),
|
||||
new SkillCoordinate("diagram-maker", "1.0.0"),
|
||||
new SkillCoordinate("documentation-writer", "1.0.0"),
|
||||
new SkillCoordinate("exam-ready", "1.0.0"),
|
||||
new SkillCoordinate("frontend-design", "1.0.0"),
|
||||
new SkillCoordinate("linkedin-post-formatter", "1.0.0"),
|
||||
new SkillCoordinate("meeting-note-summarizer", "1.0.0"),
|
||||
new SkillCoordinate("retrieval-practice-generator", "1.0.0"),
|
||||
new SkillCoordinate("storytelling-advisor", "1.0.0"),
|
||||
new SkillCoordinate("study-strategy-selector", "1.0.0"),
|
||||
new SkillCoordinate("time-blocking-scheduler", "1.0.0"),
|
||||
new SkillCoordinate("video-frames", "1.0.0"),
|
||||
new SkillCoordinate("weather", "1.0.0")
|
||||
);
|
||||
|
||||
@Autowired
|
||||
private BuiltinSkillInitializer initializer;
|
||||
|
||||
@Autowired
|
||||
private BuiltinSkillProperties properties;
|
||||
|
||||
@Autowired
|
||||
private NamespaceRepository namespaceRepository;
|
||||
|
||||
@Autowired
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@Autowired
|
||||
private UserAccountRepository userAccountRepository;
|
||||
|
||||
@Autowired
|
||||
private SkillRepository skillRepository;
|
||||
|
||||
@Autowired
|
||||
private SkillVersionRepository skillVersionRepository;
|
||||
|
||||
@Autowired
|
||||
private SkillFileRepository skillFileRepository;
|
||||
|
||||
@Autowired
|
||||
private SkillPublishService skillPublishService;
|
||||
|
||||
@MockBean
|
||||
private BuiltinSkillManifestLoader manifestLoader;
|
||||
|
||||
@MockBean
|
||||
private BuiltinSkillRemotePackageDownloader downloader;
|
||||
|
||||
@MockBean
|
||||
private BuiltinSkillPackageExtractor extractor;
|
||||
|
||||
@MockBean
|
||||
private ObjectStorageService objectStorageService;
|
||||
|
||||
@MockBean
|
||||
private SecurityScanService securityScanService;
|
||||
|
||||
@MockBean
|
||||
private SearchRebuildService searchRebuildService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(securityScanService.isEnabled()).thenReturn(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upgradePreservesUserOwnedConflictAndPublishesRemainingBuiltIns(CapturedOutput output) throws Exception {
|
||||
Namespace global = new Namespace(GLOBAL, "Global", "system");
|
||||
global.setType(NamespaceType.GLOBAL);
|
||||
global = namespaceRepository.save(global);
|
||||
|
||||
userAccountRepository.save(UserAccount.systemAccount(
|
||||
SYSTEM_PUBLISHER,
|
||||
"Built-in Skill Publisher",
|
||||
null,
|
||||
null
|
||||
));
|
||||
userAccountRepository.save(new UserAccount(
|
||||
USER_PUBLISHER,
|
||||
"Existing Weather Owner",
|
||||
"weather-owner@example.test",
|
||||
null
|
||||
));
|
||||
namespaceMemberRepository.save(new NamespaceMember(global.getId(), SYSTEM_PUBLISHER, NamespaceRole.OWNER));
|
||||
namespaceMemberRepository.save(new NamespaceMember(global.getId(), USER_PUBLISHER, NamespaceRole.OWNER));
|
||||
|
||||
SkillPublishService.PublishResult hello = publishExisting(
|
||||
"skillhub-hello", "1.0.0", SYSTEM_PUBLISHER, "existing system hello content");
|
||||
SkillPublishService.PublishResult agentguard = publishExisting(
|
||||
"agentguard", "1.1", SYSTEM_PUBLISHER, "existing system agentguard content");
|
||||
SkillPublishService.PublishResult weather = publishExisting(
|
||||
"weather", "1.0.0", USER_PUBLISHER, "existing user weather content");
|
||||
|
||||
SkillSnapshot helloBefore = snapshot(hello.skillId(), "1.0.0");
|
||||
SkillSnapshot agentguardBefore = snapshot(agentguard.skillId(), "1.1");
|
||||
SkillSnapshot weatherBefore = snapshot(weather.skillId(), "1.0.0");
|
||||
|
||||
List<ManifestItem> manifest = RELEASE_SKILLS.stream()
|
||||
.map(BuiltinSkillUpgradeConflictIntegrationTest::manifestItem)
|
||||
.toList();
|
||||
when(manifestLoader.load()).thenReturn(manifest);
|
||||
for (ManifestItem item : manifest) {
|
||||
byte[] archive = archiveBytes(item);
|
||||
when(downloader.download(URI.create(item.url()))).thenReturn(Optional.of(archive));
|
||||
when(extractor.extract(archive)).thenReturn(new SkillPackageArchiveExtractor.ExtractionResult(
|
||||
packageEntries(item.slug(), item.version(), "official " + item.slug() + " content"),
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
|
||||
properties.setEnabled(true);
|
||||
initializer.synchronize();
|
||||
|
||||
assertThat(snapshot(hello.skillId(), "1.0.0")).isEqualTo(helloBefore);
|
||||
assertThat(snapshot(agentguard.skillId(), "1.1")).isEqualTo(agentguardBefore);
|
||||
assertThat(snapshot(weather.skillId(), "1.0.0")).isEqualTo(weatherBefore);
|
||||
|
||||
List<Skill> weatherSkills = skillRepository.findByNamespaceIdAndSlug(global.getId(), "weather");
|
||||
assertThat(weatherSkills).singleElement().satisfies(existing -> {
|
||||
assertThat(existing.getId()).isEqualTo(weather.skillId());
|
||||
assertThat(existing.getOwnerId()).isEqualTo(USER_PUBLISHER);
|
||||
});
|
||||
assertThat(weatherSkills).noneMatch(skill -> SYSTEM_PUBLISHER.equals(skill.getOwnerId()));
|
||||
|
||||
List<SkillCoordinate> newlyPublished = RELEASE_SKILLS.stream()
|
||||
.filter(item -> !PREEXISTING_SLUGS.contains(item.slug()))
|
||||
.toList();
|
||||
assertThat(newlyPublished).hasSize(14);
|
||||
for (SkillCoordinate item : newlyPublished) {
|
||||
List<Skill> skills = skillRepository.findByNamespaceIdAndSlug(global.getId(), item.slug());
|
||||
assertThat(skills).singleElement().satisfies(skill -> {
|
||||
assertThat(skill.getOwnerId()).isEqualTo(SYSTEM_PUBLISHER);
|
||||
SkillVersion version = skillVersionRepository
|
||||
.findBySkillIdAndVersion(skill.getId(), item.version())
|
||||
.orElseThrow();
|
||||
assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED);
|
||||
assertThat(skill.getLatestVersionId()).isEqualTo(version.getId());
|
||||
assertThat(skillFileRepository.findByVersionId(version.getId())).hasSize(2);
|
||||
});
|
||||
}
|
||||
|
||||
assertThat(skillRepository.findAll()).hasSize(17);
|
||||
assertThat(skillRepository.findByOwnerId(SYSTEM_PUBLISHER)).hasSize(16);
|
||||
assertThat(skillVersionRepository.findBySkillId(hello.skillId())).hasSize(1);
|
||||
assertThat(skillVersionRepository.findBySkillId(agentguard.skillId())).hasSize(1);
|
||||
|
||||
for (String skippedSlug : PREEXISTING_SLUGS) {
|
||||
ManifestItem skipped = manifest.stream()
|
||||
.filter(item -> item.slug().equals(skippedSlug))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
verify(downloader, never()).download(URI.create(skipped.url()));
|
||||
}
|
||||
verify(downloader, times(14)).download(any(URI.class));
|
||||
|
||||
assertThat(output).contains(
|
||||
"Built-in skill synchronization finished: total=17, published=14, "
|
||||
+ "idempotentSkipped=2, conflictSkipped=1, failed=0"
|
||||
);
|
||||
}
|
||||
|
||||
private SkillPublishService.PublishResult publishExisting(
|
||||
String slug,
|
||||
String version,
|
||||
String publisherId,
|
||||
String content) {
|
||||
return skillPublishService.publishFromEntries(
|
||||
GLOBAL,
|
||||
packageEntries(slug, version, content),
|
||||
publisherId,
|
||||
SkillVisibility.PUBLIC,
|
||||
SUPER_ADMIN,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private SkillSnapshot snapshot(Long skillId, String versionName) {
|
||||
Skill skill = skillRepository.findById(skillId).orElseThrow();
|
||||
SkillVersion version = skillVersionRepository
|
||||
.findBySkillIdAndVersion(skillId, versionName)
|
||||
.orElseThrow();
|
||||
List<FileSnapshot> files = skillFileRepository.findByVersionId(version.getId()).stream()
|
||||
.sorted(Comparator.comparing(SkillFile::getFilePath))
|
||||
.map(FileSnapshot::from)
|
||||
.toList();
|
||||
return new SkillSnapshot(
|
||||
skill.getId(),
|
||||
skill.getOwnerId(),
|
||||
skill.getLatestVersionId(),
|
||||
skill.getDisplayName(),
|
||||
skill.getSummary(),
|
||||
skill.getUpdatedAt(),
|
||||
version.getId(),
|
||||
version.getVersion(),
|
||||
version.getStatus(),
|
||||
version.getParsedMetadataJson(),
|
||||
version.getManifestJson(),
|
||||
version.getCreatedAt(),
|
||||
files
|
||||
);
|
||||
}
|
||||
|
||||
private static ManifestItem manifestItem(SkillCoordinate coordinate) {
|
||||
String url = "https://bjcdn.openstorage.cn/integration/builtin-skills/"
|
||||
+ coordinate.slug() + "/" + coordinate.version() + ".zip";
|
||||
ManifestItem item = new ManifestItem(coordinate.slug(), coordinate.version(), url, "");
|
||||
return new ManifestItem(item.slug(), item.version(), item.url(), sha256(archiveBytes(item)));
|
||||
}
|
||||
|
||||
private static byte[] archiveBytes(ManifestItem item) {
|
||||
return ("archive:" + item.slug() + ":" + item.version()).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static List<PackageEntry> packageEntries(String name, String version, String readme) {
|
||||
byte[] skillMd = ("""
|
||||
---
|
||||
name: %s
|
||||
description: Integration fixture for %s
|
||||
version: %s
|
||||
---
|
||||
# %s
|
||||
""").formatted(name, name, version, name).getBytes(StandardCharsets.UTF_8);
|
||||
byte[] readmeBytes = readme.getBytes(StandardCharsets.UTF_8);
|
||||
return List.of(
|
||||
new PackageEntry("SKILL.md", skillMd, skillMd.length, "text/markdown"),
|
||||
new PackageEntry("README.md", readmeBytes, readmeBytes.length, "text/markdown")
|
||||
);
|
||||
}
|
||||
|
||||
private static String sha256(byte[] content) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(digest.digest(content));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private record SkillCoordinate(String slug, String version) {
|
||||
}
|
||||
|
||||
private record FileSnapshot(
|
||||
Long id,
|
||||
String path,
|
||||
Long size,
|
||||
String contentType,
|
||||
String sha256,
|
||||
String storageKey,
|
||||
Instant createdAt) {
|
||||
|
||||
private static FileSnapshot from(SkillFile file) {
|
||||
return new FileSnapshot(
|
||||
file.getId(),
|
||||
file.getFilePath(),
|
||||
file.getFileSize(),
|
||||
file.getContentType(),
|
||||
file.getSha256(),
|
||||
file.getStorageKey(),
|
||||
file.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private record SkillSnapshot(
|
||||
Long skillId,
|
||||
String ownerId,
|
||||
Long latestVersionId,
|
||||
String displayName,
|
||||
String summary,
|
||||
Instant updatedAt,
|
||||
Long versionId,
|
||||
String version,
|
||||
SkillVersionStatus status,
|
||||
String parsedMetadataJson,
|
||||
String manifestJson,
|
||||
Instant versionCreatedAt,
|
||||
List<FileSnapshot> files) {
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue