mirror of
https://git.openapi.site/https://github.com/desirecore/registry.git
synced 2026-10-20 22:53:30 +08:00
feat(registry): 插件与应用关系契约4.2 / plugin and application contract (#10)
* feat(registry): 增加插件贡献与应用依赖的4.2契约 * fix(registry): preserve legacy App manifests for published clients --------- Co-authored-by: yi-ge <6657330+yi-ge@users.noreply.github.com>
This commit is contained in:
40
README.md
40
README.md
@@ -1,6 +1,6 @@
|
||||
# DesireCore Registry
|
||||
|
||||
DesireCore 官方注册表仓库,包含可安装的应用、MCP 服务、HTTP API 服务,以及只用于发现和合规披露的第三方外部集成。
|
||||
DesireCore 官方注册表仓库,包含应用、插件、MCP 服务、HTTP API 服务,以及只用于发现和合规披露的第三方外部集成。可否安装由条目的来源和治理证据决定。
|
||||
|
||||
DesireCore 客户端启动时会克隆此仓库,并定期同步更新。用户在应用商店中看到的所有条目均来自此仓库。
|
||||
|
||||
@@ -9,7 +9,7 @@ DesireCore 客户端启动时会克隆此仓库,并定期同步更新。用户
|
||||
```text
|
||||
.
|
||||
├── README.md # 本文件
|
||||
├── SCHEMA_VERSION # 数据格式版本号(当前 4.1.0)
|
||||
├── SCHEMA_VERSION # 数据格式版本号(当前 4.2.0)
|
||||
├── manifest.json # 仓库元数据(版本、统计、维护者)
|
||||
├── package.json # Registry 校验入口
|
||||
├── schemas/ # legacy entry、仓库 manifest 与 catalog sidecar Schema
|
||||
@@ -44,10 +44,30 @@ DesireCore 客户端启动时会克隆此仓库,并定期同步更新。用户
|
||||
|
||||
原生应用必须声明宿主机 Node 版本、`docker: false`、精确管理端口、最低客户端版本、不可变 Release URL/ref/SHA-256 和审核记录。安装说明同时覆盖安装、升级、验证及卸载,不随 DesireCore 启停,不默认启用控制或隧道。条目禁止 `connection`、`exposes`、`endpoint`、工具数等内部服务配置;`allowAgentBinding` 为 false。兼容来源字段只写在 manifest.source 与 sidecar.provenance.content,二者必须一致;sidecar 不写运行时 governance.source。
|
||||
|
||||
安装要求支持 native-app 的客户端(Control 条目最低 10.0.169)及核心应用安装技能 >=1.4.0。目录同步不能升级桌面客户端或技能;旧版看不到条目或提示升级时,不得修改类型绕过兼容检查。新制品已发布不代表新版桌面客户端已发布,也不代表用户已完成 ChatGPT 联调。
|
||||
安装要求支持 native-app 的客户端(Control 条目最低 10.0.170)及核心应用安装技能 >=1.5.0。目录同步不能升级桌面客户端或技能;旧版看不到条目或提示升级时,不得修改类型绕过兼容检查。新制品已发布不代表新版桌面客户端已发布,也不代表用户已完成 ChatGPT 联调。
|
||||
|
||||
English: Native applications are independently installed host processes. Their outward MCP protocol does not make them internal service packages. Pinned artifacts, explicit client/skill compatibility and App lifecycle receipts are required; catalog publication alone cannot upgrade an older client. The Control listing is the sole application entry, not a Docker or MCP duplicate.
|
||||
|
||||
## 应用与插件(4.2)
|
||||
|
||||
插件仍是统一 App 产品,`productKind: plugin` 表示它向宿主提供能力;`productKind: application` 表示它交付独立业务。`type` 只区分交付形态:Docker、宿主进程或纯声明 `artifact`。插件使用同一来源内产品 ID、版本、安装材料与安装登记,不重复创建 MCP 服务或第二份安装事实。
|
||||
|
||||
应用类型必须明确声明 `entrypoints`。普通 native/Docker 应用可省略 `productKind`,受信读取时归一为 `application`,并至少有一个入口;插件必须明确声明 `productKind: plugin`,无 UI 插件可以声明空数组,但必须通过 `extension.contributes` 提供至少一项实际宿主贡献。新 `artifact` 仍要求明确产品归属,使用 `install.method: artifact`、`docker: false`、空端口与声明式 runtime,不能伪装成 Docker 或宿主进程。
|
||||
|
||||
`extension` 区分提供与消费:
|
||||
|
||||
- `contributes` 指明本产品提供的贡献 ID、贡献点、契约版本及包内 `definitionRef.path/sha256`。当前贡献点限于 `document.parsers`、`session.panels`、`session.widgets`、`session.actions`、`tools`、`skills`。
|
||||
- `requires` 指明必需或可选的直接能力依赖,使用精确 `sourceId/appId/contributionId/contractVersion`,可加版本范围。普通应用可以只声明依赖;插件即使有依赖也必须提供贡献。两项同时为空无实际关系,校验拒绝。
|
||||
- 产品引用不携带安装实例 ID、凭据、启用状态或权限。客户端运行时解析精确安装实例,由目标宿主维护接入、配置和撤销;目录声明不会自动安装、启用或授权依赖。
|
||||
|
||||
依赖中的 `extension.requires[].sourceId` 是产品引用;条目自身的 `sourceId` 与 `hasInstall` 仍只由受信客户端注入。贡献材料必须使用规范 POSIX 包内相对路径并固定摘要;实际获取与消费仍由客户端核验字节与文件归属。
|
||||
|
||||
4.2 对现有 9 个应用在 sidecar 中声明 `application` 身份,legacy manifest 省略旧客户端不认识的 `productKind`,并提供读取安装/使用材料后委派 Agent 的真实维护入口。现有目录没有可引用的用户 UI 服务 ID,不猜测 API endpoint 或任意本机端口为 Web 页面;`legacy-port:`/`native-port:` 仅供客户端兼容适配层生成,发布者不能自行声明。原有版本、来源、许可证、审核及 `listing-only` 事实保留,未完成治理证据的条目不会因为格式迁移变为可安装。当前官方条目数仍为 23,没有新增或默认启用插件。
|
||||
|
||||
Catalog sidecar 的 App spec 同步 `productKind`、可选 `extension` 和入口类型摘要;与 manifest 的受信归一结果必须一致。4.2 Schema 的固定 LF SHA-256 为 `ea63d6305ae17994e595aea009a131c41300d2b7fdb07ec83cc2c19c37b32038`。旧普通应用 manifest 保留已发布 10.0.177 可读取的字段;这不表示旧客户端支持 4.2 插件、artifact 或受新 authored 契约校验的外部集成。新能力上线前须确保目标客户端固定了此摘要并支持 4.2,目录发布不会升级客户端。
|
||||
|
||||
English: Plugins are App products with host contributions, sharing the canonical catalog and installation identity. Deployment type does not decide product kind. Applications may provide contributions or consume direct dependencies; a plugin must provide a real contribution. Artifact packages contain declarative materials without ports or processes. Dependency product references do not grant installation, activation or permission. Existing listings retain their source and compliance evidence; schema migration does not make listing-only content installable or publish a client release.
|
||||
|
||||
## 条目格式
|
||||
|
||||
每个条目是 `entries/<id>/` 下的一个目录,目录名即条目 ID。
|
||||
@@ -62,7 +82,7 @@ English: Native applications are independently installed host processes. Their o
|
||||
|------|------|------|------|
|
||||
| `id` | string | ✅ | 唯一标识,与目录名一致 |
|
||||
| `name` | string | ✅ | 显示名称 |
|
||||
| `type` | string | ✅ | 条目类型:`native-app` / `docker-app` / `mcp` / `http-api` / `external-integration` |
|
||||
| `type` | string | ✅ | 条目类型:`native-app` / `docker-app` / `artifact` / `mcp` / `http-api` / `external-integration` |
|
||||
| `version` | string | ✅ | 上游原始版本字符串;可为 SemVer、CalVer 或不透明版本 |
|
||||
| `description` | string | ✅ | 一行功能摘要 |
|
||||
| `author` | string | | 作者或组织 |
|
||||
@@ -176,7 +196,7 @@ Sidecar 只允许声明条目自身事实:
|
||||
- `provenance.content`:内容上游及其不可变 ref/digest;Catalog 仓库来源由客户端受信上下文注入。
|
||||
- `governance`:listing-only/installable、维护者、许可证、品牌和审核证据。
|
||||
- `compatibility`:平台必须显式区分 `known` / `all` / `unknown`。
|
||||
- `spec`:App 分类、入口类型与放置策略,或 Service 协议、鉴权类型、能力与工具数;安装命令、endpoint 和凭据不进入统一目录元数据。
|
||||
- `spec`:App 的产品归属、宿主贡献/依赖、分类、入口类型与放置策略,或 Service 协议、鉴权类型、能力与工具数;安装命令、endpoint 和凭据不进入统一目录元数据。
|
||||
|
||||
示例(证据不足的条目必须失败关闭为 listing-only):
|
||||
|
||||
@@ -231,6 +251,8 @@ cat > entries/my-app/manifest.json << 'EOF'
|
||||
"id": "my-app",
|
||||
"name": "My App",
|
||||
"type": "docker-app",
|
||||
"productKind": "application",
|
||||
"entrypoints": [{ "id": "manage", "name": "委派 Agent 打开与维护 My App", "kind": "agent" }],
|
||||
"version": "1.0.0",
|
||||
"author": "Author",
|
||||
"description": "一行功能描述",
|
||||
@@ -349,7 +371,7 @@ external integration 不是开放的自助条目类型。新增 ID、URL、扩
|
||||
|
||||
1. 编辑 `entries/<id>/manifest.json` 中的字段
|
||||
2. 如有安装/使用流程变更,同步更新 `install.md` / `usage.md`;external integration 不适用
|
||||
3. **务必更新 `version` 字段**(客户端通过版本号判断是否有更新)
|
||||
3. 上游已发布新版本时更新 `version` 字段;它保留上游版本事实,仅修改目录元数据或格式不能伪造新产品版本
|
||||
4. 新增或删除条目时同步更新根 `manifest.json#stats`
|
||||
5. 执行 `npm ci && npm test`
|
||||
6. 提交并创建 PR
|
||||
@@ -368,7 +390,9 @@ external integration 不是开放的自助条目类型。新增 ID、URL、扩
|
||||
| 1.0.0 | 初始格式 — 单文件 JSON 数组 |
|
||||
| 2.0.0 | 分散式目录 — apps/mcp/services 三目录,每个条目 `<id>/index.json` |
|
||||
| 3.0.0 | 统一 entries/ 目录,manifest.json + install.md + usage.md |
|
||||
| 4.0.0 | **当前** — Draft-07 严格判别 Schema、仓库校验和 listing-only external integration |
|
||||
| 4.0.0 | Draft-07 严格判别 Schema、仓库校验和 listing-only external integration |
|
||||
| 4.1.0 | 原生应用、不可变制品与 App 生命周期边界 |
|
||||
| 4.2.0 | **当前** — 应用/插件身份、纯声明 artifact、宿主贡献与直接能力依赖 |
|
||||
|
||||
## 校验
|
||||
|
||||
@@ -377,7 +401,7 @@ npm ci
|
||||
npm test
|
||||
```
|
||||
|
||||
校验包含 JSON Schema、目录与 ID、全局唯一性、根版本、统计、来源注入字段、external 单文件布局、固定 Kimi ID、完整官方 URL、扩展 ID、组件/权限/准入集合、真实日历日期和不可变供应链审核记录。
|
||||
校验包含 JSON Schema、目录与 ID、全局唯一性、根版本、统计、来源注入字段、贡献/入口 ID 唯一性、应用与插件关系、artifact 无进程约束、sidecar 一致性、external 单文件布局、固定 Kimi ID、完整官方 URL、扩展 ID、组件/权限/准入集合、真实日历日期和不可变供应链审核记录。条件块使用 Draft-07 的父约束补充语义;Ajv 保留严格关键字检查,关闭 `strictTypes/strictRequired` 的额外作者风格限制,不降低数据值校验。
|
||||
|
||||
Schema v4 同时保留 `catalog-metadata.v1.json` 统一目录 sidecar、strict validator 与 legacy fallback。
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
4.1.0
|
||||
4.2.0
|
||||
|
||||
@@ -76,6 +76,10 @@
|
||||
},
|
||||
"spec": {
|
||||
"kind": "app",
|
||||
"category": "tools"
|
||||
"category": "tools",
|
||||
"productKind": "application",
|
||||
"entrypointKinds": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,5 +43,12 @@
|
||||
"path": "/api/v1",
|
||||
"name": "AnythingLLM API"
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "manage",
|
||||
"name": "委派 Agent 打开与维护 AnythingLLM",
|
||||
"kind": "agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
},
|
||||
"spec": {
|
||||
"kind": "app",
|
||||
"category": "ai-platform"
|
||||
"category": "ai-platform",
|
||||
"productKind": "application",
|
||||
"entrypointKinds": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,5 +43,12 @@
|
||||
"path": "/api",
|
||||
"name": "Coze API"
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "manage",
|
||||
"name": "委派 Agent 打开与维护 Coze",
|
||||
"kind": "agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -111,6 +111,10 @@
|
||||
"showInMyApps": true,
|
||||
"allowNavPin": true,
|
||||
"allowAgentBinding": false
|
||||
}
|
||||
},
|
||||
"productKind": "application",
|
||||
"entrypointKinds": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,5 +28,12 @@
|
||||
"listingMaintainer": { "name": "DesireCore Registry maintainers", "url": "https://github.com/desirecore/registry" },
|
||||
"upstreamMaintainer": { "name": "desirecore-agent", "url": "https://github.com/desirecore-agent/desirecore-cdp-mcp" },
|
||||
"branding": { "relationship": "independent-listing", "nameUsage": "nominative", "logoStatus": "not-used" },
|
||||
"compliance": { "licenseEvidencePath": "LICENSE", "noticePath": "NOTICE", "reviewedRef": "38184fbb01f82d4dd850b67366cdac048a09474b", "reviewedAt": "2026-09-18", "reviewedBy": "release-and-package-validation (see review.md)", "upstreamEndorsed": false }
|
||||
"compliance": { "licenseEvidencePath": "LICENSE", "noticePath": "NOTICE", "reviewedRef": "38184fbb01f82d4dd850b67366cdac048a09474b", "reviewedAt": "2026-09-18", "reviewedBy": "release-and-package-validation (see review.md)", "upstreamEndorsed": false },
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "manage",
|
||||
"name": "委派 Agent 打开与维护 DesireCore Control",
|
||||
"kind": "agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@
|
||||
},
|
||||
"spec": {
|
||||
"kind": "app",
|
||||
"category": "ai-platform"
|
||||
"category": "ai-platform",
|
||||
"productKind": "application",
|
||||
"entrypointKinds": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,12 @@
|
||||
"path": "/v1",
|
||||
"name": "Dify API"
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "manage",
|
||||
"name": "委派 Agent 打开与维护 Dify",
|
||||
"kind": "agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@
|
||||
},
|
||||
"spec": {
|
||||
"kind": "app",
|
||||
"category": "chat"
|
||||
"category": "chat",
|
||||
"productKind": "application",
|
||||
"entrypointKinds": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,12 @@
|
||||
"path": "/api",
|
||||
"name": "LobeChat API"
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "manage",
|
||||
"name": "委派 Agent 打开与维护 LobeChat",
|
||||
"kind": "agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@
|
||||
},
|
||||
"spec": {
|
||||
"kind": "app",
|
||||
"category": "workflow"
|
||||
"category": "workflow",
|
||||
"productKind": "application",
|
||||
"entrypointKinds": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,5 +43,12 @@
|
||||
"path": "/api/v1",
|
||||
"name": "n8n REST API"
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "manage",
|
||||
"name": "委派 Agent 打开与维护 n8n",
|
||||
"kind": "agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@
|
||||
},
|
||||
"spec": {
|
||||
"kind": "app",
|
||||
"category": "chat"
|
||||
"category": "chat",
|
||||
"productKind": "application",
|
||||
"entrypointKinds": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,5 +43,12 @@
|
||||
"path": "/api",
|
||||
"name": "Open WebUI API"
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "manage",
|
||||
"name": "委派 Agent 打开与维护 Open WebUI",
|
||||
"kind": "agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@
|
||||
},
|
||||
"spec": {
|
||||
"kind": "app",
|
||||
"category": "ai-platform"
|
||||
"category": "ai-platform",
|
||||
"productKind": "application",
|
||||
"entrypointKinds": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,5 +45,12 @@
|
||||
"path": "/",
|
||||
"name": "OpenClaw Gateway"
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "manage",
|
||||
"name": "委派 Agent 打开与维护 OpenClaw",
|
||||
"kind": "agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@
|
||||
},
|
||||
"spec": {
|
||||
"kind": "app",
|
||||
"category": "rag"
|
||||
"category": "rag",
|
||||
"productKind": "application",
|
||||
"entrypointKinds": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,5 +45,12 @@
|
||||
"path": "/api/v1",
|
||||
"name": "RagFlow API"
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "manage",
|
||||
"name": "委派 Agent 打开与维护 RagFlow",
|
||||
"kind": "agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"id": "desirecore-registry-manifest",
|
||||
"version": "4.1.0",
|
||||
"version": "4.2.0",
|
||||
"name": "DesireCore Registry",
|
||||
"description": "DesireCore 官方注册表 — 原生应用、Docker 应用、MCP 服务、HTTP API 和第三方外部集成的统一目录",
|
||||
"description": "DesireCore 官方注册表 — 应用、插件、MCP 服务、HTTP API 和第三方外部集成的统一目录",
|
||||
"maintainer": "DesireCore Team",
|
||||
"repository": "https://github.com/desirecore/registry",
|
||||
"lastUpdated": "2026-09-18",
|
||||
"lastUpdated": "2026-10-05",
|
||||
"stats": {
|
||||
"totalEntries": 23,
|
||||
"dockerApps": 8,
|
||||
@@ -15,7 +15,7 @@
|
||||
"httpApis": 5,
|
||||
"externalIntegrations": 1
|
||||
},
|
||||
"dataVersion": "4.1.0",
|
||||
"dataVersion": "4.2.0",
|
||||
"catalogMetadata": {
|
||||
"version": "1.0.0",
|
||||
"schema": "schemas/catalog-metadata.v1.schema.json",
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@desirecore/registry",
|
||||
"version": "4.1.0",
|
||||
"version": "4.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@desirecore/registry",
|
||||
"version": "4.1.0",
|
||||
"version": "4.2.0",
|
||||
"devDependencies": {
|
||||
"ajv": "8.20.0"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@desirecore/registry",
|
||||
"version": "4.1.0",
|
||||
"version": "4.2.0",
|
||||
"private": true,
|
||||
"description": "Validated catalog data for the DesireCore Registry",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"validate": "node scripts/validate-registry.mjs",
|
||||
"test": "node --test scripts/validate-registry.test.mjs scripts/native-app.test.mjs && npm run validate"
|
||||
"test": "node --test scripts/validate-registry.test.mjs scripts/native-app.test.mjs scripts/plugin-contract.test.mjs && npm run validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ajv": "8.20.0"
|
||||
|
||||
@@ -236,14 +236,269 @@
|
||||
"required": ["kind"],
|
||||
"properties": {
|
||||
"kind": { "const": "app" },
|
||||
"productKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"application",
|
||||
"plugin"
|
||||
],
|
||||
"description": "主要产品归属;应用提供独立业务,插件增加宿主能力。旧定义缺失时由受信适配层归一为 application。"
|
||||
},
|
||||
"extension": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"apiVersion",
|
||||
"hosts",
|
||||
"runtime",
|
||||
"contributes"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiVersion": {
|
||||
"const": "1.0.0",
|
||||
"description": "宿主扩展协议版本;当前支持 1.0.0。"
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 2,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"agent-service",
|
||||
"desktop-host"
|
||||
]
|
||||
},
|
||||
"description": "执行宿主;未实现的宿主只作声明并显示不兼容,客户端展示面在 UI 定义中另行声明。"
|
||||
},
|
||||
"runtime": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "declarative",
|
||||
"description": "仅由宿主读取声明,不创建执行进程。"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"protocol",
|
||||
"descriptorRef"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "service",
|
||||
"description": "既有服务 owner 管理的协议连接;贡献调用继续走原授权和审批。"
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"mcp",
|
||||
"http"
|
||||
],
|
||||
"description": "服务通信协议,不代表连接已经建立。"
|
||||
},
|
||||
"descriptorRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"sha256"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512,
|
||||
"pattern": "^(?!/)(?!.*\\\\)(?!.*:)(?!.*\\u0000)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))(?!.*//)[^/]+(?:/[^/]+)*$",
|
||||
"description": "固定在核验制品根下的规范 POSIX 相对文件路径;拒绝绝对路径、反斜杠、盘符、空段、点段及 NUL。解析时仍须核验 realpath containment。"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$",
|
||||
"description": "固定文件字节的 SHA-256;消费前核验,编辑须提交新安装修订。"
|
||||
}
|
||||
},
|
||||
"description": "安装制品根下内容固定的材料引用;相对路径和摘要必须共同匹配。"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"description": "贡献执行承载;descriptor 只定位受核验材料,不能直接把任意 endpoint 当运行事实。"
|
||||
},
|
||||
"contributes": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"point",
|
||||
"contractVersion",
|
||||
"definitionRef"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
|
||||
"description": "产品内稳定贡献 ID;与来源、产品及精确安装实例共同定位运行绑定。"
|
||||
},
|
||||
"point": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"document.parsers",
|
||||
"session.panels",
|
||||
"session.widgets",
|
||||
"session.actions",
|
||||
"tools",
|
||||
"skills"
|
||||
],
|
||||
"description": "具有实际宿主消费者的贡献点;未知贡献点不得接入。"
|
||||
},
|
||||
"contractVersion": {
|
||||
"const": 1,
|
||||
"description": "该贡献点的消费契约版本;不兼容版本不激活。"
|
||||
},
|
||||
"definitionRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"sha256"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512,
|
||||
"pattern": "^(?!/)(?!.*\\\\)(?!.*:)(?!.*\\u0000)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))(?!.*//)[^/]+(?:/[^/]+)*$",
|
||||
"description": "固定在核验制品根下的规范 POSIX 相对文件路径;拒绝绝对路径、反斜杠、盘符、空段、点段及 NUL。解析时仍须核验 realpath containment。"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$",
|
||||
"description": "固定文件字节的 SHA-256;消费前核验,编辑须提交新安装修订。"
|
||||
}
|
||||
},
|
||||
"description": "安装制品根下内容固定的材料引用;相对路径和摘要必须共同匹配。"
|
||||
}
|
||||
},
|
||||
"description": "包内贡献定义引用;业务层补充贡献 ID 唯一性及内容摘要核验。"
|
||||
},
|
||||
"description": "本产品提供的宿主贡献;仅消费能力的普通应用允许空数组,独立插件必须至少一项。每项定义从安装材料读取并核验。"
|
||||
},
|
||||
"configurationSchemaRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"sha256"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512,
|
||||
"pattern": "^(?!/)(?!.*\\\\)(?!.*:)(?!.*\\u0000)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))(?!.*//)[^/]+(?:/[^/]+)*$",
|
||||
"description": "固定在核验制品根下的规范 POSIX 相对文件路径;拒绝绝对路径、反斜杠、盘符、空段、点段及 NUL。解析时仍须核验 realpath containment。"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$",
|
||||
"description": "固定文件字节的 SHA-256;消费前核验,编辑须提交新安装修订。"
|
||||
}
|
||||
},
|
||||
"description": "安装制品根下内容固定的材料引用;相对路径和摘要必须共同匹配。"
|
||||
},
|
||||
"requires": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sourceId",
|
||||
"appId",
|
||||
"contributionId",
|
||||
"contractVersion",
|
||||
"optional"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"sourceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
|
||||
"description": "直接依赖的受信产品来源,禁止按同名产品猜测。"
|
||||
},
|
||||
"appId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
|
||||
"description": "所依赖的应用或插件产品 ID;静态定义不携带安装实例 ID。"
|
||||
},
|
||||
"contributionId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
|
||||
"description": "消费方需要的具体贡献 ID。"
|
||||
},
|
||||
"contractVersion": {
|
||||
"const": 1,
|
||||
"description": "消费方支持的贡献契约版本。"
|
||||
},
|
||||
"optional": {
|
||||
"type": "boolean",
|
||||
"description": "true 为可选增强;false 缺失时阻断受影响功能,不自动安装或卸载。"
|
||||
},
|
||||
"versionRange": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120,
|
||||
"description": "可选发布版本 semver 范围,由满足性检查器校验。"
|
||||
}
|
||||
},
|
||||
"description": "直接能力依赖;运行时须显式解析兼容的精确安装实例,不建设递归依赖求解器。"
|
||||
},
|
||||
"description": "本产品消费的必需或可选直接能力依赖;不产生使用授权。"
|
||||
}
|
||||
},
|
||||
"not": {
|
||||
"properties": {
|
||||
"contributes": {
|
||||
"maxItems": 0
|
||||
},
|
||||
"requires": {
|
||||
"maxItems": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "产品提供或消费宿主能力的关系契约;至少声明一项贡献或依赖。普通应用可仅消费,独立插件仍须提供实际贡献。"
|
||||
},
|
||||
"category": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
|
||||
"entrypointKinds": {
|
||||
"type": "array",
|
||||
"items": { "enum": ["builtin", "embedded-web", "external"] },
|
||||
"items": { "enum": ["builtin", "embedded-web", "external", "agent"] },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"placementPolicy": { "$ref": "#/$defs/placementPolicy" }
|
||||
},
|
||||
"allOf": [{"if":{"properties":{"productKind":{"const":"plugin"}},"required":["productKind"]},"then":{"required":["extension"],"properties":{"extension":{"properties":{"contributes":{"minItems":1}}}}}}],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"serviceSpec": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
||||
"totalEntries": { "type": "integer", "minimum": 0 },
|
||||
"dockerApps": { "type": "integer", "minimum": 0 },
|
||||
"nativeApps": { "type": "integer", "minimum": 0 },
|
||||
"artifactApps": { "type": "integer", "minimum": 0, "description": "纯声明制品的 App 条目数,包括插件;不是安装数。" },
|
||||
"mcpServices": { "type": "integer", "minimum": 0 },
|
||||
"httpApis": { "type": "integer", "minimum": 0 },
|
||||
"externalIntegrations": { "type": "integer", "minimum": 0 }
|
||||
|
||||
20
scripts/catalog/application-contract.mjs
Normal file
20
scripts/catalog/application-contract.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
/** 发布者关系声明的跨字段检查;不推导安装、权限或运行时健康。 */
|
||||
export function validateApplicationContract(manifest) {
|
||||
if (!['docker-app', 'native-app', 'artifact'].includes(manifest.type)) return []
|
||||
const errors = []
|
||||
const ids = new Set()
|
||||
for (const entrypoint of Array.isArray(manifest.entrypoints) ? manifest.entrypoints : []) {
|
||||
if (!entrypoint || typeof entrypoint !== 'object') continue
|
||||
if (ids.has(entrypoint.id)) errors.push('entrypoints: duplicate entrypoint id ' + entrypoint.id)
|
||||
ids.add(entrypoint.id)
|
||||
if (entrypoint.kind === 'builtin') errors.push('entrypoints: third-party Registry cannot declare builtin routes')
|
||||
if (/^(?:legacy|native)-port:/.test(entrypoint.serviceRef ?? '')) errors.push('entrypoints: reserved port references are minted only by the trusted client adapter')
|
||||
}
|
||||
ids.clear()
|
||||
for (const contribution of Array.isArray(manifest.extension?.contributes) ? manifest.extension.contributes : []) {
|
||||
if (!contribution || typeof contribution !== 'object') continue
|
||||
if (ids.has(contribution.id)) errors.push('extension.contributes: duplicate contribution id ' + contribution.id)
|
||||
ids.add(contribution.id)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
@@ -9,9 +9,9 @@ test('metrics are rejected without interpreting examples or configuration parame
|
||||
assert.deepEqual(findStaticMetrics({ description: 'downloads files', toolCount: 13, configSchema: { properties: { rating: { type: 'number' } } } }), [])
|
||||
})
|
||||
|
||||
test('Registry 4.1 keeps the exact published client schema identity', () => {
|
||||
test('Registry 4.2 keeps the exact reviewed client schema identity', () => {
|
||||
const version = readFileSync(new URL('../../SCHEMA_VERSION', import.meta.url), 'utf8').trim()
|
||||
assert.equal(version, '4.1.0')
|
||||
assert.equal(version, '4.2.0')
|
||||
const bytes = readFileSync(new URL('../../schemas/registry-entry.schema.json', import.meta.url))
|
||||
assert.equal(createHash('sha256').update(bytes).digest('hex'), '767e103a66b4f8f722d1eb4d9073c218536e8f551ef43ac766dcd1be8f88fc62')
|
||||
assert.equal(createHash('sha256').update(bytes).digest('hex'), 'ea63d6305ae17994e595aea009a131c41300d2b7fdb07ec83cc2c19c37b32038')
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ if (json) {
|
||||
} else {
|
||||
const { counts } = report
|
||||
process.stdout.write(`Registry 校验:${report.ok ? '通过' : '失败'}\n`)
|
||||
process.stdout.write(`条目 ${counts.totalEntries}(App ${counts.dockerApps + (counts.nativeApps ?? 0)}(原生 ${counts.nativeApps ?? 0}) / MCP ${counts.mcpServices} / HTTP ${counts.httpApis})\n`)
|
||||
process.stdout.write(`条目 ${counts.totalEntries}(App ${counts.dockerApps + (counts.nativeApps ?? 0) + (counts.artifactApps ?? 0)}(原生 ${counts.nativeApps ?? 0} / 声明制品 ${counts.artifactApps ?? 0}) / MCP ${counts.mcpServices} / HTTP ${counts.httpApis})\n`)
|
||||
process.stdout.write(`Catalog sidecar ${counts.sidecars},legacy fallback ${counts.legacyOnly}\n`)
|
||||
for (const item of report.diagnostics) {
|
||||
process.stdout.write(`${item.level === 'error' ? 'ERROR' : 'WARN '} ${item.file} ${item.path} [${item.code}] ${item.message}\n`)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isDeepStrictEqual } from 'node:util'
|
||||
import { basename, join, relative, resolve, sep } from 'node:path'
|
||||
import { validateJsonSchema } from './json-schema.mjs'
|
||||
import { findStaticMetrics } from './static-metadata.mjs'
|
||||
import { validateApplicationContract } from './application-contract.mjs'
|
||||
|
||||
export const CATALOG_SIDECAR_FILENAME = 'catalog-metadata.v1.json'
|
||||
|
||||
@@ -76,7 +77,7 @@ function timestampShapeIsConsistent(timestamp) {
|
||||
function validateSidecarSemantics(manifest, sidecar, file) {
|
||||
const errors = []
|
||||
const add = (code, path, message) => errors.push(diagnostic('error', code, file, path, message))
|
||||
const expectedKind = ['docker-app', 'native-app'].includes(manifest.type) ? 'app' : 'service'
|
||||
const expectedKind = ['docker-app', 'native-app', 'artifact'].includes(manifest.type) ? 'app' : 'service'
|
||||
|
||||
if (sidecar.identity?.id !== manifest.id) add('identity-mismatch', '$.identity.id', 'sidecar identity.id 必须与 legacy manifest.id 一致')
|
||||
if (sidecar.identity?.kind !== expectedKind) add('kind-mismatch', '$.identity.kind', `legacy ${manifest.type} 必须映射为 ${expectedKind}`)
|
||||
@@ -128,6 +129,10 @@ function validateSidecarSemantics(manifest, sidecar, file) {
|
||||
|
||||
if (expectedKind === 'app') {
|
||||
if (sidecar.spec?.category !== manifest.category) add('spec-mismatch', '$.spec.category', 'App category 必须与 legacy category 一致')
|
||||
if (sidecar.spec?.productKind !== undefined && sidecar.spec.productKind !== (manifest.productKind ?? 'application')) add('product-kind-mismatch', '$.spec.productKind', '产品归属必须与 manifest 一致')
|
||||
if (sidecar.spec?.extension !== undefined && !isDeepStrictEqual(sidecar.spec.extension, manifest.extension)) add('extension-mismatch', '$.spec.extension', '宿主贡献与直接依赖必须与 manifest 一致')
|
||||
const entrypointKinds = [...new Set((Array.isArray(manifest.entrypoints) ? manifest.entrypoints : []).map(entry => entry?.kind))]
|
||||
if (sidecar.spec?.entrypointKinds !== undefined && !sameStringSet(sidecar.spec.entrypointKinds, entrypointKinds)) add('entrypoint-mismatch', '$.spec.entrypointKinds', '入口类型摘要必须与 manifest 一致')
|
||||
} else {
|
||||
const expectedProtocol = manifest.type === 'mcp' ? 'mcp' : 'http'
|
||||
if (sidecar.spec?.protocol !== expectedProtocol) add('spec-mismatch', '$.spec.protocol', `Service protocol 必须是 ${expectedProtocol}`)
|
||||
@@ -135,7 +140,7 @@ function validateSidecarSemantics(manifest, sidecar, file) {
|
||||
if (manifest.toolCount !== undefined && sidecar.spec?.toolCount !== manifest.toolCount) add('spec-mismatch', '$.spec.toolCount', 'toolCount 必须与 legacy 一致')
|
||||
}
|
||||
|
||||
if (manifest.type === 'native-app') {
|
||||
if (['native-app', 'artifact'].includes(manifest.type)) {
|
||||
for (const field of ['stewardship', 'availability', 'redistribution', 'listingMaintainer', 'upstreamMaintainer', 'branding', 'compliance']) {
|
||||
if (!isDeepStrictEqual(manifest[field], sidecar.governance?.[field])) add('native-governance-mismatch', '$.governance.' + field, '原生应用 manifest 与 sidecar 的治理事实必须一致')
|
||||
}
|
||||
@@ -201,7 +206,7 @@ export function validateRegistry(repoRoot, options = {}) {
|
||||
const manifestFile = join(root, 'manifest.json')
|
||||
const schemaVersionFile = join(root, 'SCHEMA_VERSION')
|
||||
const rootSchema = readJson(join(root, 'schemas', 'registry-manifest.v3.schema.json'), diagnostics)
|
||||
const entrySchema = readJson(join(root, 'schemas', 'registry-entry.v3.schema.json'), diagnostics)
|
||||
const legacyEntrySchema = readJson(join(root, 'schemas', 'registry-entry.v3.schema.json'), diagnostics)
|
||||
const externalEntrySchema = readJson(join(root, 'schemas', 'registry-entry.schema.json'), diagnostics)
|
||||
const sidecarSchema = readJson(join(root, 'schemas', 'catalog-metadata.v1.schema.json'), diagnostics)
|
||||
const rootManifest = readJson(manifestFile, diagnostics)
|
||||
@@ -210,8 +215,10 @@ export function validateRegistry(repoRoot, options = {}) {
|
||||
|
||||
if (rootManifest && rootSchema) diagnostics.push(...schemaDiagnostics(rootManifest, rootSchema, relativeFile(root, manifestFile)))
|
||||
const schemaVersion = existsSync(schemaVersionFile) ? readFileSync(schemaVersionFile, 'utf8').trim() : ''
|
||||
const currentEntrySchema = /^4\./.test(schemaVersion) ? externalEntrySchema : legacyEntrySchema
|
||||
if (!schemaVersion) diagnostics.push(diagnostic('error', 'missing-schema-version', 'SCHEMA_VERSION', '$', 'SCHEMA_VERSION 不能为空'))
|
||||
if (rootManifest?.version !== schemaVersion) diagnostics.push(diagnostic('error', 'schema-version-mismatch', 'manifest.json', '$.version', 'manifest.version 必须与 SCHEMA_VERSION 一致'))
|
||||
if (rootManifest?.dataVersion !== schemaVersion) diagnostics.push(diagnostic('error', 'schema-version-mismatch', 'manifest.json', '$.dataVersion', 'manifest.dataVersion 必须与 SCHEMA_VERSION 一致'))
|
||||
|
||||
const seenIds = new Set()
|
||||
for (const entryDir of entryDirs) {
|
||||
@@ -229,11 +236,13 @@ export function validateRegistry(repoRoot, options = {}) {
|
||||
counts.totalEntries += 1
|
||||
if (entry.type === 'docker-app') counts.dockerApps += 1
|
||||
if (entry.type === 'native-app') counts.nativeApps = (counts.nativeApps ?? 0) + 1
|
||||
if (entry.type === 'artifact') counts.artifactApps = (counts.artifactApps ?? 0) + 1
|
||||
if (entry.type === 'mcp') counts.mcpServices += 1
|
||||
if (entry.type === 'http-api') counts.httpApis += 1
|
||||
if (entry.type === 'external-integration') counts.externalIntegrations += 1
|
||||
const effectiveEntrySchema = ['external-integration', 'native-app'].includes(entry.type) ? externalEntrySchema : entrySchema
|
||||
const effectiveEntrySchema = ['external-integration', 'native-app', 'artifact'].includes(entry.type) ? externalEntrySchema : currentEntrySchema
|
||||
if (effectiveEntrySchema) diagnostics.push(...schemaDiagnostics(entry, effectiveEntrySchema, manifestRelative))
|
||||
for (const message of validateApplicationContract(entry)) diagnostics.push(diagnostic('error', 'application-contract', manifestRelative, '$', message))
|
||||
if (entry.id !== basename(entryDir)) diagnostics.push(diagnostic('error', 'directory-id-mismatch', manifestRelative, '$.id', 'manifest.id 必须与 entries/<id> 目录名一致'))
|
||||
if (seenIds.has(entry.id)) diagnostics.push(diagnostic('error', 'duplicate-id', manifestRelative, '$.id', 'Registry 条目 ID 重复'))
|
||||
seenIds.add(entry.id)
|
||||
@@ -255,10 +264,10 @@ export function validateRegistry(repoRoot, options = {}) {
|
||||
}
|
||||
|
||||
if (rootManifest?.stats) {
|
||||
for (const key of ['totalEntries', 'dockerApps', 'nativeApps', 'mcpServices', 'httpApis', 'externalIntegrations']) {
|
||||
if (key === 'nativeApps' && rootManifest.stats[key] === undefined && !counts.nativeApps) continue
|
||||
for (const key of ['totalEntries', 'dockerApps', 'nativeApps', 'artifactApps', 'mcpServices', 'httpApis', 'externalIntegrations']) {
|
||||
if (['nativeApps', 'artifactApps'].includes(key) && rootManifest.stats[key] === undefined && !counts[key]) continue
|
||||
if (rootManifest.stats[key] === undefined && key === 'externalIntegrations' && counts[key] === 0) continue
|
||||
if (rootManifest.stats[key] !== (key === 'nativeApps' ? (counts.nativeApps ?? 0) : counts[key])) diagnostics.push(diagnostic('error', 'stats-mismatch', 'manifest.json', `$.stats.${key}`, `声明 ${rootManifest.stats[key]},实际 ${counts[key]}`))
|
||||
if (rootManifest.stats[key] !== (counts[key] ?? 0)) diagnostics.push(diagnostic('error', 'stats-mismatch', 'manifest.json', `$.stats.${key}`, `声明 ${rootManifest.stats[key]},实际 ${counts[key] ?? 0}`))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ function codes(report) {
|
||||
return report.errors.map((item) => item.code)
|
||||
}
|
||||
|
||||
test('当前 v4.1 条目保持可读,原生应用与既有 App/Service 具备 sidecar', () => {
|
||||
test('当前 v4.2 条目保持可读,原生应用与既有 App/Service 具备 sidecar', () => {
|
||||
const report = validateRegistry(repoRoot)
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
assert.deepEqual({
|
||||
|
||||
@@ -17,7 +17,7 @@ function fixture() {
|
||||
const manifest = JSON.parse(readFileSync(join(source, 'manifest.json'), 'utf8'))
|
||||
manifest.stats = { totalEntries: 1, dockerApps: 0, nativeApps: 1, mcpServices: 0, httpApis: 0, externalIntegrations: 0 }
|
||||
writeFileSync(join(root, 'manifest.json'), JSON.stringify(manifest))
|
||||
writeFileSync(join(root, 'SCHEMA_VERSION'), '4.1.0\n')
|
||||
writeFileSync(join(root, 'SCHEMA_VERSION'), manifest.version + '\n')
|
||||
return root
|
||||
}
|
||||
function mutate(root, file, change) {
|
||||
|
||||
147
scripts/plugin-contract.test.mjs
Normal file
147
scripts/plugin-contract.test.mjs
Normal file
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test, afterEach } from 'node:test'
|
||||
import { cpSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { validateRegistry } from './catalog/validator.mjs'
|
||||
|
||||
const source = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const temporary = []
|
||||
afterEach(() => { for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }) })
|
||||
const contribution = { id: 'help', point: 'session.panels', contractVersion: 1, definitionRef: { path: 'panel.json', sha256: 'a'.repeat(64) } }
|
||||
const requires = [{ sourceId: 'official', appId: 'capability-provider', contributionId: 'help', contractVersion: 1, optional: false }]
|
||||
const extension = { apiVersion: '1.0.0', hosts: ['agent-service'], runtime: { kind: 'declarative' }, contributes: [contribution] }
|
||||
|
||||
function fixture(type = 'artifact') {
|
||||
const root = mkdtempSync(join(tmpdir(), 'registry-plugin-'))
|
||||
temporary.push(root)
|
||||
cpSync(join(source, 'schemas'), join(root, 'schemas'), { recursive: true })
|
||||
cpSync(join(source, 'entries/desirecore-control'), join(root, 'entries/desirecore-control'), { recursive: true })
|
||||
const manifest = JSON.parse(readFileSync(join(source, 'manifest.json'), 'utf8'))
|
||||
manifest.stats = { totalEntries: 1, dockerApps: 0, nativeApps: type === 'native-app' ? 1 : 0, artifactApps: type === 'artifact' ? 1 : 0, mcpServices: 0, httpApis: 0, externalIntegrations: 0 }
|
||||
writeFileSync(join(root, 'manifest.json'), JSON.stringify(manifest))
|
||||
writeFileSync(join(root, 'SCHEMA_VERSION'), manifest.version + '\n')
|
||||
mutate(root, 'manifest.json', entry => {
|
||||
entry.type = type
|
||||
entry.productKind = 'plugin'
|
||||
entry.entrypoints = []
|
||||
entry.extension = structuredClone(extension)
|
||||
if (type === 'artifact') entry.install = { method: 'artifact', requirements: { docker: false, ports: [] } }
|
||||
})
|
||||
mutate(root, 'catalog-metadata.v1.json', sidecar => {
|
||||
sidecar.spec.productKind = 'plugin'
|
||||
sidecar.spec.entrypointKinds = []
|
||||
sidecar.spec.extension = structuredClone(extension)
|
||||
})
|
||||
return root
|
||||
}
|
||||
function mutate(root, file, change) {
|
||||
const path = join(root, 'entries/desirecore-control', file)
|
||||
const data = JSON.parse(readFileSync(path, 'utf8'))
|
||||
change(data)
|
||||
writeFileSync(path, JSON.stringify(data))
|
||||
}
|
||||
function result(root) {
|
||||
const cli = spawnSync(process.execPath, [join(source, 'scripts/validate-registry.mjs'), '--root', root], { encoding: 'utf8' })
|
||||
return { cli, offline: validateRegistry(root) }
|
||||
}
|
||||
function expectValid(root) {
|
||||
const report = result(root)
|
||||
assert.equal(report.cli.status, 0, report.cli.stderr)
|
||||
assert.equal(report.offline.ok, true, JSON.stringify(report.offline.errors))
|
||||
return report
|
||||
}
|
||||
function expectInvalid(root) {
|
||||
const report = result(root)
|
||||
assert.equal(report.cli.status, 1, report.cli.stdout + report.cli.stderr)
|
||||
assert.equal(report.offline.ok, false, JSON.stringify(report.offline))
|
||||
}
|
||||
|
||||
test('无 UI artifact 插件仍为 App,无端口与执行进程', () => {
|
||||
const report = expectValid(fixture())
|
||||
assert.equal(report.offline.counts.artifactApps, 1)
|
||||
assert.equal(report.offline.counts.mcpServices, 0)
|
||||
})
|
||||
test('普通原生应用可携带宿主贡献', () => {
|
||||
const root = fixture('native-app')
|
||||
mutate(root, 'manifest.json', data => { data.productKind = 'application'; data.entrypoints = [{ id: 'manage', name: '维护', kind: 'agent' }] })
|
||||
mutate(root, 'catalog-metadata.v1.json', data => { data.spec.productKind = 'application'; data.spec.entrypointKinds = ['agent'] })
|
||||
expectValid(root)
|
||||
})
|
||||
test('旧普通应用省略主要归属时受信归一 application,保留显式维护入口', () => {
|
||||
const root = fixture('native-app')
|
||||
mutate(root, 'manifest.json', data => {
|
||||
delete data.productKind
|
||||
delete data.extension
|
||||
data.entrypoints = [{ id: 'manage', name: '维护', kind: 'agent' }]
|
||||
})
|
||||
mutate(root, 'catalog-metadata.v1.json', data => {
|
||||
data.spec.productKind = 'application'
|
||||
delete data.spec.extension
|
||||
data.spec.entrypointKinds = ['agent']
|
||||
})
|
||||
expectValid(root)
|
||||
mutate(root, 'catalog-metadata.v1.json', data => { data.spec.productKind = 'plugin' })
|
||||
const report = result(root)
|
||||
assert.equal(report.offline.ok, false)
|
||||
assert.ok(report.offline.errors.some(error => error.code === 'product-kind-mismatch'))
|
||||
})
|
||||
test('省略归属不能把无入口插件声明冒充旧普通应用', () => {
|
||||
const root = fixture('native-app')
|
||||
mutate(root, 'manifest.json', data => { delete data.productKind })
|
||||
expectInvalid(root)
|
||||
})
|
||||
test('原生应用的真实服务 runtime 由固定 descriptorRef 定位', () => {
|
||||
const root = fixture('native-app')
|
||||
const runtime = { kind: 'service', protocol: 'http', descriptorRef: { path: 'runtime.json', sha256: 'c'.repeat(64) } }
|
||||
mutate(root, 'manifest.json', data => { data.extension.runtime = structuredClone(runtime) })
|
||||
mutate(root, 'catalog-metadata.v1.json', data => { data.spec.extension.runtime = structuredClone(runtime) })
|
||||
expectValid(root)
|
||||
})
|
||||
test('普通应用可只依赖能力,依赖 sourceId 不冒充目录身份', () => {
|
||||
const root = fixture('native-app')
|
||||
mutate(root, 'manifest.json', data => { data.productKind = 'application'; data.entrypoints = [{ id: 'manage', name: '维护', kind: 'agent' }]; data.extension.contributes = []; data.extension.requires = structuredClone(requires) })
|
||||
mutate(root, 'catalog-metadata.v1.json', data => { data.spec.productKind = 'application'; data.spec.entrypointKinds = ['agent']; data.spec.extension.contributes = []; data.spec.extension.requires = structuredClone(requires) })
|
||||
expectValid(root)
|
||||
})
|
||||
for (const [name, change] of [
|
||||
['插件无贡献', data => { data.extension.contributes = []; data.extension.requires = structuredClone(requires) }],
|
||||
['贡献与依赖均为空', data => { data.extension.contributes = [] }],
|
||||
['非法贡献点', data => { data.extension.contributes[0].point = 'arbitrary.code' }],
|
||||
['材料相对路径穿越', data => { data.extension.contributes[0].definitionRef.path = '../panel.json' }],
|
||||
['材料缺摘要', data => { delete data.extension.contributes[0].definitionRef.sha256 }],
|
||||
['artifact 声明端口', data => { data.install.requirements.ports = [9333] }],
|
||||
['artifact 伪造执行服务', data => { data.extension.runtime = { kind: 'service', protocol: 'http', descriptorRef: { path: 'runtime.json', sha256: 'c'.repeat(64) } } }],
|
||||
['伪造条目来源', data => { data.sourceId = 'official' }],
|
||||
['其他层级伪造来源', data => { data.extension.runtime.sourceId = 'official' }],
|
||||
['重复贡献 ID', data => { data.extension.contributes.push(structuredClone(contribution)) }],
|
||||
['重复入口 ID', data => { data.entrypoints = [{ id: 'x', name: '一', kind: 'agent' }, { id: 'x', name: '二', kind: 'agent' }] }],
|
||||
['第三方伪造 builtin', data => { data.entrypoints = [{ id: 'x', name: '一', kind: 'builtin', route: '/settings' }] }],
|
||||
['第三方指定 reserved port', data => { data.entrypoints = [{ id: 'x', name: '一', kind: 'external', serviceRef: 'native-port:9333' }] }],
|
||||
['结构错误 entrypoints', data => { data.entrypoints = {} }],
|
||||
['结构错误贡献数组', data => { data.extension.contributes = [null] }],
|
||||
]) test('CLI 与离线校验都拒绝 ' + name, () => {
|
||||
const root = fixture()
|
||||
mutate(root, 'manifest.json', change)
|
||||
expectInvalid(root)
|
||||
})
|
||||
|
||||
test('同一产品侧录不能改变 productKind 或关系声明', () => {
|
||||
const root = fixture()
|
||||
mutate(root, 'catalog-metadata.v1.json', data => { data.spec.productKind = 'application' })
|
||||
const report = validateRegistry(root)
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(report.errors.some(error => error.code === 'product-kind-mismatch'))
|
||||
})
|
||||
test('artifact 统计必须对应真实文件,来源摘要不可漂移', () => {
|
||||
const root = fixture()
|
||||
mutate(root, 'catalog-metadata.v1.json', data => { data.provenance.content.sha256 = 'b'.repeat(64) })
|
||||
assert.equal(validateRegistry(root).ok, false)
|
||||
const manifestPath = join(root, 'manifest.json')
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
manifest.stats.artifactApps = 0
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest))
|
||||
expectInvalid(root)
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { dirname, join, relative, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import Ajv from 'ajv'
|
||||
import { findStaticMetrics } from './catalog/static-metadata.mjs'
|
||||
import { validateApplicationContract } from './catalog/application-contract.mjs'
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
const rootArgumentIndex = process.argv.indexOf('--root')
|
||||
@@ -46,11 +47,13 @@ const sorted = (values) => [...values].sort()
|
||||
const sameSet = (actual, expected) =>
|
||||
JSON.stringify(sorted(actual)) === JSON.stringify(sorted(expected))
|
||||
|
||||
const containsKey = (value, prohibitedKey) => {
|
||||
if (Array.isArray(value)) return value.some((item) => containsKey(item, prohibitedKey))
|
||||
const containsKey = (value, prohibitedKey, path = '') => {
|
||||
if (Array.isArray(value)) return value.some((item, index) => containsKey(item, prohibitedKey, `${path}[${index}]`))
|
||||
if (!value || typeof value !== 'object') return false
|
||||
if (Object.hasOwn(value, prohibitedKey)) return true
|
||||
return Object.values(value).some((item) => containsKey(item, prohibitedKey))
|
||||
// 静态依赖必须指明产品来源;这不是客户端注入的条目来源身份。
|
||||
const declaredDependency = prohibitedKey === 'sourceId' && /^extension\.requires\[\d+\]$/.test(path)
|
||||
if (Object.hasOwn(value, prohibitedKey) && !declaredDependency) return true
|
||||
return Object.entries(value).some(([key, item]) => containsKey(item, prohibitedKey, path ? `${path}.${key}` : key))
|
||||
}
|
||||
|
||||
const assertHttpsUrl = (rawUrl, context) => {
|
||||
@@ -290,7 +293,8 @@ if (rootManifest) {
|
||||
let validateEntry = null
|
||||
if (entrySchema) {
|
||||
try {
|
||||
const ajv = new Ajv({ allErrors: true, strict: true, validateFormats: false })
|
||||
// Draft-07 条件块补充父 Schema 的约束;无需在每个 then/not 再写局部 type。
|
||||
const ajv = new Ajv({ allErrors: true, strict: true, strictTypes: false, strictRequired: false, validateFormats: false })
|
||||
validateEntry = ajv.compile(entrySchema)
|
||||
} catch (error) {
|
||||
addError(`schemas/registry-entry.schema.json: unable to compile (${error.message})`)
|
||||
@@ -343,6 +347,7 @@ for (const directoryEntry of await readdir(entriesRoot, { withFileTypes: true })
|
||||
if (validateEntry && !validateEntry(manifest)) {
|
||||
addError(`entries/${directoryEntry.name}/manifest.json: ${formatAjvErrors(validateEntry.errors)}`)
|
||||
}
|
||||
for (const message of validateApplicationContract(manifest)) addError(`entries/${directoryEntry.name}/manifest.json: ${message}`)
|
||||
if (manifest.type === 'external-integration') {
|
||||
await validateExternalIntegration(entryDirectory, manifest)
|
||||
}
|
||||
@@ -353,6 +358,7 @@ if (rootManifest) {
|
||||
totalEntries: manifests.length,
|
||||
dockerApps: manifests.filter((entry) => entry.type === 'docker-app').length,
|
||||
...(manifests.some((entry) => entry.type === 'native-app') || rootManifest.stats?.nativeApps !== undefined ? { nativeApps: manifests.filter((entry) => entry.type === 'native-app').length } : {}),
|
||||
...(manifests.some((entry) => entry.type === 'artifact') || rootManifest.stats?.artifactApps !== undefined ? { artifactApps: manifests.filter((entry) => entry.type === 'artifact').length } : {}),
|
||||
mcpServices: manifests.filter((entry) => entry.type === 'mcp').length,
|
||||
httpApis: manifests.filter((entry) => entry.type === 'http-api').length,
|
||||
externalIntegrations: manifests.filter((entry) => entry.type === 'external-integration').length,
|
||||
@@ -380,7 +386,7 @@ if (errors.length > 0) {
|
||||
const counts = rootManifest.stats
|
||||
console.log(
|
||||
`Registry validation passed: ${counts.totalEntries} entries ` +
|
||||
`(${counts.dockerApps} Docker, ${counts.mcpServices} MCP, ` +
|
||||
`(${counts.dockerApps} Docker, ${counts.nativeApps ?? 0} native, ${counts.artifactApps ?? 0} artifact, ${counts.mcpServices} MCP, ` +
|
||||
`${counts.httpApis} HTTP API, ${counts.externalIntegrations} external integration).`,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user