fix: 绑定目录获取与生命周期收据 (#10)

* fix(apps): 绑定目录快照与最终执行

* fix(apps): 结算目录执行前失败

* fix(services): 拒绝不完整目录响应

* fix(services): 按生命周期收据精确卸载

* fix(apps): 采用服务端生命周期收据

* fix(apps): 绑定目录操作防重放

* fix(apps): 原子切换重装收据
This commit is contained in:
2026-08-31 04:00:50 -04:00
committed by GitHub
parent 3890514896
commit ee0bc7b044
5 changed files with 1558 additions and 36 deletions

View File

@@ -14,7 +14,7 @@
| 技能 ID | 描述 | 风险 |
|---------|------|------|
| [task-management](./task-management/SKILL.md) | 任务创建、分配、跟踪与多 Agent 编排 | low |
| [app-install-manager](./app-install-manager/SKILL.md) | 应用与服务目录的 docker-app 安装/卸载/启停 + 安装状态回写 | high |
| [app-install-manager](./app-install-manager/SKILL.md) | 经目录快照 resolver 授权的 docker-app / 服务安装卸载启停状态回写 | high |
## 技能协作关系

View File

@@ -1,7 +1,7 @@
---
name: 应用安装管理
description: 从应用与服务目录安装/卸载/启停 docker-app 与 mcp/http-api 服务docker-appinstall.md → 跑 docker compose → 健康校验 → 回写安装状态mcp 服务:按 install 字段安装 → 注册到 Agent → 连接验证 → 回写状态。Use when 用户要求"安装 Dify/n8n 等应用"、"安装某 MCP 服务"、"卸载某应用/服务"、"启动/停止/重启某应用",或安装/卸载请求以"请安装/卸载 {名称} 到/从 {设备}"形式到达。
version: "1.2.0"
description: 经目录快照或已安装生命周期收据 resolver 授权后安装/卸载/启停 docker-app 与 mcp/http-api 服务docker-app解析 manifest/installGuide → 跑 docker compose → 健康校验 → 回写安装状态mcp 服务:使用 resolver 返回的 install/connection → 注册到 Agent → 连接验证 → 回写状态。Use when 用户要求"安装 Dify/n8n 等应用"、"安装某 MCP 服务"、"卸载某应用/服务"、"启动/停止/重启某应用",或安装/卸载请求以"请安装/卸载 {名称} 到/从 {设备}"形式到达。
version: "1.3.0"
type: procedural
risk_level: high
status: enabled
@@ -9,7 +9,7 @@ disable-model-invocation: true
tags: [installation, docker, mcp, registry, app-management]
metadata:
author: desirecore
updated_at: "2026-07-20"
updated_at: "2026-08-31"
---
# app-install-manager 技能
@@ -20,7 +20,10 @@ metadata:
## L1概述与使用场景
DesireCore 的安装是**委派式**的——界面只发出"请安装 {名称} 到 {设备}"这类自然语言指令并乐观记下一条中间态(首装 `installing` / 重装 `reinstalling` / 卸载 `uninstalling`**真正的执行与终态回写由本技能(你)完成**。后端会监听安装记录文件:在状态变为 `installed` 后自动派生 docker-app 暴露的服务,在中间态保留既有派生,仅在终态(`failed`/`uninstalled`/条目移除)清理派生。
DesireCore 的安装是**委派式**的——界面发送自然语言说明加机器 envelope并乐观记下一条中间态
(首装 `installing` / 重装 `reinstalling` / 卸载 `uninstalling`**真正的执行与终态回写由本技能
(你)完成**。后端会监听安装记录文件:在状态变为 `installed` 后自动派生 docker-app 暴露的服务,
在中间态保留既有派生,仅在终态(`failed`/`uninstalled`/条目移除)清理派生。
覆盖两类目标:
- **docker-app**Dify / n8n / RagFlow 等,走 docker compose 部署。
@@ -35,17 +38,74 @@ DesireCore 的安装是**委派式**的——界面只发出"请安装 {名称}
### 关键路径与数据
- 应用/服务元数据:`<DesireCore根目录>/registry/official/entries/<entryId>/manifest.json`
- 安装指南docker-app`<目录>/install.md`含端口、docker compose 步骤、验证地址)
- 当前目录快照解析:`POST http://127.0.0.1:<agent-service-port>/api/registry/acquisitions/resolve`
- 机器消息解析与响应复核`<本技能目录>/scripts/registry-catalog-acquisition.mjs`
- 安装记录:`<DesireCore根目录>/config/installed-entries.json`
`<根目录>` 为你的 AgentFS 根,生产为 `~/.desirecore`,开发隔离为 `~/.desirecore-dev`,以自我感知里的实际根目录为准)
- agent-service API`http://127.0.0.1:<agent-service-port>`端口见自我感知mcp 服务安装/注册用)
### 执行前协议:精确机器消息 → ownership / resolver强制
任何 install/reinstall/uninstall 在环境探测、Docker 查询、包安装或其它执行类 `bash` 前,都必须
先完成本节。自然语言只用于面向用户的说明,**不得**从自然语言猜测 `kind``sourceId`
`entryId`、deviceId、snapshot、install 或 connection。
1. 从**当前这条**生命周期指令中提取唯一一行:
`RegistryCatalogAcquisition=<单行 JSON>`。App/Service envelope 都必须带
`operation:{action:"install"|"reinstall"|"uninstall",deviceId,operationId}`operationId 必须是小写
UUID并且只代表这一次 lifecycle 意图。先把指令全文交给
`parse-locator`,只取得 `sourceId+entryId+operation.deviceId` 的账本定位符;再用
`parse-message` 校验完整 envelope
```bash
node "<本技能目录>/scripts/registry-catalog-acquisition.mjs" parse-locator
node "<本技能目录>/scripts/registry-catalog-acquisition.mjs" parse-message
```
完整解析结果为 `{request,locator}`App resolver request 必须包含 operation。解析器会拒绝缺失、
重复、畸形机器行、额外字段、可变 commit、来源/snapshot 不一致和 App 夹带 install/connection。
不允许回看旧消息或从显示名、同 ID 本地条目、自然语言补字段。若 locator 已解析但完整解析
失败,先按“执行前停止的强制结算”处理;真正缺失/JSON 畸形且没有 locator 的手工指令不猜写
安装账本,只报告错误并停止。
2. 对 App 三种 operation 及 Service install/reinstall把解析结果的 `request` 原样作为 body 调 resolver
```yaml
tool: HttpRequest
parameters:
url: http://127.0.0.1:<agent-service-port>/api/registry/acquisitions/resolve
method: POST
body: <parse-message 输出的 request>
```
install/reinstall 由服务端重验 current canonicaluninstall 由服务端按 operation.deviceId 精确读取
installed-entry 的不可变 lifecycle receipt并用 receipt 对应的 Git object 返回旧版本定义。它不因
当前 catalog listing-only、stale 或下架而阻断,但无 receipt、receipt/snapshot 不一致、来源或设备
不匹配都失败关闭。
Service uninstall 不调用 current catalog resolver它只按 locator 命中 exact installed receipt随后
由精确 DELETE 端点使用 receipt.runtimeServerId 删除目录删除、listing-only 或 stale 不得卡住卸载。
3. 只有 HTTP 200 且响应通过同一脚本的 `evaluate-response` 复核后才可继续。复核
输入是 `{ "expected": <request>, "status": <HTTP状态>, "body": <响应JSON> }`。响应的
kind/sourceId/entryId/snapshot、manifest.id/type 必须一致App 还必须取得非空 installGuide。
响应还必须包含服务端生成并校验的完整 `catalogReceipt` candidate。`allowed:true` 才是本次执行
定义App 只使用其 manifest/installGuide/catalogReceipt忽略任何 install/connection。uninstall 的
manifest/installGuide/receipt 必须来自服务端 receipt 解析结果,不能改读当前目录。
4. 服务端有界拒绝码必须逐字保留:
`registry_acquisition_invalid_request`、`registry_acquisition_not_found`、
`registry_acquisition_snapshot_stale`、`registry_acquisition_blocked`、
`registry_acquisition_client_upgrade_required`、`registry_acquisition_config_mismatch`、
`registry_acquisition_install_guide_unavailable`、`registry_acquisition_receipt_missing`、
`registry_acquisition_ownership_mismatch`;同时保留通过有界校验的 reasons。任何非 200、
连接失败、非 JSON 或身份/快照/manifest 不一致都禁止执行命令,并先完成强制结算。
5. **禁止 fallback**:不得读取任何本地 Registry 条目、改用同 ID 其它来源或沿用旧 resolver 响应。
resolver/ownership 只决定本次读取与精确归属,不新增安装账本,也不替代 Human Gate、健康校验、
回滚和终态收据。
安装记录条目结构(写回时必须完整保留全部字段):
```json
{
"entryId": "dify",
"sourceId": "registry:official",
"type": "docker-app | mcp | http-api",
"deviceId": "<设备ID>",
"deviceName": "<设备名>",
@@ -54,10 +114,41 @@ DesireCore 的安装是**委派式**的——界面只发出"请安装 {名称}
"installedBy": "agent",
"status": "installing | reinstalling | installed | uninstalling | failed | uninstalled",
"conversationId": "<对话ID>",
"messageId": "<消息ID>"
"messageId": "<消息ID>",
"operationId": "<本次操作UUID>",
"catalogReceipt": {
"schemaVersion": 1,
"kind": "app | service",
"catalogSourceId": "registry:official",
"entryId": "dify",
"catalogCommit": "<40/64位commit>",
"catalogPath": "entries/dify",
"releaseVersion": "<安装版本>",
"lifecycle": {
"manifest": {
"id": "dify",
"name": "Dify",
"type": "docker-app | mcp",
"version": "<安装版本>",
"description": "<最多4096字符>"
},
"installGuide": "<仅App服务端返回且最多64KiB>"
},
"runtimeServerId": "<仅MCP注册成功后由服务端签发>"
}
}
```
`catalogReceipt` 是同一 installed-entry 内的最小不可变生命周期收据不是第二账本。UI 意图只带
snapshot 基线install/reinstall 成功时必须原样采用 resolver 返回的完整 candidate禁止 Skill 根据
snapshot、manifest 或 installGuide 自行构造/扩展 lifecycle。Service 注册成功后只允许在该 candidate
上补服务端返回的 runtimeServerId。
重装时主记录的 `catalogReceipt` 和 version 仍是旧的已安装事实;新目标只存在同一条记录的
`pendingCatalogReceipt`base snapshot无 lifecycle/runtime。Skill 不写 PUT也不把 pending 当成
最终 receipt。成功时用 resolver 的新 candidate + 新 releaseVersion 原子切换;失败/取消只用同一
operationId PATCH `installed`,省略 receipt/version让服务端保留旧事实并清除 pending。
**状态语义表(六枚举)**——中间态由界面乐观写入、终态由你回写:
| status | 谁写入 | 你的退出动作 |
@@ -72,6 +163,23 @@ DesireCore 的安装是**委派式**的——界面只发出"请安装 {名称}
- 卸载或重装失败时你回写 `installed`,派生会**无缝恢复**(从未被删);
- `failed` 语义是"应用当前不可用"——**只有确认容器已不能用才写 `failed`**,否则一律回 `installed`。
### 执行前停止的强制结算
UI 在发消息前已经写入中间态。locator 成功后完整解析失败、resolver 拒绝/异常、Human Gate
取消、环境检查失败,或任何尚未执行安装/卸载命令的退出,都必须按下面的单账本流程结算:
1. `GET /api/installed-entries` 取得当前实例记录;不得直读其它实例或构造新记录。
2. 把 `{stage,locator,entries}` 交给脚本的 `plan-settlement`。stage 只能是
`parse|resolver|human_gate|pre_execution`。它严格匹配 sourceId+entryId+deviceId 和与 operation
对应的中间态与 operationId同 ID 双来源、跨设备或旧消息重放都不会串写,零条/多条都返回
settlement:null。
3. settlement 非空时按“回写安装记录的统一方式”PATCH 它给出的精确三元组和 status
`installing → failed``reinstalling → installed``uninstalling → installed`,并原样携带
settlement.operationId。结算前后都不得执行
Docker、包管理或安装脚本。settlement:null 时报告有界原因并停止,不按数组顺序、名称或设备猜测。
4. Human Gate 取消也是正常停止而非悬空:首装回 `failed`,重装/卸载恢复 `installed`。resolver
错误码与 reasons 在完成结算后原样说明。
### 回写安装记录的统一方式端点优先404 降级)
**所有「回写安装记录」都用这一方式**——不要再直接 file-write 改 `installed-entries.json`(除非端点不可用时降级)。这样 installed-entries 成为「你校验后回写的事实」,界面以它为准。
@@ -84,49 +192,81 @@ parameters:
url: http://127.0.0.1:<agent-service-port>/api/installed-entries/<entryId>/<deviceId>
method: PATCH
body:
sourceId: <locator 中的精确 sourceId>
operationId: <locator.operation.operationId>
status: installed # 六枚举之一
# version: "<新版本>" # 可选,重装升级时更新版本号
```
- `200` → 回写成功,前端自动刷新,**无需**再手动改文件。
- `400` → status 非法枚举,检查取值。
- `400` → status/sourceId 非法,检查取值。
- `404 entry_not_found` → 该条中间态记录不存在(界面未写/已被清理)。**不要**重试或伪造记录;跳过并一句话提示用户重发指令即可。
- **连接失败 / 路由 404旧客户端无此端点** → **降级 file-write**(见下)。
- receipt PATCH 的 `catalog_receipt_*` / `catalog_operation_mismatch` /
`catalog_status_transition_invalid` / `invalid_operation_id` 400/409
表示候选非法、ownership/CAS/runtime/lifecycle/operation 不一致;
不得删字段、重建 receipt 或换 snapshot 重试。App 停止并按失败语义结算MCP 先回滚刚注册的
runtimeServerId再结算。
- **连接失败 / 路由 404旧客户端无此端点** → catalog lifecycle 回写不得降级 file-write否则会
绕过 operationId 和 receipt CAS。停止并报告客户端不兼容。
**降级 file-write**(仅端点不可用时):读 `installed-entries.json` → 按 `entryId`+`deviceId` 定位那条中间态记录 → **只改 `status`(保留 `installedAt` 等其余所有字段与其它条目)** → 写回整个文件(界面也写此文件,勿覆盖丢失)。
**降级 file-write**(仅无 catalog envelope 的历史普通操作可用;本节 catalog intent 绝无此降级):读
`installed-entries.json` → 按 `sourceId`+`entryId`+`deviceId` 精确定位那条中间态记录 → **只改
`status`(保留 `installedAt` 等其余所有字段与其它条目)** → 写回整个文件(界面也写此文件,
勿覆盖丢失)。来源缺失或存在多条候选时失败关闭,不得按同 ID 猜测。
下文各流程的「回写安装记录」一律指这套统一方式,只标注目标 `status`。
### docker-app 安装流程
1. **解析意图**:从指令提取 `action`install/uninstall/start/stop/restart、名称 → 映射到 `entryId`(查 registry entries 目录名 / manifest.id`type`manifest.type以及目标设备缺省=本机)。若 `type``mcp`/`http-api`,改走下方"mcp / http-api 服务安装流程"。
2. **读目录数据**`read` manifest.json 拿到 `install.requirements`docker/内存/磁盘/ports`exposes``read` install.md 拿到部署步骤与验证地址。
3. **环境校验**`bash``docker version` / `docker compose version` 确认 docker 就绪;用 manifest.ports 检查端口占用(`lsof -i :<port>``docker ps`);磁盘空间。任一不满足→停下,向用户说明并给出修复建议,**不要**继续
4. **高风险确认**:安装/卸载会改动本机容器,属高风险。执行前用一句话向用户确认(应用名 + 目标设备 + 端口)。用户取消则中止。
5. **执行**`bash`,严格按 install.md
1. **解析机器消息并调用 resolver**严格执行“执行前协议”。action 与 deviceId 只认 operation
`entryId`、`type`、版本、端口、exposes 和安装步骤只认复核后的服务端结果。manifest.type 不是
`docker-app` 时改走下方服务流程;身份或类型不一致立即停止
2. **读取本次授权定义**:从 resolver 返回的 manifest 读取 `install.requirements`docker/内存/
磁盘/ports与 exposes只把 resolver 返回的 installGuide 作为部署步骤与验证地址。不得从
AgentFS 或其它目录补读/覆盖同 ID 定义。
3. **环境校验**`bash``docker version` / `docker compose version` 确认 docker 就绪;用
`manifest.install.requirements.ports` 检查端口占用(`lsof -i :<port>` 或 `docker ps`);检查磁盘
空间。任一不满足→先按 `pre_execution` 结算再停下,向用户说明并给出修复建议。
4. **高风险确认**:安装/卸载会改动本机容器,属高风险。执行前用一句话向用户确认(应用名 +
目标设备 + 端口)。用户取消则按 `human_gate` 结算后中止。
5. **执行**`bash`,严格按 resolver 返回的 installGuide
- docker-compose 类:在应用工作目录 `docker compose up -d`docker 类:`docker run ...`。
- 失败立即捕获输出,进入"失败处理"。
6. **健康校验(先校验后回写,强制)**:按 install.md 的验证地址或 manifest.exposes 的 `http://localhost:<port><path>``bash``curl` 轮询(最多 ~2 分钟)确认服务可达。**只有这步通过才算安装成功**——不要仅凭 `docker compose up -d` 无报错就回写 `installed`
6. **健康校验(先校验后回写,强制)**:按 installGuide 的验证地址或 manifest.exposes 的 `http://localhost:<port><path>``bash` 用 `curl` 轮询(最多 ~2 分钟)确认服务可达。**只有这步通过才算安装成功**——不要仅凭 `docker compose up -d` 无报错就回写 `installed`。
7. **回写安装记录****本技能的核心职责**,按上方「回写安装记录的统一方式」):
- 健康校验通过 → PATCH `status: installed`;未通过/失败 → `status: failed`;重装失败但旧版本仍在运行 → 回 `installed`(见状态语义表)。
- 健康校验通过 → 用 `build-receipt-patch` 把 resolver 原样 candidate 与 `status: installed` 组成
CAS-safe PATCH并携带 locator.operationId 和 candidate.releaseVersion不得本地重建 receipt。
未通过/失败 → 首装 `status: failed`;重装失败但旧版本仍在运行 → 只 PATCH
`{sourceId,operationId,status:installed}`,不得附 receipt/version/pending见状态语义表
- 成功后无需手动派生服务——后端文件 watcher 检测到 `installed` 后自动派生;重装期间派生始终保留。
8. **回报用户**:一句话总结结果 + 访问地址(成功)或失败原因 + 排查建议(失败)。
### docker-app 卸载流程
1. 确认(高风险)。此时界面已把记录置 `uninstalling`(派生仍保留)。
2. `bash`:进应用工作目录 `docker compose down -v`(或 `docker rm -f <容器>`),按需清理卷/镜像。
3. 回写安装记录(按「回写安装记录的统一方式」):
1. 完整解析 App envelope 并调用 resolver。resolver 必须用 operation.deviceId 精确命中
sourceId+entryId+deviceId 的 `uninstalling` 记录,再按该记录的不可变 lifecycle receipt 读取旧 Git
object**不得以当前 catalog eligibility 重新授权卸载**。同 ID 其它来源、其它设备、无 receipt
legacy 记录或 receipt/snapshot 不一致都拒绝。
2. 只使用 resolver 从 receipt 返回并通过脚本复核的 manifest/installGuide 作为卸载定义;不得读取
当前 Registry 同 ID 条目补命令。resolver 拒绝时先结算回 `installed` 并提示手工管理/升级,不能
发明 compose 目录或容器名。取得定义后再做 Human Gate取消即回 `installed`。
3. `bash`:只按 receipt 绑定的 installGuide 执行卸载(如 `docker compose down -v`),按需清理
卷/镜像。
4. 回写安装记录(按「回写安装记录的统一方式」):
- **成功**(容器确已停止/删除)→ PATCH `status: uninstalled`。后端 watcher 据此清理派生服务与 per-service Skill。
- **失败**(容器未能停止/删除,应用仍在运行)→ PATCH `status: installed`,向用户说明卸载失败原因。**切勿**留在 `uninstalling`(界面卸载按钮会禁用,用户被卡住直至 stale 超时)。
4. 回报用户。
5. 回报用户。
### mcp / http-api 服务安装流程
**mcp 服务**manifest.type=`mcp`,条目含 `install` 与 `connection` 字段):
1. **解析意图 + 确认**:确定 `entryId`、目标设备mcp 通常装到本机)。高风险确认。界面已乐观写 `installing`/`reinstalling`
2. **读条目**`read` manifest.json 拿 `install``method` npx/pip/uvx/docker/binary、`packageName``command``args``postInstall`)与 `connection`transport/command/args/url/headers
1. **解析机器消息并调用 resolver + 确认**:严格执行“执行前协议”,以复核后的复合身份确定
`entryId`,以 operation.deviceId 确定目标设备mcp 通常装到本机。resolver 失败时在任何安装命令前
停止;成功后再做高风险确认。界面已乐观写 `installing`/`reinstalling`。
2. **使用本次授权定义**:只用 resolver 响应的 manifest/install/connection`install` 含 method、
packageName、command、args、postInstall`connection` 含 transport/command/args/url/headers
不读取或合并本地同 ID 条目。
3. **执行安装**(优先走 API逐条跑 `postInstall` 命令 + 可选连接测试):
```yaml
tool: HttpRequest
@@ -136,6 +276,7 @@ parameters:
body:
install: <manifest.install 原样>
connection: <manifest.connection 原样> # 传入则安装后自动测连接
catalogAcquisition: <parse-message 输出的 request 原样>
```
返回 `data.steps`(每条命令 exitCode/stdout/stderr与 `data.connectionTest`。任一命令失败→`success:false`,进入"失败处理"。无 API 可用时用 `bash` 逐条跑 `postInstall`。
4. **注册到 Agent**(让 MCP 工具下轮可用):
@@ -147,38 +288,69 @@ parameters:
body:
serverId: <entryId>
config: <manifest.connection 原样>
catalogAcquisition: <parse-message 输出的 request 原样>
```
端点锁内 read-modify-write 写入 agent.json 的 `mcp_servers`——**勿手工编辑 agent.json**(绕锁会丢并发更新)。
端点锁内 read-modify-write 写入 agent.json 的 `mcp_servers`——**勿手工编辑 agent.json**(绕锁会丢
并发更新。catalog 响应的 `data.runtimeServerId` 是服务端签发的复合 ownership key不得用
entryId、显示名或调用方值代替。
5. **连接校验(先校验后回写,强制)**:看第 3 步返回的 `connectionTest.success`,或单独 `POST /api/mcp/test-connection`body `{connection}`)确认能连通、能列出工具。**只有校验通过才算安装成功**——不要仅凭 postInstall 命令退出码 0 就回写 `installed`(装了包不等于连得上)。
6. **回写安装记录**(按「回写安装记录的统一方式」):连接校验通过 → PATCH `status: installed`;校验失败 → `status: failed`(重装失败但旧配置仍可用 → 回 `installed`)。
6. **回写安装记录**(按「回写安装记录的统一方式」):连接校验通过后,用脚本
`build-receipt-patch` 原样接收 resolver 的 catalogReceipt candidate只在其上补服务端 add 返回的
runtimeServerId并与 locator.operationId、`status: installed` 在同一次精确 PATCH 中写回原
installed-entryPATCH version 取 candidate.releaseVersion禁止从
request snapshot/manifest 自建或替换 lifecycle。缺 candidate/runtimeServerId、candidate 被篡改或
receipt CAS 回写失败时不得宣布成功,先按精确 runtimeServerId 回滚刚注册的 MCP再按失败语义
结算。连接校验失败 → 首装 `failed`;重装失败但旧配置仍可用 → 只带同一 operationId 恢复
`installed`,省略 receipt/version/pending。
7. **回报用户**:总结安装结果 + 发现的工具数(成功)或失败原因摘要(失败)。
**http-api 服务**manifest.type=`http-api`,无 `install` 字段、界面也无自动化安装动作):按「回写安装记录的统一方式」维护回写(`installing`→`installed`、`uninstalling`→`uninstalled`/失败回 `installed`),明确告知用户该类服务无本地部署步骤、只是登记可达性。
### mcp / http-api 服务卸载流程
1. 确认(高风险)。界面已置 `uninstalling`。
1. 完整解析机器消息,但不调用 current catalog resolver。用脚本 `resolve-service-uninstall` 对 locator、
request.snapshot 和 `GET /api/installed-entries` 结果做完整复核:必须精确命中
sourceId+entryId+deviceId 的唯一 `uninstalling` 记录receipt.kind 必须为 service
catalogReceipt.runtimeServerId 与 snapshot 必须有效。无 receipt/kind/runtimeServerId、身份/快照不符、
零条或多条都先结算回 `installed` 并停止。随后做高风险确认,取消同样结算回 `installed`。
2. **执行卸载**mcp从 Agent 移除 MCP server 配置:
```yaml
tool: HttpRequest
parameters:
url: http://127.0.0.1:<agent-service-port>/api/agents/desirecore/mcp-servers/<serverId>
url: http://127.0.0.1:<agent-service-port>/api/agents/desirecore/mcp-servers/<entryId>?sourceId=<sourceId>&deviceId=<deviceId>&operationId=<operationId>
method: DELETE
```
端点幂等(`serverId` 不存在也返回成功)。如安装时全局装了包,按需 `bash` 卸载可选多为无害保留。http-api 服务无需执行动作,直接进第 3 步。
URL 参数先用脚本 `build-service-delete` 从 locator 生成operationId 必须原样来自 locator。服务端
从精确 installed-entry receipt 的 runtimeServerId 删除对应
配置;不得按裸 entryId 删除,
也不得因为当前目录删除、listing-only 或 snapshot stale 改走 resolver。
端点幂等。如安装时全局装了包,按需 `bash` 卸载可选多为无害保留。http-api 服务无需执行
动作,直接进第 3 步。
- **旧客户端降级**:该 DELETE 端点是较新客户端才有的能力。若返回 **404 / Not Found / 路由不存在**,说明当前客户端版本尚未包含 mcp 卸载端点——**不要**当作卸载成功。此时回写安装记录为 `installed`(保持"仍在用"),并一句话告知用户"当前客户端版本不支持 mcp 服务卸载,请升级客户端后重试"。切勿手工编辑 agent.json 绕过(绕锁会丢并发更新)。
3. **回写安装记录**(按「回写安装记录的统一方式」):成功 → PATCH `status: uninstalled`**失败(含 DELETE mcp-servers 端点 404 降级)→ PATCH `status: installed`** 并说明原因(勿留在 `uninstalling`)。
3. **回写安装记录**(按「回写安装记录的统一方式」):成功 → 用同一 operationId PATCH
`status: uninstalled`**失败(含 DELETE mcp-servers 端点 404 降级)→ 用同一 operationId PATCH
`status: installed`** 并说明原因(勿留在 `uninstalling`)。
4. 回报用户。
### 启动 / 停止 / 重启
收到"启动/停止/重启 {应用}"docker-app:定位应用工作目录,`bash` 执行 `docker compose start|stop|restart`(或 `docker start|stop|restart <容器>`),回报结果。这类运行态切换不改变安装记录的 install 状态。
收到"启动/停止/重启 {应用}"docker-app不走 catalog acquisition envelope它只能以资源管理面
已经精确选中的 sourceId+entryId+deviceId 和实例级生命周期定义执行。缺精确实例或定义时停止,
不得用名称、本地 Registry 同 ID 条目或当前 catalog 猜容器。完成高风险确认后才用 `bash` 执行
该实例定义允许的 start/stop/restart这类运行态切换不改变 installed-entry 的 install 状态。
### 失败处理
| 场景 | 处理 |
|------|------|
| docker 未运行 | 提示用户启动 Docker安装记录回写 `failed` 或保留中间态并说明 |
| 机器行缺失/畸形/重复 | 无 locator 时不猜写;有 locator 时先强制结算。都禁止从自然语言或本地目录补全 |
| resolver 400/404/409/连接失败 | 保留真实 `registry_acquisition_*` 与有界 reasons先强制结算再停止禁止 Docker/bash/包管理和同 ID fallback |
| resolver 200 但身份/快照/manifest/指南不一致 | 视为不可信响应,先强制结算,不执行任何生命周期副作用 |
| resolver 200 缺失/篡改 catalogReceipt 或 App lifecycle guide 超过 64 KiB | 拒绝执行;禁止本地补建/截断 lifecycle先强制结算 |
| receipt PATCH CAS/runtime/lifecycle 冲突 | 禁止修改候选绕过MCP 回滚注册后结算App 按失败语义结算 |
| operationId 缺失/畸形或旧消息重放 | 失败关闭;不得用当前 installed-entry 的新 operationId 替换旧消息,禁止执行或结算新意图 |
| Human Gate 取消 | 首装回 `failed`,重装/卸载回 `installed`,不得遗留中间态 |
| docker 未运行 | 提示用户启动 Docker首装回 `failed`,重装回 `installed` |
| 端口被占用 | 列出占用进程,建议换端口或停占用,征求用户意见 |
| compose 启动失败 | `docker compose logs` 取错误,回写 `failed`,附日志摘要 |
| 健康校验超时 | 提示"可能仍在启动",给出查看日志的命令;如确认失败回写 `failed` |
@@ -189,7 +361,12 @@ parameters:
### 边界与安全
- 只装 registry 目录中存在的应用/服务;找不到 entryId 就明确告知,不要臆造安装命令
- 新安装只处理当前机器消息与 resolver 共同确认的应用/服务;卸载只处理精确 installed ownership
- `sourceId+entryId+snapshot+operation.deviceId+operation.operationId` 是本次边界;显示名、历史消息、本地同 ID 条目、
installed-entries 和调用方提供的 install/connection 都不能扩大它。
- App resolver 必须先于 Docker 探测、包管理和所有执行类 `bash`;非 200 或复核失败没有 fallback
并必须先结算中间态。uninstall 不受当前 catalog listing/stale 阻断,但只能使用精确 ownership 与
lifecycle receipt 解析出的旧版本定义。
- 所有破坏性 docker 操作与 Agent 配置写入前必须有用户确认risk_level: high
- 回写状态优先用 PATCH 端点(自带 status 枚举校验与原子写);仅端点不可用时降级 file-write此时须自行保证结构合法status 仅限六枚举值),否则界面加载会过滤掉脏条目。
- **先校验后回写**docker-app 必须健康校验通过、mcp 必须连接校验通过,才回写 `installed`——installed-entries 是「你校验过的事实」,不是「执行过命令」。
@@ -198,7 +375,10 @@ parameters:
## 与其他技能/系统的协作
- **后端 installed-entries watcher**:消费你回写的 status只在 `installed` 派生 docker-app 服务、中间态保留派生、终态清理,无需你手动调派生接口。
- **installed-entries 回写端点**`PATCH /api/installed-entries/:entryId/:deviceId`(结构化回写状态,原子写 + 自动广播刷新前端;旧客户端 404 时降级 file-write
- **Registry acquisition resolver**`POST /api/registry/acquisitions/resolve`install/reinstall 复核当前
canonical snapshotApp uninstall 按精确 ownership + lifecycle receipt 解析旧 Git object只授权
本次读取,不记录安装事实)。
- **installed-entries 回写端点**`PATCH /api/installed-entries/:entryId/:deviceId`body 携带 sourceId结构化回写状态原子写 + 自动广播刷新前端;旧客户端 404 时才降级 file-write
- **agent-service mcp API**`POST /api/mcp/install`(执行 postInstall + 连接测试)、`POST /api/agents/desirecore/mcp-servers`(注册)、`DELETE /api/agents/desirecore/mcp-servers/:serverId`(卸载)、`POST /api/mcp/test-connection`(验证)。
- **task-management**:长安装可登记为任务跟踪进度。
- **service-health**:派生出的服务由后端周期探活,你无需自行维护其健康。

View File

@@ -0,0 +1,704 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url'
import { createHash } from 'node:crypto'
const MACHINE_LINE_PREFIX = 'RegistryCatalogAcquisition='
const IMMUTABLE_COMMIT = /^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64})$/
const SHA256 = /^[a-fA-F0-9]{64}$/
const SAFE_SOURCE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,159}$/
const SAFE_ENTRY_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,239}$/
const OPERATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const RESOLVER_ERROR_CODES = new Set([
'registry_acquisition_invalid_request',
'registry_acquisition_not_found',
'registry_acquisition_snapshot_stale',
'registry_acquisition_blocked',
'registry_acquisition_client_upgrade_required',
'registry_acquisition_config_mismatch',
'registry_acquisition_install_guide_unavailable',
'registry_acquisition_receipt_missing',
'registry_acquisition_ownership_mismatch',
])
const INTERMEDIATE_STATUSES = new Set(['installing', 'reinstalling', 'uninstalling'])
const PRE_EXECUTION_STOP_STAGES = new Set(['parse', 'resolver', 'human_gate', 'pre_execution'])
function fail(code) {
throw new Error(code)
}
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function hasOnlyKeys(value, required, optional = []) {
const allowed = new Set([...required, ...optional])
return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&
Object.keys(value).every((key) => allowed.has(key))
}
function requireBoundedString(value, field, maxLength = 1024) {
if (typeof value !== 'string' || value.length === 0 || value.length > maxLength || /[\r\n\0]/.test(value)) {
fail(`registry_catalog_${field}_invalid`)
}
return value
}
function requireBoundedText(value, field, maxLength) {
if (typeof value !== 'string' || value.length === 0 || value.length > maxLength || value.includes('\0')) {
fail(`registry_catalog_${field}_invalid`)
}
return value
}
function validateSnapshot(value) {
if (!isRecord(value) || !hasOnlyKeys(
value,
['schemaVersion', 'catalogSourceId', 'catalogCommit', 'catalogPath', 'releaseVersion'],
['contentRef', 'contentSha256']
)) {
fail('registry_catalog_snapshot_invalid')
}
if (value.schemaVersion !== 1) fail('registry_catalog_snapshot_version_unsupported')
const catalogSourceId = requireBoundedString(value.catalogSourceId, 'catalog_source_id', 240)
if (!SAFE_SOURCE_ID.test(catalogSourceId)) fail('registry_catalog_catalog_source_id_invalid')
const catalogCommit = requireBoundedString(value.catalogCommit, 'catalog_commit', 64)
if (!IMMUTABLE_COMMIT.test(catalogCommit)) fail('registry_catalog_catalog_commit_invalid')
const catalogPath = requireBoundedString(value.catalogPath, 'catalog_path', 512)
if (catalogPath.startsWith('/') || /^[A-Za-z]:/.test(catalogPath) || catalogPath.split(/[\\/]+/).includes('..')) {
fail('registry_catalog_catalog_path_invalid')
}
const releaseVersion = requireBoundedString(value.releaseVersion, 'release_version', 160)
if (value.contentRef !== undefined) requireBoundedString(value.contentRef, 'content_ref', 500)
if (value.contentSha256 !== undefined && !SHA256.test(value.contentSha256)) {
fail('registry_catalog_content_sha256_invalid')
}
return {
schemaVersion: 1,
catalogSourceId,
catalogCommit,
catalogPath,
releaseVersion,
...(value.contentRef !== undefined ? { contentRef: value.contentRef } : {}),
...(value.contentSha256 !== undefined ? { contentSha256: value.contentSha256.toLowerCase() } : {}),
}
}
export function validateRegistryCatalogAcquisitionRequest(value) {
if (!isRecord(value)) {
fail('registry_catalog_request_invalid')
}
if (value.kind !== 'app' && value.kind !== 'service') fail('registry_catalog_kind_invalid')
if (
(value.kind === 'app' && !hasOnlyKeys(
value,
['kind', 'sourceId', 'entryId', 'snapshot', 'operation'],
['install', 'connection']
)) ||
(value.kind === 'service' && !hasOnlyKeys(
value,
['kind', 'sourceId', 'entryId', 'snapshot', 'operation'],
['install', 'connection']
))
) {
fail('registry_catalog_request_invalid')
}
const sourceId = requireBoundedString(value.sourceId, 'source_id', 160)
const entryId = requireBoundedString(value.entryId, 'entry_id', 240)
if (!SAFE_SOURCE_ID.test(sourceId)) fail('registry_catalog_source_id_invalid')
if (!SAFE_ENTRY_ID.test(entryId)) fail('registry_catalog_entry_id_invalid')
if (value.kind === 'app' && (value.install !== undefined || value.connection !== undefined)) {
fail('registry_catalog_app_client_config_forbidden')
}
const snapshot = validateSnapshot(value.snapshot)
if (snapshot.catalogSourceId !== sourceId) fail('registry_catalog_source_identity_mismatch')
return {
kind: value.kind,
sourceId,
entryId,
snapshot,
operation: validateOperation(value.operation),
...(value.kind === 'service' && value.install !== undefined ? { install: value.install } : {}),
...(value.kind === 'service' && value.connection !== undefined ? { connection: value.connection } : {}),
}
}
function validateOperation(value) {
if (!isRecord(value) || !hasOnlyKeys(value, ['action', 'deviceId', 'operationId'])) {
fail('registry_catalog_operation_invalid')
}
if (!['install', 'reinstall', 'uninstall'].includes(value.action)) {
fail('registry_catalog_operation_action_invalid')
}
const operationId = requireBoundedString(value.operationId, 'operation_id', 36)
if (!OPERATION_ID.test(operationId)) fail('registry_catalog_operation_id_invalid')
return {
action: value.action,
deviceId: requireBoundedString(value.deviceId, 'operation_device_id', 200),
operationId,
}
}
export function validateRegistryCatalogAcquisitionEnvelope(value) {
const request = validateRegistryCatalogAcquisitionRequest(value)
return {
request,
locator: {
sourceId: request.sourceId,
entryId: request.entryId,
operation: request.operation,
},
}
}
function parseMachineLineJson(message) {
if (typeof message !== 'string') fail('registry_catalog_message_invalid')
const matches = message
.split(/\r?\n/)
.filter((line) => line.startsWith(MACHINE_LINE_PREFIX))
if (matches.length === 0) fail('registry_catalog_machine_line_missing')
if (matches.length !== 1) fail('registry_catalog_machine_line_ambiguous')
const encoded = matches[0].slice(MACHINE_LINE_PREFIX.length)
if (!encoded || encoded !== encoded.trim()) fail('registry_catalog_machine_line_malformed')
try {
return JSON.parse(encoded)
} catch {
fail('registry_catalog_machine_line_malformed')
}
}
/** Parse only the App ledger locator; this grants no catalog acquisition or command execution. */
export function parseRegistryCatalogAcquisitionLocatorMessage(message) {
const value = parseMachineLineJson(message)
if (!isRecord(value) || (value.kind !== 'app' && value.kind !== 'service')) {
fail('registry_catalog_locator_invalid')
}
if (
(value.kind === 'app' && !hasOnlyKeys(
value,
['kind', 'sourceId', 'entryId', 'snapshot', 'operation'],
['install', 'connection']
)) ||
(value.kind === 'service' && !hasOnlyKeys(
value,
['kind', 'sourceId', 'entryId', 'snapshot', 'operation'],
['install', 'connection']
)) ||
!isRecord(value.snapshot)
) {
fail('registry_catalog_locator_invalid')
}
const sourceId = requireBoundedString(value.sourceId, 'source_id', 160)
const entryId = requireBoundedString(value.entryId, 'entry_id', 240)
if (
!SAFE_SOURCE_ID.test(sourceId) ||
!SAFE_ENTRY_ID.test(entryId) ||
value.snapshot.catalogSourceId !== sourceId
) {
fail('registry_catalog_locator_invalid')
}
return { sourceId, entryId, operation: validateOperation(value.operation) }
}
/**
* Extract exactly one standalone machine line. Human prose is never inspected for identity,
* snapshot, install, or connection fields.
*/
export function parseRegistryCatalogAcquisitionMessage(message) {
return validateRegistryCatalogAcquisitionEnvelope(parseMachineLineJson(message))
}
function snapshotsEqual(left, right) {
return left.schemaVersion === right.schemaVersion &&
left.catalogSourceId === right.catalogSourceId &&
left.catalogCommit === right.catalogCommit &&
left.catalogPath === right.catalogPath &&
left.releaseVersion === right.releaseVersion &&
left.contentRef === right.contentRef &&
left.contentSha256 === right.contentSha256
}
export function registryCatalogRuntimeServerId(sourceId, entryId) {
const digest = createHash('sha256').update(`${sourceId}\0${entryId}`).digest('hex').slice(0, 20)
const slug = entryId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 48) || 'entry'
return `registry_${digest}_${slug}`
}
function boundedReasons(value) {
if (!Array.isArray(value) || value.length > 32) return undefined
const reasons = value.filter(
(reason) => typeof reason === 'string' && reason.length > 0 && reason.length <= 160 && /^[a-z0-9._:-]+$/i.test(reason)
)
return reasons.length === value.length && reasons.length > 0 ? reasons : undefined
}
function normalizePendingEntry(value) {
if (!isRecord(value)) return undefined
if (
typeof value.sourceId !== 'string' || !SAFE_SOURCE_ID.test(value.sourceId) ||
typeof value.entryId !== 'string' || !SAFE_ENTRY_ID.test(value.entryId) ||
typeof value.deviceId !== 'string' || value.deviceId.length === 0 || value.deviceId.length > 240 || /[\r\n\0]/.test(value.deviceId) ||
typeof value.deviceName !== 'string' || value.deviceName.length === 0 || value.deviceName.length > 240 || /[\r\n\0]/.test(value.deviceName) ||
typeof value.status !== 'string' || !INTERMEDIATE_STATUSES.has(value.status) ||
typeof value.operationId !== 'string' || !OPERATION_ID.test(value.operationId)
) {
return undefined
}
return {
sourceId: value.sourceId,
entryId: value.entryId,
deviceId: value.deviceId,
deviceName: value.deviceName,
status: value.status,
operationId: value.operationId,
}
}
/**
* Select the one authoritative optimistic intent that must be settled before execution stops.
* sourceId+entryId+operation.deviceId are mandatory filters. Missing or malformed machine input
* has no authoritative locator and therefore never enters this function or mutates the ledger.
*/
export function planRegistryCatalogPreExecutionSettlement(input) {
if (!isRecord(input) || !hasOnlyKeys(input, ['stage', 'locator', 'entries'])) {
fail('registry_catalog_settlement_input_invalid')
}
if (!PRE_EXECUTION_STOP_STAGES.has(input.stage)) fail('registry_catalog_settlement_stage_invalid')
if (!Array.isArray(input.entries)) fail('registry_catalog_settlement_entries_invalid')
if (!isRecord(input.locator) || !hasOnlyKeys(input.locator, ['sourceId', 'entryId', 'operation'])) {
fail('registry_catalog_settlement_locator_invalid')
}
const sourceId = requireBoundedString(input.locator.sourceId, 'settlement_source_id', 160)
const entryId = requireBoundedString(input.locator.entryId, 'settlement_entry_id', 240)
if (!SAFE_SOURCE_ID.test(sourceId) || !SAFE_ENTRY_ID.test(entryId)) fail('registry_catalog_settlement_locator_invalid')
const operation = validateOperation(input.locator.operation)
const expectedStatus = operation.action === 'install'
? 'installing'
: operation.action === 'reinstall'
? 'reinstalling'
: 'uninstalling'
const candidates = input.entries
.map(normalizePendingEntry)
.filter((entry) => entry !== undefined)
.filter((entry) => entry.status === expectedStatus && entry.deviceId === operation.deviceId)
.filter((entry) => entry.operationId === operation.operationId)
.filter((entry) => entry.sourceId === sourceId && entry.entryId === entryId)
if (candidates.length !== 1) {
return {
allowed: false,
mayExecuteCommands: false,
settlement: null,
reason: candidates.length === 0
? 'registry_catalog_pending_intent_not_found'
: 'registry_catalog_pending_intent_ambiguous',
}
}
const target = candidates[0]
return {
allowed: false,
mayExecuteCommands: false,
settlement: {
sourceId: target.sourceId,
entryId: target.entryId,
deviceId: target.deviceId,
operationId: target.operationId,
// A first install has no usable prior resource, while reinstall/uninstall must restore it.
status: target.status === 'installing' ? 'failed' : 'installed',
},
reason: `registry_catalog_${input.stage}_stopped`,
}
}
function validateLifecycleManifest(value, kind, entryId, releaseVersion) {
if (!isRecord(value) || !hasOnlyKeys(value, ['id', 'name', 'type', 'version', 'description'])) {
fail('registry_catalog_receipt_lifecycle_manifest_invalid')
}
const id = requireBoundedString(value.id, 'receipt_lifecycle_id', 240)
const name = requireBoundedString(value.name, 'receipt_lifecycle_name', 160)
const version = requireBoundedString(value.version, 'receipt_lifecycle_version', 160)
if (typeof value.description !== 'string' || value.description.length > 4096 || value.description.includes('\0')) {
fail('registry_catalog_receipt_lifecycle_description_invalid')
}
const expectedType = kind === 'app' ? 'docker-app' : 'mcp'
if (id !== entryId || version !== releaseVersion || value.type !== expectedType) {
fail('registry_catalog_receipt_lifecycle_identity_mismatch')
}
return { id, name, type: expectedType, version, description: value.description }
}
/** Validate, but never synthesize, the server-issued immutable lifecycle receipt candidate. */
export function validateServerCatalogReceipt(value, expected) {
if (!isRecord(value) || !hasOnlyKeys(
value,
['schemaVersion', 'kind', 'catalogSourceId', 'entryId', 'catalogCommit', 'catalogPath', 'releaseVersion', 'lifecycle'],
['contentRef', 'contentSha256', 'runtimeServerId']
)) {
fail('registry_catalog_receipt_invalid')
}
if (value.kind !== 'app' && value.kind !== 'service') fail('registry_catalog_receipt_kind_invalid')
const {
kind,
entryId: rawEntryId,
lifecycle: rawLifecycle,
runtimeServerId: rawRuntimeServerId,
...snapshotValue
} = value
const snapshot = validateSnapshot(snapshotValue)
const entryId = requireBoundedString(rawEntryId, 'receipt_entry_id', 240)
if (!SAFE_ENTRY_ID.test(entryId)) fail('registry_catalog_receipt_entry_id_invalid')
if (!isRecord(rawLifecycle) || !hasOnlyKeys(rawLifecycle, ['manifest'], ['installGuide'])) {
fail('registry_catalog_receipt_lifecycle_invalid')
}
const manifest = validateLifecycleManifest(rawLifecycle.manifest, kind, entryId, snapshot.releaseVersion)
let installGuide
if (kind === 'app') {
installGuide = requireBoundedText(rawLifecycle.installGuide, 'receipt_install_guide', 64 * 1024)
} else if (rawLifecycle.installGuide !== undefined) {
fail('registry_catalog_receipt_install_guide_forbidden')
}
let runtimeServerId
if (rawRuntimeServerId !== undefined) {
runtimeServerId = requireBoundedString(rawRuntimeServerId, 'receipt_runtime_server_id', 100)
if (!/^[a-zA-Z0-9._-]+$/.test(runtimeServerId)) {
fail('registry_catalog_receipt_runtime_server_id_invalid')
}
if (kind !== 'service') fail('registry_catalog_receipt_runtime_server_id_forbidden')
}
if (expected) {
if (
kind !== expected.kind ||
entryId !== expected.entryId ||
snapshot.catalogSourceId !== expected.sourceId ||
!snapshotsEqual(snapshot, expected.snapshot)
) {
fail('registry_catalog_receipt_identity_mismatch')
}
}
return {
...snapshot,
kind,
entryId,
lifecycle: {
manifest,
...(installGuide !== undefined ? { installGuide } : {}),
},
...(runtimeServerId !== undefined ? { runtimeServerId } : {}),
}
}
/** Add only the server-signed MCP runtime key; snapshot and lifecycle remain byte-for-byte data. */
export function completeServiceCatalogReceipt(candidateValue, runtimeServerIdValue) {
const candidate = validateServerCatalogReceipt(candidateValue)
if (candidate.kind !== 'service' || candidate.runtimeServerId !== undefined) {
fail('registry_catalog_receipt_completion_invalid')
}
const runtimeServerId = requireBoundedString(runtimeServerIdValue, 'receipt_runtime_server_id', 100)
if (!/^[a-zA-Z0-9._-]+$/.test(runtimeServerId)) {
fail('registry_catalog_receipt_runtime_server_id_invalid')
}
const expectedComposite = registryCatalogRuntimeServerId(candidate.catalogSourceId, candidate.entryId)
if (runtimeServerId !== expectedComposite) fail('registry_catalog_receipt_runtime_server_id_mismatch')
return { ...candidate, runtimeServerId }
}
/** Build the exact PATCH body accepted by the server-side receipt CAS. */
export function buildInstalledCatalogReceiptPatch(input) {
if (!isRecord(input) || !hasOnlyKeys(
input,
['sourceId', 'entryId', 'operationId', 'catalogReceipt'],
['runtimeServerId']
)) {
fail('registry_catalog_receipt_patch_input_invalid')
}
const sourceId = requireBoundedString(input.sourceId, 'receipt_patch_source_id', 160)
const entryId = requireBoundedString(input.entryId, 'receipt_patch_entry_id', 240)
const operationId = requireBoundedString(input.operationId, 'receipt_patch_operation_id', 36)
if (!OPERATION_ID.test(operationId)) fail('registry_catalog_receipt_patch_operation_id_invalid')
const candidate = validateServerCatalogReceipt(input.catalogReceipt)
if (candidate.catalogSourceId !== sourceId || candidate.entryId !== entryId) {
fail('registry_catalog_receipt_identity_mismatch')
}
const catalogReceipt = candidate.kind === 'service'
? completeServiceCatalogReceipt(candidate, input.runtimeServerId)
: (() => {
if (input.runtimeServerId !== undefined) fail('registry_catalog_receipt_runtime_server_id_forbidden')
return candidate
})()
return {
sourceId,
operationId,
status: 'installed',
version: catalogReceipt.releaseVersion,
catalogReceipt,
}
}
/** Validate exact MCP uninstall ownership without consulting the current catalog. */
export function resolveServiceUninstallOwnership(input) {
if (!isRecord(input) || !hasOnlyKeys(input, ['locator', 'snapshot', 'entries'])) {
fail('registry_catalog_service_uninstall_input_invalid')
}
if (!isRecord(input.locator) || !hasOnlyKeys(input.locator, ['sourceId', 'entryId', 'operation'])) {
fail('registry_catalog_service_uninstall_locator_invalid')
}
const sourceId = requireBoundedString(input.locator.sourceId, 'service_uninstall_source_id', 160)
const entryId = requireBoundedString(input.locator.entryId, 'service_uninstall_entry_id', 240)
if (!SAFE_SOURCE_ID.test(sourceId) || !SAFE_ENTRY_ID.test(entryId)) {
fail('registry_catalog_service_uninstall_locator_invalid')
}
const operation = validateOperation(input.locator.operation)
if (operation.action !== 'uninstall') fail('registry_catalog_service_uninstall_operation_invalid')
const snapshot = validateSnapshot(input.snapshot)
if (!Array.isArray(input.entries)) fail('registry_catalog_service_uninstall_entries_invalid')
const matches = input.entries.filter((entry) =>
isRecord(entry) &&
entry.sourceId === sourceId &&
entry.entryId === entryId &&
entry.deviceId === operation.deviceId &&
entry.operationId === operation.operationId &&
entry.status === 'uninstalling'
)
if (matches.length !== 1) {
return {
allowed: false,
deleteAllowed: false,
reason: matches.length === 0
? 'registry_catalog_pending_intent_not_found'
: 'registry_catalog_pending_intent_ambiguous',
}
}
const receiptValue = matches[0].catalogReceipt
if (!isRecord(receiptValue)) {
return { allowed: false, deleteAllowed: false, reason: 'registry_acquisition_receipt_missing' }
}
let receipt
try {
receipt = validateServerCatalogReceipt(receiptValue, { kind: 'service', sourceId, entryId, snapshot })
} catch {
return { allowed: false, deleteAllowed: false, reason: 'registry_acquisition_ownership_mismatch' }
}
const runtimeServerId = receipt.runtimeServerId ?? ''
if (!runtimeServerId) {
return { allowed: false, deleteAllowed: false, reason: 'registry_acquisition_receipt_missing' }
}
const expectedComposite = registryCatalogRuntimeServerId(sourceId, entryId)
if (
runtimeServerId !== expectedComposite &&
!(sourceId === 'registry:official' && runtimeServerId === entryId)
) {
return { allowed: false, deleteAllowed: false, reason: 'registry_acquisition_ownership_mismatch' }
}
return {
allowed: true,
deleteAllowed: true,
sourceId,
entryId,
deviceId: operation.deviceId,
operationId: operation.operationId,
runtimeServerId,
}
}
/** Build the exact operation-bound query tuple for catalog MCP DELETE. */
export function buildServiceCatalogDeleteTarget(locator) {
if (!isRecord(locator) || !hasOnlyKeys(locator, ['sourceId', 'entryId', 'operation'])) {
fail('registry_catalog_service_delete_locator_invalid')
}
const sourceId = requireBoundedString(locator.sourceId, 'service_delete_source_id', 160)
const entryId = requireBoundedString(locator.entryId, 'service_delete_entry_id', 240)
if (!SAFE_SOURCE_ID.test(sourceId) || !SAFE_ENTRY_ID.test(entryId)) {
fail('registry_catalog_service_delete_locator_invalid')
}
const operation = validateOperation(locator.operation)
if (operation.action !== 'uninstall') fail('registry_catalog_service_delete_operation_invalid')
return {
entryId,
sourceId,
deviceId: operation.deviceId,
operationId: operation.operationId,
}
}
function validateSuccessData(expected, body) {
if (!isRecord(body) || body.success !== true || !isRecord(body.data)) {
fail('registry_catalog_resolver_response_invalid')
}
const data = body.data
if (!hasOnlyKeys(
data,
['kind', 'sourceId', 'entryId', 'snapshot', 'manifest', 'catalogReceipt'],
['install', 'connection', 'installGuide']
)) {
fail('registry_catalog_resolver_response_invalid')
}
if (data.kind !== 'app' && data.kind !== 'service') fail('registry_catalog_resolver_response_invalid')
const responseIdentity = {
kind: data.kind,
sourceId: requireBoundedString(data.sourceId, 'resolver_source_id', 160),
entryId: requireBoundedString(data.entryId, 'resolver_entry_id', 240),
snapshot: validateSnapshot(data.snapshot),
}
if (
responseIdentity.kind !== expected.kind ||
responseIdentity.sourceId !== expected.sourceId ||
responseIdentity.entryId !== expected.entryId ||
!snapshotsEqual(responseIdentity.snapshot, expected.snapshot)
) {
fail('registry_catalog_resolver_identity_mismatch')
}
if (!isRecord(data.manifest) || data.manifest.id !== expected.entryId) {
fail('registry_catalog_resolver_manifest_identity_mismatch')
}
const catalogReceipt = validateServerCatalogReceipt(data.catalogReceipt, {
kind: expected.kind,
sourceId: expected.sourceId,
entryId: expected.entryId,
snapshot: expected.snapshot,
})
if (catalogReceipt.runtimeServerId !== undefined) {
fail('registry_catalog_resolver_receipt_runtime_forbidden')
}
const lifecycleManifest = catalogReceipt.lifecycle.manifest
if (
data.manifest.name !== lifecycleManifest.name ||
data.manifest.version !== lifecycleManifest.version ||
data.manifest.description !== lifecycleManifest.description
) {
fail('registry_catalog_resolver_receipt_manifest_mismatch')
}
if (expected.kind === 'app') {
if (data.manifest.type !== 'docker-app') fail('registry_catalog_resolver_manifest_kind_mismatch')
const installGuide = requireBoundedText(data.installGuide, 'install_guide', 64 * 1024)
if (catalogReceipt.lifecycle.installGuide !== installGuide) {
fail('registry_catalog_resolver_receipt_install_guide_mismatch')
}
// App execution consumes only the server-authorized manifest and install guide.
return {
allowed: true,
kind: expected.kind,
sourceId: expected.sourceId,
entryId: expected.entryId,
snapshot: expected.snapshot,
manifest: data.manifest,
installGuide,
catalogReceipt,
}
}
if (data.manifest.type !== 'mcp') {
fail('registry_catalog_resolver_manifest_kind_mismatch')
}
if (data.installGuide !== undefined) fail('registry_catalog_resolver_install_guide_forbidden')
if (!isRecord(data.install) || !isRecord(data.connection)) {
fail('registry_catalog_resolver_response_invalid')
}
return {
allowed: true,
kind: expected.kind,
sourceId: expected.sourceId,
entryId: expected.entryId,
snapshot: expected.snapshot,
manifest: data.manifest,
install: data.install,
connection: data.connection,
catalogReceipt,
}
}
/**
* Convert the resolver HTTP result into a binary execution decision. Every non-200 result is
* blocked; the caller must not read local registry data or execute docker/bash as a fallback.
*/
export function evaluateRegistryCatalogResolverResult(expectedValue, status, body) {
const expected = validateRegistryCatalogAcquisitionRequest(expectedValue)
if (!Number.isInteger(status)) fail('registry_catalog_http_status_invalid')
if (status === 200) return validateSuccessData(expected, body)
const errorCode = isRecord(body) && typeof body.errorCode === 'string'
? body.errorCode
: 'unexpected_resolver_error'
const reasons = isRecord(body) ? boundedReasons(body.reasons) : undefined
if (
![400, 404, 409].includes(status) ||
(status === 400 && errorCode !== 'registry_acquisition_invalid_request') ||
(status === 404 && errorCode !== 'registry_acquisition_not_found') ||
(status === 409 && ![
'registry_acquisition_snapshot_stale',
'registry_acquisition_blocked',
'registry_acquisition_client_upgrade_required',
'registry_acquisition_config_mismatch',
'registry_acquisition_install_guide_unavailable',
'registry_acquisition_receipt_missing',
'registry_acquisition_ownership_mismatch',
].includes(errorCode))
) {
return { allowed: false, errorCode: 'unexpected_resolver_error', status, mayExecuteCommands: false }
}
return {
allowed: false,
errorCode: RESOLVER_ERROR_CODES.has(errorCode) ? errorCode : 'unexpected_resolver_error',
status,
mayExecuteCommands: false,
...(reasons ? { reasons } : {}),
}
}
async function readStdin() {
const chunks = []
for await (const chunk of process.stdin) chunks.push(chunk)
return Buffer.concat(chunks).toString('utf8')
}
async function main() {
const command = process.argv[2]
if (command === 'parse-message') {
process.stdout.write(`${JSON.stringify(parseRegistryCatalogAcquisitionMessage(await readStdin()))}\n`)
return
}
if (command === 'parse-locator') {
process.stdout.write(`${JSON.stringify(parseRegistryCatalogAcquisitionLocatorMessage(await readStdin()))}\n`)
return
}
if (command === 'evaluate-response') {
const input = JSON.parse(await readStdin())
if (!isRecord(input) || !hasOnlyKeys(input, ['expected', 'status', 'body'])) {
fail('registry_catalog_evaluation_input_invalid')
}
process.stdout.write(`${JSON.stringify(evaluateRegistryCatalogResolverResult(input.expected, input.status, input.body))}\n`)
return
}
if (command === 'plan-settlement') {
const input = JSON.parse(await readStdin())
process.stdout.write(`${JSON.stringify(planRegistryCatalogPreExecutionSettlement(input))}\n`)
return
}
if (command === 'validate-receipt-candidate') {
const input = JSON.parse(await readStdin())
process.stdout.write(`${JSON.stringify(validateServerCatalogReceipt(input))}\n`)
return
}
if (command === 'build-receipt-patch') {
const input = JSON.parse(await readStdin())
process.stdout.write(`${JSON.stringify(buildInstalledCatalogReceiptPatch(input))}\n`)
return
}
if (command === 'resolve-service-uninstall') {
const input = JSON.parse(await readStdin())
process.stdout.write(`${JSON.stringify(resolveServiceUninstallOwnership(input))}\n`)
return
}
if (command === 'build-service-delete') {
const input = JSON.parse(await readStdin())
process.stdout.write(`${JSON.stringify(buildServiceCatalogDeleteTarget(input))}\n`)
return
}
fail('usage: registry-catalog-acquisition.mjs parse-locator|parse-message|evaluate-response|plan-settlement|validate-receipt-candidate|build-receipt-patch|resolve-service-uninstall|build-service-delete')
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : 'registry_catalog_unknown_error'}\n`)
process.exitCode = 1
})
}

View File

@@ -0,0 +1,638 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
import {
buildInstalledCatalogReceiptPatch,
buildServiceCatalogDeleteTarget,
completeServiceCatalogReceipt,
evaluateRegistryCatalogResolverResult,
parseRegistryCatalogAcquisitionMessage,
parseRegistryCatalogAcquisitionLocatorMessage,
planRegistryCatalogPreExecutionSettlement,
resolveServiceUninstallOwnership,
registryCatalogRuntimeServerId,
} from '../scripts/registry-catalog-acquisition.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const skillPath = join(here, '..', 'SKILL.md')
const commitA = 'a'.repeat(40)
const commitB = 'b'.repeat(40)
const operationA = '11111111-1111-4111-8111-111111111111'
const operationB = '22222222-2222-4222-8222-222222222222'
function envelope(overrides = {}) {
const sourceId = overrides.sourceId ?? 'registry:source-a'
return {
kind: 'app',
sourceId,
entryId: 'same-id',
snapshot: {
schemaVersion: 1,
catalogSourceId: sourceId,
catalogCommit: overrides.catalogCommit ?? commitA,
catalogPath: 'entries/same-id',
releaseVersion: overrides.releaseVersion ?? '1.2.3',
contentRef: 'v1.2.3',
contentSha256: 'c'.repeat(64),
},
operation: {
action: overrides.action ?? 'install',
deviceId: overrides.deviceId ?? 'device-a',
operationId: overrides.operationId ?? operationA,
},
}
}
function request(overrides = {}) {
return envelope(overrides)
}
function pendingEntry(overrides = {}) {
return {
sourceId: overrides.sourceId ?? 'registry:source-a',
entryId: overrides.entryId ?? 'same-id',
deviceId: overrides.deviceId ?? 'device-a',
deviceName: overrides.deviceName ?? 'Device A',
status: overrides.status ?? 'installing',
operationId: overrides.operationId ?? operationA,
}
}
function manifestFor(expected) {
return {
id: expected.entryId,
type: expected.kind === 'service' ? 'mcp' : 'docker-app',
name: 'Same ID',
version: expected.snapshot.releaseVersion,
description: 'Server-authorized lifecycle',
}
}
function receiptFor(expected, overrides = {}) {
const manifest = manifestFor(expected)
return {
...expected.snapshot,
kind: expected.kind,
entryId: expected.entryId,
lifecycle: {
manifest,
...(expected.kind === 'app' ? { installGuide: '# Server-authorized guide\n' } : {}),
},
...overrides,
}
}
function success(expected, overrides = {}) {
return {
success: true,
data: {
kind: expected.kind,
sourceId: expected.sourceId,
entryId: expected.entryId,
snapshot: expected.snapshot,
manifest: manifestFor(expected),
installGuide: '# Server-authorized guide\n',
catalogReceipt: receiptFor(expected),
...overrides,
},
}
}
test('missing, duplicate, and malformed machine lines fail closed', () => {
assert.throws(() => parseRegistryCatalogAcquisitionMessage('请安装 Same ID'), /machine_line_missing/)
assert.throws(
() => parseRegistryCatalogAcquisitionMessage(
`RegistryCatalogAcquisition=${JSON.stringify(request())}\nRegistryCatalogAcquisition=${JSON.stringify(request())}`
),
/machine_line_ambiguous/
)
assert.throws(
() => parseRegistryCatalogAcquisitionMessage('RegistryCatalogAcquisition={"kind":"app"'),
/machine_line_malformed/
)
})
test('App request rejects caller-provided install/connection and source/snapshot mismatch', () => {
assert.throws(
() => parseRegistryCatalogAcquisitionMessage(
`RegistryCatalogAcquisition=${JSON.stringify({ ...request(), install: { command: 'bash' } })}`
),
/app_client_config_forbidden/
)
const mismatched = envelope()
mismatched.snapshot.catalogSourceId = 'registry:source-b'
assert.throws(
() => parseRegistryCatalogAcquisitionMessage(`RegistryCatalogAcquisition=${JSON.stringify(mismatched)}`),
/source_identity_mismatch/
)
})
test('same entryId from source A and source B remains two exact acquisition identities', () => {
const sourceA = parseRegistryCatalogAcquisitionMessage(
`安装来源 A\nRegistryCatalogAcquisition=${JSON.stringify(envelope())}`
)
const sourceB = parseRegistryCatalogAcquisitionMessage(
`安装来源 B\nRegistryCatalogAcquisition=${JSON.stringify(envelope({
sourceId: 'registry:source-b',
catalogCommit: commitB,
}))}`
)
assert.equal(sourceA.request.entryId, sourceB.request.entryId)
assert.notEqual(sourceA.request.sourceId, sourceB.request.sourceId)
assert.notDeepEqual(sourceA.request.snapshot, sourceB.request.snapshot)
assert.deepEqual(sourceA.request.operation, {
action: 'install', deviceId: 'device-a', operationId: operationA,
})
})
test('400/404 and every real resolver 409 preserve exact error codes and block execution', () => {
const expected = request()
for (const [status, errorCode] of [
[400, 'registry_acquisition_invalid_request'],
[404, 'registry_acquisition_not_found'],
[409, 'registry_acquisition_snapshot_stale'],
[409, 'registry_acquisition_blocked'],
[409, 'registry_acquisition_client_upgrade_required'],
[409, 'registry_acquisition_config_mismatch'],
[409, 'registry_acquisition_install_guide_unavailable'],
[409, 'registry_acquisition_receipt_missing'],
[409, 'registry_acquisition_ownership_mismatch'],
]) {
assert.deepEqual(
evaluateRegistryCatalogResolverResult(expected, status, {
success: false,
errorCode,
reasons: ['missing-canonical-or-kind-mismatch'],
}),
{
allowed: false,
errorCode,
status,
mayExecuteCommands: false,
reasons: ['missing-canonical-or-kind-mismatch'],
}
)
}
assert.deepEqual(
evaluateRegistryCatalogResolverResult(expected, 409, { success: false, errorCode: 'blocked' }),
{ allowed: false, errorCode: 'unexpected_resolver_error', status: 409, mayExecuteCommands: false }
)
})
test('parsed locator settles resolver/Human Gate/pre-execution stops without cross-source writes', () => {
const acquisition = parseRegistryCatalogAcquisitionMessage(
`RegistryCatalogAcquisition=${JSON.stringify(envelope({
sourceId: 'registry:source-b',
catalogCommit: commitB,
action: 'reinstall',
}))}`
)
const entries = [
pendingEntry({ sourceId: 'registry:source-a', status: 'reinstalling' }),
pendingEntry({ sourceId: 'registry:source-b', status: 'reinstalling' }),
]
for (const stage of ['resolver', 'human_gate', 'pre_execution']) {
assert.deepEqual(planRegistryCatalogPreExecutionSettlement({ stage, locator: acquisition.locator, entries }), {
allowed: false,
mayExecuteCommands: false,
settlement: {
sourceId: 'registry:source-b',
entryId: 'same-id',
deviceId: 'device-a',
operationId: operationA,
status: 'installed',
},
reason: `registry_catalog_${stage}_stopped`,
})
}
assert.equal(planRegistryCatalogPreExecutionSettlement({
stage: 'resolver',
locator: acquisition.locator,
entries: [pendingEntry({
sourceId: 'registry:source-b',
status: 'reinstalling',
operationId: operationB,
})],
}).settlement, null)
})
test('first install stops as failed, uninstall/Human Gate cancellation restores installed', () => {
const install = parseRegistryCatalogAcquisitionMessage(
`RegistryCatalogAcquisition=${JSON.stringify(envelope())}`
)
assert.equal(planRegistryCatalogPreExecutionSettlement({
stage: 'parse',
locator: install.locator,
entries: [pendingEntry()],
}).settlement.status, 'failed')
const uninstall = parseRegistryCatalogAcquisitionMessage(
`RegistryCatalogAcquisition=${JSON.stringify(envelope({ action: 'uninstall' }))}`
)
assert.equal(planRegistryCatalogPreExecutionSettlement({
stage: 'human_gate',
locator: uninstall.locator,
entries: [pendingEntry({ status: 'uninstalling' })],
}).settlement.status, 'installed')
assert.deepEqual(planRegistryCatalogPreExecutionSettlement({
stage: 'human_gate',
locator: uninstall.locator,
entries: [pendingEntry({ status: 'uninstalling', deviceId: 'device-b' })],
}), {
allowed: false,
mayExecuteCommands: false,
settlement: null,
reason: 'registry_catalog_pending_intent_not_found',
})
})
test('missing or malformed machine input has no locator and cannot guess a ledger write', () => {
assert.throws(() => parseRegistryCatalogAcquisitionMessage('请安装 Same ID'), /machine_line_missing/)
assert.throws(
() => parseRegistryCatalogAcquisitionMessage('RegistryCatalogAcquisition={"kind":"app"'),
/machine_line_malformed/
)
assert.throws(
() => planRegistryCatalogPreExecutionSettlement({
stage: 'parse',
locator: null,
entries: [pendingEntry()],
}),
/settlement_locator_invalid/
)
})
test('a parsed App locator can settle an invalid snapshot without authorizing acquisition', () => {
const invalid = envelope()
invalid.snapshot.catalogCommit = 'mutable-main'
const message = `RegistryCatalogAcquisition=${JSON.stringify(invalid)}`
const locator = parseRegistryCatalogAcquisitionLocatorMessage(message)
assert.deepEqual(locator, {
sourceId: 'registry:source-a',
entryId: 'same-id',
operation: { action: 'install', deviceId: 'device-a', operationId: operationA },
})
assert.throws(() => parseRegistryCatalogAcquisitionMessage(message), /catalog_commit_invalid/)
assert.equal(planRegistryCatalogPreExecutionSettlement({
stage: 'parse',
locator,
entries: [pendingEntry()],
}).settlement.status, 'failed')
})
test('valid 200 response returns only server-authorized App manifest and install guide', () => {
const expected = request()
const result = evaluateRegistryCatalogResolverResult(expected, 200, success(expected, {
snapshot: {
contentSha256: expected.snapshot.contentSha256,
releaseVersion: expected.snapshot.releaseVersion,
catalogPath: expected.snapshot.catalogPath,
catalogCommit: expected.snapshot.catalogCommit,
catalogSourceId: expected.snapshot.catalogSourceId,
schemaVersion: expected.snapshot.schemaVersion,
contentRef: expected.snapshot.contentRef,
},
install: { command: 'must-not-be-consumed-by-app' },
connection: { url: 'http://must-not-be-consumed.invalid' },
}))
assert.deepEqual(result, {
allowed: true,
kind: 'app',
sourceId: expected.sourceId,
entryId: expected.entryId,
snapshot: expected.snapshot,
manifest: manifestFor(expected),
installGuide: '# Server-authorized guide\n',
catalogReceipt: receiptFor(expected),
})
})
test('response source, snapshot, manifest identity, or install guide mismatch fails closed', () => {
const expected = request()
assert.throws(
() => evaluateRegistryCatalogResolverResult(expected, 200, success(expected, { sourceId: 'registry:source-b' })),
/resolver_identity_mismatch/
)
assert.throws(
() => evaluateRegistryCatalogResolverResult(expected, 200, success(expected, {
snapshot: { ...expected.snapshot, catalogCommit: commitB },
})),
/resolver_identity_mismatch/
)
assert.throws(
() => evaluateRegistryCatalogResolverResult(expected, 200, success(expected, {
manifest: { id: 'other-id', type: 'docker-app' },
})),
/manifest_identity_mismatch/
)
assert.throws(
() => evaluateRegistryCatalogResolverResult(expected, 200, success(expected, { installGuide: '' })),
/install_guide_invalid/
)
})
test('server receipt candidate is mandatory, immutable, and capped at 64 KiB', () => {
const expected = request()
const missing = success(expected)
delete missing.data.catalogReceipt
assert.throws(
() => evaluateRegistryCatalogResolverResult(expected, 200, missing),
/resolver_response_invalid/
)
assert.throws(
() => evaluateRegistryCatalogResolverResult(expected, 200, success(expected, {
catalogReceipt: {
...receiptFor(expected),
catalogCommit: commitB,
},
})),
/receipt_identity_mismatch/
)
assert.throws(
() => evaluateRegistryCatalogResolverResult(expected, 200, success(expected, {
catalogReceipt: {
...receiptFor(expected),
lifecycle: {
...receiptFor(expected).lifecycle,
manifest: { ...manifestFor(expected), version: '9.9.9' },
},
},
})),
/lifecycle_identity_mismatch/
)
const oversizedGuide = 'x'.repeat(64 * 1024 + 1)
assert.throws(
() => evaluateRegistryCatalogResolverResult(expected, 200, success(expected, {
installGuide: oversizedGuide,
catalogReceipt: {
...receiptFor(expected),
lifecycle: { manifest: manifestFor(expected), installGuide: oversizedGuide },
},
})),
/receipt_install_guide_invalid/
)
assert.throws(
() => evaluateRegistryCatalogResolverResult(expected, 200, success(expected, {
installGuide: '# changed guide\n',
})),
/receipt_install_guide_mismatch/
)
const candidate = receiptFor(expected)
assert.deepEqual(buildInstalledCatalogReceiptPatch({
sourceId: expected.sourceId,
entryId: expected.entryId,
operationId: expected.operation.operationId,
catalogReceipt: candidate,
}), {
sourceId: expected.sourceId,
operationId: expected.operation.operationId,
status: 'installed',
version: expected.snapshot.releaseVersion,
catalogReceipt: candidate,
})
})
test('reinstall atomically switches v1 to v2 while failure keeps v1 facts', () => {
const oldRequest = request({ releaseVersion: '1.0.0', catalogCommit: commitA })
const target = request({
action: 'reinstall',
releaseVersion: '2.0.0',
catalogCommit: commitB,
operationId: operationB,
})
const oldReceipt = receiptFor(oldRequest)
const pendingCatalogReceipt = {
...target.snapshot,
kind: 'app',
entryId: target.entryId,
}
const candidate = receiptFor(target)
const resolved = evaluateRegistryCatalogResolverResult(target, 200, success(target))
assert.deepEqual(resolved.catalogReceipt, candidate)
assert.deepEqual(buildInstalledCatalogReceiptPatch({
sourceId: target.sourceId,
entryId: target.entryId,
operationId: target.operation.operationId,
catalogReceipt: resolved.catalogReceipt,
}), {
sourceId: target.sourceId,
operationId: target.operation.operationId,
status: 'installed',
version: '2.0.0',
catalogReceipt: candidate,
})
const failure = planRegistryCatalogPreExecutionSettlement({
stage: 'human_gate',
locator: { sourceId: target.sourceId, entryId: target.entryId, operation: target.operation },
entries: [{
...pendingEntry({ status: 'reinstalling', operationId: operationB }),
version: '1.0.0',
catalogReceipt: oldReceipt,
pendingCatalogReceipt,
}],
})
assert.deepEqual(failure.settlement, {
sourceId: target.sourceId,
entryId: target.entryId,
deviceId: target.operation.deviceId,
operationId: target.operation.operationId,
status: 'installed',
})
assert.equal(Object.hasOwn(failure.settlement, 'version'), false)
assert.equal(Object.hasOwn(failure.settlement, 'catalogReceipt'), false)
assert.equal(Object.hasOwn(failure.settlement, 'pendingCatalogReceipt'), false)
assert.throws(() => evaluateRegistryCatalogResolverResult(target, 200, success(target, {
catalogReceipt: { ...candidate, catalogCommit: commitA },
})), /receipt_identity_mismatch/)
})
test('Service keeps only resolver-returned install and connection after exact identity validation', () => {
const expected = {
...request(),
kind: 'service',
install: { method: 'npx', packageName: '@example/server' },
connection: { transport: 'stdio', command: 'npx', args: ['@example/server'] },
}
const result = evaluateRegistryCatalogResolverResult(expected, 200, {
success: true,
data: {
kind: 'service',
sourceId: expected.sourceId,
entryId: expected.entryId,
snapshot: expected.snapshot,
manifest: manifestFor(expected),
install: { method: 'npx', packageName: '@example/server@1.2.3' },
connection: { transport: 'stdio', command: 'npx', args: ['@example/server@1.2.3'] },
catalogReceipt: receiptFor(expected),
},
})
assert.deepEqual(result, {
allowed: true,
kind: 'service',
sourceId: expected.sourceId,
entryId: expected.entryId,
snapshot: expected.snapshot,
manifest: manifestFor(expected),
install: { method: 'npx', packageName: '@example/server@1.2.3' },
connection: { transport: 'stdio', command: 'npx', args: ['@example/server@1.2.3'] },
catalogReceipt: receiptFor(expected),
})
})
test('App and Service both require a valid operation UUID', () => {
const { operation: _operation, ...serviceRequest } = request()
assert.throws(
() => parseRegistryCatalogAcquisitionMessage(
`RegistryCatalogAcquisition=${JSON.stringify({ ...serviceRequest, kind: 'service' })}`
),
/(?:request|operation)_invalid/
)
const missingId = request()
delete missingId.operation.operationId
assert.throws(
() => parseRegistryCatalogAcquisitionMessage(
`RegistryCatalogAcquisition=${JSON.stringify(missingId)}`
),
/operation_invalid/
)
for (const kind of ['app', 'service']) {
assert.throws(
() => parseRegistryCatalogAcquisitionMessage(
`RegistryCatalogAcquisition=${JSON.stringify({
...request(),
kind,
operation: { ...request().operation, operationId: 'not-a-uuid' },
})}`
),
/operation_id_invalid/
)
}
})
test('Service 200 response without install or connection fails closed before execution', () => {
const expected = { ...request(), kind: 'service' }
const baseData = {
kind: 'service',
sourceId: expected.sourceId,
entryId: expected.entryId,
snapshot: expected.snapshot,
manifest: manifestFor(expected),
catalogReceipt: receiptFor(expected),
}
assert.throws(() => evaluateRegistryCatalogResolverResult(expected, 200, {
success: true,
data: { ...baseData, connection: { transport: 'stdio', command: 'npx' } },
}), /resolver_response_invalid/)
assert.throws(() => evaluateRegistryCatalogResolverResult(expected, 200, {
success: true,
data: { ...baseData, install: { method: 'npx', packageName: '@example/server' } },
}), /resolver_response_invalid/)
})
test('MCP only completes the server receipt candidate and builds a CAS-safe PATCH', () => {
const expected = { ...request(), kind: 'service' }
const runtimeServerId = registryCatalogRuntimeServerId(expected.sourceId, expected.entryId)
const candidate = receiptFor(expected)
assert.deepEqual(completeServiceCatalogReceipt(candidate, runtimeServerId), {
...candidate,
runtimeServerId,
})
assert.deepEqual(buildInstalledCatalogReceiptPatch({
sourceId: expected.sourceId,
entryId: expected.entryId,
operationId: expected.operation.operationId,
catalogReceipt: candidate,
runtimeServerId,
}), {
sourceId: expected.sourceId,
operationId: expected.operation.operationId,
status: 'installed',
version: expected.snapshot.releaseVersion,
catalogReceipt: { ...candidate, runtimeServerId },
})
assert.throws(
() => completeServiceCatalogReceipt(candidate, 'well_formed_but_wrong'),
/runtime_server_id_mismatch/
)
assert.throws(
() => buildInstalledCatalogReceiptPatch({
sourceId: 'registry:source-b',
entryId: expected.entryId,
operationId: expected.operation.operationId,
catalogReceipt: candidate,
runtimeServerId,
}),
/receipt_identity_mismatch/
)
})
test('Service uninstall authorizes only exact active service receipt and runtime key', () => {
const expected = request({ action: 'uninstall' })
const locator = {
sourceId: expected.sourceId,
entryId: expected.entryId,
operation: expected.operation,
}
const receipt = completeServiceCatalogReceipt(
receiptFor({ ...expected, kind: 'service' }),
registryCatalogRuntimeServerId(expected.sourceId, expected.entryId)
)
const entry = { ...pendingEntry({ status: 'uninstalling' }), catalogReceipt: receipt }
assert.deepEqual(resolveServiceUninstallOwnership({ locator, snapshot: expected.snapshot, entries: [entry] }), {
allowed: true,
deleteAllowed: true,
sourceId: expected.sourceId,
entryId: expected.entryId,
deviceId: expected.operation.deviceId,
operationId: expected.operation.operationId,
runtimeServerId: registryCatalogRuntimeServerId(expected.sourceId, expected.entryId),
})
assert.deepEqual(buildServiceCatalogDeleteTarget(locator), {
entryId: expected.entryId,
sourceId: expected.sourceId,
deviceId: expected.operation.deviceId,
operationId: expected.operation.operationId,
})
for (const invalidReceipt of [
{ ...receipt, kind: undefined },
{ ...receipt, kind: 'app' },
{ ...receipt, runtimeServerId: undefined },
]) {
assert.equal(resolveServiceUninstallOwnership({
locator,
snapshot: expected.snapshot,
entries: [{ ...entry, catalogReceipt: invalidReceipt }],
}).deleteAllowed, false)
}
assert.equal(resolveServiceUninstallOwnership({
locator,
snapshot: expected.snapshot,
entries: [{ ...entry, sourceId: 'registry:source-b' }],
}).deleteAllowed, false)
assert.equal(resolveServiceUninstallOwnership({
locator,
snapshot: expected.snapshot,
entries: [{ ...entry, operationId: operationB }],
}).deleteAllowed, false)
})
test('Skill contract has no fixed official/local Registry fallback and resolves before execution', async () => {
const skill = await readFile(skillPath, 'utf8')
assert.doesNotMatch(skill, /registry\/official\/entries/)
assert.match(skill, /POST \/api\/registry\/acquisitions\/resolve/)
assert.match(skill, /禁止 fallback/)
assert.match(skill, /plan-settlement/)
assert.match(skill, /lifecycle receipt/)
assert.match(skill, /registry_acquisition_receipt_missing/)
assert.match(skill, /build-service-delete/)
assert.match(skill, /operationId=<operationId>/)
assert.ok(skill.indexOf('解析机器消息并调用 resolver') < skill.indexOf('环境校验'))
assert.ok(skill.indexOf('### 执行前协议') < skill.indexOf('`docker version`'))
})