diff --git a/agent.json b/agent.json index 68a5610..90a7b2e 100644 --- a/agent.json +++ b/agent.json @@ -2,8 +2,8 @@ "name": "DesireCore", "$schema": "http://desirecore/schemas/agent-seed.json", "id": "7da73b7f-bb08-4e7b-a3cf-5d4af6e22c7f", - "version": "1.12.1", - "requiredClientVersion": "10.0.100", + "version": "1.13.0", + "requiredClientVersion": "10.0.132", "description": "系统中枢调度器,负责任务分发、Agent 编排与全局状态监控", "author": "DesireCore Team", "repository": { diff --git a/skills/_index.md b/skills/_index.md index b777964..e076345 100644 --- a/skills/_index.md +++ b/skills/_index.md @@ -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 | ## 技能协作关系 diff --git a/skills/app-install-manager/SKILL.md b/skills/app-install-manager/SKILL.md index 981d47d..ea072cc 100644 --- a/skills/app-install-manager/SKILL.md +++ b/skills/app-install-manager/SKILL.md @@ -1,7 +1,7 @@ --- name: 应用安装管理 -description: 从应用与服务目录安装/卸载/启停 docker-app 与 mcp/http-api 服务(docker-app:读 install.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 的安装是**委派式**的——界面只发出"请安装 {名称} ### 关键路径与数据 -- 应用/服务元数据:`/registry/official/entries//manifest.json` -- 安装指南(docker-app):`<同目录>/install.md`(含端口、docker compose 步骤、验证地址) +- 当前目录快照解析:`POST http://127.0.0.1:/api/registry/acquisitions/resolve` +- 机器消息解析与响应复核:`<本技能目录>/scripts/registry-catalog-acquisition.mjs` - 安装记录:`/config/installed-entries.json` (`<根目录>` 为你的 AgentFS 根,生产为 `~/.desirecore`,开发隔离为 `~/.desirecore-dev`,以自我感知里的实际根目录为准) - agent-service API:`http://127.0.0.1:`(端口见自我感知;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:/api/registry/acquisitions/resolve + method: POST + body: + ``` + + install/reinstall 由服务端重验 current canonical;uninstall 由服务端按 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": , "status": , "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:/api/installed-entries// method: PATCH body: + sourceId: + 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 :` 或 `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 :` 或 `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:`,`bash` 用 `curl` 轮询(最多 ~2 分钟)确认服务可达。**只有这步通过才算安装成功**——不要仅凭 `docker compose up -d` 无报错就回写 `installed`。 +6. **健康校验(先校验后回写,强制)**:按 installGuide 的验证地址或 manifest.exposes 的 `http://localhost:`,`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: connection: # 传入则安装后自动测连接 + catalogAcquisition: ``` 返回 `data.steps`(每条命令 exitCode/stdout/stderr)与 `data.connectionTest`。任一命令失败→`success:false`,进入"失败处理"。无 API 可用时用 `bash` 逐条跑 `postInstall`。 4. **注册到 Agent**(让 MCP 工具下轮可用): @@ -147,38 +288,69 @@ parameters: body: serverId: config: + catalogAcquisition: ``` - 端点锁内 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-entry,PATCH 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:/api/agents/desirecore/mcp-servers/ + url: http://127.0.0.1:/api/agents/desirecore/mcp-servers/?sourceId=&deviceId=&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 snapshot;App 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**:派生出的服务由后端周期探活,你无需自行维护其健康。 diff --git a/skills/app-install-manager/scripts/registry-catalog-acquisition.mjs b/skills/app-install-manager/scripts/registry-catalog-acquisition.mjs new file mode 100755 index 0000000..15de992 --- /dev/null +++ b/skills/app-install-manager/scripts/registry-catalog-acquisition.mjs @@ -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 + }) +} diff --git a/skills/app-install-manager/tests/registry-catalog-acquisition.test.mjs b/skills/app-install-manager/tests/registry-catalog-acquisition.test.mjs new file mode 100644 index 0000000..62e1202 --- /dev/null +++ b/skills/app-install-manager/tests/registry-catalog-acquisition.test.mjs @@ -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=/) + assert.ok(skill.indexOf('解析机器消息并调用 resolver') < skill.indexOf('环境校验')) + assert.ok(skill.indexOf('### 执行前协议') < skill.indexOf('`docker version`')) +})