From 877a3b2424487bdfe7ea677bb769a8dde354dfaa Mon Sep 17 00:00:00 2001 From: Yige Date: Sun, 30 Aug 2026 13:49:44 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Kimi=20WebBridge?= =?UTF-8?q?=20=E5=8F=97=E6=8E=A7=E7=9B=AE=E5=BD=95=E6=9D=A1=E7=9B=AE=20(#2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/validate.yml | 27 ++ .gitignore | 2 + README.md | 84 +++- SCHEMA_VERSION | 2 +- entries/kimi-webbridge/manifest.json | 175 +++++++++ manifest.json | 13 +- package-lock.json | 76 ++++ package.json | 17 + schemas/registry-entry.schema.json | 552 +++++++++++++++++++++++++++ scripts/validate-registry.mjs | 381 ++++++++++++++++++ scripts/validate-registry.test.mjs | 259 +++++++++++++ 11 files changed, 1565 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 entries/kimi-webbridge/manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 schemas/registry-entry.schema.json create mode 100644 scripts/validate-registry.mjs create mode 100644 scripts/validate-registry.test.mjs diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..61df3d0 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,27 @@ +name: Validate Registry + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out registry + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Validate registry + run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c0a4a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +npm-debug.log* diff --git a/README.md b/README.md index 87f65c5..f3f9c89 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ # DesireCore Registry -DesireCore 官方注册表仓库,包含所有可安装的应用、MCP 服务和 HTTP API 服务。 +DesireCore 官方注册表仓库,包含可安装的应用、MCP 服务、HTTP API 服务,以及只用于发现和合规披露的第三方外部集成。 DesireCore 客户端启动时会克隆此仓库,并定期同步更新。用户在应用商店中看到的所有条目均来自此仓库。 ## 目录结构 -``` +```text . ├── README.md # 本文件 -├── SCHEMA_VERSION # 数据格式版本号(当前 3.0.0) +├── SCHEMA_VERSION # 数据格式版本号(当前 4.0.0) ├── manifest.json # 仓库元数据(版本、统计、维护者) +├── package.json # Registry 校验入口 +├── schemas/ +│ └── registry-entry.schema.json # Draft-07 严格判别 Schema +├── scripts/ +│ ├── validate-registry.mjs # 仓库语义校验 +│ └── validate-registry.test.mjs # 失败关闭回归测试 ├── entries/ # 🔑 所有注册表条目(统一格式) │ ├── n8n/ # 示例:Docker 应用 │ │ ├── manifest.json # 条目元数据(必需) @@ -20,9 +26,11 @@ DesireCore 客户端启动时会克隆此仓库,并定期同步更新。用户 │ │ ├── manifest.json │ │ ├── install.md │ │ └── usage.md -│ └── baidu-map/ # 示例:HTTP API(无需安装) -│ ├── manifest.json -│ └── usage.md +│ ├── baidu-map/ # 示例:HTTP API(无需安装) +│ │ ├── manifest.json +│ │ └── usage.md +│ └── kimi-webbridge/ # external-integration:只允许 manifest.json +│ └── manifest.json ├── models/ # 模型能力描述符与分类 │ ├── descriptors.json │ └── categories.json @@ -45,7 +53,7 @@ DesireCore 客户端启动时会克隆此仓库,并定期同步更新。用户 |------|------|------|------| | `id` | string | ✅ | 唯一标识,与目录名一致 | | `name` | string | ✅ | 显示名称 | -| `type` | string | ✅ | 条目类型:`docker-app` / `mcp` / `http-api` | +| `type` | string | ✅ | 条目类型:`docker-app` / `mcp` / `http-api` / `external-integration` | | `version` | string | ✅ | 语义版本号 | | `description` | string | ✅ | 一行功能摘要 | | `author` | string | | 作者或组织 | @@ -102,26 +110,46 @@ MCP `connection.transport` 取值:`stdio` / `streamable-http` / `sse` | `sourceAppId` | string | 关联的应用 ID | | `sourceAppName` | string | 关联的应用名称 | +**第三方外部集成专属字段(`type: "external-integration"`):** + +Schema v4 的 external integration 是严格、失败关闭的目录指针,不是 `StoreApp`、安装事实、连接或 Browser Provider。当前只准入经过审核的 `kimi-webbridge` ID,并固定以下治理语义: + +- `stewardship: "pointer"` +- `availability: "listing-only"` +- `redistribution: "source-pointer-only"` +- `branding.relationship: "independent-listing"` +- `branding.nameUsage: "nominative"` +- `branding.logoStatus: "not-used"` +- `admission.status: "blocked"` + +条目还必须结构化披露上游维护者、完整官方 URL、浏览器扩展 ID、物理组件、扩展权限、独立 Profile 建议、WebBridge 本地链路与 DesireCore 模型 Provider 的不同数据边界、阻塞准入原因,以及固定版本 daemon 的 SHA-256 审核记录。 + +external integration 目录必须恰好包含一个常规文件 `manifest.json`。额外文件、目录、符号链接、设备节点、FIFO、Socket 或二进制制品全部被拒绝;因此它没有可被 Agent 当成安装指令执行的自由文本面。`sourceId` 和 `hasInstall` 仅由客户端从可信来源和目录事实注入,禁止写入上游 manifest。 + ### install.md(可选) 自然语言安装说明,供 DesireCore Agent 读取并执行安装流程。 内容应包含: + - 环境要求(Node.js 版本、Python 等) - 安装步骤(可直接执行的命令) - 验证方式 -**不需要 install.md 的情况**:纯 HTTP API 服务(无需在本地安装)、通过关联应用附带安装的服务(如 dify-mcp 随 Dify 一起可用)。 +**不需要 install.md 的情况**:纯 HTTP API 服务(无需在本地安装)、通过关联应用附带安装的服务(如 dify-mcp 随 Dify 一起可用)。`external-integration` 明确禁止 `install.md`。 ### usage.md(可选) 使用说明,描述安装后如何连接和使用此服务。 内容应包含: + - 连接配置(transport、command、URL 等) - 配置示例(JSON 格式,可直接使用) - 注意事项 +`external-integration` 不允许 `usage.md` 或其他附加文件;所有用户可见披露必须是经过 Schema 约束的 manifest 字段。 + ## 添加新条目 ### 添加 Docker 应用 @@ -144,6 +172,7 @@ cat > entries/my-app/manifest.json << 'EOF' "iconLetter": "M", "platformSupport": ["macos", "windows", "linux"], "fullDesc": "详细描述...", + "shortDesc": "简短描述...", "install": { "method": "docker", "requirements": { @@ -231,6 +260,7 @@ cat > entries/my-api/manifest.json << 'EOF' "description": "一行功能描述", "tags": ["tag1"], "icon": "globe", + "platformSupport": ["macos", "windows", "linux"], "endpoint": "https://api.example.com/v1", "capabilities": ["capability_1"] } @@ -239,17 +269,24 @@ EOF HTTP API 通常不需要 install.md,只需 usage.md 说明如何调用。 +### 添加第三方外部集成 + +external integration 不是开放的自助条目类型。新增 ID、URL、扩展 ID、组件或制品审核事实需要先修改严格 Schema、校验器与 DesireCore 客户端契约,并经过安全与合规 review;未知 ID 会失败关闭。请以 [`entries/kimi-webbridge/manifest.json`](entries/kimi-webbridge/manifest.json) 为唯一当前示例。 + ## 修改现有条目 1. 编辑 `entries//manifest.json` 中的字段 -2. 如有安装/使用流程变更,同步更新 `install.md` / `usage.md` +2. 如有安装/使用流程变更,同步更新 `install.md` / `usage.md`;external integration 不适用 3. **务必更新 `version` 字段**(客户端通过版本号判断是否有更新) -4. 提交并创建 PR +4. 新增或删除条目时同步更新根 `manifest.json#stats` +5. 执行 `npm ci && npm test` +6. 提交并创建 PR ## 版本规范 - `SCHEMA_VERSION`:数据格式版本,格式不兼容时递增主版本号 -- `manifest.json#version`:仓库元数据版本 +- `manifest.json#version`:仓库元数据版本,必须等于 `SCHEMA_VERSION` +- `manifest.json#dataVersion`:仓库数据版本,必须等于 `SCHEMA_VERSION` - `entries//manifest.json#version`:条目自身版本 **Schema 版本历史:** @@ -258,7 +295,17 @@ HTTP API 通常不需要 install.md,只需 usage.md 说明如何调用。 |------|------| | 1.0.0 | 初始格式 — 单文件 JSON 数组 | | 2.0.0 | 分散式目录 — apps/mcp/services 三目录,每个条目 `/index.json` | -| 3.0.0 | **当前** — 统一 entries/ 目录,manifest.json + install.md + usage.md | +| 3.0.0 | 统一 entries/ 目录,manifest.json + install.md + usage.md | +| 4.0.0 | **当前** — Draft-07 严格判别 Schema、仓库校验和 listing-only external integration | + +## 校验 + +```bash +npm ci +npm test +``` + +校验包含 JSON Schema、目录与 ID、全局唯一性、根版本、统计、来源注入字段、external 单文件布局、固定 Kimi ID、完整官方 URL、扩展 ID、组件/权限/准入集合、真实日历日期和不可变供应链审核记录。 ## 同步机制 @@ -269,16 +316,21 @@ DesireCore 客户端的同步流程: 3. 有新 commit 时 `git pull` 并重建本地索引 4. 离线时使用本地缓存或内置 fallback 数据 -客户端读取 `entries/` 目录下所有 `manifest.json`,按 `type` 字段分类为应用、MCP 服务和 HTTP 服务展示在商店中。`install.md` 和 `usage.md` 供 AI Agent 执行安装和配置时使用。 +客户端读取 `entries/` 目录下所有 `manifest.json`,按 `type` 字段分类为应用、MCP 服务、HTTP 服务和第三方外部集成展示在商店中。旧三类条目的 `install.md` 和 `usage.md` 可供 AI Agent 执行安装和配置时使用;external integration 只有结构化 manifest,且 listing-only 条目不会被派生为 Docker 应用、installed-entry、ready 连接或 Browser Provider。 + +主仓库的 `npm run sync-registry` 在打包前执行本 checkout 的 `scripts/validate-registry.mjs`。validator 缺失或失败时同步必须失败关闭,不能生成新的 `defaults/registry.zip`。 ## 贡献指南 1. Fork 本仓库 2. 在 `entries/` 下创建以 ID 命名的子目录 -3. 按上述格式添加 `manifest.json`,按需添加 `install.md` 和 `usage.md` +3. 按上述格式添加 `manifest.json`,旧三类按需添加 `install.md` 和 `usage.md` 4. 更新根目录 `manifest.json` 中的 `stats` 统计 -5. 提交 PR 并描述变更内容 -6. 等待审核合并 +5. 执行 `npm ci && npm test` +6. 提交 PR 并描述变更内容、来源和验证结果 +7. 等待审核合并 + +external integration 需要额外安全、供应链、商标和隐私 review,不接受绕过严格 Schema 的未知 ID 或自由文本安装说明。 ## 镜像 diff --git a/SCHEMA_VERSION b/SCHEMA_VERSION index 56fea8a..fcdb2e1 100644 --- a/SCHEMA_VERSION +++ b/SCHEMA_VERSION @@ -1 +1 @@ -3.0.0 \ No newline at end of file +4.0.0 diff --git a/entries/kimi-webbridge/manifest.json b/entries/kimi-webbridge/manifest.json new file mode 100644 index 0000000..8bdac13 --- /dev/null +++ b/entries/kimi-webbridge/manifest.json @@ -0,0 +1,175 @@ +{ + "id": "kimi-webbridge", + "name": "Kimi WebBridge", + "type": "external-integration", + "version": "1.0.0", + "author": "Moonshot AI", + "description": "连接本机 Chrome 或 Edge 的第三方浏览器桥接集成目录入口", + "category": "tools", + "shortDesc": "第三方浏览器桥接集成,仅提供官方来源和合规披露", + "fullDesc": "Kimi WebBridge 由上游浏览器扩展和本地桥接服务组成,可让兼容的本地 Agent 使用现有 Chrome 或 Edge 会话执行导航、点击、填写、截图和内容提取。本条目由 DesireCore 独立收录,仅提供官方来源、权限和数据边界披露;当前不提供安装、连接或 DesireCore 原生执行能力。", + "tags": [ + "browser", + "chrome", + "edge", + "webbridge" + ], + "icon": "monitor", + "platformSupport": [ + "macos", + "windows" + ], + "requiredClientVersion": "10.0.132", + "stewardship": "pointer", + "availability": "listing-only", + "redistribution": "source-pointer-only", + "branding": { + "relationship": "independent-listing", + "nameUsage": "nominative", + "logoStatus": "not-used" + }, + "listingMaintainer": { + "name": "DesireCore Registry", + "url": "https://github.com/desirecore/registry", + "verified": true + }, + "upstreamMaintainer": { + "name": "Moonshot AI", + "url": "https://www.kimi.ai/", + "verified": true + }, + "integration": { + "kind": "local-extension-bridge", + "adapterId": "kimi-webbridge", + "executionAvailability": "blocked", + "installMode": "upstream-guided" + }, + "officialLinks": [ + { + "kind": "product", + "label": "Kimi WebBridge 官方产品页", + "url": "https://www.kimi.ai/products/kimi-webbridge" + }, + { + "kind": "documentation", + "label": "Kimi WebBridge 官方帮助", + "url": "https://www.kimi.ai/help/kimi-webbridge/kimi-webbridge-introduction" + }, + { + "kind": "chrome-web-store", + "label": "Chrome Web Store 官方条目", + "extensionId": "fldmhceldgbpfpkbgopacenieobmligc", + "url": "https://chromewebstore.google.com/detail/kimi-webbridge/fldmhceldgbpfpkbgopacenieobmligc" + }, + { + "kind": "edge-add-ons", + "label": "Microsoft Edge Add-ons 官方条目", + "extensionId": "bnlffdbcfnanfbknnlaflhlhkocccckg", + "url": "https://microsoftedge.microsoft.com/addons/detail/kimi-webbridge/bnlffdbcfnanfbknnlaflhlhkocccckg" + }, + { + "kind": "privacy", + "label": "Kimi 隐私政策", + "url": "https://www.kimi.ai/user/agreement/userPrivacy?version=v2" + }, + { + "kind": "terms", + "label": "Kimi 服务条款", + "url": "https://www.kimi.ai/user/agreement/modelUse?version=v2" + } + ], + "components": [ + { + "kind": "browser-extension", + "name": "Kimi WebBridge browser extension", + "description": "在 Chrome 或 Edge 中执行浏览器操作并与本地桥接服务通信的上游扩展", + "distribution": "official-upstream-only", + "browsers": [ + "chrome", + "edge" + ] + }, + { + "kind": "local-daemon", + "name": "Kimi WebBridge local service", + "description": "在用户设备上接收 Agent 指令并协调浏览器扩展的上游本地服务", + "distribution": "official-upstream-only", + "platforms": [ + "macos", + "windows" + ] + } + ], + "permissions": { + "browserExtension": { + "apiPermissions": [ + "tabs", + "activeTab", + "debugger", + "storage", + "alarms", + "tabGroups", + "windows" + ], + "hostPermissions": [ + "" + ], + "riskLevel": "high" + } + }, + "profileRecommendation": "dedicated-browser-profile", + "dataBoundaries": { + "webBridgeLocalExecution": "上游声明 WebBridge 的桥接执行、登录状态和页面处理发生在用户设备本地;该声明仅描述 WebBridge 自身链路。", + "desireCoreModelContext": "当页面文本、截图或操作结果进入 DesireCore Agent 上下文时,内容仍可能按用户选择的模型与数据驻留配置发送给模型 Provider;这不属于上游本地执行声明的覆盖范围。", + "modelProviderDisclosureRequired": true + }, + "admission": { + "status": "blocked", + "code": "native-integration-not-admitted", + "reason": "DesireCore 尚未获得足以把该第三方桥接提升为受治理浏览器 Provider 的稳定协议、授权和确定性治理信号,因此当前仅展示目录信息。", + "missingPrerequisites": [ + "stable-auditable-protocol", + "integration-authorization", + "deterministic-command-receipts", + "user-interaction-pause-signal" + ] + }, + "compliance": { + "reviewedAt": "2026-08-30", + "reviewedBy": "DesireCore Registry maintainers", + "upstreamEndorsed": false, + "relationshipDisclosure": "independent-listing-not-endorsed", + "artifactReviews": [ + { + "component": "local-daemon", + "platform": "macos", + "architecture": "arm64", + "version": "1.11.6", + "url": "https://cdn.kimi.com/webbridge/v1.11.6/releases/kimi-webbridge-darwin-arm64", + "sha256": "46fe401eaa5669c6d9915b4d7ea734cd3fb968c877362644212234241d0c9930", + "reviewedAt": "2026-08-30", + "reviewedBy": "DesireCore Registry maintainers" + }, + { + "component": "local-daemon", + "platform": "macos", + "architecture": "x64", + "version": "1.11.6", + "url": "https://cdn.kimi.com/webbridge/v1.11.6/releases/kimi-webbridge-darwin-amd64", + "sha256": "edd1b1265445410ef5e8684788955c7283f9c47d9121f53159fa7e5a4641ec54", + "reviewedAt": "2026-08-30", + "reviewedBy": "DesireCore Registry maintainers" + }, + { + "component": "local-daemon", + "platform": "windows", + "architecture": "x64", + "version": "1.11.6", + "url": "https://cdn.kimi.com/webbridge/v1.11.6/releases/kimi-webbridge-windows-amd64.exe", + "sha256": "e085991e0127c3872d8cbf2ec11078a1efba3c534f6229e42af0dde45ce90520", + "reviewedAt": "2026-08-30", + "reviewedBy": "DesireCore Registry maintainers" + } + ] + } +} diff --git a/manifest.json b/manifest.json index 6be02f6..5fdae22 100644 --- a/manifest.json +++ b/manifest.json @@ -1,17 +1,18 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "id": "desirecore-registry-manifest", - "version": "3.0.0", + "version": "4.0.0", "name": "DesireCore Registry", - "description": "DesireCore 官方注册表 — 应用、MCP 服务和 HTTP API 的统一仓库", + "description": "DesireCore 官方注册表 — Docker 应用、MCP 服务、HTTP API 和第三方外部集成的统一目录", "maintainer": "DesireCore Team", "repository": "https://github.com/desirecore/registry", - "lastUpdated": "2026-07-29", + "lastUpdated": "2026-08-30", "stats": { - "totalEntries": 21, + "totalEntries": 22, "dockerApps": 8, "mcpServices": 8, - "httpApis": 5 + "httpApis": 5, + "externalIntegrations": 1 }, - "dataVersion": "3.0.0" + "dataVersion": "4.0.0" } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2d0c41e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "@desirecore/registry", + "version": "4.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@desirecore/registry", + "version": "4.0.0", + "devDependencies": { + "ajv": "8.20.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3487166 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "@desirecore/registry", + "version": "4.0.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 && npm run validate" + }, + "devDependencies": { + "ajv": "8.20.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/schemas/registry-entry.schema.json b/schemas/registry-entry.schema.json new file mode 100644 index 0000000..82a8fdf --- /dev/null +++ b/schemas/registry-entry.schema.json @@ -0,0 +1,552 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/desirecore/registry/schemas/registry-entry.schema.json", + "title": "DesireCore Registry Entry", + "description": "A strictly discriminated DesireCore Registry catalog entry. External integrations are discovery-only pointers and cannot carry installation or connection semantics.", + "oneOf": [ + { "$ref": "#/definitions/dockerApp" }, + { "$ref": "#/definitions/mcp" }, + { "$ref": "#/definitions/httpApi" }, + { "$ref": "#/definitions/externalIntegration" } + ], + "definitions": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Stable entry identifier; must equal the containing directory name." + }, + "nonEmptyString": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "pattern": "^[0-9]+(?:\\.[0-9]+){1,3}(?:[-+][0-9A-Za-z.-]+)?$", + "description": "Entry or client version in dotted numeric form." + }, + "httpsUrl": { + "type": "string", + "pattern": "^https://[^\\s]+$", + "description": "An HTTPS URL without embedded whitespace." + }, + "platformSupport": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["macos", "windows", "linux"] + }, + "description": "Desktop platforms on which the entry is relevant." + }, + "tags": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/definitions/nonEmptyString" }, + "description": "Human-readable discovery and search tags." + }, + "capabilities": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z0-9]+(?:_[a-z0-9]+)*$" + } + }, + "environment": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "mcpInstall": { + "type": "object", + "required": ["method", "command", "args"], + "additionalProperties": false, + "properties": { + "method": { "$ref": "#/definitions/nonEmptyString" }, + "packageName": { "$ref": "#/definitions/nonEmptyString" }, + "command": { "$ref": "#/definitions/nonEmptyString" }, + "args": { + "type": "array", + "items": { "type": "string" } + }, + "postInstall": { + "type": "array", + "items": { "$ref": "#/definitions/nonEmptyString" } + }, + "env": { "$ref": "#/definitions/environment" } + } + }, + "connection": { + "type": "object", + "required": ["transport"], + "additionalProperties": false, + "properties": { + "transport": { + "type": "string", + "enum": ["stdio", "streamable-http", "sse"] + }, + "command": { "$ref": "#/definitions/nonEmptyString" }, + "args": { + "type": "array", + "items": { "type": "string" } + }, + "url": { "$ref": "#/definitions/nonEmptyString" }, + "env": { "$ref": "#/definitions/environment" } + } + }, + "dockerApp": { + "type": "object", + "required": [ + "id", "name", "type", "version", "author", "description", "tags", "icon", + "iconLetter", "platformSupport", "category", "fullDesc", "shortDesc", "install" + ], + "additionalProperties": false, + "properties": { + "id": { "$ref": "#/definitions/id" }, + "name": { "$ref": "#/definitions/nonEmptyString" }, + "type": { "const": "docker-app" }, + "version": { "$ref": "#/definitions/version" }, + "author": { "$ref": "#/definitions/nonEmptyString" }, + "description": { "$ref": "#/definitions/nonEmptyString" }, + "tags": { "$ref": "#/definitions/tags" }, + "icon": { "$ref": "#/definitions/nonEmptyString" }, + "iconLetter": { "type": "string", "minLength": 1, "maxLength": 2 }, + "platformSupport": { "$ref": "#/definitions/platformSupport" }, + "category": { + "type": "string", + "enum": ["ai-platform", "chat", "workflow", "rag", "tools"] + }, + "fullDesc": { "$ref": "#/definitions/nonEmptyString" }, + "shortDesc": { "$ref": "#/definitions/nonEmptyString" }, + "stars": { "type": "integer", "minimum": 0 }, + "githubUrl": { "$ref": "#/definitions/httpsUrl" }, + "install": { + "type": "object", + "required": ["method", "requirements", "configNeeded"], + "additionalProperties": false, + "properties": { + "method": { "type": "string", "enum": ["docker", "docker-compose"] }, + "requirements": { + "type": "object", + "required": ["docker", "minMemory", "minDisk", "ports"], + "additionalProperties": false, + "properties": { + "docker": { "const": true }, + "minMemory": { "$ref": "#/definitions/nonEmptyString" }, + "minDisk": { "$ref": "#/definitions/nonEmptyString" }, + "ports": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + } + }, + "configNeeded": { + "type": "array", + "items": { "$ref": "#/definitions/nonEmptyString" } + } + } + }, + "exposes": { + "type": "array", + "items": { + "type": "object", + "required": ["type", "port", "path", "name"], + "additionalProperties": false, + "properties": { + "type": { "const": "http-api" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "path": { "type": "string", "pattern": "^/" }, + "name": { "$ref": "#/definitions/nonEmptyString" } + } + } + } + } + }, + "mcp": { + "type": "object", + "required": [ + "id", "name", "type", "version", "author", "description", "tags", "icon", + "platformSupport", "capabilities", "connection" + ], + "additionalProperties": false, + "properties": { + "id": { "$ref": "#/definitions/id" }, + "name": { "$ref": "#/definitions/nonEmptyString" }, + "type": { "const": "mcp" }, + "version": { "$ref": "#/definitions/version" }, + "author": { "$ref": "#/definitions/nonEmptyString" }, + "description": { "$ref": "#/definitions/nonEmptyString" }, + "tags": { "$ref": "#/definitions/tags" }, + "icon": { "$ref": "#/definitions/nonEmptyString" }, + "platformSupport": { "$ref": "#/definitions/platformSupport" }, + "capabilities": { "$ref": "#/definitions/capabilities" }, + "toolCount": { "type": "integer", "minimum": 0 }, + "install": { "$ref": "#/definitions/mcpInstall" }, + "connection": { "$ref": "#/definitions/connection" }, + "sourceAppId": { "$ref": "#/definitions/id" }, + "sourceAppName": { "$ref": "#/definitions/nonEmptyString" } + } + }, + "httpApi": { + "type": "object", + "required": [ + "id", "name", "type", "version", "author", "description", "tags", "icon", + "platformSupport", "endpoint", "capabilities" + ], + "additionalProperties": false, + "properties": { + "id": { "$ref": "#/definitions/id" }, + "name": { "$ref": "#/definitions/nonEmptyString" }, + "type": { "const": "http-api" }, + "version": { "$ref": "#/definitions/version" }, + "author": { "$ref": "#/definitions/nonEmptyString" }, + "description": { "$ref": "#/definitions/nonEmptyString" }, + "tags": { "$ref": "#/definitions/tags" }, + "icon": { "$ref": "#/definitions/nonEmptyString" }, + "platformSupport": { "$ref": "#/definitions/platformSupport" }, + "endpoint": { "type": "string", "pattern": "^https?://[^\\s]+$" }, + "capabilities": { "$ref": "#/definitions/capabilities" }, + "sourceAppId": { "$ref": "#/definitions/id" }, + "sourceAppName": { "$ref": "#/definitions/nonEmptyString" } + } + }, + "maintainer": { + "type": "object", + "required": ["name", "url", "verified"], + "additionalProperties": false, + "properties": { + "name": { + "allOf": [{ "$ref": "#/definitions/nonEmptyString" }], + "description": "Human-readable maintainer identity." + }, + "url": { + "allOf": [{ "$ref": "#/definitions/httpsUrl" }], + "description": "Public source used to verify the maintainer identity." + }, + "verified": { + "const": true, + "description": "The Registry maintainer verified this identity against the public source." + } + } + }, + "officialLink": { + "description": "A Kimi WebBridge upstream URL pinned by kind, hostname, path, and extension ID where applicable.", + "oneOf": [ + { + "type": "object", + "required": ["kind", "label", "url"], + "additionalProperties": false, + "properties": { + "kind": { "const": "product" }, + "label": { "$ref": "#/definitions/nonEmptyString" }, + "url": { "const": "https://www.kimi.ai/products/kimi-webbridge" } + } + }, + { + "type": "object", + "required": ["kind", "label", "url"], + "additionalProperties": false, + "properties": { + "kind": { "const": "documentation" }, + "label": { "$ref": "#/definitions/nonEmptyString" }, + "url": { "const": "https://www.kimi.ai/help/kimi-webbridge/kimi-webbridge-introduction" } + } + }, + { + "type": "object", + "required": ["kind", "label", "extensionId", "url"], + "additionalProperties": false, + "properties": { + "kind": { "const": "chrome-web-store" }, + "label": { "$ref": "#/definitions/nonEmptyString" }, + "extensionId": { + "const": "fldmhceldgbpfpkbgopacenieobmligc", + "description": "The audited Chrome Web Store extension identifier." + }, + "url": { "const": "https://chromewebstore.google.com/detail/kimi-webbridge/fldmhceldgbpfpkbgopacenieobmligc" } + } + }, + { + "type": "object", + "required": ["kind", "label", "extensionId", "url"], + "additionalProperties": false, + "properties": { + "kind": { "const": "edge-add-ons" }, + "label": { "$ref": "#/definitions/nonEmptyString" }, + "extensionId": { + "const": "bnlffdbcfnanfbknnlaflhlhkocccckg", + "description": "The audited Microsoft Edge Add-ons extension identifier." + }, + "url": { "const": "https://microsoftedge.microsoft.com/addons/detail/kimi-webbridge/bnlffdbcfnanfbknnlaflhlhkocccckg" } + } + }, + { + "type": "object", + "required": ["kind", "label", "url"], + "additionalProperties": false, + "properties": { + "kind": { "const": "privacy" }, + "label": { "$ref": "#/definitions/nonEmptyString" }, + "url": { "const": "https://www.kimi.ai/user/agreement/userPrivacy?version=v2" } + } + }, + { + "type": "object", + "required": ["kind", "label", "url"], + "additionalProperties": false, + "properties": { + "kind": { "const": "terms" }, + "label": { "$ref": "#/definitions/nonEmptyString" }, + "url": { "const": "https://www.kimi.ai/user/agreement/modelUse?version=v2" } + } + } + ] + }, + "browserExtensionComponent": { + "type": "object", + "required": ["kind", "name", "description", "distribution", "browsers"], + "additionalProperties": false, + "properties": { + "kind": { "const": "browser-extension" }, + "name": { "$ref": "#/definitions/nonEmptyString" }, + "description": { "$ref": "#/definitions/nonEmptyString" }, + "distribution": { + "const": "official-upstream-only", + "description": "DesireCore does not mirror or repackage this third-party component." + }, + "browsers": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { "type": "string", "enum": ["chrome", "edge"] }, + "allOf": [{ "contains": { "const": "chrome" } }, { "contains": { "const": "edge" } }] + } + } + }, + "localDaemonComponent": { + "type": "object", + "required": ["kind", "name", "description", "distribution", "platforms"], + "additionalProperties": false, + "properties": { + "kind": { "const": "local-daemon" }, + "name": { "$ref": "#/definitions/nonEmptyString" }, + "description": { "$ref": "#/definitions/nonEmptyString" }, + "distribution": { + "const": "official-upstream-only", + "description": "DesireCore does not mirror or repackage this third-party component." + }, + "platforms": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "uniqueItems": true, + "items": { "type": "string", "enum": ["macos", "windows", "linux"] }, + "allOf": [{ "contains": { "const": "macos" } }, { "contains": { "const": "windows" } }] + } + } + }, + "externalComponent": { + "description": "One strictly discriminated Kimi WebBridge physical component.", + "oneOf": [ + { "$ref": "#/definitions/browserExtensionComponent" }, + { "$ref": "#/definitions/localDaemonComponent" } + ] + }, + "artifactReview": { + "type": "object", + "required": ["component", "platform", "architecture", "version", "url", "sha256", "reviewedAt", "reviewedBy"], + "additionalProperties": false, + "properties": { + "component": { "const": "local-daemon" }, + "platform": { "type": "string", "enum": ["macos", "windows", "linux"] }, + "architecture": { "type": "string", "enum": ["x64", "arm64"] }, + "version": { "$ref": "#/definitions/version" }, + "url": { "$ref": "#/definitions/httpsUrl" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "reviewedAt": { "type": "string", "format": "date" }, + "reviewedBy": { "$ref": "#/definitions/nonEmptyString" } + } + }, + "externalIntegration": { + "type": "object", + "required": [ + "id", "name", "type", "version", "author", "description", "category", "shortDesc", + "fullDesc", "tags", "icon", "platformSupport", "requiredClientVersion", "stewardship", + "availability", "redistribution", "branding", "listingMaintainer", "upstreamMaintainer", + "integration", "officialLinks", "components", "permissions", "profileRecommendation", + "dataBoundaries", "admission", "compliance" + ], + "additionalProperties": false, + "properties": { + "id": { + "const": "kimi-webbridge", + "description": "The only external-integration ID admitted by Registry Schema v4." + }, + "name": { "$ref": "#/definitions/nonEmptyString" }, + "type": { "const": "external-integration" }, + "version": { "$ref": "#/definitions/version" }, + "author": { "$ref": "#/definitions/nonEmptyString" }, + "description": { "$ref": "#/definitions/nonEmptyString" }, + "category": { "const": "tools" }, + "shortDesc": { "$ref": "#/definitions/nonEmptyString" }, + "fullDesc": { "$ref": "#/definitions/nonEmptyString" }, + "tags": { "$ref": "#/definitions/tags" }, + "icon": { + "type": "string", + "enum": ["monitor", "puzzle", "globe"], + "description": "A neutral DesireCore icon name; upstream logos are not permitted in this listing type." + }, + "platformSupport": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { "type": "string", "enum": ["macos", "windows"] }, + "allOf": [{ "contains": { "const": "macos" } }, { "contains": { "const": "windows" } }], + "description": "The reviewed listing must support both macOS and Windows." + }, + "requiredClientVersion": { + "allOf": [{ "$ref": "#/definitions/version" }], + "description": "Minimum DesireCore version that understands this strict listing-only entry." + }, + "stewardship": { "const": "pointer" }, + "availability": { "const": "listing-only" }, + "redistribution": { "const": "source-pointer-only" }, + "branding": { + "type": "object", + "required": ["relationship", "nameUsage", "logoStatus"], + "additionalProperties": false, + "properties": { + "relationship": { "const": "independent-listing" }, + "nameUsage": { "const": "nominative" }, + "logoStatus": { "const": "not-used" } + } + }, + "listingMaintainer": { "$ref": "#/definitions/maintainer" }, + "upstreamMaintainer": { "$ref": "#/definitions/maintainer" }, + "integration": { + "type": "object", + "required": ["kind", "adapterId", "executionAvailability", "installMode"], + "additionalProperties": false, + "properties": { + "kind": { "const": "local-extension-bridge" }, + "adapterId": { "$ref": "#/definitions/id" }, + "executionAvailability": { "const": "blocked" }, + "installMode": { "const": "upstream-guided" } + } + }, + "officialLinks": { + "type": "array", + "minItems": 6, + "maxItems": 6, + "uniqueItems": true, + "items": { "$ref": "#/definitions/officialLink" }, + "description": "One reviewed official source for each required link kind." + }, + "components": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { "$ref": "#/definitions/externalComponent" }, + "description": "The upstream browser extension and local daemon, neither redistributed by DesireCore." + }, + "permissions": { + "type": "object", + "required": ["browserExtension"], + "additionalProperties": false, + "properties": { + "browserExtension": { + "type": "object", + "required": ["apiPermissions", "hostPermissions", "riskLevel"], + "additionalProperties": false, + "properties": { + "apiPermissions": { + "type": "array", + "minItems": 7, + "maxItems": 7, + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["tabs", "activeTab", "debugger", "storage", "alarms", "tabGroups", "windows"] + } + }, + "hostPermissions": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { "const": "" } + }, + "riskLevel": { "const": "high" } + } + } + } + }, + "profileRecommendation": { "const": "dedicated-browser-profile" }, + "dataBoundaries": { + "type": "object", + "required": ["webBridgeLocalExecution", "desireCoreModelContext", "modelProviderDisclosureRequired"], + "additionalProperties": false, + "properties": { + "webBridgeLocalExecution": { + "allOf": [{ "$ref": "#/definitions/nonEmptyString" }], + "description": "A scoped statement of the upstream claim about the local WebBridge execution path." + }, + "desireCoreModelContext": { + "allOf": [{ "$ref": "#/definitions/nonEmptyString" }], + "description": "A separate disclosure that content entering DesireCore context may be sent to the selected model provider." + }, + "modelProviderDisclosureRequired": { "const": true } + } + }, + "admission": { + "type": "object", + "required": ["status", "code", "reason", "missingPrerequisites"], + "additionalProperties": false, + "properties": { + "status": { "const": "blocked" }, + "code": { "const": "native-integration-not-admitted" }, + "reason": { "$ref": "#/definitions/nonEmptyString" }, + "missingPrerequisites": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "stable-auditable-protocol", + "integration-authorization", + "deterministic-command-receipts", + "user-interaction-pause-signal" + ] + } + } + } + }, + "compliance": { + "type": "object", + "required": ["reviewedAt", "reviewedBy", "upstreamEndorsed", "relationshipDisclosure", "artifactReviews"], + "additionalProperties": false, + "properties": { + "reviewedAt": { "type": "string", "format": "date" }, + "reviewedBy": { "$ref": "#/definitions/nonEmptyString" }, + "upstreamEndorsed": { "const": false }, + "relationshipDisclosure": { "const": "independent-listing-not-endorsed" }, + "artifactReviews": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/artifactReview" }, + "description": "Immutable upstream artifact audit facts; these records do not admit installation or redistribution." + } + } + } + } + } + } +} diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs new file mode 100644 index 0000000..b817bc3 --- /dev/null +++ b/scripts/validate-registry.mjs @@ -0,0 +1,381 @@ +import { lstat, readFile, readdir } from 'node:fs/promises' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import Ajv from 'ajv' + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)) +const rootArgumentIndex = process.argv.indexOf('--root') +if (rootArgumentIndex >= 0 && !process.argv[rootArgumentIndex + 1]) { + throw new Error('--root requires a directory path') +} +const repositoryRoot = rootArgumentIndex >= 0 + ? resolve(process.argv[rootArgumentIndex + 1]) + : resolve(scriptDirectory, '..') +const entriesRoot = join(repositoryRoot, 'entries') +const errors = [] + +const addError = (message) => errors.push(message) + +const readText = async (path) => { + try { + return await readFile(path, 'utf8') + } catch (error) { + addError(`${relative(repositoryRoot, path)}: unable to read (${error.message})`) + return null + } +} + +const readJson = async (path) => { + const text = await readText(path) + if (text === null) return null + try { + return JSON.parse(text) + } catch (error) { + addError(`${relative(repositoryRoot, path)}: invalid JSON (${error.message})`) + return null + } +} + +const formatAjvErrors = (validationErrors = []) => + validationErrors + .map((error) => `${error.instancePath || '/'} ${error.message}`) + .join('; ') + +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)) + if (!value || typeof value !== 'object') return false + if (Object.hasOwn(value, prohibitedKey)) return true + return Object.values(value).some((item) => containsKey(item, prohibitedKey)) +} + +const assertHttpsUrl = (rawUrl, context) => { + try { + const url = new URL(rawUrl) + if (url.protocol !== 'https:') addError(`${context}: URL must use HTTPS`) + if (url.username || url.password) addError(`${context}: URL must not contain credentials`) + return url + } catch { + addError(`${context}: invalid URL`) + return null + } +} + +const requiredOfficialLinkKinds = [ + 'product', + 'documentation', + 'chrome-web-store', + 'edge-add-ons', + 'privacy', + 'terms', +] + +const admittedExternalIntegrationIds = new Set(['kimi-webbridge']) + +const kimiOfficialLinkPolicy = { + product: { + url: 'https://www.kimi.ai/products/kimi-webbridge', + }, + documentation: { + url: 'https://www.kimi.ai/help/kimi-webbridge/kimi-webbridge-introduction', + }, + 'chrome-web-store': { + url: 'https://chromewebstore.google.com/detail/kimi-webbridge/fldmhceldgbpfpkbgopacenieobmligc', + extensionId: 'fldmhceldgbpfpkbgopacenieobmligc', + }, + 'edge-add-ons': { + url: 'https://microsoftedge.microsoft.com/addons/detail/kimi-webbridge/bnlffdbcfnanfbknnlaflhlhkocccckg', + extensionId: 'bnlffdbcfnanfbknnlaflhlhkocccckg', + }, + privacy: { + url: 'https://www.kimi.ai/user/agreement/userPrivacy?version=v2', + }, + terms: { + url: 'https://www.kimi.ai/user/agreement/modelUse?version=v2', + }, +} + +const requiredKimiPermissions = [ + 'tabs', + 'activeTab', + 'debugger', + 'storage', + 'alarms', + 'tabGroups', + 'windows', +] + +const requiredAdmissionPrerequisites = [ + 'stable-auditable-protocol', + 'integration-authorization', + 'deterministic-command-receipts', + 'user-interaction-pause-signal', +] + +const isValidCalendarDate = (value) => { + if (typeof value !== 'string') return false + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + if (!match) return false + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const date = new Date(Date.UTC(year, month - 1, day)) + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day +} + +const describeFilesystemEntry = (entry) => { + if (entry.isSymbolicLink()) return 'symbolic link' + if (entry.isDirectory()) return 'directory' + if (entry.isFile()) return 'file' + if (entry.isBlockDevice()) return 'block device' + if (entry.isCharacterDevice()) return 'character device' + if (entry.isFIFO()) return 'FIFO' + if (entry.isSocket()) return 'socket' + return 'unsupported filesystem entry' +} + +const validateExternalDirectoryLayout = async (entryDirectory, manifestId) => { + const prefix = `entries/${manifestId}` + const directoryEntries = await readdir(entryDirectory, { withFileTypes: true }) + for (const entry of directoryEntries) { + if (entry.name !== 'manifest.json') { + addError(`${prefix}: external-integration allows only manifest.json; found ${describeFilesystemEntry(entry)} ${entry.name}`) + continue + } + if (!entry.isFile() || entry.isSymbolicLink()) { + addError(`${prefix}/manifest.json: must be a regular file, not a ${describeFilesystemEntry(entry)}`) + } + } + if (directoryEntries.length !== 1 || directoryEntries[0]?.name !== 'manifest.json') { + addError(`${prefix}: external-integration directory must contain exactly one manifest.json regular file`) + } +} + +const validateExternalIntegration = async (entryDirectory, manifest) => { + const prefix = `entries/${manifest.id}` + await validateExternalDirectoryLayout(entryDirectory, manifest.id) + if (!admittedExternalIntegrationIds.has(manifest.id)) { + addError(`${prefix}/manifest.json: unknown external-integration id ${manifest.id}`) + } + const forbiddenFields = [ + 'install', + 'entrypoints', + 'exposes', + 'endpoint', + 'connection', + 'auth', + 'operations', + 'source', + ] + for (const field of forbiddenFields) { + if (Object.hasOwn(manifest, field)) addError(`${prefix}/manifest.json: forbidden field "${field}"`) + } + + const linkKinds = manifest.officialLinks?.map((link) => link.kind) ?? [] + if (!sameSet(linkKinds, requiredOfficialLinkKinds)) { + addError(`${prefix}/manifest.json: officialLinks must contain each required kind exactly once`) + } + for (const link of manifest.officialLinks ?? []) { + const url = assertHttpsUrl(link.url, `${prefix}/manifest.json officialLinks.${link.kind}`) + const policy = kimiOfficialLinkPolicy[link.kind] + if (!policy || link.url !== policy.url) { + addError(`${prefix}/manifest.json officialLinks.${link.kind}: URL must exactly equal ${policy?.url ?? 'an admitted URL'}`) + } + if (policy?.extensionId) { + if (link.extensionId !== policy.extensionId) { + addError(`${prefix}/manifest.json officialLinks.${link.kind}: extensionId must equal ${policy.extensionId}`) + } + if (url && url.pathname.split('/').filter(Boolean).at(-1) !== link.extensionId) { + addError(`${prefix}/manifest.json officialLinks.${link.kind}: URL path must end with extensionId`) + } + } else if (Object.hasOwn(link, 'extensionId')) { + addError(`${prefix}/manifest.json officialLinks.${link.kind}: extensionId is allowed only for browser stores`) + } + } + + if (manifest.id === 'kimi-webbridge') { + for (const [field, expectedHost] of [ + ['listingMaintainer', 'github.com'], + ['upstreamMaintainer', 'www.kimi.ai'], + ]) { + const url = assertHttpsUrl(manifest[field]?.url, `${prefix}/manifest.json ${field}.url`) + if (url && url.hostname !== expectedHost) { + addError(`${prefix}/manifest.json ${field}.url: hostname must be ${expectedHost}`) + } + } + } + + if (manifest.listingMaintainer?.url !== 'https://github.com/desirecore/registry' || manifest.listingMaintainer?.verified !== true) { + addError(`${prefix}/manifest.json: listingMaintainer must be the verified DesireCore Registry URL`) + } + if (manifest.upstreamMaintainer?.url !== 'https://www.kimi.ai/' || manifest.upstreamMaintainer?.verified !== true) { + addError(`${prefix}/manifest.json: upstreamMaintainer must be the verified Kimi URL`) + } + + const componentKinds = manifest.components?.map((component) => component.kind) ?? [] + if (!sameSet(componentKinds, ['browser-extension', 'local-daemon'])) { + addError(`${prefix}/manifest.json: components must contain browser-extension and local-daemon exactly once`) + } + for (const component of manifest.components ?? []) { + if (component.kind === 'browser-extension') { + if (!sameSet(component.browsers ?? [], ['chrome', 'edge']) || Object.hasOwn(component, 'platforms')) { + addError(`${prefix}/manifest.json: browser-extension must declare only Chrome and Edge browsers`) + } + } + if (component.kind === 'local-daemon') { + if (!Array.isArray(component.platforms) || + !component.platforms.includes('windows') || + !component.platforms.includes('macos') || + Object.hasOwn(component, 'browsers')) { + addError(`${prefix}/manifest.json: local-daemon must declare at least Windows and macOS and no browsers`) + } + } + } + + const apiPermissions = manifest.permissions?.browserExtension?.apiPermissions ?? [] + if (!sameSet(apiPermissions, requiredKimiPermissions)) { + addError(`${prefix}/manifest.json: browser extension API permissions must match the audited Kimi permission set`) + } + const hostPermissions = manifest.permissions?.browserExtension?.hostPermissions ?? [] + if (!sameSet(hostPermissions, [''])) { + addError(`${prefix}/manifest.json: browser extension must disclose `) + } + + if (!sameSet(manifest.admission?.missingPrerequisites ?? [], requiredAdmissionPrerequisites)) { + addError(`${prefix}/manifest.json: blocked admission prerequisites are incomplete`) + } + + const artifactIdentities = new Set() + if (!isValidCalendarDate(manifest.compliance?.reviewedAt)) { + addError(`${prefix}/manifest.json compliance.reviewedAt: expected a real YYYY-MM-DD calendar date`) + } + for (const artifact of manifest.compliance?.artifactReviews ?? []) { + const artifactContext = `${prefix}/manifest.json artifact ${artifact.platform}/${artifact.architecture}` + const url = assertHttpsUrl(artifact.url, artifactContext) + if (url && url.hostname !== 'cdn.kimi.com') { + addError(`${artifactContext}: hostname must be cdn.kimi.com`) + } + if (url && !url.pathname.includes(`/v${artifact.version}/releases/`)) { + addError(`${artifactContext}: URL must pin the reviewed version in its release path`) + } + if (url && (url.search || url.hash)) { + addError(`${artifactContext}: URL must not contain query or fragment components`) + } + if (!isValidCalendarDate(artifact.reviewedAt)) { + addError(`${artifactContext} reviewedAt: expected a real YYYY-MM-DD calendar date`) + } + const identity = `${artifact.platform}/${artifact.architecture}` + if (artifactIdentities.has(identity)) addError(`${artifactContext}: duplicate platform/architecture review`) + artifactIdentities.add(identity) + } + +} + +const schemaVersion = (await readText(join(repositoryRoot, 'SCHEMA_VERSION')))?.trim() +const rootManifest = await readJson(join(repositoryRoot, 'manifest.json')) +const entrySchema = await readJson(join(repositoryRoot, 'schemas', 'registry-entry.schema.json')) + +if (!schemaVersion || !/^\d+\.\d+\.\d+$/.test(schemaVersion)) { + addError('SCHEMA_VERSION: expected a semantic version') +} +if (rootManifest) { + if (rootManifest.version !== schemaVersion) addError('manifest.json#version must equal SCHEMA_VERSION') + if (rootManifest.dataVersion !== schemaVersion) addError('manifest.json#dataVersion must equal SCHEMA_VERSION') +} + +let validateEntry = null +if (entrySchema) { + try { + const ajv = new Ajv({ allErrors: true, strict: true, validateFormats: false }) + validateEntry = ajv.compile(entrySchema) + } catch (error) { + addError(`schemas/registry-entry.schema.json: unable to compile (${error.message})`) + } +} + +const manifests = [] +const seenIds = new Set() +for (const directoryEntry of await readdir(entriesRoot, { withFileTypes: true })) { + if (!directoryEntry.isDirectory()) { + addError(`entries/${directoryEntry.name}: entries root may contain directories only`) + continue + } + + const entryDirectory = join(entriesRoot, directoryEntry.name) + const manifestPath = join(entryDirectory, 'manifest.json') + let manifestStat + try { + manifestStat = await lstat(manifestPath) + } catch (error) { + if (error.code === 'ENOENT') { + addError(`entries/${directoryEntry.name}: missing manifest.json`) + continue + } + throw error + } + if (!manifestStat.isFile() || manifestStat.isSymbolicLink()) { + addError(`entries/${directoryEntry.name}/manifest.json: must be a regular file and not a symbolic link`) + continue + } + const manifest = await readJson(manifestPath) + if (!manifest) continue + manifests.push(manifest) + + if (manifest.id !== directoryEntry.name) { + addError(`entries/${directoryEntry.name}/manifest.json: id must equal directory name`) + } + if (seenIds.has(manifest.id)) addError(`entries/${directoryEntry.name}/manifest.json: duplicate id ${manifest.id}`) + seenIds.add(manifest.id) + + for (const injectedField of ['sourceId', 'hasInstall']) { + if (containsKey(manifest, injectedField)) { + addError(`entries/${directoryEntry.name}/manifest.json: ${injectedField} is client-injected and must not be authored`) + } + } + + if (validateEntry && !validateEntry(manifest)) { + addError(`entries/${directoryEntry.name}/manifest.json: ${formatAjvErrors(validateEntry.errors)}`) + } + if (manifest.type === 'external-integration') { + await validateExternalIntegration(entryDirectory, manifest) + } +} + +if (rootManifest) { + const expectedStats = { + totalEntries: manifests.length, + dockerApps: manifests.filter((entry) => entry.type === 'docker-app').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, + } + for (const [field, expected] of Object.entries(expectedStats)) { + if (rootManifest.stats?.[field] !== expected) { + addError(`manifest.json#stats.${field}: expected ${expected}, received ${rootManifest.stats?.[field]}`) + } + } + const extraStats = Object.keys(rootManifest.stats ?? {}).filter((field) => !Object.hasOwn(expectedStats, field)) + if (extraStats.length > 0) addError(`manifest.json#stats: unknown fields ${extraStats.join(', ')}`) +} + +for (const manifest of manifests) { + if (manifest.sourceAppId && !seenIds.has(manifest.sourceAppId)) { + addError(`entries/${manifest.id}/manifest.json: sourceAppId ${manifest.sourceAppId} does not exist`) + } +} + +if (errors.length > 0) { + console.error(`Registry validation failed with ${errors.length} error(s):`) + for (const error of errors) console.error(`- ${error}`) + process.exitCode = 1 +} else { + const counts = rootManifest.stats + console.log( + `Registry validation passed: ${counts.totalEntries} entries ` + + `(${counts.dockerApps} Docker, ${counts.mcpServices} MCP, ` + + `${counts.httpApis} HTTP API, ${counts.externalIntegrations} external integration).`, + ) +} diff --git a/scripts/validate-registry.test.mjs b/scripts/validate-registry.test.mjs new file mode 100644 index 0000000..b8c3d43 --- /dev/null +++ b/scripts/validate-registry.test.mjs @@ -0,0 +1,259 @@ +import assert from 'node:assert/strict' +import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)) +const repositoryRoot = resolve(scriptDirectory, '..') +const validatorPath = join(scriptDirectory, 'validate-registry.mjs') + +const createFixture = async () => { + const root = await mkdtemp(join(tmpdir(), 'desirecore-registry-test-')) + await Promise.all([ + cp(join(repositoryRoot, 'entries'), join(root, 'entries'), { recursive: true }), + cp(join(repositoryRoot, 'schemas'), join(root, 'schemas'), { recursive: true }), + cp(join(repositoryRoot, 'SCHEMA_VERSION'), join(root, 'SCHEMA_VERSION')), + cp(join(repositoryRoot, 'manifest.json'), join(root, 'manifest.json')), + ]) + return root +} + +const readJson = async (path) => JSON.parse(await readFile(path, 'utf8')) +const writeJson = async (path, value) => writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8') + +const runValidator = (root) => spawnSync( + process.execPath, + [validatorPath, '--root', root], + { cwd: repositoryRoot, encoding: 'utf8' }, +) + +const expectRejected = async (mutate, expectedMessage) => { + const root = await createFixture() + try { + await mutate(root) + const result = runValidator(root) + assert.equal(result.status, 1, `validator unexpectedly passed:\n${result.stdout}`) + assert.match(`${result.stdout}\n${result.stderr}`, expectedMessage) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +test('accepts the checked-in Registry fixture', async () => { + const root = await createFixture() + try { + const result = runValidator(root) + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /Registry validation passed: 22 entries/) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('rejects an entry whose directory and id differ', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + manifest.id = 'different-id' + await writeJson(path, manifest) + }, /id must equal directory name/) +}) + +test('rejects client-injected sourceId in an upstream manifest', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + manifest.sourceId = 'untrusted-source' + await writeJson(path, manifest) + }, /sourceId is client-injected/) +}) + +test('rejects stale root statistics', async () => { + await expectRejected(async (root) => { + const path = join(root, 'manifest.json') + const manifest = await readJson(path) + manifest.stats.externalIntegrations = 0 + await writeJson(path, manifest) + }, /stats\.externalIntegrations: expected 1, received 0/) +}) + +test('rejects an unknown external-integration id', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + manifest.id = 'unreviewed-bridge' + await writeJson(path, manifest) + }, /unknown external-integration id unreviewed-bridge/) +}) + +for (const kind of ['product', 'documentation', 'chrome-web-store', 'edge-add-ons', 'privacy', 'terms']) { + test(`rejects a modified ${kind} official URL`, async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + const link = manifest.officialLinks.find((item) => item.kind === kind) + link.url = `${link.url}/tampered` + await writeJson(path, manifest) + }, /URL must exactly equal/) + }) +} + +test('rejects a browser-store extension id that does not match its URL path', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + const link = manifest.officialLinks.find((item) => item.kind === 'chrome-web-store') + link.url = 'https://chromewebstore.google.com/detail/kimi-webbridge/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + await writeJson(path, manifest) + }, /URL path must end with extensionId/) +}) + +test('rejects an incorrect structured extension id', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + const link = manifest.officialLinks.find((item) => item.kind === 'edge-addons') + ?? manifest.officialLinks.find((item) => item.kind === 'edge-add-ons') + link.extensionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + await writeJson(path, manifest) + }, /extensionId must equal bnlffdbcfnanfbknnlaflhlhkocccckg/) +}) + +test('rejects installation fields for listing-only integrations', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + manifest.install = { method: 'unsupported' } + await writeJson(path, manifest) + }, /forbidden field "install"/) +}) + +test('rejects an unverified or unexpected listing maintainer', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + manifest.listingMaintainer.url = 'https://github.com/example/registry' + manifest.listingMaintainer.verified = false + await writeJson(path, manifest) + }, /listingMaintainer must be the verified DesireCore Registry URL/) +}) + +test('rejects a non-tools external category', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + manifest.category = 'browser' + await writeJson(path, manifest) + }, /must be equal to constant/) +}) + +test('rejects an unapproved external icon', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + manifest.icon = 'kimi-logo' + await writeJson(path, manifest) + }, /must be equal to one of the allowed values/) +}) + +test('rejects extra text files in an external-integration directory', async () => { + await expectRejected(async (root) => { + await writeFile(join(root, 'entries', 'kimi-webbridge', 'usage.md'), '# not allowed\n', 'utf8') + }, /allows only manifest\.json; found file usage\.md/) +}) + +test('rejects extra directories in an external-integration directory', async () => { + await expectRejected(async (root) => { + await mkdir(join(root, 'entries', 'kimi-webbridge', 'assets')) + }, /allows only manifest\.json; found directory assets/) +}) + +test('rejects symbolic links in an external-integration directory', { skip: process.platform === 'win32' }, async () => { + await expectRejected(async (root) => { + await symlink('manifest.json', join(root, 'entries', 'kimi-webbridge', 'alias.json')) + }, /allows only manifest\.json; found symbolic link alias\.json/) +}) + +test('rejects a symbolic-link manifest', { skip: process.platform === 'win32' }, async () => { + await expectRejected(async (root) => { + const directory = join(root, 'entries', 'kimi-webbridge') + const manifestPath = join(directory, 'manifest.json') + const backupPath = join(directory, 'reviewed.json') + await cp(manifestPath, backupPath) + await rm(manifestPath) + await symlink('reviewed.json', manifestPath) + }, /manifest\.json: must be a regular file and not a symbolic link/) +}) + +test('rejects binary files in an external-integration directory', async () => { + await expectRejected(async (root) => { + await writeFile(join(root, 'entries', 'kimi-webbridge', 'payload.bin'), Buffer.from([0, 255, 1, 254])) + }, /allows only manifest\.json; found file payload\.bin/) +}) + +test('rejects special filesystem entries in an external-integration directory', { skip: process.platform === 'win32' }, async () => { + await expectRejected(async (root) => { + const fifoPath = join(root, 'entries', 'kimi-webbridge', 'control.fifo') + const result = spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr) + }, /allows only manifest\.json; found FIFO control\.fifo/) +}) + +test('rejects an impossible compliance review date', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + manifest.compliance.reviewedAt = '2026-02-30' + await writeJson(path, manifest) + }, /compliance\.reviewedAt: expected a real YYYY-MM-DD calendar date/) +}) + +test('rejects an impossible artifact review date', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + manifest.compliance.artifactReviews[0].reviewedAt = '2026-13-01' + await writeJson(path, manifest) + }, /artifact macos\/arm64 reviewedAt: expected a real YYYY-MM-DD calendar date/) +}) + +test('rejects an artifact URL that is not pinned to its reviewed version', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + manifest.compliance.artifactReviews[0].url = + 'https://cdn.kimi.com/webbridge/unpinned/releases/kimi-webbridge-darwin-arm64' + await writeJson(path, manifest) + }, /URL must pin the reviewed version in its release path/) +}) + +test('rejects query parameters on an immutable artifact URL', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + manifest.compliance.artifactReviews[0].url += '?redirect=1' + await writeJson(path, manifest) + }, /URL must not contain query or fragment components/) +}) + +test('rejects unsupported universal artifact architecture', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + manifest.compliance.artifactReviews[0].architecture = 'universal' + await writeJson(path, manifest) + }, /must be equal to one of the allowed values/) +}) + +test('rejects a local daemon that omits Windows', async () => { + await expectRejected(async (root) => { + const path = join(root, 'entries', 'kimi-webbridge', 'manifest.json') + const manifest = await readJson(path) + const daemon = manifest.components.find((item) => item.kind === 'local-daemon') + daemon.platforms = ['macos', 'linux'] + await writeJson(path, manifest) + }, /local-daemon must declare at least Windows and macOS/) +})