diff --git a/.github/workflows/check-dsh-plugin.yml b/.github/workflows/check-dsh-plugin.yml index c9a2828c7..b889cca5d 100644 --- a/.github/workflows/check-dsh-plugin.yml +++ b/.github/workflows/check-dsh-plugin.yml @@ -84,6 +84,18 @@ jobs: - name: Verify native DSH Host in an isolated Web Profile working-directory: plugins/dsh run: node scripts/smoke-native-host.mjs --artifact artifacts --report host-smoke-summary.json + - name: Verify retained Cordis family in an isolated Web Profile + if: matrix.os == 'ubuntu-latest' && matrix.node == '24.2.0' + working-directory: plugins/dsh + run: node scripts/smoke-native-host.mjs --artifact artifacts --report host-smoke-retained-summary.json --cordis-family retained + - name: Upload retained Host smoke summary + if: always() && matrix.os == 'ubuntu-latest' && matrix.node == '24.2.0' + uses: actions/upload-artifact@v4 + with: + name: skillhub-dsh-host-smoke-retained + path: plugins/dsh/host-smoke-retained-summary.json + if-no-files-found: warn + retention-days: 7 - name: Upload sanitized Host smoke summary if: always() uses: actions/upload-artifact@v4 diff --git a/openspec/changes/add-dsh-native-plugin/compatibility-design.md b/openspec/changes/add-dsh-native-plugin/compatibility-design.md index 57dc3e03b..32019d0c4 100644 --- a/openspec/changes/add-dsh-native-plugin/compatibility-design.md +++ b/openspec/changes/add-dsh-native-plugin/compatibility-design.md @@ -28,7 +28,7 @@ M0 验证 Bundle 加载、Client 页面、Remote 生成和挂载、对话工具 | DSH 主发行版 | `0.2.0-rc.2`,预发布版本 | | 官方源码对应 | [dsh-v0.2.0-rc.2 发布记录](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.0-rc.2)所关联的源码标签 | | DSH Host peers、Client 模块和 Typert 生成器 | `0.2.0-rc.2`;完整清单以插件 package.json 和宿主基线检查为准 | -| Cordis | `4.0.4` | +| Cordis | 明确验证的补丁组合,见下表;不使用宽版本范围 | | Node | `>=24.2.0 <25` | | Host 产物 | Node ESM,安装核心随插件打包 | | Client 产物 | DSH ModuleLoader factory,React 与 react/jsx-runtime 由宿主提供 | @@ -36,6 +36,19 @@ M0 验证 Bundle 加载、Client 页面、Remote 生成和挂载、对话工具 此组合固定开发和验收目标,不等于首版已可公开发布。正式支持声明仍需完成平台、真实技能使用、模型交互和升级回退验收。早期调研使用的 Alpha.2 源码不能替代本发行版契约;实现以固定 npm 发行产物及对应 RC.2 源码为准。发行包完整性和本地内容核对记录放在 verification.md,不混入产品流程。 +#### 同一 DSH 发行版内的 Cordis 补丁组合 + +DSH 主包的精确版本不锁定其使用 `~` 范围声明的传递依赖。兼容性以宿主实际加载的依赖组合判断,不能仅核对顶层安装目录或插件自己的 peer 副本。 + +| 组合 | Cordis | Loader | Include | Timer | +| --- | --- | --- | --- | --- | +| 保留基线 | `4.0.4` | `1.0.5` | `1.0.9` | `1.1.6` | +| 补丁兼容目标 | `4.0.5` | `1.0.6` | `1.0.10` | `1.1.7` | + +补丁兼容目标须通过相同的原生 Host、安装管理和历史恢复验收后,才能随新的插件版本发布。未列出的版本、交叉混装、无法核对的清单仍拒绝新写入。已有 `0.1.0` 的兼容声明不会因文档或源码更新自动改变。 + +CI 同时保留可复现的基线回归与普通干净安装探测。固定组合通过不能覆盖普通新装失败;普通新装成功也不能替代旧组合回归。开发锁文件、隔离 Host 安装约束及运行时门禁共同维护,不能用放宽检查代替兼容验证。 + ### 1.3 后续扩展 新增宿主版本支持单独进行需求评估和回归;首版交付不以第二个版本通过为前提。不因版本号更高而自动兼容,发布声明和 peers 不使用 `*` 或未经验证的宽范围。 diff --git a/openspec/changes/add-dsh-native-plugin/release-guide.md b/openspec/changes/add-dsh-native-plugin/release-guide.md index 18a7cfa68..2f2b210d8 100644 --- a/openspec/changes/add-dsh-native-plugin/release-guide.md +++ b/openspec/changes/add-dsh-native-plugin/release-guide.md @@ -73,7 +73,7 @@ npm pack --ignore-scripts --pack-destination /absolute/path/to/candidate-output ### 2.3 隔离环境验证 -当前候选固定 DSH `0.2.0-rc.2`、Cordis `4.0.4`、Node `>=24.2.0 <25`。在独立测试账户或容器中准备 Web Profile、工作区和测试技能,连接测试 Registry。仅隔离 Profile/DSH home 不足以隔离共享 inventory。 +当前候选固定 DSH `0.2.0-rc.2`、Node `>=24.2.0 <25`。Cordis、Loader、Include、Timer 须为已验证的完整组合:`4.0.4 / 1.0.5 / 1.0.9 / 1.1.6` 或 `4.0.5 / 1.0.6 / 1.0.10 / 1.1.7`,不得交叉混装;以实际 Host 解析结果为准。已发布 `0.1.0` 尚不包含后一组合支持及历史回执修复,本轮使用分支 tarball 验证。在独立测试账户或容器中准备 Web Profile、工作区和测试技能,连接测试 Registry。仅隔离 Profile/DSH home 不足以隔离共享 inventory。 首次创建测试 Web Profile,然后安装本地候选包: @@ -91,7 +91,13 @@ dsh --profile skillhub-test --host 127.0.0.1 --port 0 --no-open node scripts/smoke-native-host.mjs --artifact /absolute/path/to/candidate.tgz --report /absolute/path/to/host-smoke-summary.json ``` -该脚本验证包安装与 Host 加载,不调用模型,不能替代浏览器和技能写入验收。 +默认执行普通全新安装。保留旧组合的回归需另行执行: + +```sh +node scripts/smoke-native-host.mjs --artifact /absolute/path/to/candidate.tgz --report /absolute/path/to/host-smoke-retained-summary.json --cordis-family retained +``` + +脚本分别核对实际 Host 依赖解析与插件生命周期;旧组合同时约束 Host 和 Profile 的依赖,不能只固定顶层 Cordis。该脚本不调用模型,不能替代浏览器和技能写入验收。历史回执用例须真实停止并重启同一 Profile,验证核验状态、跳转已安装及无重复安装请求;页面刷新不能代替 Host 重启。 按 [验收矩阵](acceptance.md) 完成以下主路径: diff --git a/openspec/changes/add-dsh-native-plugin/release-notes.md b/openspec/changes/add-dsh-native-plugin/release-notes.md index c01f239ce..f8402a2c7 100644 --- a/openspec/changes/add-dsh-native-plugin/release-notes.md +++ b/openspec/changes/add-dsh-native-plugin/release-notes.md @@ -1,10 +1,16 @@ -# SkillHub DSH 原生插件 0.1.0 发布候选说明 +# SkillHub DSH 原生插件发布说明 ## 版本与交付状态 -当前为 `@astron-team/skillhub-dsh@0.1.0` 发布候选,已设置 `private: false` 和公开发布权限,尚未发布到 npm。候选可用于本地安装和验收,不代表已正式发布或全平台支持。 +`@astron-team/skillhub-dsh@0.1.0` 已发布到 npm。本分支包含待发布修复,版本号与发布时间另行确定;本轮不修改已发布包、不创建发布标签。 -目标环境固定 DSH `0.2.0-rc.2`、Cordis `4.0.4`、Node `>=24.2.0 <25`。当前已验证 Linux Host、真实浏览器页面与交互,以及 Windows 浏览器访问 Host;界面已完成人工 review。Windows 浏览器通过不代表 Windows 原生 Host 全部文件事务通过;macOS 真实 Host/客户端、其他 DSH 发行版和 SaaS 实例仍需独立验收。 +`0.1.0` 的原环境固定 DSH `0.2.0-rc.2`、Cordis `4.0.4`、Node `>=24.2.0 <25`。当前已验证 Linux Host、真实浏览器页面与交互,以及 Windows 浏览器访问 Host;界面已完成人工 review。Windows 浏览器通过不代表 Windows 原生 Host 全部文件事务通过;macOS 真实 Host/客户端、其他 DSH 发行版和 SaaS 实例仍需独立验收。 + +## 待发布修复 + +- 在同一 DSH `0.2.0-rc.2` 下支持两组完整 Cordis/Loader/Include/Timer 组合:`4.0.4/1.0.5/1.0.9/1.1.6` 和 `4.0.5/1.0.6/1.0.10/1.1.7`。每次新写入重新核验插件及实际 Host 的依赖解析和当前清单,未知、混装或不一致版本继续拒绝写入。 +- Host 重启后,历史安装回执核验原操作归属,再使用当前来源上下文读取安装记录和跳转已安装。会话、项目或来源变化时拒绝旧响应;不恢复过期搜索引用、安装目标或操作计划,不触发重复安装。 +- CI 保留普通新装矩阵,并增加旧组合的独立 Host 回归。具体结果见 [验证摘要](verification.md)。 ## 功能 diff --git a/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md b/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md index 24bafc8b2..453a163f3 100644 --- a/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md +++ b/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md @@ -265,3 +265,36 @@ The UI SHALL distinguish current inventory from historical operations. Completed #### Scenario: Completed clean upgrade history - **WHEN** an upgrade is confirmed completed without warnings - **THEN** the installed list remains the primary management view and the old operation is available in a collapsed history section without losing its stored result +### Requirement: Verified Cordis patch combinations within the DSH baseline + +The plugin SHALL declare only explicitly validated Cordis versions for the selected DSH release. Runtime write checks SHALL verify the Host's actual resolved dependency manifests and reject unknown or incoherent dependency combinations. A retained baseline regression and a normal clean-install check SHALL remain independently observable. + +#### Scenario: DSH transitive patch dependencies change +- **WHEN** a clean installation of the supported DSH release resolves the explicitly supported Cordis patch combination +- **THEN** browsing, installation, upgrade, uninstall and recovery checks use that verified Host combination without requiring an unrelated older peer copy +- **AND** the retained Cordis baseline continues to pass its separate regression + +#### Scenario: Unknown or mixed Host dependencies +- **WHEN** any required Host dependency is missing, has an unverified version, belongs to an incoherent combination, or its current manifest differs from the resolved package metadata +- **THEN** new writes remain disabled +- **AND** existing operation queries remain available without replaying writes + +### Requirement: Historical installation receipts recover current read-only context + +After a Host restart, the plugin SHALL distinguish durable operation identity from ephemeral source revisions. Current inventory verification and installed-list navigation from a historical receipt SHALL use a freshly verified current source revision only after validating the original operation's Host and session ownership, matching receipt fields and unchanged Registry identity. + +#### Scenario: Same Registry after Host restart +- **WHEN** the user reopens an installation receipt in the owning session after a Host restart and the configured Registry is unchanged +- **THEN** the original operation remains associated with its durable identity +- **AND** current inventory verification and installed-list navigation use the current source revision rather than the receipt's expired revision +- **AND** no install, upgrade, uninstall or recovery request is created + +#### Scenario: Source or conversation changes during verification +- **WHEN** the configured Registry, current source revision, owning session, Host identity or trusted project context changes before a receipt read or navigation finishes +- **THEN** the stale result is discarded and navigation is rejected +- **AND** historical selection, detail, target and plan references are not renewed as write authorization + +#### Scenario: The installed skill changed after the historical operation +- **WHEN** the historical operation completed but the skill was subsequently upgraded or uninstalled +- **THEN** the receipt preserves the original operation result while separately showing the current inventory +- **AND** it does not claim that the original version is still installed or submit a replacement installation diff --git a/openspec/changes/add-dsh-native-plugin/tasks.md b/openspec/changes/add-dsh-native-plugin/tasks.md index aa73a49e3..d4d815e12 100644 --- a/openspec/changes/add-dsh-native-plugin/tasks.md +++ b/openspec/changes/add-dsh-native-plugin/tasks.md @@ -197,3 +197,11 @@ - [ ] 25.2 提交清理与修复,最终PR提交CI通过后合并并记录main提交。 - [ ] 25.3 从确定的合并提交构建候选,完成干净DSH验收、包校验与发布权限核验。 - [ ] 25.4 确认精确发布候选后创建独立tag,经审批发布npm,校验公开包并执行干净安装回归。 + +## 26. 正式包验收后的兼容性与历史回执修复 + +- [x] 26.1 明确支持同一 DSH RC.2 下的 Cordis 4.0.4/Loader 1.0.5 与 Cordis 4.0.5/Loader 1.0.6 两组组合,保持未知及混装依赖拒绝写入。 +- [x] 26.2 兼容测试分别覆盖保留基线和普通新装,核对实际 Host 解析路径与当前清单,避免只检查顶层 peer。 +- [x] 26.3 历史安装回执在原操作归属验证通过后,以当前来源 revision 查询库存和导航;来源/会话/项目变化及旧写引用仍拒绝。 +- [x] 26.4 补齐重启、来源切换、逆序响应、已升级/卸载及零重复写入回归,并复测真实 Host 与页面。 +- [x] 26.5 更新支持说明、验收摘要及 PR;不在本轮升级版本号、推送发布标签或发布 npm。 diff --git a/openspec/changes/add-dsh-native-plugin/verification.md b/openspec/changes/add-dsh-native-plugin/verification.md index b1c0c13fe..88cc792f1 100644 --- a/openspec/changes/add-dsh-native-plugin/verification.md +++ b/openspec/changes/add-dsh-native-plugin/verification.md @@ -2,17 +2,36 @@ ## 1. 当前结论 -本轮产品交互已获人工确认。当前开发候选已通过本地构建、类型检查、lint及自动化测试;既有产品交互已通过真实 DSH 预览验证。本轮评审修复经隔离回归与独立复核,尚未更新运行中的预览,可进入最终提交检查与发布准备;不等于已完成正式发布、所有平台验收或 SaaS 部署。 +`@astron-team/skillhub-dsh@0.1.0` 已正式发布到 npm。发布后本地验收使用正式包、独立 DSH RC.2 Profile 和本地契约模拟市场;固定完整依赖基线时,14 项页面及安装管理用例通过,Host 重启后的历史卡片当前库存核验与导航失败。普通新装另复现了 Cordis 传递依赖漂移造成的写入禁用。实际文件、清单、会话、原操作及 DSH 发现保留正常,不能把卡片失败表述为数据丢失。 -当前插件发布候选版本为 `0.1.0`,`private: false`,尚未发布 npm;默认 Registry 为空,DSH 基线为 `0.2.0-rc.2`。以 [兼容性设计](compatibility-design.md) 和 [验收标准](acceptance.md) 限定支持范围。 +本分支修复上述两个问题,尚未发布新版本,不改变 npm `0.1.0` 的既有内容。修复后验收与 CI 结果在本文件单独记录;下表保留首版候选的历史验证范围。默认 Registry 仍为空,DSH 主基线仍为 `0.2.0-rc.2`,完整 Cordis 组合以 [兼容性设计](compatibility-design.md) 为准。本轮按用户决定不调用模型,也不代表真实 Java 服务端、SaaS 或其他操作系统已重新验收。 ## 2. 验证范围 +### 2.1 本轮发布后修复 + +| 检查 | 结果 | 验收边界 | +| --- | --- | --- | +| 源码与规范 | `bun run check`:构建、typecheck、lint、272 项测试、7057 次断言通过;OpenSpec 严格校验通过 | 对应修复提交 `e308efb8d`,后续仅补文档结果 | +| 独立评审 | 12 个维度评审通过;测试设计独立评审通过 | 会话对象不变但 cwd 变化的问题已修正并补回归 | +| 普通全新安装 | 未设置 Cordis overrides 的 DSH RC.2 实际解析 `4.0.5 / 1.0.6 / 1.0.10 / 1.1.7`,本地候选包正常加载并完成技能写入 | 使用全新隔离 Host/Profile;未把已发布的 `0.1.0` 当作修复包 | +| 包一致性 | 已安装的 72 个交付文件与候选 tarball 逐字节一致 | 不依赖仓库源码加载插件;候选未发布 | +| 页面完整流程 | 14 项正常场景及 1 项真实重启场景全部通过 | 包含搜索、分页、标签、版本、评论、延迟骨架、双范围安装、升级、离线卸载、主题、窄屏及可选 Tab | +| 官方工具执行 | 搜索后直接选择第二项安装,原操作 completed,资源文件和 DSH 发现核对通过 | 没有先打开详情;执行真实工具,不调用模型 | +| Host 重启 | 停止旧进程并启动同一持久 Profile 的新进程;原会话、原操作、文件和 DSH 发现保留 | 不是页面刷新;使用认证入口访问真实 Host | +| 历史回执 | 自动及手动核验均通过,跳转已安装成功;安装和 executePlan 新请求均为 0 | 来源、会话、cwd、Host 不匹配及取消/晚响应由定向回归验证 | +| 保留依赖组合 | 原 `4.0.4 / 1.0.5 / 1.0.9 / 1.1.6` 完整组合的独立 Host 基线及插件生命周期均通过 | 实际解析版本逐项核对;Client 禁用阻断写入、恢复后重新允许及插件重载通过 | +| 远端 CI | PR 对应提交的多平台结果另行核对 | 未完成前不声明 macOS/Windows 原生验收通过 | + +模型调用、真实 Java 服务端、SaaS 和其他 DSH 版本未纳入本轮重新验收。浏览器历史由真实工具结果回放构造,不能视为模型回合。原开发预览与新候选预览独立保留,未迁移或清理用户对话。 + +### 2.2 首版候选历史验证 + | 层次 | 已验证内容 | 边界 | | --- | --- | --- | -| 当前插件自动化 | `bun run check`:build、typecheck、lint、259项测试、3513次断言通过 | 结果对应本轮本地功能候选;整理后的最终提交仍需远端CI | +| 首版候选自动化 | `bun run check`:build、typecheck、lint、259项测试、3513次断言通过 | 历史结果;本轮修复以 2.1 节为准 | | 评审边界回归 | CLI integrations 129项测试、1198次断言通过,含真实SIGKILL安装/卸载恢复;大列表引用、范围降级与幂等发现回归通过 | 使用隔离夹具,未重启现有预览或重复真实模型验收 | -| 安装包 | 独立tarball安装、实际Client与候选产物一致;Host/Client/Remote及共享核心随包交付 | 尚未发布npm正式版本 | +| 安装包 | 独立tarball安装、实际Client与候选产物一致;Host/Client/Remote及共享核心随包交付 | 该记录对应发布前候选;0.1.0 现已发布 | | 技能中心 | 浏览、搜索、接口标签筛选、详情Tab、指定版本、已安装与管理入口 | 当前UI预览使用隔离Registry,不代表SaaS生产数据 | | 对话交互 | 搜索多候选、详情抽屉、明确选择安装、紧凑回执、历史状态与当前状态区分 | 模型文字不替代持久操作状态 | | 安装事务 | 官方工具直接安装、文件/资源/清单核对、发现观察;历史验证包含升级、卸载、并发与恢复 | 本轮视觉调整未重复全部历史崩溃矩阵 | diff --git a/plugins/dsh/README.en.md b/plugins/dsh/README.en.md index 971fc0cf7..cd36f1791 100644 --- a/plugins/dsh/README.en.md +++ b/plugins/dsh/README.en.md @@ -4,14 +4,14 @@ A native DSH plugin for anonymous skill browsing, conversation search and installation, and local upgrade/removal. Open **技能中心** (Skill Center) in the DSH sidebar. The installation core is bundled; a separate SkillHub CLI installation is unnecessary. -The release candidate is `@astron-team/skillhub-dsh@0.1.0`. Its manifest permits public publication, but the version has not been published to npm yet. Use the version actually available on npm and its release acceptance results to determine availability. +`@astron-team/skillhub-dsh@0.1.0` is published on npm. This branch contains compatibility and historical installation receipt fixes that have not been released yet; the next version is selected separately. Validate these changes with a tarball built from this branch, not the published `0.1.0` package. ## Requirements and validation scope | Component | Requirement or scope | | --- | --- | | DSH | `0.2.0-rc.2`, with the matching official module versions | -| Cordis | `4.0.4` | +| Cordis | The two explicitly verified complete combinations below | | Host Node | `>=24.2.0 <25`; Node 24.0.0 is unsuitable | | Build package manager | Bun `1.3.13`, using frozen lockfiles | | UI | Chinese, DSH light/dark themes and narrow layouts | @@ -43,9 +43,18 @@ npm pack --ignore-scripts --pack-destination /absolute/path/to/candidate-output A dry run does not produce an installable package. Retain each candidate and its checksum; do not overwrite packages used for acceptance or rollback. -## Install from npm (after release) +This branch verifies the following complete combinations, rejecting mixed or unlisted patch versions. Pinning the DSH main package does not lock its transitive `~` dependencies; the actual Host resolution is checked. -Use these commands after `0.1.0` is published. Check the DSH and Node requirements above, stop the target Web Profile Host, then install the exact version: +| Combination | Cordis | Loader | Include | Timer | +| --- | --- | --- | --- | --- | +| Retained baseline | `4.0.4` | `1.0.5` | `1.0.9` | `1.1.6` | +| New patch combination | `4.0.5` | `1.0.6` | `1.0.10` | `1.1.7` | + +Published `0.1.0` still requires the original Cordis `4.0.4` baseline and does not include these fixes. Historical receipts use a freshly verified read-only context; expired installation references and plans never regain write authorization. + +## Install from npm + +These commands install published `0.1.0`. Check DSH, Node and that version's original Cordis baseline, stop the target Web Profile Host, then install the exact version. Until the branch fixes are released, use the local candidate instructions below to validate them: ```sh dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.1.0 --ignore-scripts @@ -153,6 +162,6 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh The plugin Host calls anonymous Registry HTTP APIs; local file transactions stay on the Host. SaaS does not need a new API that controls user filesystems. Define and verify contracts in open source first, then synchronize missing routes, fields, anonymous policy and tests to SaaS. SaaS acceptance remains pending. See the [Registry contract](../../openspec/changes/add-dsh-native-plugin/registry-api-contract.md). -The plugin uses independent `dsh-v` tags. `check-dsh-plugin.yml` defines Linux/macOS/Windows × Node 24.2.0/current Node 24 checks and isolated Host smoke tests. `release-dsh-plugin.yml` is manual and defaults to candidate-only operation. Publishing requires an approved version, publishable manifest, candidate acceptance and protected environment approval. The `0.1.0` manifest explicitly sets `private: false` and public access; this does not mean publication has completed. +The plugin uses independent `dsh-v` tags. `check-dsh-plugin.yml` runs normal clean-install checks on Linux/macOS/Windows × Node 24.2.0/current Node 24, plus an isolated retained-Cordis baseline regression. `release-dsh-plugin.yml` is manual and defaults to candidate-only operation. Publishing requires an approved version, publishable manifest, candidate acceptance and explicit release authorization; configured environment protection rules must also be satisfied. This branch does not bump or publish the package automatically. See the [release guide](../../openspec/changes/add-dsh-native-plugin/release-guide.md) and [release notes](../../openspec/changes/add-dsh-native-plugin/release-notes.md). OpenSpec and integration links refer to the source repository; those documents are not included in the tarball. diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md index 6bfafff7b..e13b297ad 100644 --- a/plugins/dsh/README.md +++ b/plugins/dsh/README.md @@ -4,14 +4,14 @@ SkillHub 的 DSH 原生插件。在 DSH 侧栏打开 **技能中心**,匿名浏览、搜索、查看技能详情,并管理安装、升级和卸载;对话中也可搜索和明确请求安装技能。插件内置本地安装核心,无需另装 SkillHub CLI。 -当前准备发布 `@astron-team/skillhub-dsh@0.1.0`,manifest 已允许公开发布。此版本仍处于发布候选阶段,尚未发布到 npm;以 npm 实际可下载版本及发布验收结果为准。 +npm 已发布 `@astron-team/skillhub-dsh@0.1.0`。本分支包含尚未发布的兼容性和历史安装回执修复,后续版本号另行确定;验证这些修复时请安装本分支构建的 tarball,不能使用已发布的 `0.1.0` 替代。 ## 环境与验证范围 | 项目 | 要求或范围 | | --- | --- | | DSH | `0.2.0-rc.2`,配套官方模块使用相同固定版本 | -| Cordis | `4.0.4` | +| Cordis | 本分支明确验证的两组完整组合,见下表 | | Host Node | `>=24.2.0 <25`;不能使用 Node 24.0.0 | | 开发包管理器 | Bun `1.3.13`,按锁文件安装 | | 当前 UI | 中文;支持 DSH 深浅主题和窄屏 | @@ -43,9 +43,18 @@ npm pack --ignore-scripts --pack-destination /absolute/path/to/candidate-output 输出目录须先创建。`--dry-run` 不生成 tarball;实际安装使用生成的 `.tgz`。保留每次候选包及其校验值,不覆盖已经用于验收或回退的包。 -## 从 npm 安装(正式发布后) +本分支验证以下完整组合,不接受交叉混装或未列出的补丁版本。DSH 主包中的 `~` 依赖不会因主包版本固定而自动锁定;以实际宿主解析结果为准。 -以下命令在 `0.1.0` 正式发布后使用。先确认 DSH 和 Node 满足上述版本要求,停止目标 Web Profile 的 Host,然后安装精确版本: +| 组合 | Cordis | Loader | Include | Timer | +| --- | --- | --- | --- | --- | +| 保留基线 | `4.0.4` | `1.0.5` | `1.0.9` | `1.1.6` | +| 新补丁组合 | `4.0.5` | `1.0.6` | `1.0.10` | `1.1.7` | + +已发布的 `0.1.0` 尚不包含新补丁组合与历史回执修复;它仍要求原 Cordis `4.0.4` 基线。插件仅从核验后的当前上下文读取历史回执对应的库存,不恢复旧安装引用或计划的写授权。 + +## 从 npm 安装 + +以下命令安装已发布的 `0.1.0`。先确认 DSH、Node 和该版本的原 Cordis 基线满足要求,停止目标 Web Profile 的 Host,然后安装精确版本。本分支修复发布前,使用下一节的本地候选包验证方式: ```sh dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.1.0 --ignore-scripts @@ -153,6 +162,6 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh 插件 Host 调用匿名 Registry HTTP API;本地安装事务在 Host 内完成,不需要 SaaS 新增操作用户文件系统的安装接口。开源先定义和验证契约,再将缺失的路径、字段、匿名策略和测试同步到 SaaS。当前未声明 SaaS 已通过验收。详细要求见 [Registry 契约](../../openspec/changes/add-dsh-native-plugin/registry-api-contract.md)。 -插件独立使用 `dsh-v` tag,不复用 CLI 或服务镜像发布。`check-dsh-plugin.yml` 配置 Linux/macOS/Windows × Node 24.2.0/当前 Node 24 的检查及隔离 Host 冒烟;`release-dsh-plugin.yml` 手动触发,默认只产出候选包。发布需正式版本、可发布 manifest、候选验收和受保护环境审批;`0.1.0` manifest 已设置 `private: false` 和公开发布权限,但这不代表已完成发布。 +插件独立使用 `dsh-v` tag,不复用 CLI 或服务镜像发布。`check-dsh-plugin.yml` 配置 Linux/macOS/Windows × Node 24.2.0/当前 Node 24 的普通干净安装检查,并额外保留旧 Cordis 组合的隔离 Host 回归;`release-dsh-plugin.yml` 手动触发,默认只产出候选包。发布需正式版本、可发布 manifest、候选验收及明确发布授权;配置了环境保护规则时还须完成相应审批。本分支不自动升级版本或发布 npm。 具体部署、发布后检查与回退清单见 [发布指南](../../openspec/changes/add-dsh-native-plugin/release-guide.md),变化见 [发布说明](../../openspec/changes/add-dsh-native-plugin/release-notes.md)。上述 OpenSpec 与测试链接面向源码仓库,不包含在 npm tarball 中。 diff --git a/plugins/dsh/bun.lock b/plugins/dsh/bun.lock index 406dfc225..9c15b4020 100644 --- a/plugins/dsh/bun.lock +++ b/plugins/dsh/bun.lock @@ -18,6 +18,10 @@ "zod": "4.4.3", }, "devDependencies": { + "@deepseek-ai/cordis": "4.0.5", + "@deepseek-ai/cordis-plugin-include": "1.0.10", + "@deepseek-ai/cordis-plugin-loader": "1.0.6", + "@deepseek-ai/cordis-plugin-timer": "1.1.7", "@deepseek-ai/dsh-agent": "0.2.0-rc.2", "@deepseek-ai/dsh-api-gateway": "0.2.0-rc.2", "@deepseek-ai/dsh-api-session-controller": "0.2.0-rc.2", @@ -42,7 +46,7 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@deepseek-ai/cordis": "4.0.4", + "@deepseek-ai/cordis": "4.0.4 || 4.0.5", "@deepseek-ai/dsh-fs": "0.2.0-rc.2", "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2", "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2", @@ -55,16 +59,24 @@ }, }, }, + "overrides": { + "@deepseek-ai/cordis": "4.0.5", + "@deepseek-ai/cordis-plugin-include": "1.0.10", + "@deepseek-ai/cordis-plugin-loader": "1.0.6", + "@deepseek-ai/cordis-plugin-timer": "1.1.7", + }, "packages": { - "@deepseek-ai/cordis": ["@deepseek-ai/cordis@4.0.4", "https://registry.npmmirror.com/@deepseek-ai/cordis/-/cordis-4.0.4.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.5", "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@deepseek-ai/cordis-plugin-include": "~1.0.9", "@deepseek-ai/cordis-plugin-loader": "~1.0.5" }, "optionalPeers": ["@deepseek-ai/cordis-plugin-include", "@deepseek-ai/cordis-plugin-loader"], "bin": { "cordis": "bin.js" } }, "sha512-obgyxqWAmFn3Re8kvsuUnyW+ihrz6eJCnJO4fh1cQzDtmPYz/zzVeUkH9R94I0OwSVOocK67Kgakm04j/oQXzg=="], + "@deepseek-ai/cordis": ["@deepseek-ai/cordis@4.0.5", "https://registry.npmmirror.com/@deepseek-ai/cordis/-/cordis-4.0.5.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.6", "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@deepseek-ai/cordis-plugin-include": "~1.0.10", "@deepseek-ai/cordis-plugin-loader": "~1.0.6" }, "optionalPeers": ["@deepseek-ai/cordis-plugin-include", "@deepseek-ai/cordis-plugin-loader"], "bin": { "cordis": "bin.js" } }, "sha512-Sktq5axy0owk9PdnivBRNOJjHEVEibCt0+TDDZcv0+UH2KS1M910fX4kn5y45YFsrpZIi1mJf/1s5VzywMUnyg=="], "@deepseek-ai/cordis-plugin-group": ["@deepseek-ai/cordis-plugin-group@1.0.4", "https://registry.npmmirror.com/@deepseek-ai/cordis-plugin-group/-/cordis-plugin-group-1.0.4.tgz", { "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4", "@deepseek-ai/cordis-plugin-loader": "~1.0.5" } }, "sha512-/7tuY5pMQetHava4v6QUeOa4CwZA+mleNvbIoj+iqEmzkYUtlXbLqhzJIcbnDa/Hxgr1jaCwROrI77nuLyeCFg=="], - "@deepseek-ai/cordis-plugin-include": ["@deepseek-ai/cordis-plugin-include@1.0.9", "https://registry.npmmirror.com/@deepseek-ai/cordis-plugin-include/-/cordis-plugin-include-1.0.9.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.5", "js-yaml": "^4.1.0" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4", "@deepseek-ai/cordis-plugin-loader": "~1.0.5" } }, "sha512-Xmekm7u5Kpeym6nABL4T1/Mg6aQYFZxsipB0WycmCctvX1DqAv8nCFwRmg2XMx/f7EbZgNEbXQwJfabcMu8zxQ=="], + "@deepseek-ai/cordis-plugin-include": ["@deepseek-ai/cordis-plugin-include@1.0.10", "https://registry.npmmirror.com/@deepseek-ai/cordis-plugin-include/-/cordis-plugin-include-1.0.10.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.6", "js-yaml": "^4.1.0" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5", "@deepseek-ai/cordis-plugin-loader": "~1.0.6" } }, "sha512-Rdo6Uqf9WfDbfN3dsYkYj/EW+SGuWCSfthz1PoOtZYUr6qrRpqD7ND4Gj3nUYj8XhSGIo8E3xpsck6J5nM5BYQ=="], - "@deepseek-ai/cordis-plugin-loader": ["@deepseek-ai/cordis-plugin-loader@1.0.5", "https://registry.npmmirror.com/@deepseek-ai/cordis-plugin-loader/-/cordis-plugin-loader-1.0.5.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.5", "@deepseek-ai/schemastery": "~3.18.4" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4", "node-addon-require-builtin": "^0.1.6" }, "optionalPeers": ["node-addon-require-builtin"] }, "sha512-200huL2ttWcjyeYllvPYn+qMR7I89qy8MhZasbHltPzB/KMrSfsyoCYKdCGc5KqPGj5iJugcmibQzjINMO9I+Q=="], + "@deepseek-ai/cordis-plugin-loader": ["@deepseek-ai/cordis-plugin-loader@1.0.6", "https://registry.npmmirror.com/@deepseek-ai/cordis-plugin-loader/-/cordis-plugin-loader-1.0.6.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.6", "@deepseek-ai/schemastery": "~3.18.5" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5", "node-addon-require-builtin": "^0.1.9" }, "optionalPeers": ["node-addon-require-builtin"] }, "sha512-fr011A782oEjkJXCY4pePt2qaPpkGvem7Rmst6Rhwgg6icgwI4eN9QBRb6S/q9XOYGVQUdvL0wXUWyNxO8WipA=="], - "@deepseek-ai/cosmokit": ["@deepseek-ai/cosmokit@1.8.5", "https://registry.npmmirror.com/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz", {}, "sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A=="], + "@deepseek-ai/cordis-plugin-timer": ["@deepseek-ai/cordis-plugin-timer@1.1.7", "https://registry.npmmirror.com/@deepseek-ai/cordis-plugin-timer/-/cordis-plugin-timer-1.1.7.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.6" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" } }, "sha512-i83gGA1WfL1kA7RMWvLLDw5KhKar3cdvGyl7E9ByrsFv3gb64XDf5b5sFxj+cgv9ZGaYc1YwGWAjwgS2f3OGNw=="], + + "@deepseek-ai/cosmokit": ["@deepseek-ai/cosmokit@1.8.6", "https://registry.npmmirror.com/@deepseek-ai/cosmokit/-/cosmokit-1.8.6.tgz", {}, "sha512-ZSD/fN52L498LBIOYwjIOlaLv5veY6ZEwI2yzvZUKQtctn0QAPqu8tKdJ+OF5+Tv6NBq1U+wJWH+QI6OGy5EvQ=="], "@deepseek-ai/dsh-agent": ["@deepseek-ai/dsh-agent@0.2.0-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-agent/-/dsh-agent-0.2.0-rc.2.tgz", { "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4", "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", "@deepseek-ai/dsh-llm": "0.2.0-rc.2", "@deepseek-ai/dsh-scope": "0.2.0-rc.2", "@deepseek-ai/dsh-session": "0.2.0-rc.2", "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2", "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2", "@deepseek-ai/dsh-util-values": "0.2.0-rc.2", "@deepseek-ai/dsh-workspace": "0.2.0-rc.2" }, "optionalPeers": ["@deepseek-ai/dsh-workspace"] }, "sha512-CUkbU+c1G0ETXkpoLkpAFo5midz66FQGtlgQOP9pzvU9H3zJQVl4H5LrhhGVbEXKLFgdbtQiOynEW8hpWn3y0Q=="], @@ -570,6 +582,12 @@ "zwitch": ["zwitch@2.0.4", "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@deepseek-ai/cordis-plugin-loader/@deepseek-ai/schemastery": ["@deepseek-ai/schemastery@3.18.5", "https://registry.npmmirror.com/@deepseek-ai/schemastery/-/schemastery-3.18.5.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.6", "@standard-schema/spec": "^1.1.0" } }, "sha512-Mnobvqf9rHsZYv4g5edj5ddxS1oF1kIdSlz0DunUgnY3LWG5F26MqvH3YOOsSaodCgyzJ16OMN9O99/s5Crtdw=="], + + "@deepseek-ai/dsh-settings/@deepseek-ai/cosmokit": ["@deepseek-ai/cosmokit@1.8.5", "https://registry.npmmirror.com/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz", {}, "sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A=="], + + "@deepseek-ai/schemastery/@deepseek-ai/cosmokit": ["@deepseek-ai/cosmokit@1.8.5", "https://registry.npmmirror.com/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz", {}, "sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.5.0", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ=="], "@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.7.tgz", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json index 7f223809d..a917cfbae 100644 --- a/plugins/dsh/package.json +++ b/plugins/dsh/package.json @@ -73,7 +73,7 @@ } }, "peerDependencies": { - "@deepseek-ai/cordis": "4.0.4", + "@deepseek-ai/cordis": "4.0.4 || 4.0.5", "@deepseek-ai/dsh-fs": "0.2.0-rc.2", "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2", "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2", @@ -119,7 +119,11 @@ "playwright": "1.58.2", "react": "18.3.1", "react-dom": "18.3.1", - "typescript": "6.0.3" + "typescript": "6.0.3", + "@deepseek-ai/cordis": "4.0.5", + "@deepseek-ai/cordis-plugin-loader": "1.0.6", + "@deepseek-ai/cordis-plugin-include": "1.0.10", + "@deepseek-ai/cordis-plugin-timer": "1.1.7" }, "engines": { "node": ">=24.2.0 <25" @@ -136,5 +140,11 @@ }, "publishConfig": { "access": "public" + }, + "overrides": { + "@deepseek-ai/cordis": "4.0.5", + "@deepseek-ai/cordis-plugin-loader": "1.0.6", + "@deepseek-ai/cordis-plugin-include": "1.0.10", + "@deepseek-ai/cordis-plugin-timer": "1.1.7" } } diff --git a/plugins/dsh/scripts/probe-host.mjs b/plugins/dsh/scripts/probe-host.mjs index a8c91913f..0ce56aadc 100644 --- a/plugins/dsh/scripts/probe-host.mjs +++ b/plugins/dsh/scripts/probe-host.mjs @@ -90,7 +90,8 @@ if (runtimeProbe) { const installedPackage = join(root, 'home/profiles/skillhub-m0/node_modules/@astron-team/skillhub-dsh'); const manifest = JSON.parse(await readFile(join(installedPackage, 'package.json'), 'utf8')); Object.assign(config, { output: resultName, pluginEntry: join(installedPackage, 'lib/index.js'), - hostPackages: ['@deepseek-ai/dsh', '@deepseek-ai/dsh-app-boot', ...Object.keys(manifest.peerDependencies)], + hostPackages: ['@deepseek-ai/dsh', '@deepseek-ai/dsh-app-boot', ...Object.keys(manifest.peerDependencies), + '@deepseek-ai/cordis-plugin-loader', '@deepseek-ai/cordis-plugin-include', '@deepseek-ai/cordis-plugin-timer'], clientPackages: manifest.dsh.client.inject, ...(values.probe === 'runtime-unsupported' ? { expectBlocked: true } : { reloadClient: '@deepseek-ai/dsh-client-ui-layout' }) }); } diff --git a/plugins/dsh/scripts/smoke-native-host.mjs b/plugins/dsh/scripts/smoke-native-host.mjs index 9cf63a855..cb37ee080 100644 --- a/plugins/dsh/scripts/smoke-native-host.mjs +++ b/plugins/dsh/scripts/smoke-native-host.mjs @@ -10,6 +10,24 @@ import { parseArgs } from 'node:util'; const DSH_VERSION = '0.2.0-rc.2'; const PNPM_VERSION = '10.33.0'; +export const retainedCordisFamily = { + '@deepseek-ai/cordis': '4.0.4', + '@deepseek-ai/cordis-plugin-loader': '1.0.5', + '@deepseek-ai/cordis-plugin-include': '1.0.9', + '@deepseek-ai/cordis-plugin-timer': '1.1.6', +}; + +export function hostInstallManifest(family) { + if (!['fresh', 'retained'].includes(family)) throw new Error('Unknown Cordis family.'); + return { private: true, type: 'module', ...(family === 'retained' ? { overrides: retainedCordisFamily } : {}) }; +} + +export function profileInstallManifest(manifest, family) { + hostInstallManifest(family); // Reject unknown modes before writing any fixture configuration. + return family === 'retained' ? { ...manifest, pnpm: { ...manifest.pnpm, + overrides: { ...manifest.pnpm?.overrides, ...retainedCordisFamily } } } : manifest; +} + // No shell or .cmd invocation: npm and the probe run with this exact Node binary. // On Windows taskkill attempts tree termination while the parent still exists; // the independent cleanup deadline also covers a failed/hung taskkill process. @@ -146,14 +164,15 @@ async function findNpm() { } async function main() { - const { values } = parseArgs({ options: { artifact: { type: 'string' }, report: { type: 'string' }, help: { type: 'boolean' } } }); + const { values } = parseArgs({ options: { artifact: { type: 'string' }, report: { type: 'string' }, help: { type: 'boolean' }, 'cordis-family': { type: 'string', default: 'fresh' } } }); if (values.help) { - console.log('node scripts/smoke-native-host.mjs --artifact --report '); + console.log('node scripts/smoke-native-host.mjs --artifact --report [--cordis-family fresh|retained]'); return; } if (!values.artifact || !values.report) throw new Error('Specify --artifact and --report; see --help.'); const [major, minor] = process.versions.node.split('.').map(Number); if (major !== 24 || minor < 2) throw new Error('Use the fixed Node >=24.2.0 <25 baseline.'); + const installManifest = hostInstallManifest(values['cordis-family']); const artifact = await selectTarball(values.artifact); const npm = await findNpm(); const reportFile = resolve(values.report); @@ -162,13 +181,13 @@ async function main() { const env = isolatedEnvironment(root); for (const directory of ['home', 'native-install-home', 'agents', 'workspace', 'logs']) await mkdir(join(root, directory), { mode: 0o700 }); for (const name of ['empty.npmrc', 'empty-global.npmrc']) await writeFile(join(root, name), '', { mode: 0o600 }); - await writeFile(join(root, 'package.json'), JSON.stringify({ private: true, type: 'module' })); + await writeFile(join(root, 'package.json'), JSON.stringify(installManifest)); const abort = new AbortController(); const stop = () => abort.abort(); process.once('SIGINT', stop); process.once('SIGTERM', stop); const summary = { passed: false, platform: process.platform, node: process.versions.node, - dsh: DSH_VERSION, pnpm: PNPM_VERSION, + dsh: DSH_VERSION, pnpm: PNPM_VERSION, cordisFamily: values['cordis-family'], artifactSha256: createHash('sha256').update(await readFile(artifact)).digest('hex'), modelCalled: false, probes: [], stage: 'install-host' }; const run = (name, args, timeoutMs = 300000) => runBounded(args, { cwd: join(root, 'workspace'), env, @@ -181,6 +200,13 @@ async function main() { summary.stage = 'initialize-web'; await run(summary.stage, [dsh, '--profile', 'skillhub-m0', '--from-default-profile', 'web', '--dump-config']); summary.stage = 'install-candidate'; + // npm's root overrides do not propagate into the profile's separate pnpm project. + // Keep the retained fixture coherent even if a future profile enables peer auto-install. + if (values['cordis-family'] === 'retained') { + const profileManifestPath = join(root, 'home/profiles/skillhub-m0/package.json'); + const profileManifest = JSON.parse(await readFile(profileManifestPath, 'utf8')); + await writeFile(profileManifestPath, JSON.stringify(profileInstallManifest(profileManifest, 'retained'), null, 2) + '\n'); + } await run(summary.stage, [dsh, 'plugin', '--profile', 'skillhub-m0', 'add', artifact, '--ignore-scripts']); const manifest = JSON.parse(await readFile(join(root, 'home/profiles/skillhub-m0/node_modules/@astron-team/skillhub-dsh/package.json'), 'utf8')); if (manifest.name !== '@astron-team/skillhub-dsh') throw new Error('Unexpected installed candidate identity.'); @@ -191,6 +217,14 @@ async function main() { const lines = (await readFile(join(root, 'logs', probe + '.log'), 'utf8')).trim().split('\n'); const result = JSON.parse(lines.at(-1)); if (result.passed !== true || result.probe !== probe) throw new Error('Probe did not provide a successful result.'); + if (probe === 'runtime-baseline') { + const detail = JSON.parse(await readFile(result.report, 'utf8')); + const resolved = Object.fromEntries(detail.initial.host.map(pkg => [pkg.name, pkg.version])); + summary.cordisPackages = Object.fromEntries(Object.keys(retainedCordisFamily).map(name => [name, resolved[name]])); + if (values['cordis-family'] === 'retained' && Object.entries(retainedCordisFamily).some(([name, version]) => resolved[name] !== version)) { + throw new Error('Retained Cordis family was not exercised.'); + } + } summary.probes.push({ name: probe, passed: true }); } summary.stage = 'complete'; diff --git a/plugins/dsh/src/client/index.tsx b/plugins/dsh/src/client/index.tsx index 9f475f414..3baf0d391 100644 --- a/plugins/dsh/src/client/index.tsx +++ b/plugins/dsh/src/client/index.tsx @@ -1,3 +1,4 @@ +import { resolveReceiptContext, receiptSessionGuard, type ReceiptReads } from './receipt-context.js'; import { UiAvailability, UiAvailabilityNotice } from './ui-availability.js'; import type { Context } from '@deepseek-ai/cordis'; import { SessionId } from '@deepseek-ai/dsh-session/types'; @@ -65,19 +66,40 @@ export async function apply(ctx: Context): Promise<() => Promise> { operation: (id, host) => call(remote => remote.skillhub.getOperation(id, host)), }; } + const receiptGenerations = new WeakMap(); + function receiptReadsFor(sessionId: string): ReceiptReads { + const currentSession = () => ({ scope: ctx.sessions.scope(SessionId(sessionId)), + cwd: ctx.sessions.list.getSnapshot().byId[SessionId(sessionId)]?.cwd }); + const { scope, cwd } = currentSession(); + const assertCurrent = receiptSessionGuard(currentSession); + const previous = scope ? receiptGenerations.get(scope) : undefined; + const sessionGeneration = previous && previous.cwd === cwd ? previous.generation : crypto.randomUUID(); + if (scope) receiptGenerations.set(scope, { cwd, generation: sessionGeneration }); + return { sessionGeneration, assertCurrent, context: () => ctx.remote.skillhub.getContext(), + operation: (id, host) => { assertCurrent(); return withSessionRemote(scope!, remote => remote.skillhub.getOperation(id, host)); }, + installed: (revision, signal) => { assertCurrent(); return withSessionRemote(scope!, remote => remote.skillhub.listInstalled(revision, signal)); }, + }; + } ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'astron_skillhub_install', - inject: sessionId => ({ services: { client, installation: installationFor(sessionId), openInstalled: async (result: InstallResult) => { + inject: sessionId => { + const receipt = receiptReadsFor(sessionId); + return { services: { client, installation: installationFor(sessionId), receipt, openInstalled: async (result: InstallResult) => { const layout = ctx.get('layout'); if (!layout) throw new CatalogUnavailableError(); - const current = await ctx.remote.skillhub.getContext(); - if (!current.ok || current.value.sourceRevision !== result.sourceRevision || current.value.registryUrl !== result.sourceLabel - || !ctx.sessions.scope(SessionId(sessionId))) throw new Error('Installation context changed'); - navigation.tab = 'installed'; navigation.selected = null; navigation.scrollTop = 0; - navigation.installedOrigin = { sessionId, sourceRevision: result.sourceRevision }; + const reads = receipt; + const active = () => (ctx.sessions.list.getSnapshot().byId[SessionId(sessionId)]?.retainedBy.mainView ?? 0) > 0; + if (!active()) throw new Error('当前会话已变化。'); + const current = await resolveReceiptContext(reads, result, new AbortController().signal); + reads.assertCurrent(); if (ctx.get('layout') !== layout) throw new CatalogUnavailableError(); + if (!active()) throw new Error('当前会话已变化。'); + navigation.tab = 'installed'; navigation.selected = null; navigation.scrollTop = 0; + navigation.installedOrigin = { sessionId, sourceRevision: current.sourceRevision }; + client.setQueryData(['skillhub-context'], current); layout.selectPanel(panelId); - } } }), + } } }; + }, }, props => <>)); ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'astron_skillhub_search', diff --git a/plugins/dsh/src/client/install-tool-view.tsx b/plugins/dsh/src/client/install-tool-view.tsx index 4c8d40eda..8144d3e45 100644 --- a/plugins/dsh/src/client/install-tool-view.tsx +++ b/plugins/dsh/src/client/install-tool-view.tsx @@ -1,3 +1,4 @@ +import { matchesReceipt, resolveReceiptContext, readReceiptInventory, type ReceiptReads } from './receipt-context.js'; import { receiptInventoryIssue, receiptPresentation } from './install-receipt-presentation.js'; import { Button } from './ui.js'; import { ConversationStyles } from './conversation-styles.js'; @@ -17,6 +18,7 @@ import { useInstallationOperation } from './use-installation-operation.js'; export interface InstallViewServices { client: QueryClient; + receipt: ReceiptReads; installation: Pick; openInstalled(result: InstallResult): Promise; } @@ -60,34 +62,30 @@ function InstallToolViewContent(props: ToolCallViewProps & { services: InstallVi {props.inspect && } ; return - + ; } -function InstallationResult({ result, services, openInstalled }: { - result: InstallResult; services: InstallViewServices['installation']; openInstalled: InstallViewServices['openInstalled']; +function InstallationResult({ result, services, receipt, openInstalled }: { + result: InstallResult; services: InstallViewServices['installation']; receipt: ReceiptReads; openInstalled: InstallViewServices['openInstalled']; }) { const [actionStatus, setActionStatus] = useState(''); const [opening, setOpening] = useState(false); const query = useInstallationOperation(services, result.hostInstanceId, result.operationId); const operation = query.data; - const mismatch = operation && (operation.operationId !== result.operationId || operation.kind !== 'install' - || operation.context.hostInstanceId !== result.hostInstanceId || operation.namespace !== result.namespace - || operation.slug !== result.slug || operation.version !== result.version - || operation.context.sourceIdentity !== result.sourceLabel || operation.context.sourceRevision !== result.sourceRevision - || operation.targetLabel !== result.targetLabel); + const mismatch = !!operation && !matchesReceipt(operation, result); const canInspectCurrent = !query.isError && !mismatch && (operation?.phase === 'completed' || (operation?.phase === 'failed' && ['not-needed', 'restored'].includes(operation.recovery))); - const current = useQuery({ queryKey: ['skillhub-installed', services.sessionId, result.sourceRevision], retry: false, - enabled: canInspectCurrent, - queryFn: async ({ signal }) => { - const response = await services.installed(result.sourceRevision, signal); - if (!response.ok) throw new Error('当前安装记录无法核验,来源或会话可能已变化。'); - return response.value; - } }); + const context = useQuery({ queryKey: ['skillhub-receipt-context', services.sessionId, result.hostInstanceId, + result.operationId, result.sourceLabel, result.sourceRevision, receipt.sessionGeneration], retry: false, enabled: canInspectCurrent, + queryFn: ({ signal }) => resolveReceiptContext(receipt, result, signal) }); + const current = useQuery({ queryKey: ['skillhub-installed', services.sessionId, context.data?.sourceRevision, + 'receipt', result.hostInstanceId, result.operationId, result.sourceLabel, receipt.sessionGeneration], retry: false, + enabled: canInspectCurrent && !!context.data && !context.isFetching && !context.isError, + queryFn: ({ signal }) => readReceiptInventory(receipt, result, context.data!, signal) }); const entries = current.data?.items.filter(item => item.namespace === result.namespace && item.slug === result.slug) ?? []; - const inventoryIssue = canInspectCurrent ? receiptInventoryIssue(result, current.data, current.isError) : null; - const inventoryPending = canInspectCurrent && (current.isPending || current.isFetching); + const inventoryIssue = canInspectCurrent ? receiptInventoryIssue(result, context.isError ? undefined : current.data, context.isError || current.isError) : null; + const inventoryPending = canInspectCurrent && (!context.isError && (context.isPending || context.isFetching || current.isPending || current.isFetching)); const presentation = receiptPresentation(operation, query.isError || !!mismatch, inventoryPending, inventoryIssue); return

{result.slug}

@{result.namespace}/{result.slug}

{presentation.label}
@@ -111,7 +109,7 @@ function InstallationResult({ result, services, openInstalled }: {

} {canInspectCurrent &&

当前会话可见的安装记录

- {current.isPending || current.isFetching ?

正在核验当前安装记录…

+ {context.isError ?

{context.error.message}

: context.isFetching || current.isPending || current.isFetching ?

正在核验当前安装记录…

: current.isError ?

{current.error.message}

: <> {!current.data.complete &&

当前安装记录读取不完整,不能判断所有范围是否仍安装。

} diff --git a/plugins/dsh/src/client/receipt-context.ts b/plugins/dsh/src/client/receipt-context.ts new file mode 100644 index 000000000..a393dda65 --- /dev/null +++ b/plugins/dsh/src/client/receipt-context.ts @@ -0,0 +1,67 @@ +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'; +import type { CatalogContext, InstalledSkillList, NativeOperation } from '../host/types.js'; +import type { InstallResult } from './install-result.js'; + +export interface ReceiptReads { + sessionGeneration: string; + context(): Promise>; + operation(id: string, host: string): Promise>; + installed(revision: string, signal: AbortSignal): Promise>; + /** Captures the exact controller-owned Session generation, not just its id. */ + assertCurrent(): void; +} + +/** Project identity can change while the controller retains the same AgentContext object. */ +export function receiptSessionGuard(current: () => { scope: object | undefined; cwd: string | undefined }): () => void { + const captured = current(); + return () => { + const now = current(); + if (!captured.scope || !captured.cwd || now.scope !== captured.scope || now.cwd !== captured.cwd) { + throw new Error('当前会话或项目已变化。'); + } + }; +} + +function read(response: RemoteResult): T { + if (!response.ok) throw new Error('当前安装记录无法核验,来源或会话可能已变化。'); + return response.value; +} + +export function matchesReceipt(operation: NativeOperation | null | undefined, result: InstallResult): boolean { + return !!operation && operation.operationId === result.operationId && operation.kind === 'install' + && operation.context.hostInstanceId === result.hostInstanceId && operation.namespace === result.namespace + && operation.slug === result.slug && operation.version === result.version + && operation.context.sourceIdentity === result.sourceLabel && operation.context.sourceRevision === result.sourceRevision + && operation.targetLabel === result.targetLabel; +} + +/** Durable receipts authorize only a fresh read, never revive expired selections or write references. */ +export async function resolveReceiptContext(reads: ReceiptReads, result: InstallResult, signal: AbortSignal): Promise { + const check = () => { signal.throwIfAborted(); reads.assertCurrent(); }; + check(); + const before = read(await reads.context()); + check(); + if (!before.configured || before.registryUrl !== result.sourceLabel) throw new Error('技能来源已变化。'); + const operation = read(await reads.operation(result.operationId, result.hostInstanceId)); + check(); + if (!matchesReceipt(operation, result) || !(operation!.phase === 'completed' + || (operation!.phase === 'failed' && ['not-needed', 'restored'].includes(operation!.recovery)))) { + throw new Error('原安装回执无法核验。'); + } + const context = read(await reads.context()); + check(); + if (!context.configured || context.registryUrl !== result.sourceLabel || context.sourceRevision !== before.sourceRevision) throw new Error('技能来源已变化。'); + return context; +} + +export async function readReceiptInventory(reads: ReceiptReads, result: InstallResult, expected: CatalogContext, + signal: AbortSignal): Promise { + const current = await resolveReceiptContext(reads, result, signal); + if (current.sourceRevision !== expected.sourceRevision) throw new Error('技能来源已变化,请重新核验。'); + const inventory = read(await reads.installed(current.sourceRevision, signal)); + signal.throwIfAborted(); reads.assertCurrent(); + const after = await resolveReceiptContext(reads, result, signal); + signal.throwIfAborted(); reads.assertCurrent(); + if (after.sourceRevision !== current.sourceRevision || after.registryUrl !== current.registryUrl) throw new Error('技能来源已变化。'); + return inventory; +} diff --git a/plugins/dsh/src/client/use-installation-operation.ts b/plugins/dsh/src/client/use-installation-operation.ts index bdce1ef60..0556abca2 100644 --- a/plugins/dsh/src/client/use-installation-operation.ts +++ b/plugins/dsh/src/client/use-installation-operation.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'; import type { NativeOperation } from '../host/types.js'; import type { InstallationServices } from './install-control.js'; @@ -34,13 +34,19 @@ export function useInstallationOperation(services: Pick isVisible && !query.state.error && !settled(query.state.data) && query.state.data !== null ? 1000 : false, refetchIntervalInBackground: false }); } + +/** Receipts and page projections share the same settled-operation refresh boundary. */ +export async function invalidateInstallationQueries(client: QueryClient, sessionId: string): Promise { + await Promise.all([ + client.invalidateQueries({ queryKey: ['skillhub-installed', sessionId] }), + client.invalidateQueries({ queryKey: ['skillhub-installed-detail', sessionId] }), + client.invalidateQueries({ queryKey: ['skillhub-receipt-context', sessionId] }), + ]); +} diff --git a/plugins/dsh/src/host/compatibility.ts b/plugins/dsh/src/host/compatibility.ts index 5d2af31e0..69b8c319e 100644 --- a/plugins/dsh/src/host/compatibility.ts +++ b/plugins/dsh/src/host/compatibility.ts @@ -14,6 +14,14 @@ export const hostDependencyBaseline = { '@deepseek-ai/dsh-typert-protocol': '0.2.0-rc.2', } as const; +/** Only combinations exercised together by the native Host smoke are accepted. */ +export const cordisFamilies = [ + { '@deepseek-ai/cordis': '4.0.4', '@deepseek-ai/cordis-plugin-loader': '1.0.5', + '@deepseek-ai/cordis-plugin-include': '1.0.9', '@deepseek-ai/cordis-plugin-timer': '1.1.6' }, + { '@deepseek-ai/cordis': '4.0.5', '@deepseek-ai/cordis-plugin-loader': '1.0.6', + '@deepseek-ai/cordis-plugin-include': '1.0.10', '@deepseek-ai/cordis-plugin-timer': '1.1.7' }, +] as const; + const require = createRequire(import.meta.url); export function assertHostDependencies(readManifest: (name: string) => unknown = name => require(`${name}/package.json`)): void { for (const [name, version] of Object.entries(hostDependencyBaseline)) { @@ -21,7 +29,9 @@ export function assertHostDependencies(readManifest: (name: string) => unknown = try { manifest = readManifest(name); } catch { throw new Error('Host dependency baseline could not be verified; local writes are disabled.'); } if (!manifest || typeof manifest !== 'object' || !('name' in manifest) || !('version' in manifest) - || manifest.name !== name || manifest.version !== version) { + || manifest.name !== name || (name === '@deepseek-ai/cordis' + ? !cordisFamilies.some(family => family['@deepseek-ai/cordis'] === manifest.version) + : manifest.version !== version)) { throw new Error('Host dependency baseline does not match; local writes are disabled.'); } } diff --git a/plugins/dsh/src/host/runtime-compatibility.ts b/plugins/dsh/src/host/runtime-compatibility.ts index 4d6d4420d..d6fa87aa4 100644 --- a/plugins/dsh/src/host/runtime-compatibility.ts +++ b/plugins/dsh/src/host/runtime-compatibility.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { assertHostDependencies, hostDependencyBaseline } from './compatibility.js'; +import { pathToFileURL } from 'node:url'; +import { assertHostDependencies, cordisFamilies, hostDependencyBaseline } from './compatibility.js'; /** Candidate runtime range, validated at its lower bound and current Node 24 release. */ export const nodeRuntimeRange = '>=24.2.0 <25'; @@ -16,6 +17,9 @@ export const runtimePackageBaseline = { '@deepseek-ai/dsh': '0.2.0-rc.2', '@deepseek-ai/dsh-app-boot': '0.2.0-rc.2', ...hostDependencyBaseline, + '@deepseek-ai/cordis-plugin-loader': '1.0.5', + '@deepseek-ai/cordis-plugin-include': '1.0.9', + '@deepseek-ai/cordis-plugin-timer': '1.1.6', }; export const clientPackageBaseline = [ @@ -59,15 +63,29 @@ export function assertRuntimeCompatibility( ): void { try { assertNodeRuntime(); - const packages = new Map(Object.entries(runtimePackageBaseline).map(([name, version]) => { - const actual = services.packageOf(name, parentURL); - if (actual?.name !== name || actual.version !== version || !actual.manifestPath) throw new Error('Runtime package mismatch'); + const readPackage = (name: string, parent: string) => { + const actual = services.packageOf(name, parent); + if (actual?.name !== name || !actual.manifestPath) throw new Error('Runtime package mismatch'); // packageOf caches metadata until a resolution generation is replaced. const current = readHostManifest(actual.manifestPath); if (!current || typeof current !== 'object' || !('name' in current) || !('version' in current) - || current.name !== name || current.version !== version) throw new Error('Runtime manifest mismatch'); - return [name, actual]; - })); + || current.name !== name || current.version !== actual.version) throw new Error('Runtime manifest mismatch'); + return actual; + }; + const packages = new Map(Object.keys(runtimePackageBaseline).map(name => [name, readPackage(name, parentURL)])); + const family = cordisFamilies.find(candidate => Object.entries(candidate).every(([name, version]) => packages.get(name)?.version === version)); + if (!family) throw new Error('Runtime Cordis family mismatch'); + for (const [name, version] of Object.entries({ ...runtimePackageBaseline, ...family })) { + if (packages.get(name)?.version !== version) throw new Error('Runtime package mismatch'); + } + // Check the Host's own dependency lookup, not just the plugin's peer copies. + // A nested peer can otherwise hide an unsupported Host dependency graph. + for (const anchor of ['@deepseek-ai/dsh', '@deepseek-ai/dsh-app-boot']) { + const anchorURL = pathToFileURL(packages.get(anchor)!.manifestPath!).href; + for (const [name, expected] of packages) { + if (readPackage(name, anchorURL).version !== expected.version) throw new Error('Host dependency graph mismatch'); + } + } assertHostDependencies(name => packages.get(name)); for (const name of clientPackageBaseline) { const file = services.clientPath(name); diff --git a/plugins/dsh/test/compatibility.test.ts b/plugins/dsh/test/compatibility.test.ts index a14c89b44..bd3c55c52 100644 --- a/plugins/dsh/test/compatibility.test.ts +++ b/plugins/dsh/test/compatibility.test.ts @@ -4,7 +4,7 @@ import { assertHostDependencies, hostDependencyBaseline } from '../src/host/comp test('Host dependency guard matches exact peer versions and installed development artifacts', async () => { const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); - expect(hostDependencyBaseline).toEqual(manifest.peerDependencies); + expect({ ...hostDependencyBaseline, '@deepseek-ai/cordis': '4.0.4 || 4.0.5' }).toEqual(manifest.peerDependencies); expect(() => assertHostDependencies()).not.toThrow(); }); diff --git a/plugins/dsh/test/host-package-cache.test.ts b/plugins/dsh/test/host-package-cache.test.ts index 5ac4e7859..2ed933dc8 100644 --- a/plugins/dsh/test/host-package-cache.test.ts +++ b/plugins/dsh/test/host-package-cache.test.ts @@ -42,3 +42,33 @@ test('official package service caches metadata while disk verification rejects r await rm(root, { recursive: true, force: true }); } }); + +test('official Host lookup rejects a supported plugin peer masking a different nested Host Cordis', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillhub-split-host-graph-')); + const ctx = new Context(); + const fiber = ctx.plugin(PluginPackages); + try { + await fiber; + const parent = pathToFileURL(join(root, 'plugin.js')).href; + for (const [name, version] of Object.entries(runtimePackageBaseline)) { + const directory = join(root, 'node_modules', name); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, 'package.json'), JSON.stringify({ name, version })); + } + const nested = join(root, 'node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/cordis'); + await mkdir(nested, { recursive: true }); + await writeFile(join(nested, 'package.json'), JSON.stringify({ name: '@deepseek-ai/cordis', version: '4.0.5' })); + const services = { + packageOf: (name: string, parentURL: string) => ctx.pluginPackages.packageOf(name, parentURL), + clientPath: (name: string) => `/fixture/${name}/client.js`, + }; + expect(ctx.pluginPackages.packageOf('@deepseek-ai/cordis', parent)?.version).toBe('4.0.4'); + const hostURL = pathToFileURL(join(root, 'node_modules/@deepseek-ai/dsh/package.json')).href; + expect(ctx.pluginPackages.packageOf('@deepseek-ai/cordis', hostURL)?.version).toBe('4.0.5'); + expect(() => assertRuntimeCompatibility(services, parent, + (_file, name) => ({ name, version: '0.2.0-rc.2' }))).toThrow('local writes are disabled'); + } finally { + await fiber.dispose(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/plugins/dsh/test/operation-query.test.ts b/plugins/dsh/test/operation-query.test.ts index 9521234e6..dbd759dd0 100644 --- a/plugins/dsh/test/operation-query.test.ts +++ b/plugins/dsh/test/operation-query.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test'; -import { OperationQueryError, retryOperationQuery } from '../src/client/use-installation-operation.js'; +import { OperationQueryError, retryOperationQuery, invalidateInstallationQueries } from '../src/client/use-installation-operation.js'; test('operation reads retry only explicitly transient failures, at most twice', () => { expect(retryOperationQuery(0, new OperationQueryError(true))).toBe(true); @@ -8,3 +8,21 @@ test('operation reads retry only explicitly transient failures, at most twice', expect(retryOperationQuery(0, new OperationQueryError(false))).toBe(false); expect(retryOperationQuery(0, new Error('Unknown failure'))).toBe(false); }); + + +test('settled operations invalidate fresh receipt projections and existing installed queries only for their Session', async () => { + const { QueryClient } = await import('@tanstack/react-query'); + const client = new QueryClient(); + const keys = [ + ['skillhub-installed', 'a', 'new-revision', 'receipt', 'host', 'operation', 'source'], + ['skillhub-installed', 'a', 'new-revision'], + ['skillhub-installed-detail', 'a', 'new-revision', 'item'], + ['skillhub-receipt-context', 'a', 'host', 'operation', 'source', 'old-revision'], + ]; + const other = ['skillhub-installed', 'b', 'new-revision']; + for (const key of [...keys, other]) client.setQueryData(key, { present: true }); + await invalidateInstallationQueries(client, 'a'); + for (const key of keys) expect(client.getQueryState(key)?.isInvalidated).toBe(true); + expect(client.getQueryState(other)?.isInvalidated).toBe(false); + client.clear(); +}); diff --git a/plugins/dsh/test/receipt-context.test.ts b/plugins/dsh/test/receipt-context.test.ts new file mode 100644 index 000000000..114d08208 --- /dev/null +++ b/plugins/dsh/test/receipt-context.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'bun:test'; +import { randomUUID } from 'node:crypto'; +import type { CatalogContext, InstalledSkillList, NativeOperation } from '../src/host/types.js'; +import type { InstallResult } from '../src/client/install-result.js'; +import { readReceiptInventory, resolveReceiptContext, receiptSessionGuard, type ReceiptReads } from '../src/client/receipt-context.js'; +import { receiptInventoryIssue } from '../src/client/install-receipt-presentation.js'; + +function fixture() { + const receipt: InstallResult = { schemaVersion: 1, kind: 'skillhub-install', status: 'accepted', toolCallId: 'call', + operationId: randomUUID(), hostInstanceId: randomUUID(), sourceRevision: randomUUID(), + namespace: 'demo', slug: 'writer', version: '1.2.0', targetLabel: '当前项目', hostLabel: 'DSH Host', + sourceLabel: 'https://registry.example', createdAt: '2026-10-14T00:00:00Z' }; + let context: CatalogContext = { configured: true, registryUrl: receipt.sourceLabel, sourceRevision: randomUUID(), + detailTabs: { versions: 'auto', reviews: 'auto', securityAudit: 'auto' } }; + let operation: NativeOperation | null = { operationId: receipt.operationId, revision: 1, kind: 'install', + namespace: receipt.namespace, slug: receipt.slug, version: receipt.version, targetLabel: receipt.targetLabel, + context: { sourceIdentity: receipt.sourceLabel, sourceLabel: receipt.sourceLabel, sourceRevision: receipt.sourceRevision, + hostInstanceId: receipt.hostInstanceId, hostLabel: receipt.hostLabel, createdAt: receipt.createdAt, + updatedAt: receipt.createdAt, initiatedFrom: 'conversation' }, phase: 'completed', files: 'installed', + recovery: 'not-needed', discovery: 'confirmed', warningCount: 0 }; + let current = true; + let inventory: InstalledSkillList = { complete: true, unavailable: [], issues: [], items: [ + { namespace: receipt.namespace, slug: receipt.slug, version: receipt.version, scope: 'project', state: 'present', itemKey: 'fresh-item-key' }, + ] }; + const revisions: string[] = []; + const scope = {}; let cwd = '/project-a'; + const guard = receiptSessionGuard(() => ({ scope: current ? scope : undefined, cwd })); + const reads: ReceiptReads = { + sessionGeneration: randomUUID(), + assertCurrent: guard, + context: async () => ({ ok: true, value: context }), + operation: async (id, host) => { expect(id).toBe(receipt.operationId); expect(host).toBe(receipt.hostInstanceId); return { ok: true, value: operation }; }, + installed: async (revision, signal) => { expect(signal.aborted).toBe(false); revisions.push(revision); return { ok: true, value: inventory }; }, + }; + return { receipt, reads, revisions, signal: new AbortController().signal, get context() { return context; }, + set context(value: CatalogContext) { context = value; }, get operation() { return operation!; }, + set operation(value: NativeOperation | null) { operation = value; }, + get inventory() { return inventory; }, set inventory(value: InstalledSkillList) { inventory = value; }, + changeProject: () => { cwd = '/project-b'; }, closeSession: () => { current = false; } }; +} + +describe('read-only historical installation receipt reconnect', () => { + test('same durable Host and Session may read the same canonical registry at its fresh revision', async () => { + const f = fixture(); + const context = await resolveReceiptContext(f.reads, f.receipt, f.signal); + expect(context.sourceRevision).not.toBe(f.receipt.sourceRevision); + expect(await readReceiptInventory(f.reads, f.receipt, context, f.signal)).toEqual(f.inventory); + expect(f.revisions).toEqual([context.sourceRevision]); + expect(f.revisions).not.toContain(f.receipt.sourceRevision); + }); + + test('current inventory truthfully reports an upgrade or uninstall, without replaying installation', async () => { + const f = fixture(); + f.inventory.items[0]!.version = '1.3.0'; + let inventory = await readReceiptInventory(f.reads, f.receipt, f.context, f.signal); + expect(receiptInventoryIssue(f.receipt, inventory, false)).toContain('v1.3.0'); + f.inventory = { ...f.inventory, items: [] }; + inventory = await readReceiptInventory(f.reads, f.receipt, f.context, f.signal); + expect(receiptInventoryIssue(f.receipt, inventory, false)).toContain('已无此技能'); + expect(Object.keys(f.reads).sort()).toEqual(['assertCurrent', 'context', 'installed', 'operation', 'sessionGeneration']); + }); + + test('different source, Host, closed/replaced Session, or inaccessible durable operation fails closed', async () => { + for (const change of [ + (f: ReturnType) => { f.context = { ...f.context, registryUrl: 'https://other.example' }; }, + (f: ReturnType) => { f.operation!.context.hostInstanceId = randomUUID(); }, + (f: ReturnType) => f.closeSession(), + (f: ReturnType) => f.changeProject(), + (f: ReturnType) => { f.operation = null; }, + ]) { + const f = fixture(); change(f); + await expect(readReceiptInventory(f.reads, f.receipt, f.context, f.signal)).rejects.toThrow(); + expect(f.revisions).toEqual([]); + } + }); + + test('all original receipt identity fields are validated against the durable operation', async () => { + const changes: Partial[] = [{ operationId: randomUUID() }, { kind: 'upgrade' }, + { namespace: 'other' }, { slug: 'other' }, { version: '9.0.0' }, { targetLabel: '当前用户' }, { phase: 'accepted' }]; + for (const change of changes) { + const f = fixture(); f.operation = { ...f.operation!, ...change }; + await expect(resolveReceiptContext(f.reads, f.receipt, f.signal)).rejects.toThrow(); + } + for (const change of [{ sourceIdentity: 'https://other.example' }, { sourceRevision: randomUUID() }]) { + const f = fixture(); f.operation!.context = { ...f.operation!.context, ...change }; + await expect(resolveReceiptContext(f.reads, f.receipt, f.signal)).rejects.toThrow(); + } + }); + + test('source revision changing during durable-operation resolution is discarded, including A to B to A', async () => { + const f = fixture(); const original = f.reads.operation; + f.reads.operation = async (...args) => { const response = await original(...args); f.context = { ...f.context, sourceRevision: randomUUID() }; return response; }; + await expect(resolveReceiptContext(f.reads, f.receipt, f.signal)).rejects.toThrow(); + expect(f.revisions).toEqual([]); + }); + + test('late inventory is discarded after source change, Session replacement, Host replacement or cancellation', async () => { + for (const change of [ + (f: ReturnType) => { f.context = { ...f.context, sourceRevision: randomUUID() }; }, + (f: ReturnType) => f.closeSession(), + (f: ReturnType) => f.changeProject(), + (f: ReturnType) => { f.operation!.context.hostInstanceId = randomUUID(); }, + (_f: ReturnType, controller: AbortController) => controller.abort(), + ]) { + const f = fixture(); const controller = new AbortController(); const original = f.reads.installed; + f.reads.installed = async (...args) => { const response = await original(...args); change(f, controller); return response; }; + await expect(readReceiptInventory(f.reads, f.receipt, f.context, controller.signal)).rejects.toThrow(); + } + }); + + test('never substitutes a newer revision for a previously resolved inventory query key', async () => { + const f = fixture(); const expected = f.context; + f.context = { ...f.context, sourceRevision: randomUUID() }; + await expect(readReceiptInventory(f.reads, f.receipt, expected, f.signal)).rejects.toThrow(); + expect(f.revisions).toEqual([]); + }); +}); diff --git a/plugins/dsh/test/runtime-compatibility.test.ts b/plugins/dsh/test/runtime-compatibility.test.ts index 2c4e4a19f..075ae90e2 100644 --- a/plugins/dsh/test/runtime-compatibility.test.ts +++ b/plugins/dsh/test/runtime-compatibility.test.ts @@ -9,7 +9,7 @@ function fixture() { const clients = new Map(clientPackageBaseline.map(name => [name, { name, version: '0.2.0-rc.2' }])); const services = { packageOf: (name: string, parentURL: string) => { - expect(parentURL).toBe('file:///plugin/lib/index.js'); + expect(parentURL.startsWith('file:')).toBe(true); return packages.get(name); }, clientPath: (name: string) => clients.has(name) ? `/client/${name}/lib/client.js` : undefined, @@ -118,3 +118,34 @@ test('cached Host metadata cannot hide changed, malformed or removed manifests', } } finally { await rm(root, { recursive: true, force: true }); } }); + +test('only complete validated Cordis families are accepted and unknown or mixed pairs block writes', () => { + const { packages, check } = fixture(); + packages.get('@deepseek-ai/cordis')!.version = '4.0.5'; + expect(check).toThrow('local writes are disabled'); + packages.get('@deepseek-ai/cordis-plugin-loader')!.version = '1.0.6'; + expect(check).toThrow('local writes are disabled'); + packages.get('@deepseek-ai/cordis-plugin-include')!.version = '1.0.10'; + expect(check).toThrow('local writes are disabled'); + packages.get('@deepseek-ai/cordis-plugin-timer')!.version = '1.1.7'; + expect(check).not.toThrow(); + packages.get('@deepseek-ai/cordis-plugin-loader')!.version = '1.0.7'; + expect(check).toThrow('local writes are disabled'); +}); + +test('plugin peer copies cannot mask mismatched or absent Host dependency resolutions', () => { + const { packages, clients } = fixture(); + for (const target of ['@deepseek-ai/cordis', '@deepseek-ai/cordis-plugin-loader', '@deepseek-ai/dsh-session']) { + for (const anchor of ['@deepseek-ai/dsh', '@deepseek-ai/dsh-app-boot']) { + for (const invalid of [undefined, { ...packages.get(target)!, version: '0.0.0-unsupported' }]) { + const services = { + packageOf: (name: string, parentURL: string) => name === target && parentURL.endsWith(anchor) + ? invalid : packages.get(name), + clientPath: (name: string) => `/client/${name}/lib/client.js`, + }; + expect(() => assertRuntimeCompatibility(services, 'file:///plugin/lib/index.js', + (_file, name) => clients.get(name), file => packages.get(file))).toThrow('local writes are disabled'); + } + } + } +}); diff --git a/plugins/dsh/test/smoke-native-host.test.ts b/plugins/dsh/test/smoke-native-host.test.ts index 8fed0bb65..616084647 100644 --- a/plugins/dsh/test/smoke-native-host.test.ts +++ b/plugins/dsh/test/smoke-native-host.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; // Node-only CI entry; keep its helpers importable without starting a Host or downloading dependencies. // @ts-expect-error The development-only Node entry has no TypeScript declarations. -import { isolatedEnvironment, runBounded, selectTarball } from '../scripts/smoke-native-host.mjs'; +import { hostInstallManifest, isolatedEnvironment, profileInstallManifest, retainedCordisFamily, runBounded, selectTarball } from '../scripts/smoke-native-host.mjs'; const roots: string[] = []; async function root() { @@ -70,11 +70,16 @@ test('bounded process runner keeps raw output private and cancels a running proc console.log('private-output'); spawn(process.execPath, [${JSON.stringify(grandchild)}], { stdio: 'ignore' }); setInterval(() => {}, 1000);`); + // The first Windows runner also starts PowerShell and compiles windows-job.ps1's + // Add-Type Job wrapper before Node can create the readiness marker. Budget that + // cold startup separately; the test deadline also covers 10s cleanup and the + // unchanged 3.5s orphan observation after real cancellation. + const readinessTimeoutMs = process.platform === 'win32' ? 30000 : 10000; const controller = new AbortController(); const result = runBounded([script], { cwd: directory, env: process.env, log, - timeoutMs: 15000, signal: controller.signal }).then(() => undefined, (error: unknown) => error); + timeoutMs: readinessTimeoutMs + 5000, signal: controller.signal }).then(() => undefined, (error: unknown) => error); try { - const deadline = Date.now() + 10000; + const deadline = Date.now() + readinessTimeoutMs; let started = false; while (Date.now() < deadline) { try { await access(ready); started = true; break; } @@ -90,7 +95,7 @@ test('bounded process runner keeps raw output private and cancels a running proc await new Promise(resolve => setTimeout(resolve, 3500)); await expect(access(orphanMarker)).rejects.toThrow(); } finally { controller.abort(); await result; } -}, 30000); +}, process.platform === 'win32' ? 60000 : 30000); test('failed tree termination cannot make the runner wait indefinitely for child exit', async () => { const directory = await root(); @@ -133,3 +138,28 @@ test('passes Unicode and shell-looking arguments literally without a shell', asy await runBounded([script, ...args], { cwd: directory, env: process.env, log, timeoutMs: 10000 }); expect(JSON.parse((await readFile(log, 'utf8')).trim())).toEqual(args); }, 15000); + +test('fresh smoke uses ordinary resolution and retained smoke pins the entire validated Cordis family', () => { + expect(hostInstallManifest('fresh')).toEqual({ private: true, type: 'module' }); + expect(hostInstallManifest('retained')).toEqual({ private: true, type: 'module', overrides: retainedCordisFamily }); + expect(retainedCordisFamily).toEqual({ + '@deepseek-ai/cordis': '4.0.4', '@deepseek-ai/cordis-plugin-loader': '1.0.5', + '@deepseek-ai/cordis-plugin-include': '1.0.9', '@deepseek-ai/cordis-plugin-timer': '1.1.6', + }); + expect(() => hostInstallManifest('unknown')).toThrow('Unknown Cordis family'); +}); + +test('retained profile pins pnpm separately while fresh profile preserves ordinary configuration', () => { + const profile = { private: true, dependencies: { fixture: '1.0.0' }, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } }, + pnpm: { supportedArchitectures: { os: ['current'] }, overrides: { unrelated: '2.0.0' } } }; + expect(profileInstallManifest(profile, 'fresh')).toBe(profile); + expect(profileInstallManifest(profile, 'retained')).toEqual({ ...profile, pnpm: { + ...profile.pnpm, overrides: { unrelated: '2.0.0', ...retainedCordisFamily }, + } }); + expect(profile.pnpm.overrides).toEqual({ unrelated: '2.0.0' }); + expect(profileInstallManifest({ private: true }, 'retained')).toEqual({ + private: true, pnpm: { overrides: retainedCordisFamily }, + }); + expect(() => profileInstallManifest(profile, 'unknown')).toThrow('Unknown Cordis family'); +});