Преглед изворни кода

feat(desktop): add ordinary and mandatory update flows

winewill пре 2 дана
родитељ
комит
63dd8fb80d
100 измењених фајлова са 5906 додато и 307 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  2. 10 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  3. 22 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml
  5. 65 0
      .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md
  6. 65 0
      .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md
  7. 2 2
      .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml
  8. 3 3
      .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md
  9. 3 3
      .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md
  10. 6 0
      .agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.i18n.yaml
  11. 51 0
      .agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md
  12. 51 0
      .agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md
  13. 6 0
      .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.i18n.yaml
  14. 29 0
      .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md
  15. 29 0
      .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.zh.md
  16. 6 0
      .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml
  17. 47 0
      .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md
  18. 47 0
      .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md
  19. 6 0
      .agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml
  20. 148 0
      .agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md
  21. 148 0
      .agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md
  22. 6 0
      .agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml
  23. 113 0
      .agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md
  24. 113 0
      .agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md
  25. 6 0
      .agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.i18n.yaml
  26. 185 0
      .agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md
  27. 185 0
      .agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md
  28. 3 1
      apps/desktop-host/package.json
  29. 27 4
      apps/desktop-host/src/index.ts
  30. 51 0
      apps/desktop-host/src/update-tasks.ts
  31. 3 0
      apps/desktop-host/tsconfig.json
  32. 8 0
      apps/desktop/.env.macos.example
  33. 8 0
      apps/desktop/.env.windows.example
  34. 2 2
      apps/desktop/README.i18n.yaml
  35. 82 5
      apps/desktop/README.md
  36. 82 5
      apps/desktop/README.zh.md
  37. 11 0
      apps/desktop/electron-builder.config.d.mts
  38. 3 152
      apps/desktop/electron-builder.config.mjs
  39. 1 0
      apps/desktop/package.json
  40. 12 0
      apps/desktop/renderer/mandatory-update.css
  41. 37 0
      apps/desktop/renderer/mandatory-update.html
  42. 108 0
      apps/desktop/renderer/mandatory-update.js
  43. 8 0
      apps/desktop/renderer/update-close.svg
  44. 26 0
      apps/desktop/renderer/update-dialog.css
  45. 22 0
      apps/desktop/renderer/update-dialog.html
  46. 42 0
      apps/desktop/renderer/update-dialog.js
  47. 29 0
      apps/desktop/scripts/build-installed-update-worker.mjs
  48. 3 5
      apps/desktop/scripts/desktop-auto-update-environment.mjs
  49. 2 1
      apps/desktop/scripts/desktop-build-paths.mjs
  50. 4 2
      apps/desktop/scripts/desktop-package-environment.mjs
  51. 14 0
      apps/desktop/scripts/desktop-policy-environment.d.mts
  52. 36 0
      apps/desktop/scripts/desktop-policy-environment.mjs
  53. 26 12
      apps/desktop/scripts/desktop-upload-plan.ts
  54. 113 0
      apps/desktop/scripts/desktop-upload-run.ts
  55. 2 0
      apps/desktop/scripts/electron-builder-config.d.mts
  56. 193 0
      apps/desktop/scripts/electron-builder-config.mjs
  57. 39 0
      apps/desktop/scripts/installed-update-builder.ts
  58. 98 0
      apps/desktop/scripts/installed-update-cos.ts
  59. 74 0
      apps/desktop/scripts/installed-update-distribution.ts
  60. 7 0
      apps/desktop/scripts/installed-update-identity.d.mts
  61. 27 0
      apps/desktop/scripts/installed-update-identity.mjs
  62. 89 0
      apps/desktop/scripts/installed-update-network.ps1
  63. 87 0
      apps/desktop/scripts/installed-update-package-content.ts
  64. 122 0
      apps/desktop/scripts/installed-update-packaging.ts
  65. 217 0
      apps/desktop/scripts/installed-update-publication.ts
  66. 277 0
      apps/desktop/scripts/installed-update-qualification.ts
  67. 10 0
      apps/desktop/scripts/installed-update-signature.d.mts
  68. 77 0
      apps/desktop/scripts/installed-update-signature.mjs
  69. 35 0
      apps/desktop/scripts/macos-app-update-config.d.mts
  70. 91 0
      apps/desktop/scripts/macos-app-update-config.mjs
  71. 30 0
      apps/desktop/scripts/package-installed-update.ts
  72. 14 3
      apps/desktop/scripts/package-macos.ts
  73. 34 12
      apps/desktop/scripts/package-target.ts
  74. 40 0
      apps/desktop/scripts/packaging-run.d.mts
  75. 171 0
      apps/desktop/scripts/packaging-run.mjs
  76. 94 0
      apps/desktop/scripts/prepare-installed-update-application.ts
  77. 29 0
      apps/desktop/scripts/prepare-installed-update-bootstrap.ts
  78. 62 0
      apps/desktop/scripts/prepare-installed-update-network.ts
  79. 71 0
      apps/desktop/scripts/prepare-installed-update-runtime.ts
  80. 60 0
      apps/desktop/scripts/prepare-installed-update.ts
  81. 43 0
      apps/desktop/scripts/publish-installed-update.ts
  82. 54 0
      apps/desktop/scripts/test-host-updates.ts
  83. 38 0
      apps/desktop/scripts/test-local-updater.mjs
  84. 72 0
      apps/desktop/scripts/test-signed-updates.mjs
  85. 80 0
      apps/desktop/scripts/test-windows-update-signature.mjs
  86. 75 0
      apps/desktop/scripts/test-workspace-updates.ts
  87. 7 33
      apps/desktop/scripts/upload-target.ts
  88. 132 0
      apps/desktop/scripts/upload-with-credentials.ps1
  89. 125 0
      apps/desktop/scripts/verify-installed-update-package.ts
  90. 9 0
      apps/desktop/scripts/windows-sign.d.mts
  91. 34 5
      apps/desktop/scripts/windows-sign.mjs
  92. 14 0
      apps/desktop/scripts/windows-signing-state.d.mts
  93. 49 0
      apps/desktop/scripts/windows-signing-state.mjs
  94. 67 5
      apps/desktop/src/host-process.ts
  95. 38 1
      apps/desktop/src/ipc.ts
  96. 110 2
      apps/desktop/src/locale.ts
  97. 307 43
      apps/desktop/src/main.ts
  98. 4 0
      apps/desktop/src/mandatory-update-ipc.ts
  99. 224 0
      apps/desktop/src/mandatory-update-policy.ts
  100. 251 0
      apps/desktop/src/mandatory-update-window.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
-2026-08-25-electron-desktop-packaging-and-updates.md: c532db9c3078a8c0cc67935f88457bf06b850141
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: fad71d59ea720542bfdcd15a010ece60b295f2d8
+2026-08-25-electron-desktop-packaging-and-updates.md: d6afdb4cf108330c8711e93eea87d39754ee3504
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: 97532eda76b9292da838016b81bd8cf8a3881495

Разлика између датотеке није приказан због своје велике величине
+ 10 - 2
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md


+ 22 - 2
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md

@@ -67,14 +67,22 @@ Desktop 在直接修改 profile 前停止 Host。包操作失败后保留部分
 
 ## 更新与恢复
 
-Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。
+共享 Web Host 通过 Connection 的请求 waterfall 接纳已认证的 HTTP API 请求。Desktop 的安装锁拒绝新请求,同时统计已接纳的响应传输和真实 Agent/job 工作;它不会取消进行中的任务。Electron 仅接受所属主窗口中、位于当前 Host origin 的顶层 frame 发起的更新操作。安装既要求进程成功退出,也要求收到共享 profile 清理完成后发送的 IPC 确认:profile runner 超时后可能以零退出码强制退出,因此仅凭退出码不足以判断清理成功。
+
+Desktop 在排他创建锁文件和启动 pnpm 前解析 profile 目录的实际路径。否则,Windows 目录 junction 可能让排他创建报告不存在的锁已存在,或让子进程无法创建嵌套包目录。锁仍位于同一 profile 内,并保留 PID 归属校验。
+
+Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。[更新策略提案](../../proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md)负责固定 Nightly 和独立的下载/重启授权;本记录继续负责发布身份、签名和发布完整性。
 
 [立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责本地加载页、直接启动 Host 和主窗口恢复。profile 协调遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。
 
-`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。
+`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试环境,也可选择生产环境,并同时决定目标专用的 generic-provider URL 与 COS 目标。部署配置提供测试 origin 和 bucket 身份;生产使用 `https://download.deepseek.com`。打包排除凭据,并仅在签名和公证完成后记录完成状态。上传在读取凭据前验证完成记录、目标、共同版本号、文件名、大小和 SHA-512。带版本的二进制文件和 blockmap 位于 `dsh-desk/bin/<target>/`;可变 YAML 在二进制文件上传后写入 `dsh-desk/feeds/<target>/`。固定 Nightly 与额外的稳定版元数据遵循更新策略。上传将缓存头交给部署基础设施管理,并保留历史对象以供差分更新。发布操作人员负责串行发布同一目标并验证公网字节;仓库测试不等于云端交付验收
 
 ## 安全与发布策略
 
+Windows NSIS 分发包含固定版本构建器生成的独立 `.exe.blockmap`。上传拒绝缺失或空 blockmap,并在发布 feed 前上传它。构建器的外部映射元数据不要求 `blockMapSize`;要求该 web-installer 字段会拒绝有效 NSIS 产物。回归 fixture 执行真实 blockmap 生成器,并证明修复前不匹配的验证会失败。文件验证仍不能证明签名合格或已安装应用升级成功。
+
+本地 Windows COS 上传通过[凭据启动器](../../../../apps/desktop/scripts/upload-with-credentials.ps1)读取仓库外经 DPAPI 加密的 CLIXML 凭据对象。显式选择部署环境,避免根据文件名路由凭据;默认动作只检查本地子进程注入,不连接 COS。只有显式上传才调用发布上传程序。解密后的凭据仅存在于进程内存和上传子进程环境中,不进入命令参数或持久环境设置。启动器清除该子进程的无关密钥及 Node 预加载钩子,不显示原始 stderr,并遮盖 stdout 中的凭据值。DPAPI 将文件绑定到 Windows 用户和机器,但不能隔离以同一用户身份运行的其他代码。[Windows 凭据测试](../../../../apps/desktop/tests/upload-with-credentials.spec.ts)覆盖本地注入、父进程状态不变及拒绝时不输出密钥;云端授权和签名发布上传需要单独验收。
+
 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。
 
 Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
@@ -93,8 +101,14 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
 
 分架构构建报告实际组件级压缩体积和安装体积。
 
+Windows updater 在 `win.signtoolOptions.publisherName` 中固定公开发布证书的 `CN`、`O` 和 `C`,electron-builder 将其写入已安装应用的 `app-update.yml`。仅提供自定义签名回调不会产生这项元数据,字段缺失时 electron-updater 会跳过验签。这些属性在 Node/OpenSSL 与 Windows 证书主题中使用相同名称;发布者序列化会转义 DN 分隔符,并拒绝缺失或多值身份属性。保留身份而非证书指纹,允许同一发布者续期证书;身份变更需要单独验收转换流程。[真实文件验签检查](../../../../apps/desktop/scripts/test-windows-update-signature.mjs) 验证匹配、身份不符和未签名输入,不执行文件;这与已安装应用升级验收分开进行。
+
 ## 实现
 
+发布[上传执行器](../../../../apps/desktop/scripts/desktop-upload-run.ts)要求两种部署环境都保留持久化本地证据。它在发送请求前保存已验证目标、产物哈希和 feed 字节,每次 PUT 前刷盘意图、之后刷盘可用响应元数据,上传或证据写入失败即停止。SDK 禁用重试,因为结果不确定的写入必须保留为一次可检查的尝试,不能静默重复更新可变 feed。终端断连后,仅有终端输出无法确定哪次操作已完成;原始 SDK 错误可能暴露签名请求数据,因此不予记录。记录独立于产物清理保留,但本地存储不是远端审计服务,缺少最终结果不能证明远端失败。上传回执不认证 CDN 传播。发布者保留记录并独立验证公网字节。
+
+Windows 打包通过[监督程序](../../../../apps/desktop/scripts/packaging-run.mjs)保留每次运行的脱敏输出和带时间戳的阶段/签名事件。[硬件锁定机制](../../../../apps/desktop/scripts/windows-signing-state.mjs)在调用命令解释器前记录意图,失败或运行中断后仍在同一账户的不同进程间保留。致命通知会终止所属阶段的进程树;失败运行绝不生成发布完成记录。只有签名成功才释放锁定状态。恢复必须由操作者明确批准,因为进程内的已拒绝 Promise 无法在重新启动构建后保护令牌。这些记录证明应用级操作,不代表 CSP 内部的 PIN 尝试次数。测试使用假签名和真实隔离进程树,绝不使用发布令牌。
+
 | 表面 | 实现 |
 |---|---|
 | 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 |
@@ -106,6 +120,8 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
 
 ## 考虑过的替代方案
 
+**将本地 COS 密钥持久保存在明文文件或用户环境中。** 两者都会使上传调用之外的进程能够读取密钥。仓库外的 DPAPI 文件避免明文存储,同时允许专用发布进程解密;同用户进程仍能访问凭据,这是明确保留的限制。
+
 **使用 Electron 的 Node.js 执行 dsh。** 这可以减小包体积,但会让 dsh 耦合到 Electron 的 Node 补丁、fuse、原生 ABI、TLS 行为和进程生命周期。内置上游 Node.js 可以让 dsh 继续使用其受支持运行时。
 
 **通过 JSON IPC 以 Base64 承载 Fetch 消息体。** 这会膨胀请求与响应消息体、在两个进程中构造大字符串,并在分派前缓冲请求。原始分帧管道避免 Base64 膨胀,但仍需维护第二套传输;[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)选择已有的 Web HTTP 传输。
@@ -124,6 +140,8 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
 
 **把凭据写进已跟踪脚本或系统环境。** 本地平台文件把配置限制在单个 checkout,并使打包输入明确。代价是凭据以明文落盘:构建账号需要限制文件访问权限,CI 必须清理临时配置,Git 与发布文件映射都必须排除真实配置。已提交的模板不含凭据;Windows CMD 只包含变量引用,签名串行执行并在首次失败后停止,文件格式不会免除 Token 的错误 PIN 计数。
 
+**依赖构建器队列或单实例 Promise 控制失败。** 构建器在一个文件失败后仍会继续处理其他文件,新签名器实例也不会保留已拒绝的 Promise。持久化的单账户锁定状态和父进程控制的终止机制阻止这些继续执行路径;任何超时都不会自动授权再次尝试。
+
 **让 electron-builder 或通用目录同步直接发布。** 直接发布可能在所有引用产物就绪前暴露频道元数据,可能把陈旧或其他目标的文件混入发布,也无法证明已完成签名的构建仍与当前 dsh 版本一致。目标专用且经过校验的上传可以明确控制发布顺序与发布身份。
 
 ## 结果
@@ -171,3 +189,5 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
 ## 相关提案
 
 [内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源与外部插件依赖。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)取代无端口传输与私有后端组合。发布身份、签名、进程归属及仅限 Electron 的包授权仍由本记录负责。
+
+[桌面更新提案](../../proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md)定义待实现的交互与强制策略变更;签名、发布身份与发布完整性仍由本记录负责。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md
+2026-09-11-desktop-mandatory-update-client.md: 1a241fedf85fedf32e0935d4d39bb6a2c488ad0e
+2026-09-11-desktop-mandatory-update-client.zh.md: 6a444ec418c52010ba79bd0f75aaba92c7feb811

+ 65 - 0
.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md

@@ -0,0 +1,65 @@
+# Agent Note: Desktop mandatory-update client and blocking window
+
+Status: implemented
+
+English | [中文](2026-09-11-desktop-mandatory-update-client.zh.md)
+
+## Problem
+
+A local business server does not deliver remote minimum-version policy. The Desktop shell must independently learn that an update is required, block subsequent interaction without interrupting existing tasks, and retain a recovery path when policy queries or updater preparation fail.
+
+## Decision
+
+The shell polls the guest protocol in the [API proposal](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.md), independently of business traffic. Configuration and application identity are embedded at packaging; packaged applications ignore environment overrides. The [Desktop README](../../../../apps/desktop/README.md) owns configuration fields and defaults. Packaging requires the policy origin selected by the updater deployment before artifact preparation or signing. Test uses isolated Feishu authentication; production remains anonymous. Only unpackaged development can omit policy configuration. This prevents a missing policy setting from silently producing a release without mandatory checks or a test build querying production policy.
+
+A coordinator owns one immutable installed-client identity, one in-flight request, a deadline, interval jitter, and bounded failure backoff. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking even when required presentation fields are missing or invalid; localized fallback text and a retry action remain available. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided.
+
+User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory window. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows.
+
+A shell-owned modal uses a separate sandboxed preload with dependency-free IPC names. The main process verifies its exact document URL and WebContents identity. Server title/detail are text, never HTML. Close, Esc, ordinary updater prompts, plugin-window entry, plugin mutations, and recovery actions cannot bypass the policy. The Host keeps existing work running; the existing updater's task-aware confirmation alone authorizes installation teardown. Closing the application remains available.
+
+The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned document and main frame; closing, aborting, or losing an ordinary renderer cancels its approval.
+
+The recovery layout keeps localized errors and folded technical details inside the block. Page actions appear only for failure or absent-artifact recovery, not beside the normal action. Exact HTTPS origins are checked before browser or clipboard use. A browser request immediately exposes copy recovery; its completion cannot prove external page loading. Only copy failure reveals the complete validated address for manual copying. Navigation and copy results are independent of updater errors and ignore completion after a changed policy destination. Neither browser opening, readiness, nor installer handoff clears policy or changes the updater target.
+
+Background readiness requests parent-window taskbar attention on Windows or an informational Dock bounce on macOS and attempts one silent notification. Returning to the current confirmation clears native reminders without granting installation permission. Repeated state, deferral, and focus changes do not repeat a readiness reminder. Policy clearance, installation, and disposal also clear it. Unsupported or denied notifications leave the modal usable; native display requires installed-platform qualification.
+
+This decision implements the client portion of the [interaction proposal](../../proposed/feature/2026-09-08-desktop-update-policy-and-installation.md). That proposal and the API proposal remain active for backend integration, platform release qualification, and unresolved product decisions. The [release policy](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) retains artifact signing and publication requirements; none of these records is fully superseded.
+
+Host exit and successful task teardown are separate outcomes. The process owner reports confirmed but unsuccessful exit with a dedicated error type. Backend cleanup can complete for that outcome while installation still fails; the shell restores the current-version Host and requires fresh installation confirmation without clearing mandatory policy. Other stop failures retain the backend's cleanup failure and prevent replacement, because the old process may still own profile state.
+
+For confirmed unsuccessful teardown, localized recovery guidance remains separate from expandable technical details in both dialog types. The disclosure is closed initially and does not grant update permission. Diagnostics expose process lifecycle facts rather than arbitrary plugin stderr, which can contain credentials. The mandatory dialog preserves an expanded disclosure on unchanged state and collapses it when its diagnostic changes or clears.
+
+Task warnings describe running agents, queued input, and running or stopping jobs, not request traffic. After installation approval, the Host refuses new API requests, waits for admitted requests to finish, and checks tasks again. This preserves admitted writes without warning about page reads. A timed-out or superseded drain cannot authorize installation; the shell unlocks admission after failure.
+
+The product preload publishes semantic updater phase, version, progress, and classified failure fields without localized strings or raw diagnostics. The account-row and collapsed-sidebar presentations resolve that data through the active Web locale, while native dialogs retain the Desktop shell locale. Intentional application quit hides the product window before Host teardown and ignores focus requests until exit, so the expected connection loss cannot appear as recovery work.
+
+## Alternatives considered
+
+**Use the system browser without a gateway handoff.** Its cookies are not shared with Electron requests. Explicit test deployments instead use a sandboxed BrowserWindow and a dedicated in-memory Session, without Node integration or a preload. Document navigation permits only the configured HTTPS policy origin and the reviewed Feishu origins; permissions, downloads, new windows, and HTTP authentication are denied. The gateway needs no new token-exchange endpoint. This trades persistent sign-in and unrestricted identity-provider navigation for isolation. The default anonymous protocol and updater transport are unchanged.
+
+**Treat a completed login redirect as an update decision.** The Windows gateway probe changed from unauthenticated 401 to application-level 422 and reused its Session after closing the login window. That establishes same-process cookie transport, not compatibility with the Harness policy API. The client rechecks policy after login, retains known blocks on authentication expiry or malformed responses, and never uses the gateway-provided login URL. User confirmation precedes every login window. The initial packaged startup check can explain the authentication requirement before the local backend is ready; later automatic checks stay silent. One operation owns explanation, login, and recheck, so concurrent entry points cannot create competing login windows.
+
+**Make policy availability a precondition for ordinary update feedback.** A policy transport or protocol failure would suppress a valid updater result and make ordinary package delivery depend on the enforcement service. The two checks therefore share a user trigger but keep separate result handling; only an established block can preempt ordinary presentation.
+
+**Wait for business APIs to return a version error.** Desktop business traffic is local, so remote policy must have an independent guest request.
+
+**Use a cancellable ordinary message box.** It does not own persistent download progress, retry, page fallback, and policy refresh. A dedicated modal keeps these actions available without permitting cancellation of the requirement.
+
+**Terminate tasks when policy arrives.** Policy requires future interaction to stop, not already-running work. Installation approval remains the separate authorization to stop affected tasks.
+
+**Let the policy select or revoke updater packages.** This creates a second artifact authority and contradicts the approved higher-version replacement strategy. The updater alone selects and validates its version-bound package.
+
+**Keep technical details only in logs.** In-dialog disclosure lets users inspect a stop failure without locating local logs, while the default recovery message stays readable.
+
+**Warn for every in-flight API request.** Startup reads produce a task-interruption warning without affected tasks. Separating request draining from task inspection avoids that warning without classifying RPCs by URL or abandoning pending writes.
+
+**Send localized update strings through the preload.** The Desktop shell locale can differ from the language selected inside the Web application, and transported strings cannot react to a later language change. Semantic presentation data keeps user-selected Web language ownership with the component.
+
+**Keep the product window visible during ordinary quit.** Closing the owned Host first exposes its expected disconnect to the connection-recovery UI. Hiding the window before teardown preserves graceful process cleanup without presenting a retry action for intentional shutdown.
+
+## Consequences
+
+Ordinary check and download failures separate locale-owned summaries from expandable updater diagnostics. Hover feedback contains no raw error text; download hover identifies the operation and target version. Web update status follows the active in-application locale independently of native dialog language. During shell-reported installation, update status takes precedence over connection feedback because stopping the owned backend is expected. An installation failure clears that priority so genuine reconnection remains visible. Ordinary quit removes the product window before the same expected backend disconnect. Long ordinary diagnostics scroll independently of the confirmation action; disclosure does not authorize installation.
+
+The [local qualification](../testing/2026-09-10-desktop-local-updater-qualification.md) executes the actual Electron modal, restricted preload, renderer button handlers, network policy, and updater download. The main-entry regression verifies retained Host work and rejected business controls. A Chinese owner-local DOM expectation guards visible copy and actions. These tests do not establish production API compatibility, native browser/clipboard integration, complete-workspace visuals, signed installation, or post-restart health; [verification records](../../../../apps/desktop/tests/README.md) identify remaining evidence.

+ 65 - 0
.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md

@@ -0,0 +1,65 @@
+# Agent Note: Desktop 强更客户端与阻塞窗口
+
+Status: implemented
+
+[English](2026-09-11-desktop-mandatory-update-client.md) | 中文
+
+## 问题
+
+本地业务 server 不会传递远程最低版本策略。Desktop 壳必须独立获知强更要求,在不中断现有任务的情况下阻止后续交互,并在策略查询或 updater 准备失败时保留恢复路径。
+
+## 决策
+
+壳独立于业务流量轮询[接口提案](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md)中的游客协议。配置和应用身份在打包时嵌入;打包应用忽略环境覆盖。[Desktop README](../../../../apps/desktop/README.zh.md)负责配置字段与默认值。打包在准备产物或签名前要求提供由 updater 部署环境选定的策略源站。测试环境使用隔离的飞书鉴权;正式环境保持匿名。只有未打包开发模式可以省略策略配置。这避免因遗漏策略配置而静默产出没有强更检查的发布包,或让测试包查询正式环境策略。
+
+协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 即使缺少必需展示字段或字段无效也会建立阻塞;本地化兜底文案和重试操作仍可使用。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。
+
+用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更窗口。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。
+
+壳拥有的模态窗口使用独立沙箱预加载和无依赖的 IPC 名称。主进程校验其精确文档 URL 和 WebContents 身份。服务端标题/详情按文本展示,绝不作为 HTML。关闭、Esc、常规更新弹窗、插件窗口入口、插件修改和恢复操作都不能绕过策略。Host 保持现有工作运行;只有现有 updater 的任务影响确认才能授权安装收尾。仍可退出应用。
+
+模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属文档与主 frame;关闭、中止或失去常规渲染进程会取消其批准。
+
+恢复界面在阻塞窗口内保留本地化错误和折叠技术详情。页面操作仅在失败或没有可用产物时出现,不与正常操作并列。浏览器或剪贴板使用前校验精确 HTTPS 源站。请求打开浏览器后立即提供复制恢复入口;请求完成不能证明外部网页加载。仅在复制失败时展示完整、经过校验的地址供手动复制。导航和复制结果独立于 updater 错误,策略目标地址改变后忽略迟到结果。打开浏览器、下载就绪和安装器交接均不清除策略或改变 updater 目标。
+
+后台就绪时,Windows 请求父窗口任务栏提醒,macOS 请求信息级 Dock 弹跳,并尝试一次无声通知。返回当前确认界面会清理原生提醒,不授予安装权限。重复状态、稍后更新和焦点变化不会重复提醒同一次就绪。策略解除、安装和资源释放也会清理提醒。通知不受支持或被拒绝时仍可使用弹窗;原生展示需通过安装包平台验收。
+
+此决策实现[交互提案](../../proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md)的客户端部分。该提案与接口提案仍负责后端联调、各平台发布验收和未决产品事项。[发布策略](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)保留产物签名和发布要求;这些记录均未被完整取代。
+
+Host 退出与任务成功收尾是两个独立结果。进程所有者通过专用错误类型报告已确认但非正常的退出。该结果允许后端完成清理,但安装仍失败;壳恢复当前版本 Host,并要求重新确认安装,不清除强更策略。其他停止失败会保留后端清理错误并阻止替代进程启动,因为旧进程可能仍持有 profile 状态。
+
+对于已确认的非正常收尾,两类弹窗都将本地化恢复提示与可展开的技术详情分开展示。详情初始折叠,不授予更新权限。诊断展示进程生命周期事实,而非可能包含凭据的任意插件 stderr。强更弹窗在状态未变时保留已展开的详情,诊断改变或清除时则折叠。
+
+任务警告描述运行中的 agent、排队输入和运行中或停止中的后台任务,而非请求流量。用户批准安装后,Host 拒绝新 API 请求,等待已接收请求结束,再检查任务。这既保护已接收的写操作,也不因页面读取弹出警告。超时或被替代的等待不能授权安装;失败后壳解除准入锁。
+
+产品预加载只发布语义化 updater 阶段、版本、进度和失败类别,不携带本地化字符串或原始诊断。账户行和侧栏收起状态通过当前 Web locale 解析这些数据,原生弹窗仍使用 Desktop 壳 locale。主动退出应用时,产品窗口会在 Host 收尾前隐藏,并在进程退出前忽略聚焦请求,避免把预期连接中断展示成恢复操作。
+
+## 考虑过的替代方案
+
+**没有网关交接机制时使用系统浏览器。** 其 Cookie 不会与 Electron 请求共享。显式启用的测试部署改用沙箱 BrowserWindow 和独立内存 Session,不启用 Node 集成或预加载。文档导航只允许配置的 HTTPS 策略源站及已审核的飞书源站;拒绝权限、下载、新窗口和 HTTP 鉴权。网关不需要新增 token 交换接口。这以跨重启登录及不受限的身份提供方导航换取隔离。默认匿名协议及 updater 传输不变。
+
+**将登录重定向完成视为更新决定。** Windows 网关探针由未鉴权的 401 变为应用层 422,关闭登录窗口后仍复用 Session。这验证了同进程 Cookie 传输,并不证明与 Harness 策略 API 兼容。客户端在登录后重新检查策略,鉴权过期或响应畸形时保留已知阻塞,且从不使用网关提供的登录 URL。每次登录窗口都先经过用户确认。打包应用的首次启动检查可在本地后端就绪前说明鉴权要求;之后的自动检查保持静默。说明、登录和重新检查由同一个操作负责,使并发入口无法创建相互竞争的登录窗口。
+
+**把策略可用作为常规更新反馈的前置条件。** 策略传输或协议失败会压制有效的 updater 结果,并使常规产物分发依赖强更服务。因此两个检查共用用户触发入口,但分别处理结果;只有已建立的强更状态可以抢占常规展示。
+
+**等待业务 API 返回版本错误。** Desktop 业务流量在本地,因此远程策略必须有独立游客请求。
+
+**使用可取消的常规消息框。** 它不负责持久下载进度、重试、页面兜底和策略刷新。专用模态窗口保留这些操作,同时不允许取消强更要求。
+
+**策略到达时终止任务。** 策略要求停止后续交互,而非正在运行的工作。安装批准仍是停止受影响任务的独立授权。
+
+**让策略选择或作废 updater 安装包。** 这会增加第二个产物权威,并违背已确认的高版本覆盖策略。只有 updater 选择和校验其版本绑定的安装包。
+
+**技术详情仅保留在日志中。** 弹窗内的折叠详情让用户无需查找本地日志即可排查停止失败,同时保持默认恢复提示可读。
+
+**对每个在途 API 请求都弹出警告。** 启动读取会在没有受影响任务时触发中断警告。将请求收尾与任务检查分开,可避免这种误报,也无需按 URL 分类 RPC 或丢弃未完成的写操作。
+
+**通过预加载传递本地化更新文案。** Desktop 壳 locale 可能不同于 Web 应用内选择的语言,已传递的字符串也无法响应之后的语言切换。语义化展示数据让组件继续拥有用户所选 Web 语言下的文案。
+
+**常规退出时保持产品窗口可见。** 先关闭自有 Host 会把预期断开展示到连接恢复界面。收尾前隐藏窗口既保留进程正常清理,也不会为主动关闭展示重试操作。
+
+## 后果
+
+常规检查和下载失败将语言字典提供的摘要与可展开的 updater 诊断分离。悬停提示不含原始错误文本;下载悬停提示说明操作和目标版本。Web 更新状态独立于原生弹窗语言,跟随应用内当前 locale。壳报告正在安装时,更新状态优先于连接反馈,因为停止自有后端是预期行为。安装失败会清除该优先级,使真实重连保持可见。常规退出会在发生同一预期后端断开前移除产品窗口。普通长诊断独立于确认操作滚动;展开详情不授予安装权限。
+
+[本地验证](../testing/2026-09-10-desktop-local-updater-qualification.zh.md)执行真实 Electron 模态窗口、受限预加载、页面按钮处理、网络策略和 updater 下载。主入口回归验证 Host 工作保留和业务控制拒绝。所属测试目录内的中文 DOM 预期输出约束可见文案和操作。这些测试不证明生产 API 兼容性、原生浏览器/剪贴板集成、完整工作区视觉效果、签名安装或重启后健康状态;[验证记录](../../../../apps/desktop/tests/README.zh.md)列出尚缺证据。

+ 2 - 2
.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md
-2026-09-09-parallel-macos-notarization.md: 152da4661207bf130389e600c16398335fd9fe70
-2026-09-09-parallel-macos-notarization.zh.md: d5375e39a6348b49ffe5bb93c2c056151d5abe75
+2026-09-09-parallel-macos-notarization.md: 18358444426575e27a21f0f56541e22e37fcdf2c
+2026-09-09-parallel-macos-notarization.zh.md: a69d586eff1413debe096656a7572a53f0938e98

+ 3 - 3
.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md

@@ -10,11 +10,11 @@ The Desktop release distributes a DMG for installation and a ZIP for updates. Wa
 
 ## Decision
 
-The fixed-target installer command signs and verifies one App, then creates two independent copies with `ditto`. The App lane notarizes and staples its copy, verifies its signature, ticket, and Gatekeeper acceptance, and asks electron-builder to create the ZIP and updater metadata. The DMG lane immediately packages its copy, signs the image, and uses the existing artifact-completion hook to notarize, staple, and verify the image. Each electron-builder process receives the actual `.app` path through `--prepackaged`, an isolated output directory, and `--publish never`.
+The fixed-target installer command assembles one App, writes and verifies its `app-update.yml`, then signs it and creates two independent copies with `ditto`. Writing the configuration explicitly is required because electron-builder skips its macOS update-config hook when the initial build has only the directory target, while the later `--prepackaged` invocations do not reassemble the App. The App lane notarizes and staples its copy, verifies its signature, ticket, Gatekeeper acceptance, and update configuration, and asks electron-builder to create the ZIP and updater metadata. The DMG lane verifies its copy's update configuration, immediately packages it, signs the image, and uses the existing artifact-completion hook to notarize, staple, and verify the image. Each electron-builder process receives the actual `.app` path through `--prepackaged`, an isolated output directory, and `--publish never`.
 
 The ZIP contains an individually stapled App. The DMG contains the signed App without an individually stapled ticket; its outer ticket covers the nested code, following Apple's [container guidance](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution). Apple describes [ticket ingestion when Gatekeeper checks the outer container](https://developer.apple.com/forums/thread/125512). Independent extraction of the unstapled App relies on an online or cached ticket; the ZIP supplies an embedded ticket. The directory-only command continues to notarize and staple its App.
 
-Both lanes settle before error propagation or temporary-directory cleanup. Only two successful lanes allow promotion of the DMG, ZIP, ZIP blockmap, and channel metadata. The stapled App replaces the signed directory build, and the caller writes the release completion record last. An error leaves that record absent, so the existing upload validation rejects the incomplete release. Separate output directories also prevent concurrent writes to electron-builder diagnostics and channel metadata.
+Both lanes settle before error propagation or temporary-directory cleanup. Only two successful lanes with the configured update feed allow promotion of the DMG, ZIP, ZIP blockmap, and channel metadata. The stapled App replaces the signed directory build, and the caller writes the release completion record last. Missing or mismatched App update configuration fails before promotion; any error leaves the record absent, so the existing upload validation rejects the incomplete release. Separate output directories also prevent concurrent writes to electron-builder diagnostics and channel metadata.
 
 This refines the notarization ordering in the [Desktop packaging decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md); that note remains the owner of release identity, signatures, update ownership, and publishing requirements.
 
@@ -34,4 +34,4 @@ On 2026-09-09, full arm64 packaging on the same Mac through the same system prox
 
 Two temporary App copies and separate artifact directories increase peak disk usage. Two uploads can contend for network bandwidth, and Apple can queue either submission independently; phase timings describe observed behavior rather than a CI latency budget. A failed lane waits for the other lane to finish before cleanup, which can delay failure reporting but avoids deleting files still owned by a child process.
 
-The [orchestration tests](../../../../apps/desktop/tests/package-macos.spec.ts) use barriers to prove overlap, ticket isolation, both-error collection, and refusal to promote incomplete artifacts. A controlled serial regression fails the overlap assertion. Real signed macOS packaging, extracted ZIP verification, DMG integrity and nested signature checks, and final upload-plan validation qualify the platform tools; cross-version installed updates and offline installation on a clean Mac remain release qualification work.
+The [orchestration tests](../../../../apps/desktop/tests/package-macos.spec.ts) use barriers to prove overlap, ticket isolation, both-error collection, update-configuration enforcement, and refusal to promote incomplete artifacts. Focused configuration tests cover writing the fixed feed and rejecting missing, mismatched, or incomplete fields. A controlled serial regression fails the overlap assertion. Real signed macOS packaging, extracted ZIP verification, DMG integrity and nested signature checks, and final upload-plan validation qualify the platform tools; cross-version installed updates and offline installation on a clean Mac remain release qualification work.

+ 3 - 3
.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md

@@ -10,11 +10,11 @@ Desktop 发布同时提供用于安装的 DMG 和用于更新的 ZIP。等待 Ap
 
 ## 决策
 
-固定目标安装包命令先签名并验证一个 App,再通过 `ditto` 创建两个独立副本。App 路线公证其副本并钉票,验证签名、票据与 Gatekeeper 接受状态,再由 electron-builder 生成 ZIP 和更新元数据。DMG 路线立即封装副本、签署映像,再通过现有 artifact-completion hook 公证映像、钉票并验证。每个 electron-builder 进程都通过 `--prepackaged` 接收真正的 `.app` 路径、独立的输出目录和 `--publish never`。
+固定目标安装包命令先组装一个 App,写入并验证其中的 `app-update.yml`,随后签名,再通过 `ditto` 创建两个独立副本。必须显式写入该配置,因为初始构建只有目录目标时,electron-builder 会跳过 macOS 更新配置钩子,而后续 `--prepackaged` 调用不会重新组装 App。App 路线公证其副本并钉票,验证签名、票据、Gatekeeper 接受状态与更新配置,再由 electron-builder 生成 ZIP 和更新元数据。DMG 路线验证其副本的更新配置,随后立即封装副本、签署映像,再通过现有 artifact-completion hook 公证映像、钉票并验证。每个 electron-builder 进程都通过 `--prepackaged` 接收真正的 `.app` 路径、独立的输出目录和 `--publish never`。
 
 ZIP 包含已单独钉票的 App。DMG 包含已签名但未单独附加票据的 App;根据 Apple 的[容器说明](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution),外层票据覆盖内嵌代码。Apple 还说明了 [Gatekeeper 检查外层容器时接收票据的行为](https://developer.apple.com/forums/thread/125512)。单独提取未钉票 App 依赖在线或缓存票据;ZIP 则提供内嵌票据。仅生成目录的命令仍会公证 App 并钉票。
 
-错误传播和临时目录清理前必须等待两路均结束。只有两路都成功,才允许移入 DMG、ZIP、ZIP blockmap 和频道元数据。已钉票的 App 替换签名目录构建,调用方最后写入发布完成记录。发生错误时该记录保持缺失,现有上传校验因而会拒绝不完整发布。独立输出目录还避免了 electron-builder 诊断文件与频道元数据的并发写入。
+错误传播和临时目录清理前必须等待两路均结束。只有两路均成功且使用已配置更新源时,才允许移入 DMG、ZIP、ZIP blockmap 和频道元数据。已钉票的 App 替换签名目录构建,调用方最后写入发布完成记录。App 更新配置缺失或不匹配会在移入前失败;任何错误都会使该记录保持缺失,现有上传校验因而会拒绝不完整发布。独立输出目录还避免了 electron-builder 诊断文件与频道元数据的并发写入。
 
 本决策细化了 [Desktop 打包决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的公证顺序;原决策继续负责发布身份、签名、更新归属与发布要求。
 
@@ -34,4 +34,4 @@ ZIP 包含已单独钉票的 App。DMG 包含已签名但未单独附加票据
 
 两个临时 App 副本与独立产物目录增加了磁盘峰值占用。两次上传可能争用网络带宽,Apple 也可能分别排队处理;阶段计时记录实际行为,不构成 CI 延迟预算。一路失败后会等待另一路结束再清理,这可能延迟错误报告,但能避免删除仍由子进程持有的文件。
 
-[编排测试](../../../../apps/desktop/tests/package-macos.spec.ts)通过同步屏障验证重叠执行、票据隔离、收集两路错误,以及拒绝移入不完整产物。受控的串行回归会使重叠断言失败。真实签名 macOS 打包、ZIP 解压后验证、DMG 完整性与内嵌签名检查、最终上传计划验证用于验收平台工具;跨版本已安装应用更新和干净 Mac 上的离线安装仍属于发布验收工作。
+[编排测试](../../../../apps/desktop/tests/package-macos.spec.ts)通过同步屏障验证重叠执行、票据隔离、收集两路错误、强制检查更新配置,以及拒绝移入不完整产物。聚焦配置测试覆盖固定更新源的写入,并拒绝缺失、不匹配或字段不完整的配置。受控的串行回归会使重叠断言失败。真实签名 macOS 打包、ZIP 解压后验证、DMG 完整性与内嵌签名检查、最终上传计划验证用于验收平台工具;跨版本已安装应用更新和干净 Mac 上的离线安装仍属于发布验收工作。

+ 6 - 0
.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md
+2026-09-10-desktop-local-updater-qualification.md: 10c0a384106cc06aaae50d8782b58114ea86fb79
+2026-09-10-desktop-local-updater-qualification.zh.md: 9d554bc5001fc53f60ba9cd0d08b02e975f8261f

+ 51 - 0
.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md

@@ -0,0 +1,51 @@
+# Agent Note: Qualify Desktop update downloads with an isolated local server
+
+Status: implemented
+
+English | [中文](2026-09-10-desktop-local-updater-qualification.zh.md)
+
+## Problem
+
+Desktop update interaction depends on feed parsing, network failures, download integrity, and platform preparation. Simulated updater events cannot establish that these operations work together. Requiring cloud cache configuration and hardware signing for each feedback cycle makes local product validation depend on release infrastructure.
+
+## Decision
+
+The [Windows local qualification command](../../../../apps/desktop/README.md) runs the built production coordinator inside Electron with the real `ElectronHttpExecutor` and `NsisUpdater`. An isolated application adapter supplies a test version and private update configuration. A loopback server supplies Nightly YAML, mandatory policy, and inert binary bytes. The installer, external browser, and clipboard calls are replaced with observations. Publisher verification is absent only from this private fixture configuration, never from production configuration.
+
+Each invocation atomically allocates a temporary directory and an operating-system-assigned loopback port. Request barriers establish concurrency without sleeps. Server close and child exit are awaited before temporary files are removed. Independent processes can run concurrently without sharing updater cache or user data. The command requires built Desktop modules and stays separate from source-plane unit tests.
+
+This testing decision supplements the [release policy](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md); it does not supersede signing, publication integrity, installed-artifact qualification, or the pending [mandatory update proposal](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.md). Those records remain active.
+
+## Alternatives considered
+
+**Use only simulated updater events.** They cover state transitions but cannot establish actual YAML parsing, Electron transport, downloaded bytes, or checksum rejection. Local qualification uses real dependency implementations; native-dialog and renderer component tests remain separate.
+
+**Require signed cloud-hosted packages for every local iteration.** That couples ordinary interaction debugging to signing hardware and CDN configuration. Release qualification still requires those systems, but local download tests do not.
+
+**Relax production signing or execute a dummy installer.** Neither is needed to observe download readiness and installation authorization. The fixture never loads production update configuration and records installation without launching downloaded bytes.
+
+## Consequences
+
+The executable scenarios cover no update, downgrade rejection, HTTP 404/408 and invalid YAML, stalled-feed and stalled-download deadlines, same-address feed replacement, one in-flight check or download, explicit download authorization, SHA-512 mismatch, interrupted transfers, explicit retry, prepared-version retention, restart refusal, shutdown failure, and disposal during a pending check. Native-dialog regressions cover checking feedback, check failure, download refusal, and separate installation action; account-row tests own progress and persistent retry presentation.
+
+The ordinary polling regression uses the real coordinator with a fake clock, instance-local random samples, and deferred network completion. It verifies completion-based jitter, capped exponential backoff, successful reset, manual failure visibility while joining an automatic check, retained download failures and prepared packages, and no timer rearming after disposal. Main-entry tests connect the same deadline to focus, resume, explicit IPC, and application shutdown. Removing failure backoff makes the timing assertion fail. These deterministic tests establish request timing, not fleet capacity or startup-burst distribution.
+
+The actual [mandatory-update window](../feature/2026-09-11-desktop-mandatory-update-client.md), sandboxed preload, and renderer button handlers run against the policy server and updater. A stalled policy request reaches its real deadline without clearing the block. Owner-local DOM expectations pin Chinese text and actions. Screenshot failures are recorded separately; missing screenshots cannot establish visual acceptance.
+
+The [workspace browser scenario](../../../../apps/web/tests/desktop-updates.e2e.ts) checks built sidebar composition in both locales against the production presentation function. A page-local carrier supplies update state and records actions; the Host, client plugins, styles, and connection remain real. This separates slot placement and click-guard evidence from Electron IPC, task authorization, and installation evidence. Screenshots and result files use an invocation-specific ignored directory and do not replace release qualification.
+
+The [Host qualification runner](../../../../apps/desktop/scripts/test-host-updates.ts) loads the built Host and standard agent preset in a private profile. Real task registries, queued messages, pending tool questions/approvals, and Node jobs establish task-detection and admission-lock evidence without replacing Host composition. Scripted model output and held human answerers control the waiting points; cancellation and process exit establish completion. This evidence excludes the development launcher's dependency projection, Electron installation confirmation, and failed task shutdown. Concurrent invocations own separate home, project, and session directories.
+
+The [Electron workspace runner](../../../../apps/desktop/scripts/test-workspace-updates.ts) couples the compiled main entry and actual preload to a separate real Host. A private profile plugin controls real queued tasks and deliberately holds teardown; local HTTP delivery replaces release infrastructure, and the updater's installer call is intercepted. It verifies confirmation-time task changes, refused installation after failed teardown, replacement Host readiness, and fresh confirmation under ordinary and mandatory policy. Renderer actions wait for finite layout animations and reject obscured targets; window-listener cleanup retains web contents independently of destroyed windows. The runner does not prove bundled-main deployment or operating-system composition of overlapping windows.
+
+The runner launches Electron without hiding its GUI and asserts main-window visibility before input or screenshots. Hidden or occluded renderers can suspend frame callbacks even when DOM queries complete. Each DOM, click-layout, and screenshot operation temporarily disables background throttling on its own WebContents, then restores and checks the original setting. Real animations and click-obscuration assertions remain enabled; production window configuration is unchanged. A main-process deadline and invocation-local trace bound each operation; failure diagnostics record window visibility and animation state with their own deadline. Diagnostic capture failure cannot replace the original test failure. Re-enabling Windows process hiding fails the visibility assertion, and concurrent unprotected windows reproduce a suspended frame wait.
+
+The production `DesktopUpdateHttpExecutor` retains the library transport and adds an inactivity deadline to actual Electron requests. Electron 44 emits writable `close` before response headers, so that event cannot indicate HTTP completion. Response completion, abort, and errors release the timer; received bytes refresh it. Both stalled headers and stalled payloads fail and recover through an explicit retry in the local fixture.
+
+The [signed-download runner](../../../../apps/desktop/scripts/test-signed-updates.mjs) complements inert-byte tests with existing signed and unsigned executables, a public certificate, and real Windows Authenticode verification. Its private feed uses a synthetic version, and installation is intercepted. Correct-hash files with a wrong publisher or no signature are rejected and removed from cache; corrupt transfer bytes fail checksum verification first. Automatic checks retain the failure without another request, while explicit retry with a signed payload reaches readiness and requires separate restart approval. Inputs remain unchanged. Each process owns its port and cache; the deadline stops its complete Electron/PowerShell process tree before runtime cleanup. Missing publisher configuration makes the rejection assertion fail.
+
+Optional old-installer input enables real differential reconstruction from private cache and original blockmaps. The server supports single and multipart byte ranges, with explicit missing-blockmap and rejected-range failures. Hash and signature verification cover reconstructed and full-fallback results; request records require actual byte reuse and reject full fallback as differential success. Disabling differential download fails the blockmap-request assertion. Historical input files remain read-only, and independent concurrent runs retain separate caches and ports.
+
+The local download fixture injects `ENOSPC` into its executable write stream after writing partial bytes to disk. The actual updater must clear the partial executable, refuse installation, retain explicit retry, and download verified bytes after the original writer is restored. Only the private download path is intercepted, and stream closure is awaited. This qualifies handling of a filesystem write failure without exhausting a shared host volume.
+
+This evidence does not establish actual installer execution, post-restart health, production CDN Range behavior, macOS updater preparation, or actual volume exhaustion. The test servers publish nothing and never read cloud credentials; production delivery and signed installed-version upgrades remain separate qualification.

+ 51 - 0
.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md

@@ -0,0 +1,51 @@
+# Agent Note: 使用隔离的本地服务器验证 Desktop 更新下载
+
+Status: implemented
+
+[English](2026-09-10-desktop-local-updater-qualification.md) | 中文
+
+## 问题
+
+Desktop 更新交互依赖清单解析、网络失败、下载完整性和平台准备。模拟 updater 事件无法证明这些操作能够协同工作。每轮反馈都要求配置云端缓存并完成硬件签名,会让本地产品验证依赖发布基础设施。
+
+## 决策
+
+[Windows 本地验证命令](../../../../apps/desktop/README.zh.md) 在 Electron 中运行构建后的生产协调器,并使用真实的 `ElectronHttpExecutor` 和 `NsisUpdater`。隔离的应用适配器提供测试版本和私有更新配置。回环服务器提供 Nightly YAML、强更策略和不可执行的二进制字节。安装器、外部浏览器和剪贴板调用被替换为观测记录。发布者验证仅在此私有 fixture(测试前置数据)配置中缺省,生产配置不受影响。
+
+每次调用都原子分配临时目录和由操作系统分配的回环端口。请求屏障在不使用休眠的情况下建立并发条件。移除临时文件之前,测试会等待服务器关闭和子进程退出。独立进程可以并发运行,互不共享 updater 缓存或用户数据。此命令依赖构建后的 Desktop 模块,与源码侧单元测试分开运行。
+
+此测试决策补充[发布策略](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md),并不取代签名、发布完整性、已安装产物验收或待实施的[强制更新提案](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md)。这些记录保持有效。
+
+## 考虑过的替代方案
+
+**仅使用模拟 updater 事件。** 它们覆盖状态转换,但无法证明真实的 YAML 解析、Electron 传输、下载字节或校验和拒绝。本地验证使用真实依赖实现;原生弹窗和渲染器组件测试仍独立执行。
+
+**每轮本地迭代都要求云端托管的签名包。** 这会让常规交互调试依赖签名硬件和 CDN 配置。发布验收仍需要这些系统,但本地下载测试不需要。
+
+**放宽生产签名要求或执行伪安装器。** 观测下载就绪和安装授权不需要这两种做法。fixture 从不加载生产更新配置,只记录安装,不启动下载的字节。
+
+## 后果
+
+可执行场景覆盖无更新、拒绝降级、HTTP 404/408 和无效 YAML、清单与下载停滞截止时间、同地址清单替换、单一在途检查或下载、显式下载授权、SHA-512 不匹配、传输中断、显式重试、已准备版本保留、拒绝重启、关闭失败,以及检查未完成时的 dispose(资源释放)。原生弹窗回归覆盖检查反馈、检查失败、拒绝下载和独立安装操作;账户行测试负责进度和持久重试展示。
+
+常规轮询回归使用真实协调器、模拟时钟、实例私有随机样本和可控的网络完成时机。它验证从完成时刻起算的抖动、有上限的指数退避、成功重置、手动复用自动检查时的失败可见性、下载失败和已准备安装包保留,以及 dispose 后不重新启动计时器。主入口测试将同一期限连接到窗口聚焦、系统恢复、显式 IPC 和应用退出。移除失败退避会使计时断言失败。这些确定性测试证明请求时序,不证明客户端总体容量或启动峰值分布。
+
+真实的[强更窗口](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)、沙箱预加载和页面按钮处理会对接策略服务器与 updater。停滞的策略请求达到真实截止时间后不会清除阻塞。所属测试目录内的 DOM 预期输出约束中文文案和操作。截图失败单独记录;缺少截图不能证明视觉验收通过。
+
+[工作区浏览器场景](../../../../apps/web/tests/desktop-updates.e2e.ts) 使用生产展示函数检查两种语言下构建后的侧栏组合。页面私有的载体提供更新状态并记录操作;Host、客户端插件、样式和连接保持真实。slot 位置与点击保护的证据因此与 Electron IPC、任务授权和安装证据分开。截图和结果文件使用每次调用独有的忽略目录,不替代发布验收。
+
+[Host 验证运行器](../../../../apps/desktop/scripts/test-host-updates.ts) 在私有 profile 中加载构建后的 Host 和 standard agent 预设。真实任务注册表、排队消息、等待答复的工具提问/审批和 Node job 提供任务探测与准入锁定证据,不替换 Host 组合。脚本化模型输出和暂停的人工答复器控制等待点;取消和进程退出确立完成状态。这些证据不包含开发启动器的依赖投影、Electron 安装确认和任务停止失败。并发调用各自拥有独立的 home、项目和会话目录。
+
+[Electron 工作区运行器](../../../../apps/desktop/scripts/test-workspace-updates.ts) 将编译后的主入口和真实预加载连接到独立的真实 Host。私有 profile 插件控制真实排队任务并刻意阻塞停止过程;本地 HTTP 分发替代发布基础设施,updater 的安装器调用被拦截。它验证确认期间任务变化、收尾失败后拒绝安装、替代 Host 就绪,以及普通和强更策略下的重新确认。页面操作等待有限布局动画完成,并拒绝被遮挡的目标;窗口监听清理独立保留 web contents,不依赖已销毁窗口。运行器不证明打包主入口部署或重叠窗口的操作系统合成效果。
+
+运行器启动 Electron 时不隐藏 GUI,并在输入或截图之前断言主窗口可见。隐藏或被遮挡的渲染器即使能够完成 DOM 查询,也可能暂停帧回调。每次 DOM、点击布局和截图操作仅临时关闭自身 WebContents 的后台节流,随后恢复并检查原设置。真实动画和点击遮挡断言保持启用;生产窗口配置不变。每项操作都有主进程截止时间及本次运行独有的跟踪记录;失败诊断在独立截止时间内记录窗口可见性和动画状态。诊断捕获失败不能替换原始测试失败。重新启用 Windows 进程隐藏会使可见性断言失败,未保护的并发窗口可复现帧等待停滞。
+
+生产使用的 `DesktopUpdateHttpExecutor` 保留依赖库传输,并为真实 Electron 请求添加无活动截止时间。Electron 44 会在响应头到达前触发可写流的 `close`,因此该事件不能代表 HTTP 完成。响应完成、中止和错误释放计时器;收到字节会刷新计时器。响应头停滞与负载停滞均在本地 fixture 中失败,并通过显式重试恢复。
+
+[签名下载运行器](../../../../apps/desktop/scripts/test-signed-updates.mjs) 使用已有的签名及未签名可执行文件、公开证书和真实 Windows Authenticode 验签,补充不可执行字节测试。私有清单使用合成版本号,安装调用被拦截。哈希正确但发布者错误或未签名的文件被拒绝,并从缓存移除;传输损坏的字节先被校验和验证拒绝。自动检查保留失败,不发送新请求;显式重试签名文件后进入就绪,并要求单独批准重启。输入文件保持不变。每个进程独占端口和缓存;截止时间触发时先停止完整的 Electron/PowerShell 进程树,再清理运行时目录。缺少发布者配置会使拒绝断言失败。
+
+可选的旧安装器输入启用真实差分重建,使用私有缓存和原始 blockmap。服务器支持单段与多段字节范围,并显式提供缺少 blockmap 和拒绝 Range 的故障。哈希与签名验证覆盖重建及全量回退结果;请求记录要求实际复用字节,不允许全量回退冒充差分成功。禁用差分下载会使 blockmap 请求断言失败。历史输入文件保持只读,独立并发运行各自拥有缓存和端口。
+
+本地下载 fixture 在向磁盘写入部分字节后,为其可执行文件写入流注入 `ENOSPC`。真实 updater 必须清理不完整的可执行文件、拒绝安装、保留显式重试,并在恢复原始写入器后下载通过校验的字节。只拦截私有下载路径,且等待流关闭。这验证文件系统写入失败的处理,不耗尽共享主机卷。
+
+这些证据不能证明真实安装器执行、重启后健康状态、生产 CDN Range 行为、macOS updater 准备或真实卷耗尽。测试服务器不发布任何内容,也不读取云端凭据;生产分发和签名已安装版本升级仍需独立验收。

+ 6 - 0
.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md
+2026-09-14-desktop-installed-update-journal.md: 2a0ecbde408d1207102ba206ada333de6d123711
+2026-09-14-desktop-installed-update-journal.zh.md: 129efa7c123b6945e857e55e3632b18850ceb67d

+ 29 - 0
.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md

@@ -0,0 +1,29 @@
+# Agent Note: Preserve installed Desktop update evidence across restarts
+
+Status: implemented
+
+English | [中文](2026-09-14-desktop-installed-update-journal.zh.md)
+
+## Problem
+
+An installed update replaces application files and exits the process that observed the download. Terminal output alone cannot connect a failed transfer, explicit retry, installation authorization, and the next version's startup. Raw updater diagnostics can contain private URLs or credentials.
+
+## Decision
+
+The Desktop main entry accepts an opt-in absolute `DSH_DESKTOP_UPDATE_JOURNAL_DIR`. Qualification packages must retain the same external directory across versions. Each process exclusively creates a separate JSONL file and flushes whitelisted state and action records before continuing. The installed version, PID, sequence, and UTC time identify records. Integer progress changes limit repeated writes; raw errors, URLs, request headers, and chat content are omitted. Known error tokens produce fixed classifications instead of copied diagnostics.
+
+Without the variable, the journal is disabled. Explicit qualification storage failures propagate rather than silently claiming complete evidence. Startup and workspace readiness are observations, not proof of installer success or preserved user data. The [local qualification decision](2026-09-10-desktop-local-updater-qualification.md) remains active: this journal does not supersede its isolation, intercepted-installation limits, or hardware restrictions.
+
+The [installed-run tool](../../../../apps/desktop/scripts/installed-update-qualification.ts) allocates a private test manifest without credentials or remote operations. Its read-only inspector requires an ordered failure, manual retry, readiness, confirmation, and quit in one original process, followed by successor startup and workspace readiness. Unknown fields, partial records, sequence gaps, and mixed process identities are rejected. Even complete recorded flow retains independent operator checks for publication timing, network recovery, installer completion, and preserved data; a collection of startup logs cannot substitute for those observations.
+
+## Alternatives considered
+
+**Keep only terminal output or installation-directory files.** Installation replaces application files, and the new process can outlive the terminal. The evidence directory remains outside the installation tree.
+
+**Persist arbitrary logs and redact known secrets.** Unknown authentication values and private paths cannot be exhaustively enumerated. Field whitelisting retains less detail but excludes those strings.
+
+## Consequences
+
+Journal collection snapshots validated bytes once and derives both retained files and milestone reports from those bytes. Re-reading a changing source separately for each output could report observations absent from the saved logs. Each collection uses a new directory and hashes its files; earlier collections and source journals remain unchanged. Fixed diagnostic values are checked at the file reader, not only at the writer, before bytes can enter a collection. Invalid input refuses collection; storage failure preserves partial evidence rather than claiming completion. A complete milestone sequence still leaves operator acceptance pending.
+
+Journal and main-entry tests cover persistence, retry milestones, separate version files, omitted private fields, and unavailable storage. Installed restart, installation completion, data preservation, and network fault injection still require operator qualification. Logs have no automatic deletion; the operator owns retention and must reject missing or incomplete evidence. The journal never clears signing locks or authorizes installation.

+ 29 - 0
.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 跨重启保留已安装 Desktop 的更新证据
+
+Status: implemented
+
+[English](2026-09-14-desktop-installed-update-journal.md) | 中文
+
+## 问题
+
+已安装应用的更新会替换应用文件,并退出观测下载的进程。仅有终端输出无法串联传输失败、显式重试、安装授权与下一版本启动。原始 updater 诊断可能包含私有 URL 或凭据。
+
+## 决策
+
+Desktop 主入口接受显式启用的绝对路径 `DSH_DESKTOP_UPDATE_JOURNAL_DIR`。验收包必须在不同版本间保留同一个外部目录。每个进程独占创建单独的 JSONL 文件,并在继续执行前刷新白名单内的状态与操作记录。已安装版本、PID、序号和 UTC 时间标识记录。整数进度变化限制重复写入;原始错误、URL、请求头和聊天内容均被省略。已知错误标记产生固定分类,不复制诊断文本。
+
+未设置该变量时不启用日志。显式启用的验收存储失败会向上传播,不会静默声称证据完整。启动和工作区就绪只是观测结果,不能证明安装成功或用户数据保留。[本地验收决策](2026-09-10-desktop-local-updater-qualification.zh.md)继续有效:本日志不取代其隔离要求、安装拦截的局限或硬件限制。
+
+[安装版批次工具](../../../../apps/desktop/scripts/installed-update-qualification.ts)创建独立 test 清单,不读取凭据或执行远程操作。其只读检查器要求同一原进程内按序记录失败、手动重试、就绪、确认和退出,再记录后继版本启动与工作区就绪。未知字段、不完整记录、序号缺口与混合进程身份均被拒绝。即使记录流程完整,也保留发布时间、网络恢复、安装器完成和数据保留的独立人工检查;一组启动日志不能代替这些观测。
+
+## 考虑过的替代方案
+
+**仅保留终端输出或安装目录内的文件。** 安装会替换应用文件,新进程也可能在终端结束后继续运行。证据目录保留在安装目录树之外。
+
+**保存任意日志并对已知秘密脱敏。** 未知认证值和私有路径无法穷尽枚举。字段白名单保留的细节较少,但排除了这些字符串。
+
+## 结果
+
+日志收集只读取一次通过验证的字节,并从这些字节生成保存文件和里程碑报告。分别为各输出重读变化中的源文件,可能报告保存日志中不存在的观测。每次收集使用新目录并记录文件哈希;旧收集和源日志不变。在字节进入收集目录前,文件读取器也检查固定诊断值,而非仅依赖写入器。输入无效时拒绝收集;存储失败保留部分证据,不宣称完成。完整的里程碑序列仍将人工验收保持为待确认。
+
+日志与主入口测试覆盖持久化、重试标记、不同版本的独立文件、私有字段省略和存储不可用。已安装应用重启、安装完成、数据保留与网络故障注入仍需人工验收。日志不会自动删除;操作者负责保留记录,并必须拒绝证据缺失或不完整的结果。日志不会清除签名锁定或授权安装。

+ 6 - 0
.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md
+2026-09-14-desktop-installed-update-materials.md: 0b35dacf5ec655dacf97624360894a6a1a8e9bcc
+2026-09-14-desktop-installed-update-materials.zh.md: 0b4df0f67c440b126ec1e2d012c09f03d2382899

+ 47 - 0
.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md

@@ -0,0 +1,47 @@
+# Agent Note: Isolate installed-update materials from releases and user data
+
+Status: implemented
+
+English | [中文](2026-09-14-desktop-installed-update-materials.zh.md)
+
+## Problem
+
+An installed-update walkthrough needs two increasing versions with a shared application identity. Reusing normal release paths can expose test metadata to unrelated clients, while storing test state inside an installation directory loses evidence during replacement. A successful copy does not establish that a signed installer exists.
+
+## Decision
+
+The [material preparer](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts) accepts a retained test-only manifest with a random identity and distribution namespace. It copies a verified source runtime into separate synthetic Nightly versions, changes only release-family versions and matching dependency references, and seals and verifies both inventories. The original runtime is verified again and its descriptor hash must remain unchanged. Existing output directories are not overwritten; a failed copy retains a failure record and cannot create a completion receipt.
+
+The generated bootstrap validates the installed package identity before importing production main. Each version assigns the same application-data subdirectory to its test Electron profile, Harness home, and external journals. Environment values inherited from the first launch do not determine those paths. The manifest reader rejects changed test destinations, identities, and directory locations. The [operator guide](../../../../apps/desktop/tests/installed-update/README.md) distinguishes source preparation, private runtime copies, and signed installed-app qualification.
+
+The [journal decision](2026-09-14-desktop-installed-update-journal.md) and [local updater qualification decision](2026-09-10-desktop-local-updater-qualification.md) remain active: material preparation neither proves an installed upgrade nor changes hardware authorization. Signing, upload, installation, and network mutation are absent from these preparation tools.
+
+## Alternatives considered
+
+**Retag the repository's package manifests between builds.** That changes the working release version and can affect other tasks or release commands. Private runtime copies and package-specific metadata keep the source checkout's versions unchanged.
+
+**Use the production identity or terminal-only data overrides.** Production identity shares registration and data with ordinary installs; terminal overrides may not survive installer-triggered startup. A private identity and packaged bootstrap retain isolation across versions.
+
+**Resume by overwriting a partially prepared version.** That can mix source generations and discard failure evidence. An existing output or preparation record refuses reuse; a clean attempt requires a new run.
+
+**Infer upload failure from a denied bucket-configuration query.** Object access and bucket configuration have separate permissions. An added preflight can reject credentials whose object operations work. Diagnose the exact API and compare existing successful evidence before asking for broader permissions; no attempted PUT means no observed upload failure.
+
+## Consequences
+
+Feed publication reuses a completed binary-upload result whose retained plan exactly matches the current verified distribution, including run identity, version, destinations, sizes, and hashes. It records the receipt and plan hashes and reads only the feed remotely. Initial binary upload retains full public-byte verification. Repeating that transfer for every feed write adds latency and traffic without changing immutable artifacts; the retained receipt establishes prior delivery, not a guarantee against later external deletion. Missing, failed, or mismatched receipts refuse publication rather than silently triggering another large download.
+
+The separate [publication entry](../../../../apps/desktop/tests/installed-update/publication/README.md) defaults to local receipt and byte verification. Binary upload cannot advertise a version; successor feed publication requires original-version startup evidence and a matching previous feed. The test-only transport disables SDK write retries, requires an authoritative unversioned-bucket check before immutable-object protection, and verifies exact public bytes. A local lock does not provide distributed exclusion: one operator must own the mutable feed across machines. Each authorized operation retains independent stage records; uncertain writes require inspection, not automatic repetition. Matching objects can be reconciled without replacement. In-memory sequencing and substituted HTTP transport tests do not establish real COS publication or installed-app startup.
+
+The separate [operator packaging entry](../../../../apps/desktop/scripts/package-installed-update.ts) checks the durable signing interlock before loading credentials and rechecks after interactive confirmation. Each execution owns one version and a never-reused packaging directory. It uses the existing supervisor with a 15-minute stage deadline, retains redacted output and selected source/tool hashes, and compares those inputs after building. Upload credentials never reach the builder; publication and installation are absent. Inert subprocess tests cover refusal, redaction, retained failure, and no reuse; actual signed output and package verification remain separate evidence. An overall deadline cannot limit CSP-internal PIN attempts or authorize unattended signing.
+
+Application preparation freezes built main/preload modules, renderer files, and the generated bootstrap once per run, records SHA-256 values, and rejects changed, missing, or additional frozen files. The [qualification builder configuration](../../../../apps/desktop/scripts/installed-update-builder.ts) reuses the ordinary configuration factory with the selected private runtime, so resource verification and signing use the same runtime version. It preserves installer hooks and publisher checks while selecting a shared test identity, package entry, and fixed test feed. Dependency trees and build tools remain separate mutable inputs and require final-build records. Configuration validation uses the pinned builder with a substituted signer, not hardware or installed-app evidence.
+
+The read-only [distribution planner](../../../../apps/desktop/scripts/installed-update-distribution.ts) checks each version's generated YAML against local installer bytes and separates immutable binary objects from one mutable feed URL. It reconstructs feed fields from validated values instead of preserving arbitrary input fields. The output explicitly carries file-integrity-only status and no publication authority; credentials, signatures, package identity, and version 1 startup are outside this check.
+
+The separate [signature reader](../../../../apps/desktop/scripts/installed-update-signature.mjs) requires the real updater verifier, valid Authenticode and timestamp attributes, and unchanged executable bytes. It runs verification children with scrubbed credentials and without inherited PowerShell module paths; incompatible module overrides cannot be treated as successful verification. The publisher comes from the trusted public certificate rather than remote YAML. Read-only verification never calls SignTool, accesses the signing token, or executes the inspected file. Signature success cannot establish package contents or a completed installation.
+
+The [package verifier](../../../../apps/desktop/scripts/verify-installed-update-package.ts) extracts the signed installer itself rather than trusting a neighboring unpacked directory. Its archived identity, update configuration, frozen application files, and Harness runtime must match the retained run; unsigned runtime executable changes cannot pass through a regenerated inventory alone. Independent immutable records preserve each check's stages and failures. Final input hashes prevent an installer/feed change from inheriting an earlier result. Real ASAR and runtime fixtures cover mismatches while substituting only external verification and archive processes; actual signed packages still require their own receipts. Installer registration and installed-app behavior are outside this file inspection and remain operator checks.
+
+The [network fault procedure](../../../../apps/desktop/tests/installed-update/network/README.md) binds a program-specific outbound rule to the verified original executable hash and random run identity. It rejects changed bytes before creation and after confirmation, and removes only a matching owned rule. Recovery remains available if the executable disappears. The operator controls both mutations; stages persist before and after each operation, and failures stop without retry. Firewall-cmdlet substitutes exercise the real script, but a rule's recorded presence or absence never proves traffic interruption or successful download recovery. Adapter-wide or VPN changes are excluded because they can disconnect the operator's remote control.
+
+Generated-entry tests execute both versions with only Electron's path API substituted and verify the shared directories before main imports. Runtime tests verify dependency retagging, unchanged third-party versions and source bytes, refusal of damaged input, and retained partial failures. Copies consume extra disk space. Source preparation can boot the source runtime, but copied-version startup, final package contents, signatures, publication, and installation require separate evidence; completion receipts name those limits rather than inferring success.

+ 47 - 0
.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md

@@ -0,0 +1,47 @@
+# Agent Note: 将安装版更新物料与发布及用户数据隔离
+
+Status: implemented
+
+[English](2026-09-14-desktop-installed-update-materials.md) | 中文
+
+## 问题
+
+安装版更新演练需要两个递增版本共用应用身份。复用正常发布路径可能向无关客户端暴露测试元数据,而把测试状态放在安装目录中会在替换时丢失证据。复制成功不能证明已有签名安装包。
+
+## 决策
+
+[物料准备器](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts)接受保留的 test 专用清单,其中包含随机身份和分发命名空间。它将已验证的原始运行时复制为独立的合成 Nightly 版本,仅修改发布家族版本和匹配的依赖引用,再生成并验证两个完整性清单。原始运行时再次验证,其描述文件哈希必须保持不变。已有输出目录不被覆盖;复制失败保留失败记录,不能生成完成回执。
+
+生成的启动入口在导入生产主程序前验证已安装包身份。两个版本将相同的应用数据子目录用于测试 Electron profile、Harness home 和外部日志。首次启动继承的环境变量不决定这些路径。清单读取器拒绝已改变的 test 目标、身份和目录位置。[人工指南](../../../../apps/desktop/tests/installed-update/README.zh.md)区分源码准备、独立运行时副本和签名安装版验收。
+
+[日志决策](2026-09-14-desktop-installed-update-journal.zh.md)与[本地 updater 验收决策](2026-09-10-desktop-local-updater-qualification.zh.md)继续有效:物料准备既不证明已安装应用升级通过,也不改变硬件授权。这些准备工具不包含签名、上传、安装或网络改动。
+
+## 考虑过的替代方案
+
+**在两次构建之间修改仓库包清单的版本。** 这会改变工作区发布版本,可能影响其他任务或发布命令。独立运行时副本与包专属元数据保持源码工作区版本不变。
+
+**使用生产身份或仅通过终端覆盖数据目录。** 生产身份与普通安装共享注册和数据;终端覆盖不一定在安装器触发启动时保留。独立身份与包内启动入口维持跨版本隔离。
+
+**覆盖准备到一半的版本来继续执行。** 这可能混合不同源码代际并丢弃失败证据。已有输出或准备记录拒绝复用;全新尝试需要新批次。
+
+**从桶配置查询被拒绝推断上传失败。** 对象访问与桶配置使用不同权限。新增前置检查可能拒绝对象操作原本可用的凭据。申请扩大权限前,先定位准确 API 并对照已有成功证据;没有尝试 PUT,就没有观察到上传失败。
+
+## 结果
+
+feed 发布复用已完成的二进制上传结果,其保留计划必须与当前已验证分发计划完全匹配,包括批次身份、版本、目标地址、大小和哈希。发布记录回执及计划哈希,仅在远端读取 feed。首次二进制上传保留完整公网字节验证。每次写 feed 都重复传输会增加延迟和流量,却不改变不可变物料;保留回执证明此前分发成功,不保证对象此后未被外部删除。回执缺失、失败或不匹配时拒绝发布,不隐式触发另一次大文件下载。
+
+独立的[发布入口](../../../../apps/desktop/tests/installed-update/publication/README.zh.md)默认仅验证本地回执与字节。二进制上传不能公布版本;后继版本 feed 发布要求原版本启动证据及匹配的旧 feed。test 专用传输禁用 SDK 写入重试,在使用不可变对象保护前要求权威查询确认存储桶未启用版本控制,并验证准确的公网字节。本地锁不提供分布式排他:必须由一个操作者跨机器独占可变 feed。每次授权操作保留独立阶段记录;写入结果不确定时要求检查,不能自动重试。匹配对象可核对而不替换。内存顺序测试和替代 HTTP 传输测试不证明真实 COS 发布或已安装应用启动通过。
+
+独立的[人工打包入口](../../../../apps/desktop/scripts/package-installed-update.ts)在加载凭据前检查持久签名保护锁,并在交互确认后再次检查。每次执行只拥有一个版本和一个不复用的打包目录。它使用现有监督程序与 15 分钟阶段期限,保留脱敏输出和选定的源码/工具哈希,并在构建后比较这些输入。上传凭据绝不传给构建器;入口不包含发布和安装。无功能子进程测试覆盖拒绝、脱敏、失败保留与不复用;实际签名产物和包验证仍需独立证据。整体期限不能限制 CSP 内部 PIN 尝试,也不能授权无人值守签名。
+
+应用准备按批次冻结一次已构建的主程序/预加载模块、界面文件和生成的启动入口,记录 SHA-256,并拒绝冻结文件被修改、缺失或额外增加。[验收打包配置](../../../../apps/desktop/scripts/installed-update-builder.ts)将选定的独立运行时传给常规配置工厂,使资源校验与签名使用同一运行时版本。它保留安装器钩子和发布者检查,同时选择共享测试身份、包入口和固定 test feed。依赖树和构建工具仍是独立的可变输入,需要最终构建记录。配置验证使用固定版本构建器和替代签名器,不是硬件或已安装应用证据。
+
+只读[分发规划器](../../../../apps/desktop/scripts/installed-update-distribution.ts)逐版本核对生成的 YAML 与本地安装包字节,并将不可变二进制对象与一个可变 feed URL 分开。它从已验证的值重建 feed 字段,不保留任意输入字段。输出明确标识仅验证文件完整性、没有发布授权;凭据、签名、包身份与版本 1 启动不属于本检查。
+
+独立的[签名读取器](../../../../apps/desktop/scripts/installed-update-signature.mjs)要求真实 updater 验签、有效 Authenticode 与时间戳属性,以及未变的可执行文件字节。验签子进程移除凭据和继承的 PowerShell 模块路径;不兼容模块覆盖不能被当作验签成功。发布者来自可信公钥证书,而非远端 YAML。只读检查绝不调用 SignTool、访问签名令牌或执行被检查文件。签名通过不能证明包内容或安装完成。
+
+[安装包检查器](../../../../apps/desktop/scripts/verify-installed-update-package.ts)解开签名安装包本身,而非信任相邻的解包目录。归档内身份、更新配置、冻结应用文件和 Harness 运行时必须与保留批次一致;未签名的运行时可执行文件变化不能仅靠重新生成清单就通过。独立且不可覆盖的记录保留每次检查的阶段与失败。最终输入哈希防止安装包/feed 变化继承旧结果。真实 ASAR 和运行时夹具覆盖不符情况,仅替换外部验签和归档进程;实际签名包仍需各自回执。安装器注册和已安装应用行为不属于文件检查,保留为人工检查项。
+
+[网络故障步骤](../../../../apps/desktop/tests/installed-update/network/README.zh.md)将指定程序的出站规则绑定到已验证原版本可执行文件哈希及随机批次身份。它在创建前和确认后拒绝变化的字节,仅移除匹配的自有规则。可执行文件消失后仍可恢复。两次改动均由操作者控制;每次操作前后持久记录阶段,失败即停、不重试。防火墙命令替身执行真实脚本,但记录规则存在或不存在绝不证明流量中断或下载恢复成功。不改网卡或 VPN,因为这可能断开操作者的远控。
+
+生成入口测试只替换 Electron 路径 API,执行两个版本并在主程序导入前核对共享目录。运行时测试验证依赖版本替换、第三方版本和原始字节不变、损坏输入拒绝以及失败现场保留。副本额外占用磁盘。源码准备可启动原始运行时,但副本版本启动、最终包内容、签名、发布和安装均需独立证据;完成回执说明这些局限,不推断成功。

+ 6 - 0
.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md
+2026-09-08-desktop-mandatory-update-api.md: f88216065fe09221c7a14805e4d25fb97a259abc
+2026-09-08-desktop-mandatory-update-api.zh.md: cb3e631040d221b87979b7fcd379c8881dba36bd

+ 148 - 0
.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md

@@ -0,0 +1,148 @@
+# Agent Note: Desktop mandatory-update API
+
+Status: proposed
+
+English | [中文](2026-09-08-desktop-mandatory-update-api.zh.md)
+
+## Problem
+
+Desktop requires mandatory-update decisions when its business server is local and when the user is not signed in. Mobile ordinary-update responses do not define Desktop artifact installation. Backend and Desktop owners need a self-contained protocol with explicit success, blocking, and error semantics, separate from updater artifact selection.
+
+## Proposal
+
+Use a guest-accessible policy endpoint. This document owns its Desktop request and response fields; the [initial update proposal](2026-09-08-desktop-update-policy-and-installation.md) owns client scheduling, UI, release ordering, and installation. Backend integration is in progress; API origins and gateway details are deployment inputs, not evidence of a live service. No sibling repository or local document is needed to interpret this protocol.
+
+### Endpoint and access
+
+The [client decision](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.md) owns the implemented polling and blocking UI. This API proposal remains active for backend deployment and live integration; local fixtures do not certify the service.
+
+```http
+GET /api/v0/check_client_update
+```
+
+The query itself must return the complete mandatory response; do not rely on intercepting unrelated business APIs. Preserve Android and iOS behavior without requiring existing mobile versions to send new Desktop fields. Desktop receives mandatory policy only, not ordinary-update prompts, installer metadata, updater target versions, device IDs, installation IDs, or per-installation rollout assignments.
+
+### Request
+
+```http
+GET /api/v0/check_client_update?scenario=launch
+x-client-platform: desktop-win
+x-client-version: 0.1.3-rc.2
+x-client-bundle-id: com.deepseek.dsh
+x-client-locale: zh-CN
+x-client-arch: x64
+x-client-update-channel: nightly
+x-client-bundled-dsh-version: 0.1.3-rc.2
+```
+
+All listed headers are required for Desktop. Values describing installed software come from the application and its release metadata, not editable UI fields.
+
+| Header | Meaning and allowed values |
+|---|---|
+| `x-client-platform` | `desktop-win` or `desktop-mac` |
+| `x-client-version` | Full Desktop SemVer, retaining prerelease identifiers; initially equal to bundled dsh |
+| `x-client-bundle-id` | Application identity, for example `com.deepseek.dsh`; distinguishes Harness from Chat |
+| `x-client-locale` | UI locale, for example `zh-CN`; selects localized content, not region |
+| `x-client-arch` | Windows `x64`; macOS `x64` or `arm64` |
+| `x-client-update-channel` | Initially always `nightly`, independent of version suffix |
+| `x-client-bundled-dsh-version` | Full bundled dsh version from release metadata |
+
+| Query | Client behavior | Initial backend behavior |
+|---|---|---|
+| `scenario` | Carry the query source; `launch` is a startup example | Ignore; final desktop enumeration does not block integration |
+| `region` | Optional; send only when trustworthy region information exists | Ignore; do not infer it from UI locale |
+
+Neither query affects initial policy matching or parameter validation. Changing or omitting either query must not change the decision for otherwise identical client conditions. Lifecycle triggers and query enum values need not have a one-to-one mapping; enumeration and analytics can be agreed before backend use is enabled.
+
+### No mandatory update
+
+```json
+{
+  "code": 0,
+  "msg": "",
+  "data": {
+    "biz_code": 0,
+    "biz_msg": "",
+    "biz_data": null
+  }
+}
+```
+
+This means the current client does not require a mandatory update, not that its installed version is latest. A fresh valid response for the current client conditions may clear a known block. Desktop does not consume mobile ordinary-update payloads in this response.
+
+### Mandatory update
+
+```json
+{
+  "code": 40005,
+  "msg": "Client version too low",
+  "data": {
+    "show_content": {
+      "title": "请更新 DeepSeek Harness",
+      "detail": "当前版本已停止支持,请下载并安装新版本。"
+    },
+    "desktop_app_link": "https://example.com/harness/download"
+  }
+}
+```
+
+The example URL is a placeholder, not an approved deployment destination. Desktop uses the flattened `data` fields, with no `alt_app` or `biz_data` wrapper. No Desktop has shipped, so no compatibility parser for the earlier nested proposal is required. Mobile `alt_app`, Android links, and iOS app identifiers retain their existing format.
+
+| Field | Requirement |
+|---|---|
+| `code` | `40005` is the response-body code, not an HTTP status |
+| `msg` | Diagnostics only, not dialog copy |
+| `data.show_content.title` | Required localized plain-text title |
+| `data.show_content.detail` | Required localized plain-text detail |
+| `data.desktop_app_link` | Required HTTPS page matching product, platform, architecture, and channel; must satisfy the client allowlist |
+
+Do not add `mode`, `force_update`, `show_key`, `target_version`, button copy, or updater metadata initially. `40005` determines that upgrading is mandatory; updater metadata determines the actual version and package. The client does not perform an additional mandatory-target version check from this API. Client state selects localized actions, and every download still requires user action. The page remains a fallback after in-app updater integration. The HTTP status mapping follows gateway integration and must be confirmed; the gateway must preserve the JSON body instead of replacing it with generic text or HTML.
+
+### Errors and policy matching
+
+Missing required headers, invalid SemVer, and unsupported platform/architecture/channel combinations return explicit parameter errors, not no-force success or a mandatory policy. Prefer the existing business meanings of `biz_code = 1` for a missing version and `biz_code = 2` for an invalid version; final error allocation belongs to the backend. Service failures must not masquerade as success because success may remove an existing block.
+
+Match application identity, platform, architecture, Desktop version, bundled dsh version, and channel using server-owned ranges and precedence. Use complete SemVer, not lexical sorting or truncated prerelease values. Initially the two versions are equal and channel is fixed Nightly; independent revisions and channel switching are deferred. Do not require a downgrade or stop returning `40005` merely because a client has queried or displayed it before.
+
+### Policy publication and current decisions
+
+Every active policy requires deterministic matching conditions, localized title/detail, and a valid destination. The server evaluates current client conditions on every query. These are server configuration requirements, not extra response fields or an artifact revocation mechanism.
+
+- Publish and verify a higher release that resolves the mandatory requirement before enabling its policy; the matching page and platform package must be available.
+- For clients with in-app mandatory updating, publish the resolving version to their updater feed before activating the mandatory requirement. Release owners coordinate this ordering; the API does not supply a separate installer target, artifact eligibility response, or ordinary-update rollout.
+- Match links to the product, platform, architecture, and channel; reject policy activation when content or an allowed HTTPS destination is missing.
+- Return no-force success when the current client no longer matches, including after upgrading. Continue returning mandatory policy while it still matches.
+
+Handle a defective release by publishing a higher fixed version and updating updater metadata. Do not add a revoked-version list, pre-install artifact-revocation query, or target-version validation to this API. A fresh no-force response can clear the UI block but does not select, replace, or invalidate an updater package. Artifact hash/signature checks remain required independently of the mandatory decision.
+
+### Capacity and caching
+
+Capacity and rate limits must account for recurring guest requests, client-side coalescing, and failure backoff. Policy delivery does not depend on chat requests or SSE. Online delivery latency depends on polling and connectivity; offline clients cannot receive new policy immediately. Prefer `Cache-Control: no-store`. A gateway must not share responses across client versions, platforms, architectures, or channels based only on URL; future caching must specify all policy/content keys and decision freshness.
+
+## Alternatives considered
+
+**Relying on remote business interception.** Local dsh requests need not reach the remote gateway; an independent guest query is necessary.
+
+**Reusing the mobile nested payload for Desktop.** The backend agreement flattens Desktop fields. Preserve mobile compatibility by platform, without making new Desktop clients support an undistributed nested variant.
+
+**Adding installation identity and ordinary-update metadata.** Initial policy needs neither per-installation rollout nor a second installer feed. Keep artifact discovery in the updater.
+
+## Acceptance criteria
+
+| Case | Expected result |
+|---|---|
+| Unauthenticated Desktop | Query works without business login |
+| Windows x64, macOS x64 and arm64 | Correct platform policy and page |
+| No policy match | `code = 0`, `biz_code = 0`, `biz_data = null` |
+| Policy match | Top-level `40005`, direct content and page under `data` |
+| Changed or omitted query parameters | Same decision for otherwise identical conditions |
+| Equal prerelease Desktop/dsh versions, fixed Nightly | Full SemVer matching, no downgrade or channel-switch requirement |
+| Client no longer matches, including after upgrading | No-force success; does not invalidate updater artifacts |
+| Unavailable resolving release, page, or applicable feed | Policy activation rejected |
+| Invalid required fields or service failure | Explicit error, not false no-force success |
+| Sequential requests from different client conditions | No cross-client cache leakage |
+| Existing mobile requests | Existing requirements and response structure preserved |
+
+## Risks
+
+Backend owners must provide test/production origins, guest gateway access, final HTTP/error-code mapping, limits, real page destinations, allowed domains, fallback locale, and policy configuration ownership. Release owners must coordinate available updater releases and mandatory-policy activation per platform. These inputs remain pending and must not be filled with developer credentials or guessed URLs.

+ 148 - 0
.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md

@@ -0,0 +1,148 @@
+# Agent Note: Desktop 强制更新接口
+
+Status: proposed
+
+[English](2026-09-08-desktop-mandatory-update-api.md) | 中文
+
+## 问题
+
+Desktop 在业务 server 为本地进程、用户尚未登录时也需要获取强制更新判定。移动端普通更新响应不定义 Desktop 产物安装。后端与 Desktop 需要一份自包含协议,明确成功、阻断和错误语义,与 updater 的产物选择分开。
+
+## 提案
+
+使用未登录可访问的策略接口。本文负责 Desktop 请求响应字段,[一期更新提案](2026-09-08-desktop-update-policy-and-installation.zh.md)负责客户端调度、界面、发布顺序与安装。后端正在接入;API 地址与网关细节是部署输入,不表示服务已经可用。理解本协议不依赖旁边的仓库或本机文档。
+
+### 接口与访问
+
+[客户端决策](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)负责已实现的轮询和阻塞界面。本接口提案仍负责后端部署与真实联调;本地 fixture(测试前置数据)不能认证服务。
+
+```http
+GET /api/v0/check_client_update
+```
+
+查询接口本身必须返回完整强制响应,不能依赖拦截其他业务接口。保持 Android 与 iOS 既有行为,不要求旧移动端补传 Desktop 新字段。Desktop 仅接收强制策略,不接收普通更新提示、安装包元数据、updater 目标版本、设备 ID、安装实例 ID 或按实例灰度分配。
+
+### 请求
+
+```http
+GET /api/v0/check_client_update?scenario=launch
+x-client-platform: desktop-win
+x-client-version: 0.1.3-rc.2
+x-client-bundle-id: com.deepseek.dsh
+x-client-locale: zh-CN
+x-client-arch: x64
+x-client-update-channel: nightly
+x-client-bundled-dsh-version: 0.1.3-rc.2
+```
+
+以上 header 对 Desktop 均为必填。描述已安装软件的值来自应用与发布元数据,不来自可编辑 UI 字段。
+
+| Header | 含义与取值 |
+|---|---|
+| `x-client-platform` | `desktop-win` 或 `desktop-mac` |
+| `x-client-version` | 完整 Desktop SemVer,保留预发布标识;一期等于内置 dsh |
+| `x-client-bundle-id` | 应用身份,例如 `com.deepseek.dsh`,区分 Harness 与 Chat |
+| `x-client-locale` | UI 语言,例如 `zh-CN`;选择本地化内容,不代表区域 |
+| `x-client-arch` | Windows 为 `x64`;macOS 为 `x64` 或 `arm64` |
+| `x-client-update-channel` | 一期固定 `nightly`,独立于版本后缀 |
+| `x-client-bundled-dsh-version` | 发布元数据中的完整内置 dsh 版本 |
+
+| Query | 客户端行为 | 后端一期行为 |
+|---|---|---|
+| `scenario` | 携带查询来源;`launch` 是启动示例 | 忽略;桌面最终枚举不阻塞接入 |
+| `region` | 可选;仅在有可信区域信息时携带 | 忽略;不从 UI 语言推断 |
+
+两个 query 都不参与一期策略匹配或参数校验。其他客户端条件相同时,改变或省略任一 query 不得改变判定。生命周期触发点与 query 枚举不必一一对应;后端启用使用前再约定枚举与统计。
+
+### 无需强制更新
+
+```json
+{
+  "code": 0,
+  "msg": "",
+  "data": {
+    "biz_code": 0,
+    "biz_msg": "",
+    "biz_data": null
+  }
+}
+```
+
+这表示当前客户端无需强制更新,不表示已安装版本最新。针对当前客户端条件的新鲜有效响应可以清除已有阻断。Desktop 不消费此响应中的移动端普通更新载荷。
+
+### 需要强制更新
+
+```json
+{
+  "code": 40005,
+  "msg": "Client version too low",
+  "data": {
+    "show_content": {
+      "title": "请更新 DeepSeek Harness",
+      "detail": "当前版本已停止支持,请下载并安装新版本。"
+    },
+    "desktop_app_link": "https://example.com/harness/download"
+  }
+}
+```
+
+示例 URL 是占位符,不是获准部署的目标地址。Desktop 使用拍平的 `data` 字段,不包裹 `alt_app` 或 `biz_data`。尚无已分发 Desktop,因此不需要兼容较早嵌套提案的解析器。移动端 `alt_app`、Android 链接与 iOS 应用标识保持既有格式。
+
+| 字段 | 要求 |
+|---|---|
+| `code` | `40005` 是响应体错误码,不是 HTTP 状态 |
+| `msg` | 仅用于诊断,不作弹窗文案 |
+| `data.show_content.title` | 必填,本地化纯文本标题 |
+| `data.show_content.detail` | 必填,本地化纯文本正文 |
+| `data.desktop_app_link` | 必填,匹配产品、平台、架构与通道的 HTTPS 页面,必须符合客户端允许列表 |
+
+一期不增加 `mode`、`force_update`、`show_key`、`target_version`、按钮文案或 updater 元数据。`40005` 决定必须升级,实际版本与安装包由 updater 元数据决定。客户端不依据本接口另做强制目标版本校验。客户端状态决定本地化操作,每次下载仍需用户操作。接入应用内 updater 后,页面仍作为兜底。HTTP 状态映射按网关联调确认;网关必须保留 JSON 错误体,不能替换为通用文本或 HTML。
+
+### 错误与策略匹配
+
+缺少必填 header、非法 SemVer 或不支持的平台/架构/通道组合返回明确参数错误,不能伪装为无强制成功或强制策略。优先沿用 `biz_code = 1` 表示缺少版本、`biz_code = 2` 表示版本非法的既有业务含义;最终错误码分配由后端负责。服务异常不得伪装成成功,因为成功可能解除已有阻断。
+
+按应用身份、平台、架构、Desktop 版本、内置 dsh 版本与通道匹配,由服务端维护版本范围和优先级。使用完整 SemVer,不按字符串排序或截断预发布部分。一期两个版本相等、通道固定 Nightly,独立修订与通道切换延后。不得要求降级,也不得仅因客户端曾查询或展示过就停止返回 `40005`。
+
+### 策略发布与当前判定
+
+每条生效策略需要可确定的匹配条件、本地化标题正文和有效目标页面。服务端每次查询均判定当前客户端条件。这些是服务端配置要求,不增加响应字段,也不是安装包撤回机制。
+
+- 启用策略前发布并验证能解除强制要求的更高版本,确保匹配的页面与平台安装包可用。
+- 对启用应用内强制更新的客户端,先向其 updater feed 发布能解除要求的版本,再启用强制要求。发布负责人协调该顺序;API 不提供独立安装目标、产物适用性响应或普通更新灰度。
+- 链接匹配产品、平台、架构与通道;缺少文案或获准 HTTPS 目标时拒绝启用策略。
+- 当前客户端不再命中时返回无强制成功响应,包括升级后;仍命中时持续返回强制策略。
+
+问题版本通过发布更高修复版本并更新 updater 元数据处理。不向本接口增加作废版本清单、安装前产物撤回查询或目标版本校验。新鲜的无强制响应可以解除 UI 阻断,但不选择、替换或作废 updater 安装包。产物哈希/签名校验仍独立于强制判定执行。
+
+### 容量与缓存
+
+容量与限流需覆盖持续未登录请求,并考虑客户端合并与失败退避。策略触达不依赖聊天请求或 SSE(Server-Sent Events)。在线触达延迟受轮询和网络影响,离线客户端无法立即获知新策略。建议 `Cache-Control: no-store`。网关不得仅按 URL 跨客户端版本、平台、架构或通道共用响应;后续缓存必须定义所有策略与文案维度和判定新鲜度。
+
+## 考虑过的替代方案
+
+**依赖远程业务拦截。** 本地 dsh 请求不一定到达远程网关,需要独立的未登录查询。
+
+**Desktop 复用移动端嵌套载荷。** 后端约定拍平 Desktop 字段。按平台保留移动端兼容,不让新 Desktop 支持从未分发的嵌套变体。
+
+**增加安装身份与普通更新元数据。** 初期策略不需要按安装实例灰度或第二个安装器 feed。产物发现继续由 updater 负责。
+
+## 验收标准
+
+| 用例 | 预期结果 |
+|---|---|
+| 未登录 Desktop | 无需业务登录即可查询 |
+| Windows x64、macOS x64 和 arm64 | 正确的平台策略与页面 |
+| 不命中策略 | `code = 0`、`biz_code = 0`、`biz_data = null` |
+| 命中策略 | 顶层 `40005`,文案与页面直接位于 `data` 下 |
+| 改变或省略 query 参数 | 其他条件相同时判定一致 |
+| 相等的 Desktop/dsh 预发布版本、固定 Nightly | 完整 SemVer 匹配,不要求降级或切换通道 |
+| 客户端不再命中,包括升级后 | 返回无强制成功,不作废 updater 产物 |
+| 可解除要求的发布、页面或适用 feed 不可用 | 拒绝启用策略 |
+| 必填字段非法或服务失败 | 明确错误,不伪装为无强制成功 |
+| 不同客户端条件连续请求 | 不跨客户端串用缓存 |
+| 现有移动端请求 | 保持既有要求与响应结构 |
+
+## 风险
+
+后端需提供测试/生产地址、未登录网关访问、最终 HTTP/错误码映射、限流、真实页面、允许域名、兜底语言和策略配置负责人。发布负责人需逐平台协调 updater 版本可用与强制策略启用。这些输入仍待提供,不得用开发者凭据或猜测 URL 填充。

+ 6 - 0
.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md
+2026-09-08-desktop-update-extensions.md: 6c8a2ebe4a38809f6c3a7fb4741b3520111402c6
+2026-09-08-desktop-update-extensions.zh.md: 06096c8b28136ab2e22325857021926e06b3b17e

+ 113 - 0
.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md

@@ -0,0 +1,113 @@
+# Agent Note: Deferred Desktop update extensions
+
+Status: proposed
+
+English | [中文](2026-09-08-desktop-update-extensions.zh.md)
+
+## Problem
+
+Frequent releases may eventually benefit from independently revised Desktop packages, a stable subscription, next-launch installation, or replacing an older pending update. These choices introduce additional release and installer states that are not needed to ship the initial fixed-Nightly, explicitly approved installation flow.
+
+## Proposal
+
+Keep these candidates separate from the [initial update design](2026-09-08-desktop-update-policy-and-installation.md). None is an initial acceptance requirement or authorization to change dependencies. Product approval and platform qualification are required before implementation. The [mandatory-update API](2026-09-08-desktop-mandatory-update-api.md) remains independent; automatic installation does not add installer fields to it.
+
+### Independent revisions
+
+If Desktop-only fixes become necessary on a prerelease line, evaluate the reserved `.dsk.N` format. Keep full SemVer comparison so introducing it does not require a custom version parser. The intended ordering is:
+
+```text
+0.1.3-rc.2
+< 0.1.3-rc.2.dsk.1
+< 0.1.3-rc.2.dsk.2
+< 0.1.3-rc.3
+```
+
+Such a release would record Desktop `0.1.3-rc.2.dsk.1` separately from bundled dsh `0.1.3-rc.2`. Introduce independent `desktopVersion`, `bundledDshVersion`, and `releaseChannel` records only with corresponding build, runtime descriptor, profile reconciliation, publisher, and backend-policy validation. The initial build continues to require equal versions.
+
+Do not use `0.1.3-dsk.1` to repair stable `0.1.3`: SemVer orders it below the stable release. Stable fixes require a higher shared dsh/Desktop patch version, such as `0.1.4`. Independent prerelease revisions are an exceptional release path, not a routine per-build counter.
+
+### Stable subscription and channel switching
+
+| User option | Feed | Release contents |
+|---|---|---|
+| Stable | `latest.yml` / `latest-mac.yml` | Stable only |
+| Nightly | `nightly.yml` / `nightly-mac.yml` | Alpha, rc, and stable |
+
+The [initial publication rule](2026-09-08-desktop-update-policy-and-installation.md) adds the stable feed when shared stable releases exist, without switching fixed-Nightly clients. A user-facing stable subscription and selector remain deferred. If enabled, persist the selected channel; never infer it from the installed version. Preserve `allowDowngrade = false` when assigning the updater channel. Moving from Nightly to stable keeps a higher installed Nightly until a higher stable version exists, without downgrading Desktop or bundled dsh.
+
+Channel switching may immediately check; download behavior must follow the approved setting. Ignore old-channel responses and reconfirm downloaded artifacts against the new channel before installation. A channel change does not clear mandatory blocking without a valid policy response for the new conditions. Display a persistent Nightly explanation under the selector, with candidate Chinese copy “提前体验新功能,版本可能不稳定。”; hide it after selecting stable, while separately explaining any wait for a higher stable version.
+
+Design compatibility only for Desktop packages actually distributed. If a future migration has real clients reading `alpha.yml` or `rc.yml`, publish a higher bridge version to the old feed and Nightly until the old entry can be retired. No such bridge is required initially.
+
+### Automatic-installation setting
+
+The candidate setting is automatic installation, potentially enabled by default only after product approval and measured platform behavior. Candidate Chinese helper text is “关闭后仍会提示更新,需手动升级。” If next-launch installation is not acceptable, evaluate a download-only setting and retain explicit installation confirmation.
+
+| Setting | Newer version discovered | Later complete app launch |
+|---|---|---|
+| Enabled | Download and prepare automatically, including discovery from a manual check | Apply a valid pending update when installation conditions hold |
+| Disabled | Continue checking and prompting; wait for explicit download | Do not install automatically; keep the manual path |
+
+Automatic download and installation in this table are deferred product choices; the initial release requires user action for every download and separate installation approval. Turning a future automatic setting off prevents new automatic downloads and installations; an active download may complete without a new cancel button. Determine whether platform-staged installation can be cancelled before promising immediate effect. Never proactively exit a running app or bypass task-impact approval. Preserve initial check triggers and feedback, without adding network-recovery checks or unsolicited global indicators.
+
+### Next-launch installation
+
+Target a complete process launch, not reopening a window or reloading the renderer. Prefer installation before restoring business tasks or starting local dsh; surviving managed resources require explicit shutdown approval. A cached ZIP or installer is not proof of completed native preparation. Surface extraction, verification, and preparation honestly without fabricated progress.
+
+Use the already prepared target instead of delaying launch for a fresh package download. A bounded check may discover a newer candidate and defer this installation while the current app opens, but only if the platform still permits abandoning the staged target. Native installer handoff may prevent cancellation or retargeting; never simulate it by deleting native staging directories. Measure download-to-ready, click-to-exit, replacement, and new-version profile reconciliation and Host startup separately.
+
+### Updater dependency qualification
+
+The initial dependency declaration remains in [Desktop package metadata](../../../../apps/desktop/package.json). A coordinated electron-builder 27 / electron-updater 7 migration is an evaluation candidate, not a dependency bump authorized here. The following API and platform items require verification against the selected release and real packages; this proposal does not claim they have been exercised locally.
+
+| Candidate item | Required qualification |
+|---|---|
+| `autoInstallEvent` | Verify `manual`, `onQuit`, and `onNextLaunch`; migration alone must retain manual installation |
+| Object-form `quitAndInstall` | Verify `isSilent` and `isForceRunAfter`, plus macOS-specific semantics |
+| Next-launch cache | Re-fetch metadata and validate artifact hashes and platform signatures before installing |
+| Windows per-user NSIS | Verify next-launch behavior without UAC; do not assume per-machine installs are silent |
+| Windows session ending | Verify shutdown/restart/logoff does not launch unsafe on-quit installation; NSIS is not assumed atomic |
+| macOS Squirrel staging | Verify native preparation, quit/relaunch replacement, signing, notarization, and both architectures |
+| Metadata and security defaults | Inspect modern `files[]`, legacy `path`/`sha512` consumers, `sha2` retirement, and NSIS Web Installer defaults |
+| Build-tool migration | Verify Node engine requirements, including the candidate 22.12 floor, native ESM, configuration, signing hooks, and publisher compatibility |
+| Distributed old clients | Exercise their real feeds and payloads before changing metadata; do not infer compatibility from dependency version alone |
+
+### Multiple pending versions
+
+The candidate policy follows the latest eligible version without exposing a package collection to users. Own only one effective download/install target; temporary files do not promise an older installable fallback.
+
+| Situation | Candidate behavior |
+|---|---|
+| A downloading when C is found | Do not repeatedly cancel A; handle newer candidates in a subsequent check |
+| A ready, automatic setting enabled, C found | Prepare C and replace A's install entry with C progress |
+| A ready, automatic setting disabled, C found | Show C; start its download only on user action |
+| D appears during C download | Do not interrupt C; consider D later |
+| Installation and task shutdown approved | Lock the chosen target against background replacement |
+| C preparation fails after replacing A | Keep running the installed app, show error/retry, and do not promise installation of A |
+
+Reconfirm platform support before abandoning a prepared target. Cache identity includes artifact metadata and hashes, not just a version string. Replacement after readiness is application coordination, not an assumed automatic capability of the updater library. It must be separately tested with channel changes, new releases, retries, and installer handoff.
+
+### Cache invalidation and revocation
+
+Reuse verified complete cache; do not promise partial-download resumption or preservation of an older installable package. Publish a higher fix for a defective release and update the feed. Metadata mismatch may invalidate some cache but is not strict revocation: already staged or installing artifacts may still apply. Continuing to run the installed app after failure is different from installing an older cache. Do not implement an independent artifact revocation list or pre-install revocation interception. The mandatory API decides whether upgrading is required, not which updater artifact to install or invalidate.
+
+## Alternatives considered
+
+**Enable every extension in the first release.** This adds unqualified installer and release paths before a Desktop distribution exists. Fixed Nightly and explicit installation provide the smaller initial scope.
+
+**Treat changing a setting as cancellation.** Native staging may already own installation. Product wording and state transitions must reflect verified cancellation capability.
+
+## Acceptance criteria
+
+- Approve each extension independently; retain initial behavior until its acceptance criteria are satisfied.
+- Test independent revision ordering and all consumers of split versions before releasing `.dsk.N`.
+- Verify both channel publication, persistent selection, stale-response rejection, cache revalidation, and no downgrades.
+- Measure real Windows per-user and macOS x64/arm64 update preparation, termination, next launch, native staging, profile reconciliation, and Host startup.
+- Prove the disabled setting's effect on active and staged updates before promising immediate revocation.
+- Cover A-to-C replacement, C failure, intervening D, channel switching, and locked installation targets.
+- Preserve task approval, honest progress, silent automatic-check failures, and recovery to the usable installed app; do not block normal startup on network failure.
+
+## Risks
+
+Native installer ownership limits cancellation and replacement. Faster startup cannot be guaranteed from API availability alone, and metadata replacement is not a universal rollback mechanism. Library API candidates, security defaults, build-tool engines, and old-client compatibility need release-specific verification; none is a verified product capability merely because it appears in this proposal.

+ 113 - 0
.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md

@@ -0,0 +1,113 @@
+# Agent Note: Desktop 更新后续扩展
+
+Status: proposed
+
+[English](2026-09-08-desktop-update-extensions.md) | 中文
+
+## 问题
+
+高频发布后续可能需要 Desktop 独立修订、稳定版订阅、下次启动安装或替换较旧待安装更新。这些选择引入额外发布与安装器状态,初期固定 Nightly、明确确认安装的流程并不需要它们。
+
+## 提案
+
+将这些候选与[一期更新设计](2026-09-08-desktop-update-policy-and-installation.zh.md)分开。它们不是一期验收要求,也不授权修改依赖;实现前需要产品批准与平台验证。[强制更新接口](2026-09-08-desktop-mandatory-update-api.zh.md)继续独立,自动安装不向其中增加安装器字段。
+
+### 独立修订
+
+预发布线上确有 Desktop 独立修复需求时,评估预留的 `.dsk.N` 格式。保持完整 SemVer 比较,使引入该格式不需要自定义版本解析器。预期排序为:
+
+```text
+0.1.3-rc.2
+< 0.1.3-rc.2.dsk.1
+< 0.1.3-rc.2.dsk.2
+< 0.1.3-rc.3
+```
+
+这类发布将 Desktop `0.1.3-rc.2.dsk.1` 与内置 dsh `0.1.3-rc.2` 分别记录。只有同时补齐构建、运行时描述、profile 校准、发布工具与后端策略校验,才引入独立的 `desktopVersion`、`bundledDshVersion` 和 `releaseChannel` 记录。一期构建继续要求版本相等。
+
+不能用 `0.1.3-dsk.1` 修复稳定版 `0.1.3`,因为 SemVer 将其排在稳定版之前。稳定版修复需要更高的共用 dsh/Desktop 补丁版本,例如 `0.1.4`。独立预发布修订是特殊发布路径,不是每次构建的常规计数器。
+
+### 稳定版订阅与通道切换
+
+| 用户选项 | Feed | 发布内容 |
+|---|---|---|
+| 稳定版 | `latest.yml` / `latest-mac.yml` | 仅稳定版本 |
+| Nightly | `nightly.yml` / `nightly-mac.yml` | alpha、rc 和稳定版本 |
+
+[一期发布规则](2026-09-08-desktop-update-policy-and-installation.zh.md)在共用稳定版本出现时增加稳定 feed,不切换固定 Nightly 客户端。面向用户的稳定版订阅与选择器仍延后。启用后持久化所选通道,不从已安装版本反推。设置 updater 通道时保持 `allowDowngrade = false`。从 Nightly 切到稳定版时,较高的已安装 Nightly 持续运行,直到出现更高稳定版;不得降级 Desktop 或内置 dsh。
+
+切换通道可以立即检查,下载行为必须遵循获准设置。忽略旧通道响应,安装前按新通道重新确认已下载产物。通道改变不能在缺少新条件有效策略响应时解除强制阻断。选择器下方常驻 Nightly 说明,候选中文为“提前体验新功能,版本可能不稳定。”;选择稳定版后隐藏,必要时另行说明需等待更高稳定版。
+
+仅为实际分发过的 Desktop 安装包设计兼容。后续迁移若存在读取 `alpha.yml` 或 `rc.yml` 的真实客户端,应向旧 feed 与 Nightly 发布更高桥接版本,直到可淘汰旧入口。一期不需要这类桥接。
+
+### 自动安装设置
+
+候选设置为自动安装;仅在产品批准与平台实测后考虑默认开启。候选中文辅助文案为“关闭后仍会提示更新,需手动升级。” 若下次启动安装体验不满足要求,则评估仅自动下载的设置,并保留明确安装确认。
+
+| 设置 | 发现更高版本 | 后续完整启动应用 |
+|---|---|---|
+| 开启 | 自动下载与准备,包含手动检查发现的版本 | 安装条件满足时应用有效待安装更新 |
+| 关闭 | 继续检查与提示,等待明确下载操作 | 不自动安装,保留手动入口 |
+
+表中的自动下载与安装是后续产品选择;一期每次下载都需要用户操作,安装另行确认。关闭未来的自动设置后不发起新的自动下载与安装;在途下载可以完成,不新增取消按钮。承诺立即生效前需确认能否取消平台已暂存安装。不得主动退出使用中的应用或绕过任务影响确认。沿用一期检查触发与反馈,不新增网络恢复检查或未经确认的全局提示。
+
+### 下次启动安装
+
+目标是完整进程启动,不是重开窗口或刷新渲染层。优先在恢复业务任务或启动本地 dsh 前安装;仍存活的受管资源需要明确关闭确认。已缓存 ZIP 或安装器不证明原生准备完成。如实展示解压、校验和准备,不伪造进度。
+
+使用已经准备好的目标,不为下载新包延迟启动。限时检查若发现更高候选,且平台仍允许放弃暂存目标,可以推迟本次安装并打开当前应用;原生安装器接管后可能无法取消或改换目标,不得通过删除原生暂存目录模拟。分别测量下载到就绪、点击到退出、应用替换,以及新版本 profile 校准与 Host 启动耗时。
+
+### updater 依赖验证
+
+一期依赖声明仍由 [Desktop 包元数据](../../../../apps/desktop/package.json)负责。electron-builder 27 / electron-updater 7 配套迁移是评估候选,不是本文授权的依赖升级。以下 API 与平台项目需针对选定版本和真实安装包核验;本提案不宣称已在本机执行验证。
+
+| 候选项目 | 必需验证 |
+|---|---|
+| `autoInstallEvent` | 核验 `manual`、`onQuit` 与 `onNextLaunch`;仅迁移依赖必须保留手动安装 |
+| 对象参数形式的 `quitAndInstall` | 核验 `isSilent`、`isForceRunAfter` 及 macOS 特定语义 |
+| 下次启动缓存 | 安装前重新读取元数据,校验产物哈希与平台签名 |
+| Windows 每用户 NSIS | 验证无需 UAC 的下次启动行为;不假定每机器安装可静默 |
+| Windows 会话结束 | 验证关机/重启/注销不启动不安全的退出安装;不假定 NSIS 原子性 |
+| macOS Squirrel 暂存 | 验证原生准备、退出/重启替换、签名、公证与两种架构 |
+| 元数据与安全默认值 | 检查现代 `files[]`、旧 `path`/`sha512` 消费者、`sha2` 淘汰与 NSIS Web Installer 默认值 |
+| 构建工具迁移 | 核验 Node 引擎要求,包括候选 22.12 下限,以及原生 ESM、配置、签名 hook 与发布工具兼容 |
+| 已分发旧客户端 | 改元数据前实测其真实 feed 与载荷,不仅按依赖版本推断兼容 |
+
+### 多个待安装版本
+
+候选策略跟随最新适用版本,不向用户提供安装包集合管理。同一时刻仅持有一个有效下载/安装目标;暂存文件不承诺可安装的旧版本兜底。
+
+| 场景 | 候选行为 |
+|---|---|
+| A 下载时发现 C | 不反复取消 A,后续检查处理更新候选 |
+| A 就绪、自动设置开启、发现 C | 准备 C,用 C 进度替换 A 安装入口 |
+| A 就绪、自动设置关闭、发现 C | 显示 C,仅在用户操作后开始下载 |
+| C 下载时出现 D | 不打断 C,之后再考虑 D |
+| 已确认安装与任务关闭 | 锁定所选目标,后台不能替换 |
+| 替换 A 后准备 C 失败 | 继续运行已安装应用,显示错误重试,不承诺安装 A |
+
+放弃已准备目标前重新确认平台支持。缓存身份包含产物元数据与哈希,不仅是版本字符串。就绪后的替换是应用协调逻辑,不是假定 updater 库自动提供的能力;必须结合通道变化、新发布、重试与安装器交接单独测试。
+
+### 缓存失效与撤回
+
+复用已校验完整缓存,不承诺部分下载续传或保留旧可安装包。问题版本通过发布更高修复版本并更新 feed 处理。元数据不匹配可能使部分缓存失效,但不是严格撤回:已暂存或安装中的产物仍可能被应用。失败后继续运行已安装应用不同于安装旧缓存。不实现独立的安装包撤回清单或安装前撤回拦截。强制 API 判定是否必须升级,不决定安装或作废哪个 updater 产物。
+
+## 考虑过的替代方案
+
+**首版启用全部扩展。** 在尚无 Desktop 分发时增加未经验证的安装与发布路径。固定 Nightly、明确确认安装提供较小一期范围。
+
+**将修改设置视作取消。** 原生暂存可能已经持有安装操作。产品文案与状态转换必须反映经过验证的取消能力。
+
+## 验收标准
+
+- 独立批准每项扩展;满足对应验收前保持一期行为。
+- 发布 `.dsk.N` 前测试独立修订排序及所有版本拆分消费者。
+- 验证两个通道发布、选择持久化、拒绝陈旧响应、重新校验缓存和禁止降级。
+- 实测 Windows 每用户安装与 macOS x64/arm64 的更新准备、终止、下次启动、原生暂存、profile 校准与 Host 启动。
+- 承诺即时撤销前证明关闭设置对在途及暂存更新的实际影响。
+- 覆盖 A 替换为 C、C 失败、期间出现 D、通道切换与安装目标锁定。
+- 保留任务确认、真实进度、自动检查失败静默和恢复到可用已安装应用的行为;不因网络失败阻断正常启动。
+
+## 风险
+
+原生安装器归属限制取消与替换。API 存在本身不能保证启动更快,元数据替换也不是通用回滚机制。库 API 候选、安全默认值、构建引擎与旧客户端兼容性需要按发布版本核验;写入本提案不代表已验证为产品能力。

+ 6 - 0
.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md
+2026-09-08-desktop-update-policy-and-installation.md: b2de5500f4e5faaa99c03211480b5d2e35a2b222
+2026-09-08-desktop-update-policy-and-installation.zh.md: 8692c790fb992cdb72095a14f206514c17297727

+ 185 - 0
.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md

@@ -0,0 +1,185 @@
+# Agent Note: Desktop update policy and installation
+
+Status: proposed
+
+English | [中文](2026-09-08-desktop-update-policy-and-installation.zh.md)
+
+## Problem
+
+Desktop usually runs a local dsh server, so remote business errors cannot reliably deliver mandatory-update policy. Users need automatic discovery, user-initiated downloads, visible preparation state, and separate restart approval that accounts for running tasks.
+
+## Proposal
+
+This is the review entry for the initial Desktop update design, not a statement that the feature or backend integration has shipped. The documents below contain the complete proposal; review and implementation do not require local planning folders, mobile repository checkouts, screenshots in temporary folders, backup patches, or machine-specific configuration.
+
+| Document | Owns |
+|---|---|
+| This proposal | Initial scope, regular and mandatory interaction, versioning, publication, implementation order, verification |
+| [Mandatory-update API](2026-09-08-desktop-mandatory-update-api.md) | Request and response fields, server policy requirements, backend integration checklist |
+| [Deferred extensions](2026-09-08-desktop-update-extensions.md) | Independent Desktop revisions, channel switching, automatic installation, replacement of pending updates |
+
+### Initial scope
+
+The [mandatory client decision](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.md) and [local verification record](../../../../apps/desktop/tests/README.md) describe implemented client behavior and its evidence. Release qualification, backend integration, and unresolved product choices keep this proposal active.
+
+Regular updates follow check → offer update → user starts download → verification and preparation → separate installation approval → installation and restart → confirmation that the new version started. Every initial download, including retries and mandatory-update downloads, requires user action; checks never authorize predownloads. Mandatory updates use remote policy checks and a blocking dialog. The intended integration reuses the regular updater after real-package qualification, while retaining the configured official download page as a fallback. Before qualification, the page path remains usable without claiming in-app installation works.
+
+All initial clients use fixed Nightly because Desktop follows the dsh CLI release line, whose initial versions are prereleases. There is no channel selector, automatic-installation setting, installation on ordinary quit or next launch, or required stop-download button. No Desktop release has been distributed, so existing npm dsh releases do not require a Desktop channel bridge. Independent npm installations are outside this update flow; additional pre-upgrade plugin compatibility checks and post-update What's New are not initial requirements. Existing profile validation and startup recovery remain active.
+
+### Version and publication rules
+
+Desktop, the bundled dsh, and the private Host keep the single release version required by [release packaging](../../../../apps/desktop/src/release.ts). Compare complete SemVer values and install only higher versions; never sort lexically or discard prerelease identifiers. Independent `.dsk.N` revisions are deferred, not an exception to the initial equality rule.
+
+| dsh | Desktop | Channel |
+|---|---|---|
+| `0.1.3-alpha.2` | `0.1.3-alpha.2` | `nightly` |
+| `0.1.3-rc.2` | `0.1.3-rc.2` | `nightly` |
+| `0.1.3` | `0.1.3` | `nightly` |
+
+Configure `detectUpdateChannel = false` and publish `nightly.yml` on Windows and `nightly-mac.yml` on macOS explicitly. Nightly carries alpha, rc, and subsequent stable releases. Once shared stable versions are published, also publish `latest.yml` / `latest-mac.yml` with the same signed stable artifacts. Creating that stable feed does not switch existing Nightly clients or introduce a selector. Runtime selection must keep `allowDowngrade = false` after selecting the channel; a missing Nightly feed must not silently select another channel.
+
+Use the generic provider with a configured HTTPS update source. Windows uses a signed NSIS package; macOS uses signed and notarized ZIP update payloads, with DMG retained for manual distribution. Channel YAML points to the latest version and carries file URLs, SHA-512, sizes, and update information; it is not a chain of mandatory intermediate versions. The mandatory-policy page URL is never an updater feed.
+
+For each platform, validate the shared version and target, build and sign the artifacts, generate metadata, upload immutable versioned packages and blockmaps, verify remote availability and hashes, and only then publish the mutable channel YAML. Use no-cache or short-cache policy for YAML. Do not publish credentials, certificate paths, token PINs, or developer-specific bucket settings in design documents.
+
+### Regular checks
+
+| Trigger | Rule |
+|---|---|
+| Startup | Check asynchronously after basic initialization; do not delay opening the app |
+| Foreground or system resume | Best effort; check only when the configured interval has elapsed |
+| Continuous operation | Initially every 10 minutes, configurable; the final default remains pending |
+| Top application menu | Check immediately, joining an existing check rather than issuing another |
+| Network recovery | No trigger |
+
+One main-process coordinator owns updater checks and downloads across all windows. At most one updater check is in flight. Manual requests bypass the interval but not in-flight deduplication, and retain manual feedback when joining an automatic request. An active download must not be started again or retargeted by another trigger. Mandatory-policy requests have a separate schedule and in-flight request; the updater interval does not set backend policy frequency.
+
+Automatic checks with no update or a check failure remain silent and leave the app usable. A manual check displays a checking dialog, then no-update feedback with the actual installed version, a failure dialog with retry, or the update flow. No known result must not be presented as already up to date.
+
+### Download, preparation, and UI
+
+On discovery of an applicable newer version, offer a download action without fetching its package. Keep `autoDownload = false` and `autoInstallOnAppQuit = false`. Only an explicit download or retry action authorizes the coordinator to call `downloadUpdate()` for the selected target. Startup, periodic checks, manual checks, restored state, and mandatory-policy responses do not authorize new downloads. These are implementation requirements, not a claim that the current coordinator already implements the proposed flow.
+
+Use `download-progress` for real progress and wait for `update-downloaded` plus any required platform preparation before offering installation. Download reaching 100% is not readiness or installation progress. Show phase text rather than a download percentage during verification or preparation. The main process sends state to the UI through restricted IPC; the renderer does not choose arbitrary download URLs or execute installation commands.
+
+| State or action | Required presentation; approved Chinese copy |
+|---|---|
+| Manual checking | Dialog: “正在检查更新” |
+| Manual check finds no update | Dialog: “当前暂无可用更新”; “当前版本:Vx.x.x” uses the real installed version |
+| Manual check fails | Failure dialog with retry and persistent lower-left error indicator; final wording pending |
+| Available, not downloaded | Lower-left “检查到新版本” entry starts downloading on click, without another confirmation; a manual-check result offers “下载更新” |
+| Downloading | Lower-left account row, spinner and real percentage such as “58%...” |
+| Verification or preparation | “正在校验更新文件”; no fabricated percentage or premature “安装中” |
+| Ready | Download completion automatically opens “安装并重启” confirmation; deferring retains a blue lower-left entry that reopens confirmation |
+| Download, preparation, or observable installation failure | Persistent red icon/dot and “重试更新”; hover or keyboard focus shows the failure and recovery tip; do not display ready |
+| Installation approved | Lock target and show applicable preparation state; no promise of in-app progress after exit |
+
+The menu and lower-left states are sufficient for the initial flow; do not add a settings-page check entry. Backend connection feedback has priority in the shared account-row status position; its recovery interaction remains owned by the connection UI. Preserve update state and cache while hidden. Do not label updater failures as backend connection errors. A collapsed sidebar shows the update dot on its top expand button, red on failure. Dialogs use the centered white card, rounded corners, black primary action, and dimmed, blurred background from [Figma](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2351-18426). Release-note placement remains a design follow-up.
+
+Errors must remain discoverable in the lower-left entry, not only in a transient toast. Tips distinguish network/timeout, insufficient disk space, hash/signature rejection, and observable preparation or installation errors; retry is user-initiated and never bypasses validation. Automatic check failures remain silent; manual check failures retain their requested dialog feedback. A mandatory dialog takes priority over ordinary feedback; retain its block and show error/retry feedback inside it as well, since the sidebar is inaccessible. Do not stack an ordinary error dialog. Installer handoff or a failed new-version launch may prevent the old UI from reporting anything; no automatic recovery or rollback is promised. Route client-owned copy through locale dictionaries; server content is plain text.
+
+### Installation and task safety
+
+Download approval is separate from installation approval. The first download click starts transfer and preparation; readiness automatically opens restart confirmation, so installation still requires a second click. Deferring retains readiness without repeatedly opening the dialog; the update entry lets users reopen it. A complete reusable cache still requires confirmation. Inspect unfinished tasks before presenting the confirmation and recheck at handoff. With no affected tasks, the user confirms “安装并重启”; with affected tasks, use the following confirmed Chinese copy. English wording remains subject to localization review.
+
+| Element | Chinese copy |
+|---|---|
+| Title | 仍有进行中的任务 |
+| Body | 重启更新可能中断这些任务,是否要继续更新。 |
+| Defer | 稍后更新 |
+| Confirm | 停止任务并更新 |
+
+Task protection uses live local-server state, not transcript wording. The [Session Controller types](../../../../packages/api/session-controller/src/types.ts) expose session `running` and background-job status, and [session-list construction](../../../../packages/api/session-controller/src/list.ts) derives `running` from the agent lifecycle. Include live running sessions, their in-flight approval/question waits, and affected running or stopping managed jobs. The [question tool](../../../../packages/interaction/tool-ask-user/src/index.ts) awaits an answer before returning; this differs from a completed turn merely inviting the next user message. If the API reports no active work or pending interaction, do not infer an unfinished task from such prose. Missing or failed state reads are unknown, not completed; report the failure and defer installation. Implementation must verify coverage across all local sessions and managed resources rather than relying only on the selected session.
+
+Do not stop tasks before confirmation. Deferring keeps tasks and valid download cache. At installation handoff, lock the target, reject new managed tasks, and recheck affected resources so tasks started during confirmation are not silently terminated. Save necessary state and stop application-owned dsh processes and other affected managed resources. Stop or save failures abort handoff and return a recoverable error; approval is not permission to proceed after failed cleanup.
+
+After successful preparation, the intended call is `quitAndInstall(true, true)` for silent Windows installation followed by relaunch; macOS replacement and restart require separate platform qualification. Calling the API is not evidence of successful installation. After restart, verify the actual new Desktop version and complete runtime-bound profile reconciliation and Host startup before showing the product UI. The shell loading window may appear immediately. Use the existing in-place profile and recovery flow; do not reinstall a core seed, introduce a second health-check Host, or add automatic profile rollback. Download completion, ordinary quit, process termination, and a later app launch do not independently authorize installation.
+
+### Cache and target ownership
+
+Reuse a complete cached artifact only after validating its identity, metadata, and integrity; persisted UI state is insufficient. Incomplete transfers may restart after a new user download action, with no promise of resumable downloads. A complete package may still need extraction or native preparation after app restart; preparation does not authorize installation or a replacement download.
+
+Pending product confirmation, the proposed conservative rule retains A while A downloads or is ready, even if a check finds C. Never silently turn confirmation to install A into installation of C. A mandatory response does not independently select or invalidate the updater target. Automatic target replacement is deferred. Handle defective releases by publishing a higher fixed version and updating the feed, not an artifact revocation list or a pre-install revocation interception. Publishing a newer version does not guarantee that an already downloaded or staged older package cannot install; hash and signature checks still apply.
+
+### Mandatory policy and blocking
+
+The [API proposal](2026-09-08-desktop-mandatory-update-api.md) owns all wire fields. Query it independently of local dsh requests at startup, on configurable periodic ticks, on manual checks, and best effort on foreground or resume after the minimum interval. Coalesce matching requests, apply configurable jitter and failure backoff, and bind responses to the requesting app identity, version, platform, architecture, and channel. Old responses cannot overwrite a changed context. Do not query on network recovery or suppress queries because a dialog was already shown. Continue querying while blocked to receive the current mandatory decision and content; this query is not an artifact revocation check.
+
+A valid mandatory response immediately blocks subsequent Desktop operations without stopping existing tasks or the dsh process. Users cannot bypass the dialog with cancellation, Esc, or its backdrop, but may exit the app. This is a Desktop UI restriction, not a claim to prevent independent CLI clients from accessing the local server.
+
+The dialog renders the server title and detail as plain text, with locale-owned defaults when absent. Product previews use ordinary copy; literal HTML-tag inputs belong in dedicated safety tests. The approved normal flow uses one persistent modal without a close control, raw URL, or permanent refresh, browser, copy, and quit button grid. Application exit remains available through the operating system or application menu. The following presentation and background-attention decisions are implemented by the client; the implemented client decision and verification record distinguish local evidence from outstanding installed-platform acceptance.
+
+| Mandatory normal state | Content and action; approved Chinese copy |
+|---|---|
+| Checking for an artifact | “正在检查可用更新…”; loading state without duplicate submission |
+| Available | Target version and “下载更新”; this click authorizes the download |
+| Downloading | Real progress such as “正在下载更新,58%”; no stop-download requirement |
+| Verifying update files, including preparation | “正在校验更新文件…”; no separate preparation stage, fabricated percentage, or installation action |
+| Ready, no affected tasks | “更新已准备就绪,安装后将重新启动应用。” and “安装并重启” |
+| Ready, affected tasks | The approved task-impact warning, “停止任务并更新”, and secondary “稍后更新” |
+| Installation approved | “正在准备重启”; show “正在安全结束应用中的任务。” only while stopping affected tasks, otherwise “应用即将重启,请稍候。”; no duplicate submission while preparing handoff |
+
+File verification and required package preparation share one user-visible stage. Both must finish before the dialog offers installation; combining their presentation does not remove either operation. Task-stopping feedback follows actual affected tasks, not pending read-only requests.
+
+Readiness changes the existing mandatory modal into installation confirmation; it does not replace the modal or add an identical confirmation after the install click. Task inspection must finish before that click can authorize installation. Unknown task state is not idle. The usual flow has one download click and one installation click; new tasks discovered at handoff still require renewed approval. Deferral retains the modal, existing tasks, and valid cache without repeatedly presenting task confirmation. The user can request confirmation again from the retained ready state. Keyboard focus must not move onto an installation button automatically, and a held key or the click that started downloading cannot confirm a later installation state.
+
+### Background installation attention
+
+Approved scope: mandatory updating when readiness requires a second user decision while neither the product window nor its mandatory modal is focused. Update the same modal without restoring a minimized window, switching applications or desktops, or stealing focus. Windows requests attention on the parent taskbar window using [flashFrame](https://www.electronjs.org/docs/latest/api/browser-window#winflashframeflag); macOS requests a short [informational Dock bounce](https://www.electronjs.org/docs/latest/api/dock#dockbouncetype), not a continuous critical bounce. Returning to the confirmation clears the outstanding attention request.
+
+Also attempt one silent system notification per readiness episode. Suggested copy is “更新已准备就绪” / “返回应用确认安装并重启。”, with no task contents. A notification click only returns to the current modal; it never downloads, stops tasks, or installs. Recheck current state on click so an old notification cannot confirm an obsolete target. Repeated state publication, polling, task-count changes, foreground/background switches, and deferral do not send another notification for the same readiness episode. Policy clearance, installation, and disposal clear owned reminders. Errors and renewed reminder scheduling are not part of this approved readiness trigger.
+
+[Electron notification platform requirements](https://www.electronjs.org/docs/latest/tutorial/notifications#platform-considerations) require installed Windows application identity/shortcut configuration and signed macOS notification qualification. Notification permissions, system focus modes, and OS preferences can suppress visible reminders; requesting attention is not proof that a user saw it. Notifications must not become a prerequisite for upgrading or trigger repeated permission prompts. Keep the ready modal available when the user returns. Verify this behavior on installed Windows and macOS applications, including minimized/hidden windows, denied notifications, system focus modes, and stale notification clicks; API documentation is not platform acceptance evidence.
+
+### Mandatory failure recovery and browser fallback
+
+The failure layout keeps a localized reason and stage-specific retry inside the same blocking modal, with sanitized technical details collapsed by default. Artifact-check failure or no applicable release offers another check; transfer or preparation failure offers explicit download/preparation retry; unknown task state or failed task shutdown requires a fresh task inspection and installation confirmation. A background policy-refresh failure must not discard an applicable active download or valid ready package. Neither retry nor external navigation authorizes installation or clears mandatory policy. Installer handoff can prevent the old application from reporting later failures.
+
+Before in-app qualification, retain the page-only fallback. In the integrated design, show “前往官网下载” when the updater is unavailable, times out, has no applicable release, or cannot complete download/preparation; do not keep it beside the normal ready action. The main process validates the configured HTTPS destination against allowed download origins before either opening or copying it. Missing or disallowed links cannot be opened or copied. The page is never a command or an updater feed; opening it does not clear the block.
+
+Browser-recovery interaction: immediately after the user requests the website, offer “若页面未打开,可复制下载链接”, “复制下载链接”, and “重新打开”, regardless of whether the OS reports an error. Keep the raw URL hidden unless copying fails. The [Electron shell implementation](https://raw.githubusercontent.com/electron/electron/main/shell/common/api/electron_api_shell.cc) resolves or rejects from the platform open callback; it does not observe the external browser's page loading. Handle an explicit rejection with “无法打开浏览器,请复制下载链接后手动打开。”; a resolved request must not produce “页面已打开”. Do not infer success from app blur, browser process presence, or a separate HTTP probe. Navigation outcome stays separate from updater errors, and copy success only changes its own feedback. Verification must include a resolved open request whose page never loads, a rejected request, a pending request with usable copy recovery, and failed copying.
+
+Copy-failure recovery: show “复制失败,请手动选择下方地址复制。” followed by the complete validated download URL in a read-only, keyboard-selectable field. Wrap long addresses without ellipsis. Keep copy retry available; successful copying hides the manual-copy field. Revealing the address neither confirms browser navigation nor releases the mandatory-update block.
+
+Without a known mandatory policy, background policy-check failure leaves business operations available; manual failure is visible. With a known policy, request failure, malformed data, browser navigation, download completion, and installer invocation do not clear the block. Only a fresh successful no-force response for the current client conditions clears it; after upgrading, query using the actual new version. Persisting this block across same-version offline restarts remains a proposed behavior awaiting product confirmation.
+
+When the regular updater is qualified for in-app mandatory updating, show the blocking dialog before asynchronous updater checking and bound the check so a stalled request reaches browser fallback. `40005` determines that upgrading is mandatory; updater metadata determines the actual version and artifact under the shared version, cache, and target rules. Do not add a separate client-side mandatory-version check or artifact revocation check. Reuse suitable user-initiated in-flight downloads or verified complete cache; otherwise offer “下载更新” and wait for a click. Expose installation only after verification and separate restart approval, preserving task-impact confirmation. Timeout, no available update, or download/preparation failure retains the blocking dialog, error/retry feedback, and official-page fallback. Release owners publish a resolving updater release before enabling the corresponding mandatory requirement.
+
+### Diagnostics and implementation order
+
+Record trigger, channel, current and candidate versions, failure phase, and policy result without tokens or signing credentials. Candidate analytics names are `update_window_show` and `update_window_update`; a state refresh or polling response must not count as another dialog display. There is no mandatory-dialog cancel event.
+
+Implement in order: (1) the independently configured policy client and response tests; (2) the blocking page-link dialog and task-preserving behavior; (3) regular check/download/install separation, menu and sidebar UI, and task-impact confirmation; (4) real package qualification on every release target, then reuse the qualified updater in the mandatory dialog while retaining the page fallback. Backend origins, page allowlists, and signing secrets remain deployment inputs, never copied from a developer machine. Fixtures permit client development before backend integration, not claims of successful live integration.
+
+The [packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) retains signing, release identity, and publication integrity. The [bundled-runtime decision](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md), [in-place profile decision](../../implemented/architecture/2026-09-09-desktop-in-place-profile.md), and [immediate-window decision](../../implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md) own resource layout, profile reconciliation, and startup recovery. This proposal changes update interaction and policy delivery without replacing those mechanisms. Deferred release and automatic-installation choices remain in the [extension proposal](2026-09-08-desktop-update-extensions.md).
+
+## Alternatives considered
+
+**Automatic package predownload.** This consumes bandwidth before the user requests a download. Automatic discovery remains useful, but every initial transfer and retry requires user action independently of later installation approval.
+
+**Automatic restart or installation on ordinary quit.** This bypasses explicit task-impact approval. Automatic installation requires separate product authorization and platform verification.
+
+**One remote API controlling all updates.** Mandatory policy and ordinary artifact discovery have different responsibilities. Keep the policy page separate from updater metadata and reuse only the installation coordinator when qualified.
+
+## Acceptance criteria
+
+- Isolated fixtures with controlled clocks prove startup, interval, manual, foreground/resume, no network-recovery trigger, request merging, download reuse, and cleanup without late publications.
+- Owner-local UI expectations cover the menu, not-downloaded entry, separate download and restart actions, verification text, lower-left progress/readiness and persistent red error/retry tips, approved Chinese copy, locale routing, silent automatic checks, and visible manual failures.
+- Startup, periodic and manual checks, policy responses, failures, and app restarts never start a package transfer without a user download/retry action. Mandatory failures retain blocking. Invalid hashes or signatures prevent installation; no independent artifact-revocation check is required.
+- Blocking a policy preserves existing tasks. Installation only stops affected tasks after approval; include approval/question waits and background jobs, completed turns without active work, unknown task state, new-task races, save/stop failures, and target changes.
+- Complete-cache validation and repeated triggers do not duplicate download. No initial install is authorized by quit, restart, or download completion alone.
+- Release tests prove shared versions, fixed Nightly metadata, full SemVer ordering, artifact-before-metadata publication, and rejection of wrong signatures or hashes.
+- Real signed Windows x64 and macOS x64/arm64 packages demonstrate discovery, download, verification, process shutdown, install, restart, actual new-version Host startup, and runtime-bound profile reconciliation. A source-mode test is insufficient.
+- Backend integration validates guest access, forced and no-force responses, errors, and the API proposal's platform matrix; fixtures do not substitute for this evidence.
+
+## Risks
+
+| Open item | Current review position |
+|---|---|
+| Updater frequency | Configurable 10-minute starting point; final default pending |
+| Mandatory-policy frequency and origins | Backend integration inputs; independent from updater interval |
+| Offline restart after a mandatory response | Persistence is proposed, not confirmed |
+| Ready A followed by newer C | Retain A is a conservative proposal; automatic replacement is deferred |
+| Collapsed sidebar, English copy, release notes, preparation visuals | Require product/design follow-up |
+| Plugin compatibility | Additional pre-upgrade checks for minimum supported dsh versions are deferred; preserve existing peer validation and explicit startup recovery, without automatically disabling plugins to repair an installer failure |
+| Restart-only experience | Requires real signed-package qualification; not established by this document |

+ 185 - 0
.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md

@@ -0,0 +1,185 @@
+# Agent Note: Desktop 更新策略与安装
+
+Status: proposed
+
+[English](2026-09-08-desktop-update-policy-and-installation.md) | 中文
+
+## 问题
+
+Desktop 通常运行本地 dsh server,无法依靠远程业务错误可靠触达强制更新策略。用户需要自动发现、主动下载、可见的准备状态,以及考虑运行中任务影响的独立重启确认。
+
+## 提案
+
+本文是 Desktop 首期更新设计的 review 入口,不表示功能或后端联调已经完成。下列仓库内文档包含完整方案;review 与实现不依赖本机工作文档目录、移动端仓库副本、临时目录截图、备份 patch 或机器专属配置。
+
+| 文档 | 负责内容 |
+|---|---|
+| 本提案 | 一期范围、常规与强制交互、版本、发布、实现顺序与验证 |
+| [强制更新接口](2026-09-08-desktop-mandatory-update-api.zh.md) | 请求响应字段、服务端策略要求与后端联调清单 |
+| [后续扩展](2026-09-08-desktop-update-extensions.zh.md) | Desktop 独立修订、通道切换、自动安装及待安装更新替换 |
+
+### 一期范围
+
+[强更客户端决策](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)和[本地验证记录](../../../../apps/desktop/tests/README.zh.md)描述已实现的客户端行为与证据。发布验收、后端联调和未决产品选择使本提案继续有效。
+
+常规更新流程为检查 → 提示更新 → 用户点击下载 → 校验与准备 → 独立安装确认 → 安装并重启 → 确认新版本启动。一期所有下载,包括重试与强制更新下载,均由用户操作触发;检查不授权预下载。强制更新包含远程策略查询和阻塞弹窗。目标是在真实安装包验证通过后复用普通 updater,同时保留配置的官方下载页面作为兜底。验证通过前保持页面路径可用,不宣称应用内安装已经可用。
+
+初期所有客户端固定使用 Nightly,因为 Desktop 跟随 dsh CLI(命令行界面)的发布版本,而初期版本均为预发布。不提供通道选择、自动安装开关、普通退出或下次启动时安装,也不要求停止下载按钮。目前没有已分发 Desktop,已有 npm dsh 版本不构成 Desktop 通道桥接需求。独立安装的 npm 包不属于本更新流程;额外的升级前插件兼容预检与更新后的 What's New 不属于一期要求。现有 profile 校验与启动恢复继续生效。
+
+### 版本与发布规则
+
+Desktop、内置 dsh 与私有 Host 保持[发布打包](../../../../apps/desktop/src/release.ts)要求的单一版本。比较完整 SemVer,只安装更高版本;不得按字符串排序或丢弃预发布标识。独立 `.dsk.N` 修订属于后续扩展,不是一期版本相等规则的例外。
+
+| dsh | Desktop | 通道 |
+|---|---|---|
+| `0.1.3-alpha.2` | `0.1.3-alpha.2` | `nightly` |
+| `0.1.3-rc.2` | `0.1.3-rc.2` | `nightly` |
+| `0.1.3` | `0.1.3` | `nightly` |
+
+显式配置 `detectUpdateChannel = false`,Windows 发布 `nightly.yml`,macOS 发布 `nightly-mac.yml`。Nightly 包含 alpha、rc 与后续稳定版本。共用版本开始发布稳定版后,用相同签名产物同时发布 `latest.yml` / `latest-mac.yml`。产生稳定 feed 不会切换现有 Nightly 客户端,也不引入通道选择器。运行时选择通道后必须保持 `allowDowngrade = false`;Nightly 更新源不可用时不得静默选择其他通道。
+
+使用 generic provider 与配置的 HTTPS 更新源。Windows 使用签名的 NSIS 安装包;macOS 使用签名并公证的 ZIP 更新载荷,DMG 保留用于手动分发。通道 YAML 指向最新版本,携带文件 URL、SHA-512、大小和更新信息,不是必须逐个安装的中间版本链。强制策略的页面 URL 绝不是 updater feed。
+
+每个平台依次校验共用版本与目标、构建签名产物、生成元数据、上传带版本号的不可变安装包与 blockmap、验证远端可用性与哈希,最后发布可变通道 YAML。YAML 使用禁止缓存或短缓存策略。设计文档不包含凭据、证书本机路径、令牌 PIN 或开发者专属存储桶配置。
+
+### 常规检查
+
+| 触发方式 | 规则 |
+|---|---|
+| 应用启动 | 基础初始化后异步检查,不延迟打开应用 |
+| 回到前台或系统唤醒 | 尽力支持;仅在已达到配置间隔时检查 |
+| 持续运行 | 暂定每 10 分钟,可配置;最终默认值待确认 |
+| 顶部应用菜单 | 立即检查;已有检查时加入该请求,不另发请求 |
+| 网络恢复 | 不触发 |
+
+主进程中的一个协调器持有所有窗口的 updater 检查与下载,同一时刻最多一个 updater 检查在途。手动请求绕过间隔但不绕过去重;加入自动请求时仍提供手动反馈。在途下载不能被其他触发点重复启动或更换目标。强制策略请求具有独立调度与在途请求;updater 间隔不决定后端策略查询频率。
+
+自动检查无更新或检查失败时静默,应用保持可用。手动检查先显示检查中弹窗,随后显示包含实际安装版本的无更新反馈、可重试的失败弹窗,或进入更新流程。未知结果不得显示为已是最新。
+
+### 下载、准备与界面
+
+发现适用的更高版本后,提供下载操作,不下载安装包。保持 `autoDownload = false` 与 `autoInstallOnAppQuit = false`。只有明确的下载或重试操作才授权协调器针对选定目标调用 `downloadUpdate()`。启动、周期检查、手动检查、状态恢复和强制策略响应均不授权新下载。这些是实现要求,不表示当前协调器已经实现提案流程。
+
+使用 `download-progress` 展示真实进度,等待 `update-downloaded` 和必要的平台准备完成后才提供安装。下载达到 100% 不等于就绪,也不是安装进度。校验或准备期间显示阶段文案,不沿用下载百分比。主进程通过受限 IPC 向 UI 传递状态;渲染层不选择任意下载 URL 或执行安装命令。
+
+| 状态或操作 | 必需展示;已确认中文文案 |
+|---|---|
+| 手动检查中 | 弹窗:“正在检查更新” |
+| 手动检查无更新 | 弹窗:“当前暂无可用更新”;“当前版本:Vx.x.x”使用实际安装版本 |
+| 手动检查失败 | 可重试的失败弹窗及持续的左下角错误提示;最终文案待补齐 |
+| 已发现,尚未下载 | 点击左下角“检查到新版本”即开始下载,不再确认;手动检查的结果弹窗提供“下载更新” |
+| 下载中 | 左下角账号行,加载图标与真实百分比,例如“58%...” |
+| 校验或准备中 | 显示“正在校验更新文件”;不伪造百分比,不提前显示“安装中” |
+| 就绪 | 下载完成自动打开“安装并重启”确认;推迟后保留蓝色左下角入口,可重新打开确认 |
+| 下载、准备或可观测的安装失败 | 持续显示红色图标/红点及“重试更新”;悬停或键盘聚焦显示原因与恢复提示,不显示就绪 |
+| 已确认安装 | 锁定目标并显示适用的准备状态;不承诺退出后的应用内进度 |
+
+顶部菜单和左下角状态足以承载一期流程,不增加设置页检查入口。共用账号行状态位优先显示后端连接反馈,其恢复交互仍由连接 UI 负责。更新状态被遮盖时保留状态与缓存。不得把 updater 失败标成后端连接异常。侧栏收起时,顶部展开按钮显示更新圆点,失败时变红。弹窗采用 [Figma](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2351-18426) 中的居中白色卡片、圆角、黑色主操作和暗化模糊背景。更新说明位置仍需补充设计。
+
+异常须持续可见于左下角入口,不能仅依靠短暂 toast。提示区分网络/超时、空间不足、哈希/签名拒绝,以及可观测的准备或安装错误;重试由用户操作触发,不绕过校验。自动检查失败仍静默,手动检查失败保留用户请求的弹窗反馈。强制弹窗优先于普通反馈;侧栏不可操作时,保留阻断并在弹窗内同时提供错误与重试,不叠加普通错误弹窗。安装器接管或新版本无法启动后,旧 UI 可能无法报告错误;不承诺自动恢复或回滚。客户端文案经本地化字典提供,服务端内容按纯文本展示。
+
+### 安装与任务保护
+
+下载授权与安装授权分开。第一次点击下载即开始传输与准备;就绪后自动打开重启确认,因此安装仍需第二次点击。推迟后保留就绪状态,不重复弹窗;用户可从更新入口再次打开。复用完整缓存时仍需确认。展示确认前检查尚未结束的任务,交接时再次检查。没有受影响任务时,用户确认“安装并重启”;有受影响任务时使用以下已确认中文文案。英文待本地化 review。
+
+| 元素 | 中文文案 |
+|---|---|
+| 标题 | 仍有进行中的任务 |
+| 正文 | 重启更新可能中断这些任务,是否要继续更新。 |
+| 推迟 | 稍后更新 |
+| 确认 | 停止任务并更新 |
+
+任务保护依据本地 server 实时状态,不根据对话措辞推测。[Session Controller 类型](../../../../packages/api/session-controller/src/types.ts)提供会话 `running` 与后台任务状态,[会话列表构建](../../../../packages/api/session-controller/src/list.ts)从 agent(智能体)生命周期派生 `running`。纳入运行中的本地会话、其中尚未返回的审批/提问等待,以及受影响的运行中或停止中的受管后台任务。[提问工具](../../../../packages/interaction/tool-ask-user/src/index.ts)等待回答后才返回,这不同于一轮已结束、仅邀请用户发送下一条消息。API 未报告活动任务或待处理交互时,不从这类措辞推断未完成任务。状态读取缺失或失败表示未知,不表示完成;显示错误并推迟安装。实现需验证全部本地会话与受管资源的覆盖,不能只检查当前选中会话。
+
+未确认前不停止任务。稍后更新保留任务与有效下载缓存。安装交接时锁定目标、拒绝新的受管任务,并重新检查受影响资源,避免静默终止确认期间启动的任务。保存必要状态,停止应用持有的 dsh 进程及其他受影响受管资源。停止或保存失败时中止交接并给出可恢复错误;用户确认不等于允许在清理失败后继续安装。
+
+准备成功后的预期调用为 `quitAndInstall(true, true)`,用于 Windows 静默安装后重新启动;macOS 替换与重启需要单独的平台验证。调用 API 不代表安装成功。重启后验证实际新 Desktop 版本,完成运行时绑定的 profile 校准与 Host 启动后再显示产品 UI;shell 加载窗口可以立即出现。沿用现有原地 profile 与恢复流程,不重新安装核心 seed、不引入第二个健康检查 Host,也不增加 profile 自动回滚。下载完成、普通退出、终止进程和后续启动均不独立授权安装。
+
+### 缓存与目标归属
+
+只有在校验产物身份、元数据和完整性后才复用完整缓存,不能仅依赖持久化 UI 状态。未完成的传输允许在用户再次操作下载后重新开始,不承诺断点续传。完整安装包在应用重启后仍可能需要解压或原生准备;准备不授权安装或替代包下载。
+
+产品确认前的保守建议是:A 下载中或已就绪时,即使检查发现 C 也保留 A。不得静默把安装 A 的确认变成安装 C。强制响应不独立选择或作废 updater 目标。自动替换目标延后。问题版本通过发布更高修复版本并更新 feed 处理,不增加安装包撤回清单或安装前撤回拦截。发布新版不保证已下载或已暂存的旧包无法安装;哈希与签名校验仍然有效。
+
+### 强制策略与阻断
+
+[接口提案](2026-09-08-desktop-mandatory-update-api.zh.md)负责全部协议字段。独立于本地 dsh 请求,在启动、可配置周期和手动检查时查询;前台或唤醒满足最小间隔时尽力查询。合并相同条件的请求,配置随机抖动与失败退避,并把响应绑定到请求时的应用身份、版本、平台、架构和通道。旧响应不能覆盖新条件。不在网络恢复时查询,也不因已经展示弹窗而停止查询。阻断期间继续查询,以接收当前强制判定和文案;该查询不是安装包撤回检查。
+
+有效强制响应立即阻断后续 Desktop 操作,但不停止已有任务或 dsh 进程。用户不能通过取消、Esc 或遮罩绕过弹窗,但可以退出应用。这是 Desktop UI 限制,不承诺阻止独立 CLI 客户端访问本地 server。
+
+弹窗把服务端标题和正文按纯文本展示,缺失时使用本地化默认文案。产品预览使用正常文案;包含 HTML 标签字面量的输入留在专门的安全测试中。已确认的正常流程使用一个持续存在的模态弹窗,不包含关闭控件、原始 URL 或常驻的重新检查、浏览器、复制、退出按钮组。应用仍可通过操作系统或应用菜单退出。下述展示与后台提醒决策已由客户端实现;已实现客户端决策和验证记录区分本地证据与尚待完成的安装包平台验收。
+
+| 强更正常状态 | 内容与操作;已确认中文文案 |
+|---|---|
+| 查询安装包 | “正在检查可用更新…”;加载态,不允许重复提交 |
+| 可下载 | 目标版本与“下载更新”;此次点击授权下载 |
+| 下载中 | 真实进度,例如“正在下载更新,58%”;不要求停止下载 |
+| 校验更新文件(含准备) | “正在校验更新文件…”;不单独展示准备阶段,不伪造百分比,不提供安装操作 |
+| 就绪,无受影响任务 | “更新已准备就绪,安装后将重新启动应用。”与“安装并重启” |
+| 就绪,有受影响任务 | 已确认的任务影响警告、“停止任务并更新”及次操作“稍后更新” |
+| 已确认安装 | “正在准备重启”;仅在停止受影响任务时显示“正在安全结束应用中的任务。”,否则显示“应用即将重启,请稍候。”;准备交接期间不允许重复提交 |
+
+文件校验与必要的更新包准备共用一个用户可见阶段。两者都必须完成后弹窗才提供安装操作;合并展示不移除任何底层操作。结束任务的提示依据实际受影响任务,不由尚未完成的只读请求触发。
+
+就绪时将现有强更弹窗切换为安装确认态,不更换弹窗,也不在安装点击后追加相同确认。只有任务检查完成后,该次点击才可授权安装;任务状态未知不等于空闲。通常一次点击下载、一次点击安装;交接时发现新任务仍须重新获得批准。推迟后保留弹窗、已有任务与有效缓存,不重复呈现任务确认。用户可从保留的就绪态重新请求确认。键盘焦点不能自动跳到安装按钮,持续按键或启动下载的那次点击不能确认后续安装状态。
+
+### 后台安装确认提醒
+
+已确认范围:强更就绪需要用户二次决策,且产品窗口与强更弹窗均未获得焦点时。在同一弹窗中更新状态,不自动恢复最小化窗口、不切换应用或桌面、不抢焦点。Windows 使用父窗口的 [flashFrame](https://www.electronjs.org/docs/latest/api/browser-window#winflashframeflag) 请求任务栏提醒;macOS 使用短暂的 [informational Dock 弹跳](https://www.electronjs.org/docs/latest/api/dock#dockbouncetype),不使用持续的 critical 弹跳。用户回到确认界面后清除尚未结束的提醒请求。
+
+每轮就绪还尽力发送一次无声系统通知。建议文案为“更新已准备就绪”/“返回应用确认安装并重启。”,不包含任务内容。点击通知仅返回当前弹窗,不下载、不停止任务、不安装。点击时重新读取当前状态,旧通知不能确认失效目标。重复状态发布、轮询、任务数量变化、前后台切换与推迟均不为同一轮就绪再次发送通知。策略解除、安装与资源释放时清除自身提醒。错误提醒及再次提醒调度不属于本次已确认的就绪触发范围。
+
+[Electron 通知平台要求](https://www.electronjs.org/docs/latest/tutorial/notifications#platform-considerations)涉及 Windows 已安装应用身份/快捷方式配置,以及签名 macOS 应用的通知验证。通知权限、系统专注模式与操作系统偏好可能抑制可见提醒;请求提醒不证明用户已经看到。通知不能成为更新前置条件,也不能反复触发权限申请。用户返回时仍可看到就绪弹窗。须在已安装 Windows 与 macOS 应用上验证最小化/隐藏窗口、拒绝通知、系统专注模式和旧通知点击;API 文档不是平台验收证据。
+
+### 强更失败恢复与浏览器兜底
+
+失败布局在同一阻塞弹窗内保留本地化原因与分阶段重试,脱敏技术详情默认折叠。安装包查询失败或无适用版本时重新检查;下载或准备失败时明确重试下载/准备;任务状态未知或停止失败时重新检查任务并确认安装。后台强更策略刷新失败不能丢弃适用的在途下载或有效就绪包。重试与外部导航均不授权安装,也不解除强更策略。安装器接管后,旧应用可能无法报告后续失败。
+
+应用内更新验收前保留仅页面跳转的兜底。集成后的设计在 updater 不可用、超时、无适用版本或无法完成下载/准备时展示“前往官网下载”,不与正常就绪主操作常驻并列。打开或复制前,主进程均按允许的下载来源校验配置 HTTPS 地址。缺失或不允许的链接不能打开或复制。页面绝不是命令或 updater feed;打开页面不解除阻断。
+
+浏览器恢复交互:用户请求前往官网后立即提供“若页面未打开,可复制下载链接”“复制下载链接”和“重新打开”,不依赖操作系统是否报错。除复制失败外,保持原始 URL 隐藏。[Electron shell 实现](https://raw.githubusercontent.com/electron/electron/main/shell/common/api/electron_api_shell.cc)根据平台打开回调兑现或拒绝请求,不观测外部浏览器的网页加载。明确拒绝时显示“无法打开浏览器,请复制下载链接后手动打开。”;请求兑现不能显示“页面已打开”。不能通过应用失焦、浏览器进程存在或另发 HTTP 探测来推断成功。导航结果与 updater 错误分开,复制成功只更新自身反馈。验证须覆盖打开请求兑现但网页没有加载、请求拒绝、请求未返回时仍可复制,以及复制失败。
+
+复制失败的恢复方式:显示“复制失败,请手动选择下方地址复制。”,下方用只读且可通过键盘选中的文本框展示经过校验的完整下载地址。长地址换行,不使用省略号。保留复制重试入口;复制成功后隐藏手动复制框。展示地址既不代表浏览器导航成功,也不解除强制更新阻塞。
+
+没有已知强制策略时,后台策略检查失败不阻断业务,手动失败可见。已有策略时,请求失败、非法数据、浏览器跳转、下载完成和安装器调用均不解除阻断。仅针对当前客户端条件的新鲜成功无强制响应可以解除;升级后按实际新版本查询。相同版本离线重启后是否持久保留阻断仍是待产品确认的建议行为。
+
+普通 updater 通过应用内强制更新验证后,先显示阻塞弹窗,再异步检查 updater;检查必须有截止时间,停滞后可进入浏览器兜底。`40005` 决定必须升级;实际版本与安装包由 updater 元数据按共用版本、缓存与目标规则决定。客户端不增加独立的强制目标版本校验或安装包撤回检查。复用适用的用户已发起下载或已校验完整缓存;否则提供“下载更新”并等待点击。校验完成并获得独立重启确认后才安装,保留任务影响确认。超时、无可用更新或下载/准备失败时保留阻塞弹窗、错误重试反馈和官网页面兜底。发布负责人先发布能解除要求的 updater 新版本,再启用对应强制要求。
+
+### 诊断与实现顺序
+
+日志记录触发来源、通道、当前与候选版本、失败阶段和策略结果,不记录令牌或签名凭据。候选埋点名为 `update_window_show` 和 `update_window_update`;状态刷新或轮询响应不能再次计为弹窗展示。强制弹窗没有取消事件。
+
+依次实现:(1)独立配置的策略客户端与响应测试;(2)阻塞页面跳转弹窗及不终止任务的行为;(3)普通检查/下载/安装分离、菜单与侧栏 UI、任务影响确认;(4)所有发布目标的真实安装包验证,再把已验证 updater 接入强制弹窗并保留页面兜底。后端地址、页面允许列表和签名秘密是部署输入,不从开发者机器复制。fixture(测试前置数据)允许在后端联调前开发客户端,但不证明真实联调成功。
+
+[打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)继续负责签名、发布身份与发布完整性。[内置运行时决策](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)、[原地 profile 决策](../../implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md)与[立即显示窗口决策](../../implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责资源布局、profile 校准和启动恢复。本提案调整更新交互与策略触达,不替换这些机制。后续发布与自动安装选择由[扩展提案](2026-09-08-desktop-update-extensions.zh.md)负责。
+
+## 考虑过的替代方案
+
+**自动预下载安装包。** 会在用户请求下载前消耗流量。保留自动发现,但一期每次传输与重试都需要用户操作,与后续安装授权分开。
+
+**自动重启或普通退出时安装。** 会绕过明确的任务影响确认。自动安装需要单独的产品授权与平台验证。
+
+**一个远程接口控制全部更新。** 强制策略与普通产物发现职责不同。策略页面与 updater 元数据分离,验证通过后仅复用安装协调器。
+
+## 验收标准
+
+- 隔离 fixture 与受控时钟验证启动、周期、手动、前台/唤醒、不由网络恢复触发、请求合并、下载复用,以及清理后不再发布状态。
+- Owner-local UI 预期输出覆盖菜单、未下载入口、独立下载与重启操作、校验文案、左下角进度/就绪及持续红色错误重试提示、已确认中文文案、本地化路由、自动检查静默和手动失败可见。
+- 启动、周期与手动检查、策略响应、失败和应用重启均不在缺少用户下载/重试操作时发起安装包传输。强制失败保留阻断。非法哈希或签名阻止安装;不要求独立的安装包撤回检查。
+- 强制阻断保留已有任务。安装仅在确认后停止受影响任务;覆盖审批/提问等待与后台任务、没有活动工作的已结束轮次、任务状态未知、新任务竞态、保存/停止失败及目标变化。
+- 完整缓存校验和重复触发不导致重复下载。退出、重启或仅下载完成均不独立授权一期安装。
+- 发布测试验证共用版本、固定 Nightly 元数据、完整 SemVer 排序、产物先于元数据发布,以及拒绝错误签名或哈希。
+- 使用真实签名 Windows x64 与 macOS x64/arm64 包证明发现、下载、校验、进程关闭、安装、重启、实际新版本 Host 启动与运行时绑定的 profile 校准;源码模式测试不足以证明这些行为。
+- 后端联调验证未登录访问、强制与无强制响应、错误和接口提案的平台矩阵;fixture 不能替代此证据。
+
+## 风险
+
+| 待确认项 | 当前 review 立场 |
+|---|---|
+| updater 频率 | 可配置的 10 分钟起点,最终默认值待确认 |
+| 强制策略频率与地址 | 后端联调输入,独立于 updater 间隔 |
+| 命中强制后的离线重启 | 持久保留阻断是建议,尚未确认 |
+| 已就绪 A 后发现新版 C | 保留 A 是保守建议;自动替换属于后续扩展 |
+| 收起侧栏、英文文案、更新说明、准备态视觉 | 需要产品与设计补充 |
+| 插件兼容性 | 最低支持 dsh 版本的额外升级前预检延后;保留现有 peer 校验与显式启动恢复,不通过自动关闭插件修复安装器失败 |
+| 仅需重启的更新体验 | 必须通过真实签名安装包验证;本文不构成证明 |

+ 3 - 1
apps/desktop-host/package.json

@@ -12,10 +12,12 @@
   "dependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
-    "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
+    "@deepseek-ai/dsh-home-paths": "workspace:^",
+    "@deepseek-ai/dsh-jobs": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^"
   }
 }

+ 27 - 4
apps/desktop-host/src/index.ts

@@ -8,6 +8,8 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import * as workspaceDependencies from './workspace-dependencies.ts'
 
+import { installDesktopUpdateTaskControl } from './update-tasks.ts'
+
 async function main(): Promise<void> {
   const runtimeDir = process.argv[2] as string
   const projectDir = process.argv[3] as string
@@ -21,17 +23,38 @@ async function main(): Promise<void> {
     patchFiles: [],
     args: ['--no-open', '--port', '19387'],
   })
-  const stop = async (): Promise<void> => {
+  let stopping: Promise<void> | undefined
+  const control: { updateTasks?: ReturnType<typeof installDesktopUpdateTaskControl> } = {}
+  const send = (message: object): Promise<void> => new Promise((resolve, reject) => {
+    if (!process.connected || process.send === undefined) { resolve(); return }
+    process.send(message, (error) => { if (error === null) resolve(); else reject(error) })
+  })
+  const stop = (): Promise<void> => stopping ??= (async () => {
     // Startup failure is reported by main; shutdown only owns a tree that booted.
     const running = await application.catch(() => undefined)
     await running?.shutdown.shutdown(0)
+    await send({ type: 'shutdown-complete' })
     if (process.connected) process.disconnect()
-  }
-  process.on('message', (message: { type?: string } | null) => {
-    if (message?.type === 'shutdown') void stop()
+  })()
+  process.on('message', (message: unknown) => {
+    if (typeof message !== 'object' || message === null || !('type' in message)) return
+    if (message.type === 'shutdown') { void stop(); return }
+    if (message.type !== 'update-tasks' || !('requestId' in message) || !Number.isSafeInteger(message.requestId)
+      || !('action' in message) || !['inspect', 'lock', 'unlock'].includes(String(message.action))) return
+    void (async () => {
+      try {
+        if (stopping !== undefined || control.updateTasks === undefined) throw new Error('desktop update: Host is unavailable')
+        const active = await control.updateTasks(message.action as 'inspect' | 'lock' | 'unlock')
+        await send({ type: 'update-tasks', requestId: message.requestId, active })
+      } catch (error) {
+        await send({ type: 'update-tasks', requestId: message.requestId, active: true,
+          error: error instanceof Error ? error.message : String(error) })
+      }
+    })().catch((error: unknown) => { console.error(error) })
   })
   process.once('disconnect', () => { void stop() })
   const { ctx } = await application
+  control.updateTasks = installDesktopUpdateTaskControl(ctx)
   await ctx.plugin(workspaceDependencies, {
     source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'),
     root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'),

+ 51 - 0
apps/desktop-host/src/update-tasks.ts

@@ -0,0 +1,51 @@
+/** Desktop installation admission and task inspection for the shared Web Host. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-agent'
+import type {} from '@deepseek-ai/dsh-jobs'
+import type {} from '@deepseek-ai/dsh-client-connection'
+
+/**
+ * Register update admission on the owning Host context.
+ * @param ctx - Booted Desktop profile context; disposal removes the request listener.
+ * @returns Task inspector whose lock refuses new API requests, drains admitted requests, and rechecks work.
+ */
+export function installDesktopUpdateTaskControl(ctx: Context): (action: 'inspect' | 'lock' | 'unlock') => Promise<boolean> {
+  let locked = false
+  let lockGeneration = 0
+  let stopped = false
+  ctx.effect(() => () => { stopped = true })
+  const pendingRequests = new Set<Promise<void>>()
+  ctx.on('connection/request', async (_request, response, next) => {
+    if (locked) {
+      response.writeHead(503)
+      response.end()
+      return
+    }
+    const finished = Promise.withResolvers<void>()
+    pendingRequests.add(finished.promise)
+    try { await next() }
+    finally { pendingRequests.delete(finished.promise); finished.resolve() }
+  })
+  return async (action) => {
+    if (stopped) throw new Error('desktop update: Host is stopping')
+    if (action === 'unlock') { locked = false; lockGeneration++ }
+    const agents = ctx.get('agents')
+    const jobs = ctx.get('jobs')
+    if (agents === undefined || jobs === undefined) throw new Error('desktop update: task services are unavailable')
+    if (action === 'lock') {
+      locked = true
+      const generation = ++lockGeneration
+      // Read requests are not tasks; admitted writes must finish before the final work check.
+      await Promise.all(pendingRequests)
+      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Disposal can run while admitted requests drain.
+      if (stopped) throw new Error('desktop update: Host is stopping')
+      if (generation !== lockGeneration) throw new Error('desktop update: admission lock was superseded')
+    }
+    const liveAgents = agents.list()
+    return liveAgents.some(agent => agent.status === 'running'
+      || agent.inbox.nextTurn.length > 0 || agent.inbox.nextStep.length > 0)
+      || [undefined, ...liveAgents].some(agent => jobs.list(agent)
+        .some(job => job.status === 'running' || job.status === 'stopping'))
+  }
+}

+ 3 - 0
apps/desktop-host/tsconfig.json

@@ -8,6 +8,9 @@
     "src"
   ],
   "references": [
+    { "path": "../../vendor/cordis" },
+    { "path": "../../packages/core/agent" },
+    { "path": "../../packages/jobs/jobs" },
     { "path": "../../packages/util/home-paths" },
     { "path": "../../packages/core/tools" },
     {

+ 8 - 0
apps/desktop/.env.macos.example

@@ -3,6 +3,14 @@
 DSH_DESKTOP_APP_ID=com.deepseek.harness
 DSH_DESKTOP_AUTO_UPDATE_ENV=test
 DOWNLOAD_TEST_ORIGIN=
+
+# The selected mandatory-update origin is required, including unsigned and preparation builds.
+DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=https://harness-test.deepseek.com
+DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=https://harness.deepseek.com
+# Optional polling and fallback-page options; origin and authentication are selected automatically.
+# The fallback-page allowlist defaults to the selected service origin.
+# DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"intervalMs":600000}'
+
 DSH_DESKTOP_MACOS_SIGNING_IDENTITY=
 DSH_DESKTOP_MACOS_TEAM_ID=
 

+ 8 - 0
apps/desktop/.env.windows.example

@@ -3,6 +3,14 @@
 DSH_DESKTOP_APP_ID=com.deepseek.harness
 DSH_DESKTOP_AUTO_UPDATE_ENV=test
 DOWNLOAD_TEST_ORIGIN=
+
+# The selected mandatory-update origin is required, including unsigned and preparation builds.
+DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=https://harness-test.deepseek.com
+DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=https://harness.deepseek.com
+# Optional polling and fallback-page options; origin and authentication are selected automatically.
+# The fallback-page allowlist defaults to the selected service origin.
+# DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"intervalMs":600000}'
+
 DSH_DESKTOP_WINDOWS_CER_FILE=
 DSH_DESKTOP_WINDOWS_SIGNTOOL=
 DSH_DESKTOP_WINDOWS_KEY_CONTAINER=

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: 9075e86b0817853b28b28aa1cee45f35904a38d0
-README.zh.md: c25cc17a89ff65a7deedceb78778651b2d4a5c1f
+README.md: 41fb52394940d8468a7eee681399446425d91a07
+README.zh.md: 53188b1207a304e95c02ad34769eab0b3b7df1df

+ 82 - 5
apps/desktop/README.md

@@ -38,7 +38,7 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09
 
 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
 
-The application preload exposes boot readiness and fatal startup reporting. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
+The application preload exposes boot readiness and fatal startup reporting. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. The separate plugin window receives structured package-management operations. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
 
 The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles.
 
@@ -128,7 +128,11 @@ The Windows installer extracts the new version beside the installation directory
 
 ### Upload updates
 
-`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`, while the production origin remains `https://download.deepseek.com`. Upload additionally requires the selected deployment's COS bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. The target path is `_/harness/desktop/stable/<target>/`, where `target` is `mac-arm64`, `mac-x64`, or `win-x64`.
+Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/<environment>-<target>-*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs, and SDK writes have no automatic retries. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result.
+
+Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. This launcher supports permanent keys, not STS credentials, and never changes the packaged update destination.
+
+`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Feed directories are `dsh-desk/feeds/<target>/`; versioned packages and blockmaps live in `dsh-desk/bin/<target>/`. Targets are `mac-arm64`, `mac-x64`, and `win-x64`.
 
 The update destination and upload credentials follow the selected deployment:
 
@@ -144,7 +148,9 @@ pnpm run package:desktop:mac:arm64
 pnpm run upload:mac:arm64
 ```
 
-Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` in the target dotenv file before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename.
+The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching.
+
+Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification.
 
 The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
 
@@ -174,8 +180,16 @@ Run `pnpm --dir apps/desktop run test:installer` from the repository root on an
 
 ### Windows EV signing
 
+Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field.
+
+Signed Windows configuration derives the updater's `publisherName` from the same public certificate's `CN`, `O`, and `C` attributes. Each must be present, nonempty, and single-valued. These identity attributes allow certificate renewal without pinning a leaf thumbprint. The installed application's `app-update.yml` carries the expected publisher; the downloaded feed does not choose it. Unsigned test builds omit updater configuration. See the [signature qualification record](tests/README.md) for real-file verification and its limits.
+
 For this project's SafeNet token, `SignTool Error: No private key is available.` indicates an incorrect PIN. Stop all signing attempts immediately and wait for the user to correct the PIN before continuing. Five incorrect PIN attempts lock the token. Do not retry packaging or signing probes after this error. The signer serializes token operations and rejects all queued tasks after the first failure.
 
+Windows package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `.desktop-build/packaging-runs/`. Each run retains `run.json`, timestamped `events.jsonl`, redacted `stdout.log` and `stderr.log`, and `result.json`. A signing failure also writes `fatal.json` and notifies the parent through stderr; the supervisor immediately requests termination of its stage process tree and waits for exit. Failed stages cannot start subsequent stages or create a release completion record. Journal failures also stop the run. Termination errors remain failures and require operator inspection; a missing final record means completion was not established.
+
+Hardware signing requires a supervised run. Before invoking the command interpreter, the signer atomically acquires `%USERPROFILE%/.dsh-desktop-signing/attempt.json` and records the attempt. Only successful signing releases that file. Failure, interruption, an existing interlock, or unavailable audit storage prevents further hardware access, including from another signer instance, process, or checkout under the same Windows account. There is no timed recovery or automatic retry. An administrator must inspect the retained evidence and token state before explicitly authorizing interlock recovery; logging into the token or replacing a PIN file does not clear it. The records distinguish signing intent, command-interpreter PID, and completion; they do not measure internal CSP/token authentication attempts. Command arguments, PINs, and credential environments are not recorded. Independent Windows accounts and unrelated signing programs are outside this interlock.
+
 Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract.
 
 NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree.
@@ -207,13 +221,63 @@ This diagnostic command is an alternative stopping point, not the first half of
 
 Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
 
+macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload.
+
 An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine.
 
 ## Updates
 
-A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater.
+Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the top-menu check runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version.
+
+`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries.
+
+The lower-left account row displays localized availability, a spinner with download percentage, verification, readiness, or a persistent red retry action with an accessible tooltip. This Web-embedded copy follows the active in-application language; native dialogs use the Desktop shell locale. The collapsed sidebar shows a dot on its top expand button. Connection status takes priority. Selecting an available release starts downloading immediately. Successful preparation automatically opens a shell-owned restart confirmation; closing it retains readiness without reopening the dialog. Selecting the ready entry opens confirmation again. Running agents, queued input, and running or stopping jobs trigger an interruption warning in that confirmation. API requests alone do not trigger the warning. After approval, the Host locks new requests, drains admitted requests, and rechecks tasks, including work created by an admitted write. A drain that exceeds the control-request deadline refuses installation and unlocks admission. Unknown task status, newly started work without interruption approval, or unsuccessful graceful teardown prevents installation. Ordinary quit hides the product window before stopping the Host, ignores new focus requests during teardown, and never installs an update. The next launch reconciles the version-bound runtime through the existing startup and recovery path.
+
+If task teardown fails after confirmed Host exit, installation is refused and the shell restores the current-version Host before allowing another restart confirmation. Installer launch failure after a clean Host stop uses the same recovery. An unconfirmed process exit never permits a replacement Host. The downloaded target remains available for retry. A known mandatory policy remains blocking throughout recovery; unsuccessful Host restoration opens the native fatal-recovery dialog.
+
+Confirmed Host exit without successful task teardown displays localized recovery guidance in both ordinary and mandatory update dialogs. “View technical details” is collapsed by default and exposes only exit status, signal, shutdown acknowledgement, and deadline facts, not plugin stderr. Expanding it neither retries nor authorizes installation.
+
+### Mandatory update policy
+
+The [mandatory client decision](../../.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md) owns policy polling and the blocking window. Packaging reads `.env.windows` or `.env.macos`: `DSH_DESKTOP_AUTO_UPDATE_ENV=test` (the default) selects `DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN`; `production` selects `DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN`. Templates use `https://harness-test.deepseek.com` and `https://harness.deepseek.com`, respectively. The selected origin is required before preparation or signing, including unsigned and preparation-only builds; the unselected origin is optional. These settings never fall back to the parent environment or the other deployment. Packaging embeds the selected policy with the application ID; packaged applications ignore runtime overrides.
+
+Optional `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` JSON supplies polling and download-page options; packaging rejects `origin` and `authentication` inside it. The page allowlist defaults to the selected service origin; explicitly allow other approved download-page origins when needed. Test builds select `feishu-test`, and production selects `anonymous`. Policy requests reject redirects; only test authentication carries gateway cookies. Unpackaged development instead reads a complete policy JSON from this variable and requires `DSH_DESKTOP_APP_ID`; absent JSON disables development policy queries, and only anonymous development permits HTTP `127.0.0.1`. A user-initiated ordinary check triggers policy work concurrently but never waits for or reports a policy failure. Only a confirmed blocking decision cancels ordinary dialogs. Test authentication waits until the active ordinary dialog finishes, and cancellation or failure does not discard the updater result.
+
+| Resolved policy field | Meaning and default |
+|---|---|
+| `origin` | Required HTTPS API origin, without credentials, path, query, or fragment; requests use `/api/v0/check_client_update` |
+| `allowedPageOrigins` | Nonempty array of exact HTTPS origins; packaging defaults to the selected API origin; subdomains and alternate ports are not implied |
+| `authentication` | Packaging selects `feishu-test` for test and `anonymous` for production; unpackaged development defaults to `anonymous` |
+| `intervalMs` | Polling interval; default `600000` |
+| `timeoutMs` | Request deadline; default `15000` |
+| `maxBackoffMs` | Maximum failed-request interval including jitter; default `3600000`, at least `intervalMs` |
+| `jitter` | Random additional interval fraction; default `0.2`, allowed range `0` through `1` |
+
+Durations are integers from 1000 through 2147483647 milliseconds. Startup and scheduled polling are independent of business requests; foreground/resume checks respect the next due time, while manual checks bypass it and join any request in flight. The client sends the installed platform, architecture, exact shell and bundled dsh versions, application ID, locale, and fixed Nightly. It uses no business login credentials or installation ID.
 
-Signed packaging emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies.
+With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENTICATED"` offers login during user-initiated checks and the packaged application's initial startup check, without waiting for the local backend. A localized explanation identifies the test build, the need for Feishu authentication, and that login neither downloads nor installs updates. Confirmation closes the explanation before opening a sandboxed window at the configured origin’s root, not a response-provided login URL. Concurrent checks reuse the entire confirmation/login operation and focus its existing window. Cancellation does not trigger repeated prompts from periodic or foreground checks; users can retry manually.
+
+Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified.
+
+A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server content is plain text. Window close and Esc do not clear the block; application exit remains available through the system or menu. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
+
+Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked.
+
+Background mandatory-installation confirmation requests Windows taskbar attention or an informational macOS Dock bounce plus one silent notification per readiness episode. It does not restore or focus the app. Notification clicks only return to current confirmation. Foreground return, installation, policy clearance, and shutdown clear owned reminders. System permissions and focus modes can suppress notifications; installed Windows and macOS notification qualification remains required.
+
+### Local updater qualification
+
+Ordinary update HTTP requests have a per-connection inactivity deadline: no response headers or no further response bytes for `60000` ms fails the operation. `DSH_DESKTOP_UPDATE_HTTP_IDLE_TIMEOUT_MS` accepts an integer from `1000` through `2147483647` to adjust it; active downloads have no total-duration deadline. Failed downloads retain the retry indicator and require another user action.
+
+On Windows with workspace dependencies installed, run this command from the repository root:
+
+```sh
+node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:updates:local
+```
+
+The command builds the Desktop shell and runs its coordinator with real Electron HTTP requests and `NsisUpdater` against a private loopback server. It checks user-authorized full downloads, SHA-512 rejection, explicit retry, concurrent request coalescing, feed replacement, and installation handoff. It also opens the real mandatory-update renderer with its sandboxed preload and checks button actions, close/Esc prevention, text-only content, a stalled policy request, and policy clearance. Success prints `LOCAL_UPDATER_RESULT` and exits with code zero; functional failure exits nonzero. Each invocation owns a random port and temporary user-data/cache directory, closes the listener, waits for Electron exit, and removes its temporary files. Reports and available screenshots remain in a unique `.desktop-build/qualification/local-updater-*` directory. Screenshot failure is recorded separately, never reported as visual acceptance. No COS or signing credentials are required.
+
+The downloaded bytes are inert, and the installation call is recorded rather than executed. The test substitutes browser opening and clipboard writing to avoid external navigation and clipboard changes. It does not boot the full product workspace, qualify a real installer or restart, verify publisher signatures, or exercise differential updates or macOS. Stalled policy requests, feed requests, and payload transfers exercise real deadlines and recovery. The actual ordinary dialog verifies isolated preload loading, card geometry, background blur, cancellation, task-warning choices, and explicit installation approval; account-row component tests provide separate evidence. The [local qualification decision](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md) and [verification record](tests/README.md) preserve these limits; production release requirements remain unchanged.
 
 ## Low-level development overrides
 
@@ -224,3 +288,16 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i
 - Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment.
 - Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog.
 - The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.
+
+## Dev Note
+
+### Pre-launch CDN and capacity TODOs
+
+These are pending recommendations, not active configuration or completed release qualification. Operations and Desktop maintainers must confirm the values and record verification before production rollout; this checklist does not change the upload or runtime settings.
+
+- [ ] Operations: separate `/dsh-desk/feeds/*` from `/dsh-desk/bin/*`; do not retain a blanket cache bypass for large production downloads. For feeds, evaluate client revalidation with `max-age=0`, a 30–60-second edge TTL, and a purge on publication. Agree on the maximum propagation delay and measure fixed-URL replacement across regions; a purge is not a guarantee of immediate global visibility.
+- [ ] Operations and release owner: evaluate a 30-day to one-year edge TTL for versioned or hash-named packages and blockmaps, without overwriting their URLs. Upload and verify binaries, prewarm them, then publish the feed; confirm retention covers older clients' differential-update inputs.
+- [ ] Operations and Desktop maintainers: verify node TTL separately from client Cache-Control, using repeat-request cache status, hit ratio, and COS origin metrics. Check whether updater-added query parameters fragment the cache key or bypass caching; ignore only parameters proven irrelevant to content. Verify Range/206, Content-Range, complete-file hashes, and feed freshness through the actual updater. See Tencent's [node TTL](https://cloud.tencent.com/document/product/1552/70777), [browser TTL](https://cloud.tencent.com/document/product/1552/70758), and [cache configuration](https://cloud.tencent.com/document/product/1552/95263) documentation.
+- [ ] Desktop maintainers and product owner: confirm startup and overdue-resume burst handling. Periodic jitter and bounded failure backoff are implemented and tested; startup and overdue wakeups still check immediately. Adding a short randomized delay to those triggers needs product confirmation. Mandatory-policy polling remains a separate API and scheduling policy.
+- [ ] Operations and release owner: size request and bandwidth budgets using online clients, startup/manual/retry peaks, package sizes, and expected download participation. At evenly distributed ten-minute polling, 100,000 online clients average about 167 checks/second and 1,000,000 about 1,667, before extra triggers. CDN caching reduces origin load, not client download traffic charges; a functional probe is not a load test.
+- [ ] Operations: configure cache-hit, origin-QPS, error-rate, bandwidth, and cost alerts with agreed thresholds and an on-call owner. Qualify abuse protection without breaking updater requests or shared-NAT clients; updater endpoints must not require an interactive browser challenge. Record an incident response procedure before launch.

+ 82 - 5
apps/desktop/README.zh.md

@@ -38,7 +38,7 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
 
 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
 
-应用 preload 暴露启动就绪和致命启动失败上报。产品渲染器使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两种渲染器都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
+应用 preload 暴露启动就绪和致命启动失败上报。产品页面通过共享认证 HTTP API 访问业务,并获得 Desktop 标记、更新展示数据和打开原生确认的操作;它不能选择安装产物或授权安装。独立插件窗口获得结构化的包管理操作。任何渲染器都无法获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。
 
 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
 
@@ -128,7 +128,11 @@ Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用
 
 ### 上传更新
 
-`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包时写入的更新 URL 与后续 COS 上传目标,可取 `test` 或 `production`;未设置时使用 `test`。测试打包必须通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin,生产 origin 仍为 `https://download.deepseek.com`。上传还必须通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选环境的 COS bucket。目标路径为 `_/harness/desktop/stable/<target>/`,其中 `target` 为 `mac-arm64`、`mac-x64` 或 `win-x64`。
+test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/<environment>-<target>-*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT,SDK 写入没有自动重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。
+
+Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。此启动器支持长期密钥,不支持 STS 凭据,也不会改变安装包中的更新目标。
+
+`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。清单目录为 `dsh-desk/feeds/<target>/`;带版本的安装包和 blockmap 位于 `dsh-desk/bin/<target>/`。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。
 
 更新目标与上传凭据都与所选环境对应:
 
@@ -144,7 +148,9 @@ pnpm run package:desktop:mac:arm64
 pnpm run upload:mac:arm64
 ```
 
-生产发布需在打包前在目标 `.env` 中设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证钩子成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。
+内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。
+
+前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。
 
 macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
 
@@ -174,8 +180,16 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI
 
 ### Windows EV 签名
 
+Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。
+
+签名 Windows 配置从同一份公开证书的 `CN`、`O` 和 `C` 属性生成 updater 的 `publisherName`。每个属性都必须存在、非空且只有一个值。这些身份属性允许证书续期,无需固定叶证书指纹。已安装应用的 `app-update.yml` 保存预期发布者,下载的清单不能选择该身份。未签名测试构建省略 updater 配置。真实文件验证及其限制见[签名验收记录](tests/README.zh.md)。
+
 本项目使用的 SafeNet Token 出现 `SignTool Error: No private key is available.` 时,说明 PIN(密码)错误。立即停止所有签名尝试,等待用户处理 PIN 后再继续。PIN 输错达到五次会锁定 Token。遇到该错误后,不得重试打包或签名探针。签名器串行执行 Token 操作,首次失败后拒绝所有排队任务。
 
+Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/packaging-runs/` 下的唯一目录。每次运行保留 `run.json`、带时间戳的 `events.jsonl`、脱敏后的 `stdout.log` 和 `stderr.log`,以及 `result.json`。签名失败还会写入 `fatal.json` 并通过 stderr 通知父进程;监督程序立即请求终止当前阶段的进程树并等待退出。失败阶段不能启动后续阶段或生成发布完成记录。日志写入失败也会停止运行。终止错误仍按失败处理,需要操作者检查;缺少最终记录表示尚未确认完成。
+
+硬件签名必须属于受监督的打包运行。调用命令解释器前,签名器原子获取 `%USERPROFILE%/.dsh-desktop-signing/attempt.json` 并记录本次尝试。只有签名成功才释放该文件。失败、中断、已有锁定文件或审计存储不可用都会阻止再次访问硬件,包括同一 Windows 账户下的另一个签名器实例、进程或代码检出目录。没有定时恢复或自动重试。管理员必须检查保留的证据及令牌状态,再明确授权恢复锁定状态;登录令牌或替换 PIN 文件不会清除它。记录区分签名意图、命令解释器 PID 和完成结果,不计量 CSP/令牌内部的认证次数。不记录命令参数、PIN 或凭据环境。其他 Windows 账户及无关签名程序不在此锁定机制的保护范围内。
+
 Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。
 
 NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。
@@ -207,13 +221,63 @@ pnpm run prepare:desktop
 
 每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
 
+macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。
+
 未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。
 
 ## 更新
 
-打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。
+打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。回到前台和系统恢复时遵守相同的单调时钟截止时间;顶部菜单检查立即执行,并复用正在进行的检查。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。
+
+`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
+
+左下角账户行显示本地化的更新可用状态、加载图标与下载百分比、验证、就绪状态,或带可访问提示的持久红色重试操作。嵌入 Web 界面的文案跟随应用内当前语言;原生弹窗使用 Desktop 壳语言。侧栏收起时,顶部展开按钮显示圆点。连接状态优先展示。选择可用版本即开始下载。准备成功后自动打开壳拥有的重启确认;关闭后保留就绪状态,不重复弹窗。选择就绪入口可再次打开确认。运行中的 agent、排队输入,以及运行中或停止中的后台任务都会在该确认中触发中断警告。仅有 API 请求不会触发警告。用户批准后,Host 锁定新请求,等待已接收的请求结束,再检查任务,包括已接收写操作创建的工作。等待超过控制请求截止时间时,拒绝安装并解除准入锁。任务状态未知、未获中断授权的新任务,或未成功完成正常收尾,都会阻止安装。常规退出会在停止 Host 前隐藏产品窗口,在收尾期间忽略新的聚焦请求,且从不安装更新。下次启动通过已有的启动与恢复流程校准版本绑定的运行时。
+
+若任务收尾失败但已确认 Host 退出,安装会被拒绝,壳会在允许再次确认重启前恢复当前版本的 Host。Host 正常停止后的安装器启动失败使用同一恢复路径。未确认进程退出时,绝不允许启动替代 Host。已下载目标保留以供重试。已知强更策略在恢复过程中继续阻塞;Host 恢复失败打开原生致命故障恢复弹窗。
+
+已确认 Host 退出但任务未成功收尾时,常规与强更弹窗均展示本地化恢复提示。“查看技术详情”默认折叠,仅展示退出状态、信号、关闭确认和截止时间事实,不展示插件 stderr。展开详情既不重试,也不授权安装。
+
+### 强制更新策略
+
+[强更客户端决策](../../.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)负责策略查询和阻塞窗口。打包读取 `.env.windows` 或 `.env.macos`:`DSH_DESKTOP_AUTO_UPDATE_ENV=test`(默认值)选择 `DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN`;`production` 选择 `DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN`。模板分别使用 `https://harness-test.deepseek.com` 和 `https://harness.deepseek.com`。在准备产物或签名前,所选源站必须配置,包括未签名和仅准备构建;未选环境的源站可不填。这些配置不会回退到父进程环境或另一部署环境。打包将选定策略与应用 ID 写入元数据;打包应用忽略运行时覆盖。
+
+可选的 `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` JSON 提供轮询和下载页面选项;打包拒绝其中的 `origin` 和 `authentication`。页面白名单默认只包含所选服务源站;需要其他已批准下载页面源站时应显式配置。测试包选择 `feishu-test`,正式包选择 `anonymous`。策略请求拒绝重定向;仅测试鉴权携带网关 Cookie。未打包开发模式则从此变量读取完整策略 JSON,并要求 `DSH_DESKTOP_APP_ID`;缺少 JSON 会禁用开发模式策略查询,仅匿名开发允许 HTTP `127.0.0.1`。用户发起常规检查时会并发触发策略检查,但不会等待或展示策略失败。只有已确认的强更决定可以关闭常规弹窗。测试环境鉴权会等待当前常规弹窗结束,取消或失败不会丢弃 updater 结果。
+
+| 解析后的策略字段 | 含义与默认值 |
+|---|---|
+| `origin` | 必填 HTTPS API 源站,不含凭据、路径、查询或片段;请求使用 `/api/v0/check_client_update` |
+| `allowedPageOrigins` | 非空的精确 HTTPS 源站数组;打包时默认只包含所选 API 源站;不隐含子域名或其他端口 |
+| `authentication` | 打包时测试环境选择 `feishu-test`,正式环境选择 `anonymous`;未打包开发模式默认为 `anonymous` |
+| `intervalMs` | 轮询间隔;默认 `600000` |
+| `timeoutMs` | 请求截止时间;默认 `15000` |
+| `maxBackoffMs` | 含抖动的失败请求最大间隔;默认 `3600000`,不小于 `intervalMs` |
+| `jitter` | 随机增加的间隔比例;默认 `0.2`,范围为 `0` 至 `1` |
+
+时长必须是 1000 至 2147483647 毫秒的整数。启动与定时轮询独立于业务请求;前台/恢复检查遵守下次到期时间,手动检查绕过该时间并复用在途请求。客户端发送已安装平台、架构、完整壳与内置 dsh 版本、应用 ID、语言和固定 Nightly。不使用业务登录凭据或安装 ID。
 
-签名打包为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。
+启用 `feishu-test` 时,包含 `error.code: "UNAUTHENTICATED"` 的 HTTP 401 JSON 响应会在用户主动检查和打包应用首次启动检查时提供登录入口,不等待本地后端就绪。本地化说明指出这是测试版、需要飞书鉴权,且登录不会下载或安装更新。确认后先关闭说明,再打开配置源站根路径的沙箱窗口,不使用响应中的登录 URL。并发检查复用整个确认/登录流程,并聚焦已有窗口。取消后,定时或前台检查不会反复弹窗;用户可手动重试。
+
+登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
+
+扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端内容按纯文本展示。关闭窗口和 Esc 不解除阻塞;仍可通过系统或菜单退出应用。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
+
+失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
+
+后台强更安装确认请求 Windows 任务栏提醒或 macOS 信息级 Dock 弹跳,并在每轮就绪时尝试一次无声通知,不还原窗口或抢焦点。点击通知只返回当前确认界面。回到前台、安装、策略解除和退出时清理提醒。系统权限和专注模式可能抑制通知;仍需完成 Windows 与 macOS 安装包通知验收。
+
+### 本地 updater 验证
+
+常规更新 HTTP 请求具有逐连接的无活动截止时间:`60000` 毫秒内未收到响应头或后续响应字节会使操作失败。`DSH_DESKTOP_UPDATE_HTTP_IDLE_TIMEOUT_MS` 接受 `1000` 至 `2147483647` 的整数进行调整;活跃下载没有总时长限制。下载失败保留重试提示,并要求用户再次操作。
+
+在已安装工作区依赖的 Windows 上,从仓库根目录运行:
+
+```sh
+node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:updates:local
+```
+
+此命令构建 Desktop 壳,让其协调器通过真实 Electron HTTP 请求和 `NsisUpdater` 访问私有回环服务器。它验证用户授权的完整下载、SHA-512 拒绝、显式重试、并发请求合并、清单替换和安装交接。它还打开使用沙箱预加载的真实强更页面,检查按钮操作、关闭/Esc 拦截、纯文本内容、策略请求停滞和策略解除。成功时打印 `LOCAL_UPDATER_RESULT` 并以零退出码结束;功能失败时返回非零退出码。每次调用独占随机端口和临时用户数据/缓存目录,关闭监听器、等待 Electron 退出,并移除临时文件。报告和可用截图保存在唯一的 `.desktop-build/qualification/local-updater-*` 目录中。截图失败单独记录,绝不当作视觉验收通过。不需要 COS 或签名凭据。
+
+下载内容是不可执行的测试字节,安装调用仅记录而不执行。测试替换浏览器打开与剪贴板写入,避免外部导航和剪贴板修改。它不启动完整产品工作区,不验证真实安装器或重启,不验证发布者签名,也不覆盖差分更新或 macOS。停滞的策略请求、清单请求和负载传输会执行真实截止时间及恢复。真实常规弹窗验证隔离预加载、卡片尺寸、背景模糊、取消、任务警告选项与显式安装批准;账户行组件测试另行提供证据。[本地验证决策](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md)和[验证记录](tests/README.zh.md)保留这些限制;生产发布要求保持不变。
 
 ## 底层开发覆盖项
 
@@ -224,3 +288,16 @@ pnpm run prepare:desktop
 - 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。
 - 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。
 - 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。
+
+## 开发备注
+
+### 上线前 CDN 与容量待办
+
+以下为待确认建议,不代表已生效配置或已完成发布验收。运维与 Desktop 维护者须在生产上线前确认参数并记录验证结果;本清单不改变上传或运行时配置。
+
+- [ ] 运维:分别配置 `/dsh-desk/feeds/*` 与 `/dsh-desk/bin/*`,生产大文件下载不应长期沿用全路径绕过缓存。清单建议评估客户端通过 `max-age=0` 重新验证、CDN 节点缓存 30~60 秒、发布时刷新。约定最长传播延迟,并跨区域测量固定 URL 覆盖后的可见时间;刷新不保证全球立即可见。
+- [ ] 运维与发布负责人:对带版本号或哈希的安装包和 blockmap,评估 30 天至一年的节点缓存,不覆盖其 URL。先上传并验证安装包、完成预热,再发布清单;确认保留周期覆盖旧客户端差分更新所需的输入。
+- [ ] 运维与 Desktop 维护者:将节点 TTL 与客户端 Cache-Control 分开验证,检查重复请求的缓存状态、命中率和 COS 回源指标。确认 updater 附加的查询参数是否分散缓存键或绕过缓存;只忽略已证明不影响内容的参数。通过实际 updater 验证 Range/206、Content-Range、完整文件哈希和清单及时更新。参阅腾讯云[节点 TTL](https://cloud.tencent.com/document/product/1552/70777)、[浏览器 TTL](https://cloud.tencent.com/document/product/1552/70758) 与[缓存配置](https://cloud.tencent.com/document/product/1552/95263)文档。
+- [ ] Desktop 维护者与产品负责人:确认启动及到期恢复的突发请求处理。周期抖动和有上限的失败退避已实现并测试;启动和到期唤醒仍立即检查。为这些触发增加短暂随机延迟需要产品确认。强更策略轮询仍使用独立 API 与调度策略。
+- [ ] 运维与发布负责人:依据在线客户端数、启动/手动/重试峰值、安装包大小和预计下载比例制定请求量与带宽预算。按均匀分布的十分钟轮询,10 万在线客户端平均约 167 次检查/秒,100 万约 1667 次,尚未计入额外触发。CDN 缓存减少回源压力,不消除客户端下载流量费用;功能探测不是负载测试。
+- [ ] 运维:配置缓存命中率、回源 QPS、错误率、带宽及费用告警,约定阈值与值班负责人。验证异常请求防护不会破坏 updater 请求或误伤共享 NAT 客户端;updater 端点不能要求交互式浏览器验证。上线前记录故障处置流程。

+ 11 - 0
apps/desktop/electron-builder.config.d.mts

@@ -12,6 +12,7 @@ export interface DesktopElectronBuilderConfig {
     { readonly from: string, readonly to: 'dsh', readonly filter: readonly ['**/*'] },
     { readonly from: string, readonly to: 'dsh/node_modules', readonly filter: readonly ['**/*'] },
   ]
+  readonly extraMetadata: { readonly dshDesktopAppId: string }
   readonly asarUnpack: readonly string[]
   readonly extraResources: readonly [{ readonly from: string, readonly to: 'runtime' }]
   readonly mac: {
@@ -24,6 +25,14 @@ export interface DesktopElectronBuilderConfig {
     readonly sign: boolean
     readonly writeUpdateInfo: boolean
   }
+  readonly win: {
+    readonly forceCodeSigning: boolean
+    readonly signtoolOptions: {
+      readonly publisherName: string | undefined
+      readonly sign: ((configuration: { path: string, hash: string, isNest: boolean }) => Promise<void>) | undefined
+      readonly signingHashAlgorithms: readonly string[]
+    }
+  }
   readonly nsis: {
     readonly include: string
     readonly oneClick: false
@@ -42,12 +51,14 @@ export interface DesktopElectronBuilderConfig {
  * @param env - Packaging environment.
  * @param hostPlatform - Build-host platform used when no explicit target is present.
  * @param hostArch - Build-host architecture used when no explicit target is present.
+ * @param preparedRuntime - Verified private qualification runtime; ordinary releases use target-owned resources.
  * @returns electron-builder configuration.
  */
 export function createElectronBuilderConfig(
   env?: NodeJS.ProcessEnv,
   hostPlatform?: NodeJS.Platform,
   hostArch?: string,
+  preparedRuntime?: string,
 ): DesktopElectronBuilderConfig
 
 declare const electronBuilderConfig: DesktopElectronBuilderConfig

+ 3 - 152
apps/desktop/electron-builder.config.mjs

@@ -1,154 +1,5 @@
-import { join } from 'node:path'
-import { fileURLToPath } from 'node:url'
-import { execFile } from 'node:child_process'
-import { promisify } from 'node:util'
-import {
-  resolveDesktopAppId,
-  resolveMacOSNotarizationEnvironment,
-  resolveMacOSSigningEnvironment,
-} from './scripts/desktop-release-environment.mjs'
-import { notarizeMacOSDiskImageArtifact } from './scripts/notarize-macos-disk-images.mjs'
-import { verifyMacOSSignatureAfterSign } from './scripts/verify-macos-signature.mjs'
-import {
-  createWindowsTokenSigner,
-  installWindowsNsisBootstrapSigner,
-  scrubWindowsSigningEnvironment,
-} from './scripts/windows-sign.mjs'
-import { resolveDesktopAutoUpdateConfig } from './scripts/desktop-auto-update-environment.mjs'
-import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './scripts/desktop-build-paths.mjs'
-import { installWindowsDirectoryInstaller } from './scripts/windows-directory-installer.mjs'
-
-/**
- * Create electron-builder configuration from one release environment.
- * @param {NodeJS.ProcessEnv} env - Packaging environment.
- * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no explicit target is present.
- * @param {string} hostArch - Build-host architecture used when no explicit target is present.
- * @returns {object} electron-builder configuration.
- */
-export function createElectronBuilderConfig(
-  env = process.env,
-  hostPlatform = process.platform,
-  hostArch = process.arch,
-) {
-  const appId = resolveDesktopAppId(env)
-  const targetPlatform = env.DSH_DESKTOP_TARGET_PLATFORM
-  const resolvedPlatform = targetPlatform ?? hostPlatform
-  const resolvedArch = env.DSH_DESKTOP_TARGET_ARCH ?? hostArch
-  if (env.DSH_DESKTOP_UNSIGNED !== undefined && !['0', '1'].includes(env.DSH_DESKTOP_UNSIGNED)) {
-    throw new Error('desktop package: DSH_DESKTOP_UNSIGNED must be 0 or 1')
-  }
-  const unsigned = env.DSH_DESKTOP_UNSIGNED === '1'
-  if (unsigned && resolvedPlatform !== 'win32') throw new Error('desktop package: unsigned builds require Windows')
-  const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin')
-  const packagesWindows = targetPlatform === 'win32'
-  if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller()
-  const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined
-  if (packagesMacOS) resolveMacOSNotarizationEnvironment(env)
-  const windowsSigner = packagesWindows && !unsigned
-    ? createWindowsTokenSigner({
-        certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE,
-        signTool: env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
-        tokenPin: env.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
-        keyContainer: env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
-      })
-    : undefined
-  if (windowsSigner !== undefined) {
-    installWindowsNsisBootstrapSigner({ sign: windowsSigner })
-  }
-  const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch)
-  const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
-  return {
-    appId,
-    productName: 'DeepSeek Harness',
-    artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}',
-    directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts },
-    asar: true,
-    electronDist: buildPaths.electron,
-    electronFuses: { runAsNode: true },
-    beforeBuild: async () => {
-      if (resolvedPlatform !== 'win32') return true
-      await promisify(execFile)('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
-        fileURLToPath(new URL('./scripts/prepare-windows-installer.ps1', import.meta.url)),
-        '-OutputDirectory', join(buildPaths.root, 'installer-ui')], {
-        env: scrubWindowsSigningEnvironment(env), windowsHide: true,
-      })
-      if (windowsSigner !== undefined) {
-        await windowsSigner({ path: join(buildPaths.root, 'installer-ui', 'window-frame.dll'), hash: 'sha256', isNest: false })
-      }
-      // A falsy result tells electron-builder to omit its production node_modules collection.
-      return true
-    },
-    files: [
-      'lib/*.js',
-      'lib/*.cjs',
-      'renderer/**/*',
-      'package.json',
-      { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] },
-      // electron-builder excludes a source directory's root node_modules.
-      { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] },
-    ],
-    asarUnpack: [
-      '**/*.{node,dylib,dll,so,exe}',
-      '**/*.so.*',
-      '**/spawn-helper',
-      '**/@vscode/ripgrep/bin/rg',
-    ],
-    extraResources: [
-      { from: buildPaths.runtime, to: 'runtime' },
-    ],
-    mac: {
-      icon: fileURLToPath(new URL('./resources/icon-macos.png', import.meta.url)),
-      category: 'public.app-category.developer-tools',
-      identity: macOSSigning?.signingIdentity,
-      forceCodeSigning: true,
-      hardenedRuntime: true,
-      // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle.
-      signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'],
-      notarize: true,
-      target: ['dmg', 'zip'],
-    },
-    dmg: {
-      sign: true,
-      writeUpdateInfo: false,
-    },
-    afterSign: async context => {
-      if (context.electronPlatformName !== 'darwin') return
-      verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env))
-    },
-    artifactBuildCompleted: artifact => {
-      if (!artifact.file.endsWith('.dmg')) return
-      return notarizeMacOSDiskImageArtifact(
-        artifact,
-        env,
-        macOSSigning ?? resolveMacOSSigningEnvironment(env),
-      )
-    },
-    win: {
-      icon: fileURLToPath(new URL('./resources/icon-windows.png', import.meta.url)),
-      forceCodeSigning: !unsigned,
-      signtoolOptions: {
-        sign: windowsSigner,
-        signingHashAlgorithms: ['sha256'],
-      },
-      target: ['nsis'],
-    },
-    linux: {
-      category: 'Development',
-      target: ['AppImage'],
-    },
-    nsis: {
-      installerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'),
-      uninstallerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'),
-      include: fileURLToPath(new URL('./scripts/installer.nsh', import.meta.url)),
-      oneClick: false,
-      perMachine: false,
-      allowElevation: false,
-      allowToChangeInstallationDirectory: false,
-      installerLanguages: ['en_US', 'zh_CN'],
-      differentialPackage: true,
-    },
-    publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl }],
-  }
-}
+/** Ordinary release entry; qualification imports the environment-independent factory. */
+import { createElectronBuilderConfig } from './scripts/electron-builder-config.mjs'
 
+export { createElectronBuilderConfig }
 export default createElectronBuilderConfig()

+ 1 - 0
apps/desktop/package.json

@@ -11,6 +11,7 @@
     "build": "tsc -b && tsdown",
     "dev": "tsx scripts/dev.ts",
     "start": "tsx scripts/dev.ts --skip-build",
+    "test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs",
     "prepare:runtime": "tsx scripts/prepare-runtime.ts",
     "prepare:primary-runtime": "tsx scripts/prepare-primary-runtime.ts",
     "prepare:packages": "tsx scripts/prepare-package-set.ts",

+ 12 - 0
apps/desktop/renderer/mandatory-update.css

@@ -0,0 +1,12 @@
+#status, #version { margin-top: 12px; color: #555962; }
+#status:empty, #version:empty { display: none; }
+#error { margin-top: 12px; color: #d93025; }
+#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid #e6e7eb; border-radius: 8px; background: #f5f5f5; font: 12px/20px monospace; color: #555962; overflow-wrap: anywhere; resize: vertical; }
+#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: #555962; background: transparent; }
+#update, #refresh { border-radius: 100px; }
+#fallback { border-top: 1px solid #e6e7eb; margin-top: 12px; padding-top: 12px; }
+#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: #555962; }
+#copy { min-height: 32px; padding: 3px 0; background: transparent; color: #4d6bfe; font-size: 12px; }
+#copy-message { color: #d93025 !important; }
+#manual-copy { display: block; margin-top: 8px; }
+#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; accent-color: #4d6bfe; }

+ 37 - 0
apps/desktop/renderer/mandatory-update.html

@@ -0,0 +1,37 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'">
+    <title></title>
+    <link rel="stylesheet" href="update-dialog.css">
+    <link rel="stylesheet" href="mandatory-update.css">
+  </head>
+  <body>
+    <main role="alertdialog" aria-modal="true" aria-labelledby="title" aria-describedby="detail">
+      <header><h1 id="title"></h1></header>
+      <p id="detail"></p>
+      <p id="version"></p>
+      <p id="status" role="status" aria-live="polite"></p>
+      <progress id="progress" max="100" value="0" hidden></progress>
+      <p id="error" role="alert" hidden></p>
+      <details id="technical-details" hidden>
+        <summary id="technical-details-label"></summary>
+        <pre id="technical-details-content" tabindex="0"></pre>
+      </details>
+      <div id="actions">
+        <button id="update" class="primary" type="button" hidden></button>
+        <button id="refresh" type="button"></button>
+        <button id="page" type="button"></button>
+        <button id="later" type="button" hidden></button>
+      </div>
+      <div id="fallback" hidden>
+        <p><span id="browser-message"></span> <button id="copy" type="button"></button></p>
+        <p id="copy-message" role="status"></p>
+        <label id="manual-copy" hidden><span id="address-label"></span><textarea id="address" readonly rows="3" spellcheck="false"></textarea></label>
+      </div>
+    </main>
+    <script src="mandatory-update.js"></script>
+  </body>
+</html>

+ 108 - 0
apps/desktop/renderer/mandatory-update.js

@@ -0,0 +1,108 @@
+/** Text-only policy rendering; the main process owns task inspection and every privileged action. */
+const api = window.dshMandatoryUpdate
+let current
+let busy = false
+let localError
+const element = id => document.getElementById(id)
+const format = (message, values) => message.replaceAll(/\{([^{}]+)\}/gu, (match, key) => values[key] ?? match)
+
+function render(view) {
+  const wasConfirming = current?.confirmation !== undefined
+  current = view
+  const { locale: { id, messages }, policy, update, confirmation, navigation } = view
+  const confirming = confirmation !== undefined
+  const failed = update.phase === 'error'
+  const ready = update.phase === 'ready' || (failed && update.failedOperation === 'install')
+  const downloadable = update.phase === 'available' || (failed && update.failedOperation === 'download')
+  const authenticationRequired = policy.error === 'authentication-required'
+  const fallback = failed || update.phase === 'idle' || view.error !== undefined
+  let title = policy.title ?? messages.mandatoryTitle
+  let detail = policy.detail ?? messages.mandatoryDetail
+  let primary = '', action = '', status = ''
+  if (downloadable) { primary = failed ? messages.updateRetry : messages.updateDownload; action = 'download' }
+  if (ready) { primary = view.deferred ? messages.mandatoryContinue : messages.updateRetry; action = 'install' }
+  if (view.deferred) { title = messages.mandatoryReady; detail = messages.mandatoryDeferred }
+  if (update.phase === 'downloading') status = format(messages.updateDownloading, { percent: String(Math.floor(update.percent ?? 0)) })
+  if (update.phase === 'verifying') status = messages.updateVerifying
+  if (update.phase === 'installing') status = messages.mandatoryInspecting
+  if (update.phase === 'checking' || (policy.checking && ['idle', 'error'].includes(update.phase))) status = messages.updateChecking
+  if (confirming) {
+    title = confirmation.active ? messages.updateActiveTasks : messages.mandatoryReady
+    detail = confirmation.active ? messages.updateActiveTasksDetail : messages.mandatoryReadyDetail
+    primary = confirmation.active ? messages.updateStopTasks : messages.installAndRestart
+    action = 'install'; status = ''
+  }
+  if (view.restart !== undefined) {
+    title = messages.updateInstalling
+    detail = view.restart === 'stopping-tasks' ? messages.mandatoryStopping : messages.mandatoryRestarting
+    status = ''; primary = ''; action = ''
+  }
+  const error = localError ?? view.error ?? (authenticationRequired ? messages.policyLoginRequired : failed
+    ? update.failedOperation === 'download' ? messages.mandatoryDownloadFailed
+      : update.failedOperation === 'install'
+        ? [messages.updateStopFailed, messages.updateTasksUnavailable, messages.updateTasksChanged].includes(update.message)
+          ? update.message : messages.mandatoryInstallFailed
+        : messages.mandatoryUnavailable
+    : update.phase === 'idle' && !policy.checking ? messages.mandatoryNoRelease : undefined)
+  const technicalDetails = failed ? update.technicalDetails ?? update.message ?? '' : ''
+  document.documentElement.lang = id
+  document.title = messages.mandatoryTitle
+  element('title').textContent = title
+  element('detail').textContent = detail
+  element('status').textContent = status
+  element('version').textContent = update.version === undefined ? '' : format(messages.mandatoryVersion, { version: update.version })
+  element('progress').hidden = update.phase !== 'downloading'
+  element('progress').value = update.percent ?? 0
+  element('progress').setAttribute('aria-label', status)
+  element('error').textContent = error ?? ''
+  element('error').hidden = error === undefined || confirming
+  if (element('technical-details-content').textContent !== technicalDetails) element('technical-details').open = false
+  element('technical-details').hidden = technicalDetails === '' || confirming
+  element('technical-details-label').textContent = messages.updateTechnicalDetails
+  element('technical-details-content').textContent = technicalDetails
+  element('update').hidden = !primary
+  element('update').textContent = primary
+  element('update').dataset.action = action
+  element('update').disabled = busy && !confirming
+  element('later').textContent = messages.updateLater
+  element('later').hidden = !confirmation?.active
+  element('refresh').textContent = authenticationRequired ? messages.policyLogin : messages.mandatoryRefresh
+  element('refresh').hidden = confirming || view.restart !== undefined || (!authenticationRequired && (!!primary || !fallback))
+  element('refresh').disabled = busy || policy.checking
+  element('page').textContent = navigation ? messages.mandatoryReopen : messages.mandatoryPage
+  element('page').hidden = !fallback || confirming || policy.page === undefined
+  element('actions').hidden = [...element('actions').children].every(child => child.hidden)
+  element('fallback').hidden = !navigation || !fallback || confirming || policy.page === undefined
+  element('browser-message').textContent = navigation?.page === 'failed' ? messages.mandatoryPageFailed : messages.mandatoryOpenHelp
+  element('copy').textContent = navigation?.copy === 'copied' ? messages.mandatoryCopied : messages.mandatoryCopy
+  element('copy-message').textContent = navigation?.copy === 'failed' ? messages.mandatoryCopyFailed : ''
+  element('manual-copy').hidden = navigation?.copy !== 'failed'
+  element('address-label').textContent = messages.mandatoryAddress
+  element('address').value = navigation?.copy === 'failed' ? policy.page ?? '' : ''
+  // A download button can become an install button while a key remains held.
+  if (confirming && !wasConfirming && document.activeElement?.id === 'update') document.activeElement.blur()
+}
+
+async function act(action) {
+  const navigation = ['page', 'copy'].includes(action)
+  const confirmation = current?.confirmation !== undefined && ['install', 'later'].includes(action)
+  if (current === undefined || (busy && !navigation && !confirmation)) return
+  if (!navigation) busy = true
+  localError = undefined
+  render(current)
+  try { await api.action(action, current.confirmation?.version ?? current.update.version, current.confirmation?.revision) }
+  catch { localError = current.locale.messages.mandatoryActionFailed }
+  finally { if (!navigation) busy = false; render(current) }
+}
+
+for (const action of ['refresh', 'page', 'copy', 'later']) element(action).addEventListener('click', () => { void act(action) })
+element('update').addEventListener('click', event => {
+  if (event.detail <= 1) void act(element('update').dataset.action)
+})
+document.addEventListener('keydown', event => {
+  if (event.key === 'Escape' || (event.repeat && ['Enter', ' '].includes(event.key))) event.preventDefault()
+})
+let changed = false
+const unsubscribe = api.subscribe(view => { changed = true; render(view) })
+window.addEventListener('pagehide', unsubscribe, { once: true })
+void api.status().then(view => { if (!changed) render(view) })

+ 8 - 0
apps/desktop/renderer/update-close.svg

@@ -0,0 +1,8 @@
+<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="ic_ds_close_outline_16">
+<g id="Vector">
+<path d="M12.3522 11.5473L11.5473 12.3522L1.64785 2.45266L2.45267 1.64784L12.3522 11.5473Z" fill="#0F1115"/>
+<path d="M11.5473 1.64785L12.3522 2.45267L2.45267 12.3522L1.64785 11.5473L11.5473 1.64785Z" fill="#0F1115"/>
+</g>
+</g>
+</svg>

+ 26 - 0
apps/desktop/renderer/update-dialog.css

@@ -0,0 +1,26 @@
+:root {
+  color-scheme: light;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
+  color: #0f1115;
+  background: transparent;
+}
+* { box-sizing: border-box; }
+body { margin: 0; height: 100vh; display: grid; place-items: center; background: rgb(0 0 0 / 24%); }
+main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); }
+header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
+main:focus { outline: none; }
+h1 { margin: 0; min-height: 28px; font-size: 16px; font-weight: 500; line-height: 24px; overflow-wrap: anywhere; }
+p { margin: 0; font-size: 14px; line-height: 22px; white-space: pre-wrap; overflow-wrap: anywhere; }
+#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: #fff; }
+button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; cursor: pointer; }
+.primary { background: #0f1115; color: #fff; }
+.secondary { background: #f5f5f5; color: inherit; }
+.close { display: grid; place-items: center; flex: none; width: 28px; min-height: 28px; padding: 7px; border-radius: 50%; background: transparent; }
+.close img { width: 14px; height: 14px; }
+button:hover { filter: brightness(0.95); }
+button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; }
+details { margin-top: 12px; font-size: 13px; line-height: 20px; }
+summary { cursor: pointer; color: #5b6472; }
+pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
+button:disabled { opacity: 0.5; cursor: default; }
+[hidden] { display: none !important; }

+ 22 - 0
apps/desktop/renderer/update-dialog.html

@@ -0,0 +1,22 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self'">
+    <title></title>
+    <link rel="stylesheet" href="update-dialog.css">
+  </head>
+  <body>
+    <main id="dialog" role="dialog" tabindex="-1" aria-modal="true" aria-labelledby="title" aria-describedby="detail" hidden>
+      <header><h1 id="title"></h1><button id="close" class="close" type="button"><img src="update-close.svg" alt=""></button></header>
+      <p id="detail"></p>
+      <details id="technical-details" hidden>
+        <summary id="technical-details-label"></summary>
+        <pre id="technical-details-content" tabindex="0"></pre>
+      </details>
+      <div id="actions"></div>
+    </main>
+    <script src="update-dialog.js"></script>
+  </body>
+</html>

+ 42 - 0
apps/desktop/renderer/update-dialog.js

@@ -0,0 +1,42 @@
+/** Main-owned copy and responses; Escape is cancellation, never acceptance. */
+const api = window.dshUpdateDialog
+let view
+let responding = false
+function respond(index) {
+  if (responding || view === undefined) return
+  responding = true
+  void api.respond(index).catch(() => { responding = false })
+}
+document.getElementById('close').addEventListener('click', () => { respond(view.cancelId) })
+document.addEventListener('keydown', event => {
+  if (event.key === 'Escape' && view !== undefined) { event.preventDefault(); respond(view.cancelId) }
+  if (event.key !== 'Tab') return
+  const controls = [...document.querySelectorAll('button, details:not([hidden]) > summary, details[open]:not([hidden]) > pre')]
+  const current = controls.indexOf(document.activeElement)
+  const next = current < 0 ? (event.shiftKey ? controls.length - 1 : 0)
+    : (current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length
+  event.preventDefault()
+  controls[next].focus()
+})
+void api.status().then(state => {
+  view = state
+  document.documentElement.lang = state.locale
+  document.title = state.title
+  document.getElementById('title').textContent = state.message
+  document.getElementById('detail').textContent = state.detail
+  document.getElementById('detail').hidden = state.detail === ''
+  document.getElementById('close').setAttribute('aria-label', state.closeLabel)
+  document.getElementById('technical-details').hidden = state.technicalDetails === ''
+  document.getElementById('technical-details-label').textContent = state.technicalDetailsLabel
+  document.getElementById('technical-details-content').textContent = state.technicalDetails
+  for (const [index, label] of state.buttons.entries()) {
+    const button = document.createElement('button')
+    button.type = 'button'
+    button.textContent = label
+    button.className = index === 0 ? 'primary' : 'secondary'
+    button.addEventListener('click', () => { respond(index) })
+    document.getElementById('actions').append(button)
+  }
+  document.querySelector('main').hidden = false
+  document.getElementById('dialog').focus()
+})

+ 29 - 0
apps/desktop/scripts/build-installed-update-worker.mjs

@@ -0,0 +1,29 @@
+/** Internal builder child; requires its live parent's version-scoped packaging record and never publishes. */
+import { readFile, realpath } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { readInstalledUpdateRun } from './installed-update-qualification.ts'
+import { assertInstalledUpdateSigningClear } from './installed-update-packaging.ts'
+import { createInstalledUpdateBuilderConfig } from './installed-update-builder.ts'
+import { packagingOutputRedactor } from './packaging-run.mjs'
+
+try {
+  const [manifest, version, ...extra] = process.argv.slice(2)
+  if (!manifest || !version || extra.length !== 0) throw new Error('invalid worker arguments')
+  const run = await readInstalledUpdateRun(manifest)
+  const record = await realpath(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR ?? '')
+  if (dirname(record) !== await realpath(join(run.root, version, 'packaging'))) throw new Error('wrong packaging record directory')
+  const metadata = JSON.parse(await readFile(join(record, 'run.json'), 'utf8'))
+  if (metadata.mode !== 'operator-authorized-single-version' || metadata.id !== run.id
+    || metadata.version !== version || metadata.pid !== process.ppid) throw new Error('worker is not owned by the authorized parent')
+  await assertInstalledUpdateSigningClear()
+  const config = await createInstalledUpdateBuilderConfig(manifest, version, process.env)
+  const { build, Platform, Arch } = await import('electron-builder')
+  await build({ projectDir: join(import.meta.dirname, '..'), config,
+    targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never' })
+} catch (error) {
+  const redactor = packagingOutputRedactor(Object.entries(process.env)
+    .filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD/iu.test(name)).map(([, value]) => value ?? ''), text => process.stderr.write(text))
+  redactor.write(Buffer.from(error instanceof Error ? `${error.stack ?? error.message}\n` : 'installed update: builder worker failed\n'))
+  redactor.end()
+  process.exitCode = 1
+}

+ 3 - 5
apps/desktop/scripts/desktop-auto-update-environment.mjs

@@ -1,6 +1,6 @@
 /** Resolve the Desktop auto-update channel and its Tencent COS destination. */
 
-import { prerelease, valid } from 'semver'
+import { valid } from 'semver'
 
 /** Environment variable that selects the Desktop update deployment. */
 export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV'
@@ -77,9 +77,7 @@ export function desktopUpdateMetadataFilename(version, platform) {
   if (platform !== 'darwin' && platform !== 'win32') {
     throw new Error(`desktop auto-update: unsupported metadata platform ${platform}`)
   }
-  const release = prerelease(version)
-  const channel = release === null ? 'latest' : String(release[0])
-  return `${channel}${platform === 'darwin' ? '-mac' : ''}.yml`
+  return `nightly${platform === 'darwin' ? '-mac' : ''}.yml`
 }
 
 /**
@@ -139,7 +137,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) {
     if (originEnvName === undefined) throw new Error('desktop auto-update: selected deployment has no origin')
     origin = httpsOrigin(requiredEnvironmentValue(env, originEnvName), originEnvName)
   }
-  const keyPrefix = `_/harness/desktop/stable/${target}`
+  const keyPrefix = `dsh-desk/feeds/${target}`
   return {
     environment,
     target,

+ 2 - 1
apps/desktop/scripts/desktop-build-paths.mjs

@@ -19,7 +19,8 @@ export function resolveDesktopBuildTarget(
   hostArch = process.arch,
 ) {
   const platform = env.DSH_DESKTOP_TARGET_PLATFORM ?? env.npm_config_platform ?? hostPlatform
-  const arch = env.DSH_DESKTOP_TARGET_ARCH ?? env.npm_config_arch ?? hostArch
+  const arch = env.DSH_DESKTOP_TARGET_ARCH ?? env.npm_config_arch
+    ?? (platform === 'win32' || platform === 'win' ? 'x64' : hostArch)
   const os = platform === 'darwin' ? 'mac' : platform === 'win32' || platform === 'win' ? 'win' : platform
   const target = `${os}-${arch}`
   if (!SUPPORTED_TARGETS.has(target)) {

+ 4 - 2
apps/desktop/scripts/desktop-package-environment.mjs

@@ -7,12 +7,13 @@ import { parseEnv } from 'node:util'
 import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment } from './desktop-release-environment.mjs'
 import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
 import { createWindowsTokenSigner } from './windows-sign.mjs'
+import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
 
 const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
-const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV)|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
+const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
 const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u
 const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
-const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
+const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
 const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN']
 
 /**
@@ -74,6 +75,7 @@ function requireReadableFile(environment, name) {
  */
 export function validateDesktopPackageEnvironment(environment, target, options = {}) {
   resolveDesktopAppId(environment)
+  resolveDesktopPolicyEnvironment(environment)
   if (options.unsigned) return
   if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
   if (target.platform === 'win32') {

+ 14 - 0
apps/desktop/scripts/desktop-policy-environment.d.mts

@@ -0,0 +1,14 @@
+/** Required deployment-selected metadata for mandatory-update policy requests. */
+export interface DesktopPolicyEnvironment {
+  origin: string
+  allowedPageOrigins: string[]
+  authentication: 'anonymous' | 'feishu-test'
+  [key: string]: unknown
+}
+
+/**
+ * Resolve policy settings before artifact preparation or signing.
+ * @param environment File-owned release settings; only the selected origin is required.
+ * @returns Policy metadata with deployment-selected origin and authentication.
+ */
+export function resolveDesktopPolicyEnvironment(environment: NodeJS.ProcessEnv): DesktopPolicyEnvironment

+ 36 - 0
apps/desktop/scripts/desktop-policy-environment.mjs

@@ -0,0 +1,36 @@
+/** Resolve the required policy service from the same deployment as updater publication. */
+import { resolveDesktopAutoUpdateEnvironment } from './desktop-auto-update-environment.mjs'
+
+function origin(value, name) {
+  let url
+  try { url = new URL(value) } catch { throw new Error(`desktop package: ${name} requires an HTTPS origin`) }
+  if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
+    throw new Error(`desktop package: ${name} requires an HTTPS origin without credentials, path, query, or fragment`)
+  }
+  return url.origin
+}
+
+/**
+ * Resolve mandatory policy metadata before preparing artifacts or accessing signing hardware.
+ * @param {NodeJS.ProcessEnv} environment File-owned release settings; the unselected origin is not required.
+ * @returns {{ origin: string, allowedPageOrigins: string[], authentication: 'anonymous' | 'feishu-test', [key: string]: unknown }} Selected policy.
+ */
+export function resolveDesktopPolicyEnvironment(environment) {
+  const deployment = resolveDesktopAutoUpdateEnvironment(environment)
+  const name = deployment === 'test' ? 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN' : 'DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN'
+  const selected = origin(environment[name], name)
+  let settings = {}
+  if (environment.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG !== undefined) {
+    try { settings = JSON.parse(environment.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG) }
+    catch { throw new Error('desktop package: DSH_DESKTOP_MANDATORY_UPDATE_CONFIG must be valid JSON') }
+  }
+  if (typeof settings !== 'object' || settings === null || Array.isArray(settings)
+    || 'origin' in settings || 'authentication' in settings) {
+    throw new Error('desktop package: policy options must be an object without origin or authentication; use the deployment origin settings')
+  }
+  const pages = settings.allowedPageOrigins ?? [selected]
+  if (!Array.isArray(pages) || pages.length === 0) throw new Error('desktop package: allowedPageOrigins must be a nonempty array')
+  return { ...settings, origin: selected,
+    allowedPageOrigins: pages.map(value => origin(value, 'allowedPageOrigins')),
+    authentication: deployment === 'test' ? 'feishu-test' : 'anonymous' }
+}

+ 26 - 12
apps/desktop/scripts/desktop-upload-plan.ts

@@ -4,7 +4,8 @@ import { createHash } from 'node:crypto'
 import { createReadStream } from 'node:fs'
 import { readFile, stat } from 'node:fs/promises'
 import { basename, join, resolve } from 'node:path'
-import { load } from 'js-yaml'
+import { dump, load } from 'js-yaml'
+import { prerelease } from 'semver'
 import type { DesktopPackageTargetName } from './package-target.ts'
 import {
   desktopBuildRecordFilename,
@@ -31,8 +32,9 @@ export interface DesktopUploadArtifact {
   readonly filename: string
   readonly key: string
   readonly contentType: string
-  readonly cacheControl: string
   readonly channelMetadata: boolean
+  /** Published YAML with normalized artifact URLs; binary bytes remain file-backed. */
+  readonly contents?: string
 }
 
 /** A fully validated upload operation with channel metadata ordered last. */
@@ -156,9 +158,6 @@ function uploadArtifact(
     filename,
     key: `${keyPrefix}/${filename}`,
     contentType,
-    cacheControl: channelMetadata
-      ? 'no-cache'
-      : 'public, max-age=31536000, immutable',
     channelMetadata,
   }
 }
@@ -223,27 +222,42 @@ export async function createDesktopUploadPlan(
   const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`)
   const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo)
   const artifacts: DesktopUploadArtifact[] = []
+  const binaryPrefix = `dsh-desk/bin/${targetName}`
 
   if (target.platform === 'darwin') {
     const dmgPath = await requireArtifact(artifactsRoot, `${base}.dmg`)
     const blockmapPath = await requireArtifact(artifactsRoot, `${base}.zip.blockmap`)
     artifacts.push(
-      uploadArtifact(dmgPath, update.keyPrefix, 'application/x-apple-diskimage'),
-      uploadArtifact(updaterPath, update.keyPrefix, 'application/zip'),
-      uploadArtifact(blockmapPath, update.keyPrefix, 'application/octet-stream'),
+      uploadArtifact(dmgPath, binaryPrefix, 'application/x-apple-diskimage'),
+      uploadArtifact(updaterPath, binaryPrefix, 'application/zip'),
+      uploadArtifact(blockmapPath, binaryPrefix, 'application/octet-stream'),
     )
   }
   else {
-    const blockMapSize = object(metadata.files[0], `${metadataFilename}.files[0]`).blockMapSize
-    numberField(blockMapSize, `${metadataFilename}.files[0].blockMapSize`)
+    const blockmapPath = await requireArtifact(artifactsRoot, `${base}.exe.blockmap`)
     artifacts.push(uploadArtifact(
       updaterPath,
-      update.keyPrefix,
+      binaryPrefix,
       'application/vnd.microsoft.portable-executable',
     ))
+    artifacts.push(uploadArtifact(blockmapPath, binaryPrefix, 'application/octet-stream'))
   }
 
-  artifacts.push(uploadArtifact(metadataPath, update.keyPrefix, 'application/yaml', true))
+  const payloadUrl = `${update.origin}/${binaryPrefix}/${updaterInfo.filename}`
+  const published = {
+    ...metadata,
+    files: [{ ...object(metadata.files[0], `${metadataFilename}.files[0]`), url: payloadUrl }],
+    ...(metadata.path === undefined ? {} : { path: payloadUrl }),
+  }
+  const channelArtifact = {
+    ...uploadArtifact(metadataPath, update.keyPrefix, 'application/yaml', true),
+    contents: dump(published),
+  }
+  artifacts.push(channelArtifact)
+  if (prerelease(dshVersion) === null) {
+    const stableFilename = metadataFilename.replace('nightly', 'latest')
+    artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` })
+  }
   return {
     environment: update.environment,
     target: targetName,

+ 113 - 0
apps/desktop/scripts/desktop-upload-run.ts

@@ -0,0 +1,113 @@
+/** Durable, credential-free evidence for test and production release uploads. */
+import { createHash } from 'node:crypto'
+import { createReadStream } from 'node:fs'
+import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { PutObjectCommand, type S3Client } from '@aws-sdk/client-s3'
+import type { DesktopUploadArtifact, DesktopUploadPlan } from './desktop-upload-plan.ts'
+import { recordPackagingEvent } from './packaging-run.mjs'
+
+const FAILURE_CODES = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'BadDigest', 'SignatureDoesNotMatch',
+  'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT'])
+
+async function fingerprint(artifact: DesktopUploadArtifact) {
+  const sha512 = createHash('sha512')
+  const md5 = createHash('md5')
+  let size = 0
+  const source = artifact.contents === undefined ? createReadStream(artifact.path) : [Buffer.from(artifact.contents)]
+  for await (const bytes of source) {
+    size += bytes.length
+    sha512.update(bytes)
+    md5.update(bytes)
+  }
+  return { size, sha512: sha512.digest('base64'), md5: md5.digest('base64') }
+}
+
+function receipt(value: unknown): object {
+  if (typeof value !== 'object' || value === null || !('$metadata' in value)) return {}
+  const metadata = value.$metadata
+  if (typeof metadata !== 'object' || metadata === null) return {}
+  return {
+    ...('httpStatusCode' in metadata && typeof metadata.httpStatusCode === 'number'
+      ? { httpStatus: metadata.httpStatusCode } : {}),
+    ...('requestId' in metadata && typeof metadata.requestId === 'string' && /^[\w+/=.-]{1,256}$/u.test(metadata.requestId)
+      ? { requestId: metadata.requestId } : {}),
+    ...('attempts' in metadata && typeof metadata.attempts === 'number' ? { attempts: metadata.attempts } : {}),
+  }
+}
+
+function failureReceipt(error: unknown): object {
+  const codes = typeof error === 'object' && error !== null
+    ? ['code' in error ? error.code : undefined, 'name' in error ? error.name : undefined] : []
+  const errorCode = codes.find(code => typeof code === 'string' && FAILURE_CODES.has(code)) ?? 'UNCLASSIFIED'
+  return { errorCode, ...receipt(error) }
+}
+
+/**
+ * Upload an already validated release, flushing intent and response evidence around every PUT.
+ * @param plan Validated release metadata; credential values must not be included.
+ * @param client Caller-owned COS client configured with maxAttempts: 1.
+ * @param recordsRoot Local retained evidence parent, outside disposable artifact directories.
+ * @returns Fresh record directory after all PUTs succeed; errors retain partial evidence and stop later PUTs.
+ */
+export async function uploadDesktopRelease(plan: DesktopUploadPlan, client: S3Client, recordsRoot: string): Promise<string> {
+  await mkdir(recordsRoot, { recursive: true })
+  const directory = await mkdtemp(join(recordsRoot, `${plan.environment}-${plan.target}-`))
+  process.stdout.write(`desktop upload: record ${directory}\n`)
+  const startedAt = new Date().toISOString()
+  let stage = 'prepare'
+  let key: string | undefined
+  let confirmedPuts = 0
+  let success = false
+  let failure: object | undefined
+  try {
+    await writeFile(join(directory, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true })
+    recordPackagingEvent(directory, { type: 'upload-start', environment: plan.environment, target: plan.target, version: plan.version })
+    if (await client.config.maxAttempts() !== 1) throw new Error('desktop upload: automatic retries must be disabled')
+    const artifacts = []
+    for (const artifact of plan.artifacts) {
+      stage = 'hash-input'
+      key = artifact.key
+      artifacts.push({ ...artifact, ...await fingerprint(artifact) })
+    }
+    const sourceSha256: Record<string, string> = {}
+    for (const filename of ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts']) {
+      sourceSha256[filename] = createHash('sha256').update(await readFile(join(import.meta.dirname, filename))).digest('hex')
+    }
+    await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ schemaVersion: 1,
+      environment: plan.environment, target: plan.target, version: plan.version, bucket: plan.bucket,
+      publicUrl: plan.publicUrl, maxAttempts: 1, sourceSha256, artifacts }, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true })
+    for (const artifact of artifacts) {
+      key = artifact.key
+      stage = 'verify-input'
+      const current = await fingerprint(artifact)
+      if (current.sha512 !== artifact.sha512 || current.size !== artifact.size) throw new Error('desktop upload: input changed')
+      stage = 'put'
+      recordPackagingEvent(directory, { type: 'put-intent', key, size: artifact.size, sha512: artifact.sha512,
+        channelMetadata: artifact.channelMetadata })
+      const body = artifact.contents ?? createReadStream(artifact.path)
+      try {
+        const response = await client.send(new PutObjectCommand({ Bucket: plan.bucket, Key: key,
+          Body: body, ContentLength: artifact.size, ContentMD5: artifact.md5, ContentType: artifact.contentType }))
+        confirmedPuts++
+        stage = 'record-response'
+        recordPackagingEvent(directory, { type: 'put-confirmed', key, ...receipt(response) })
+      } finally {
+        if (typeof body !== 'string') body.destroy()
+      }
+      process.stdout.write(`desktop upload: uploaded ${key}\n`)
+    }
+    stage = 'complete'
+    recordPackagingEvent(directory, { type: 'upload-complete', confirmedPuts })
+    success = true
+    return directory
+  } catch (error) {
+    failure = failureReceipt(error)
+    throw new Error(`desktop upload: stopped at ${stage}; inspect ${directory} before another upload`)
+  } finally {
+    await writeFile(join(directory, 'result.json'), `${JSON.stringify({ schemaVersion: 1, startedAt,
+      finishedAt: new Date().toISOString(), environment: plan.environment, target: plan.target, version: plan.version,
+      success, stage, key, confirmedPuts, failure, publicReadback: 'not-performed' }, null, 2)}\n`,
+    { flag: 'wx', mode: 0o600, flush: true })
+  }
+}

+ 2 - 0
apps/desktop/scripts/electron-builder-config.d.mts

@@ -0,0 +1,2 @@
+/** Import the factory without evaluating an ordinary release configuration. */
+export { createElectronBuilderConfig } from '../electron-builder.config.mjs'

+ 193 - 0
apps/desktop/scripts/electron-builder-config.mjs

@@ -0,0 +1,193 @@
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import {
+  resolveDesktopAppId,
+  resolveMacOSNotarizationEnvironment,
+  resolveMacOSSigningEnvironment,
+} from './desktop-release-environment.mjs'
+import { notarizeMacOSDiskImageArtifact } from './notarize-macos-disk-images.mjs'
+import { verifyMacOSSignatureAfterSign } from './verify-macos-signature.mjs'
+import {
+  createWindowsTokenSigner,
+  installWindowsNsisBootstrapSigner,
+  resolveWindowsUpdatePublisher,
+  scrubWindowsSigningEnvironment,
+} from './windows-sign.mjs'
+import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
+import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
+import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
+import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs'
+import {
+  resolveMacOSAppUpdateFeed,
+  verifyMacOSAppUpdateConfig,
+  writeMacOSAppUpdateConfig,
+} from './macos-app-update-config.mjs'
+
+/**
+ * Create electron-builder configuration from one release environment.
+ * @param {NodeJS.ProcessEnv} env - Packaging environment.
+ * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no explicit target is present.
+ * @param {string} hostArch - Build-host architecture used when no explicit target is present.
+ * @param {string | undefined} preparedRuntime - Verified private dsh tree for installed-update qualification; ordinary releases use the target tree.
+ * @returns {object} electron-builder configuration.
+ */
+export function createElectronBuilderConfig(
+  env = process.env,
+  hostPlatform = process.platform,
+  hostArch = process.arch,
+  preparedRuntime = undefined,
+) {
+  const appId = resolveDesktopAppId(env)
+  const policy = resolveDesktopPolicyEnvironment(env)
+  const targetPlatform = env.DSH_DESKTOP_TARGET_PLATFORM
+  const resolvedPlatform = targetPlatform ?? hostPlatform
+  const resolvedArch = env.DSH_DESKTOP_TARGET_ARCH ?? hostArch
+  if (env.DSH_DESKTOP_UNSIGNED !== undefined && !['0', '1'].includes(env.DSH_DESKTOP_UNSIGNED)) {
+    throw new Error('desktop package: DSH_DESKTOP_UNSIGNED must be 0 or 1')
+  }
+  const unsigned = env.DSH_DESKTOP_UNSIGNED === '1'
+  if (unsigned && resolvedPlatform !== 'win32') throw new Error('desktop package: unsigned builds require Windows')
+  const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin')
+  const packagesWindows = resolvedPlatform === 'win32'
+  if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller()
+  const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined
+  if (packagesMacOS) resolveMacOSNotarizationEnvironment(env)
+  const windowsSigner = packagesWindows && !unsigned
+    ? createWindowsTokenSigner({
+        certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE,
+        signTool: env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
+        tokenPin: env.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
+        keyContainer: env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
+      })
+    : undefined
+  if (windowsSigner !== undefined) {
+    installWindowsNsisBootstrapSigner({ sign: windowsSigner })
+  }
+  const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch)
+  const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
+  if (preparedRuntime !== undefined) buildPaths.dsh = preparedRuntime
+  return {
+    appId,
+    extraMetadata: { dshDesktopAppId: appId, dshMandatoryUpdatePolicy: policy },
+    productName: 'DeepSeek Harness',
+    artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}',
+    directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts },
+    asar: true,
+    electronDist: buildPaths.electron,
+    electronFuses: { runAsNode: true },
+    beforeBuild: async () => {
+      if (resolvedPlatform !== 'win32') return true
+      await promisify(execFile)('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
+        fileURLToPath(new URL('./prepare-windows-installer.ps1', import.meta.url)),
+        '-OutputDirectory', join(buildPaths.root, 'installer-ui')], {
+        env: scrubWindowsSigningEnvironment(env), windowsHide: true,
+      })
+      if (windowsSigner !== undefined) {
+        await windowsSigner({ path: join(buildPaths.root, 'installer-ui', 'window-frame.dll'), hash: 'sha256', isNest: false })
+      }
+      // A falsy result tells electron-builder to omit its production node_modules collection.
+      return true
+    },
+    files: [
+      'lib/*.js',
+      'lib/*.cjs',
+      'renderer/**/*',
+      'package.json',
+      { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] },
+      // electron-builder excludes a source directory's root node_modules.
+      { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] },
+    ],
+    asarUnpack: [
+      '**/*.{node,dylib,dll,so,exe}',
+      '**/*.so.*',
+      '**/spawn-helper',
+      '**/@vscode/ripgrep/bin/rg',
+    ],
+    extraResources: [
+      { from: buildPaths.runtime, to: 'runtime' },
+    ],
+    mac: {
+      icon: fileURLToPath(new URL('../resources/icon-macos.png', import.meta.url)),
+      category: 'public.app-category.developer-tools',
+      identity: macOSSigning?.signingIdentity,
+      forceCodeSigning: true,
+      hardenedRuntime: true,
+      // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle.
+      signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'],
+      notarize: true,
+      target: ['dmg', 'zip'],
+    },
+    dmg: {
+      sign: true,
+      writeUpdateInfo: false,
+    },
+    beforePack: async () => {
+      if (policy === undefined) return
+      const { resolveDesktopPolicyConfig } = await import('../lib/types/mandatory-update-policy.js')
+      resolveDesktopPolicyConfig(policy)
+    },
+    afterPack: async context => {
+      const { verifyDesktopRuntime, writeDesktopRuntime } = await import('../lib/types/runtime-tree.js')
+      const resourcesDir = context.packager.getResourcesDir(context.appOutDir)
+      if (resolvedPlatform === 'darwin' && update !== undefined) {
+        await writeMacOSAppUpdateConfig(resourcesDir, resolveMacOSAppUpdateFeed(context.packager.config.publish),
+          context.packager.appInfo.updaterCacheDirName)
+      }
+      if (resolvedPlatform === 'win32' && !unsigned) {
+        // Windows signs copied executable resources before afterPack runs.
+        const prepared = await verifyDesktopRuntime(buildPaths.dsh,
+          context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
+        writeDesktopRuntime(buildPaths.dsh, prepared.release, prepared.sharedPackages.map(entry => entry.name),
+          { platform: resolvedPlatform, arch: resolvedArch })
+      }
+      await verifyDesktopRuntime(buildPaths.dsh,
+        context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
+    },
+    afterSign: async context => {
+      if (context.electronPlatformName !== 'darwin') return
+      const appPath = join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
+      if (update !== undefined) {
+        await verifyMacOSAppUpdateConfig(appPath, resolveMacOSAppUpdateFeed(context.packager.config.publish),
+          context.packager.appInfo.updaterCacheDirName)
+      }
+      verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env))
+    },
+    artifactBuildCompleted: artifact => {
+      if (!artifact.file.endsWith('.dmg')) return
+      return notarizeMacOSDiskImageArtifact(
+        artifact,
+        env,
+        macOSSigning ?? resolveMacOSSigningEnvironment(env),
+      )
+    },
+    win: {
+      icon: fileURLToPath(new URL('../resources/icon-windows.png', import.meta.url)),
+      forceCodeSigning: !unsigned,
+      signtoolOptions: {
+        sign: windowsSigner,
+        publisherName: windowsSigner === undefined ? undefined : resolveWindowsUpdatePublisher(env.DSH_DESKTOP_WINDOWS_CER_FILE),
+        signingHashAlgorithms: ['sha256'],
+      },
+      target: ['nsis'],
+    },
+    linux: {
+      category: 'Development',
+      target: ['AppImage'],
+    },
+    nsis: {
+      installerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'),
+      uninstallerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'),
+      include: fileURLToPath(new URL('./installer.nsh', import.meta.url)),
+      oneClick: false,
+      perMachine: false,
+      allowElevation: false,
+      allowToChangeInstallationDirectory: false,
+      installerLanguages: ['en_US', 'zh_CN'],
+      differentialPackage: true,
+    },
+    detectUpdateChannel: false,
+    publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl, channel: 'nightly' }],
+  }
+}

+ 39 - 0
apps/desktop/scripts/installed-update-builder.ts

@@ -0,0 +1,39 @@
+/** Select isolated qualification inputs without weakening the ordinary Windows installer or signing hooks. */
+import { join } from 'node:path'
+import { createElectronBuilderConfig } from './electron-builder-config.mjs'
+import { readInstalledUpdateRun } from './installed-update-qualification.ts'
+import { verifyInstalledUpdateApplication } from './prepare-installed-update-application.ts'
+import { verifyDesktopRuntime } from '../src/runtime-tree.ts'
+
+/**
+ * Validate one version and construct its signed-only, test-only builder configuration.
+ * @param manifest Original run.json with prepared runtime and application files.
+ * @param version One of the run's two versions.
+ * @param environment File-owned .env.windows settings loaded by a supervised caller; never logged here.
+ * @returns Configuration only. Calling builder hooks requires separate hardware authorization and supervision.
+ */
+export async function createInstalledUpdateBuilderConfig(manifest: string, version: string, environment: NodeJS.ProcessEnv) {
+  const run = await readInstalledUpdateRun(manifest)
+  if (!run.versions.includes(version)) throw new Error('installed update: package version is outside the qualification run')
+  if (environment.DSH_DESKTOP_AUTO_UPDATE_ENV !== 'test' || environment.DSH_DESKTOP_UNSIGNED === '1'
+    || environment.DOWNLOAD_TEST_ORIGIN !== run.origin || environment.DOWNLOAD_TEST_COS_BUCKET !== run.bucket) {
+    throw new Error('installed update: signed ordinary-update qualification requires matching test deployment settings')
+  }
+  const application = await verifyInstalledUpdateApplication(run.root)
+  const dsh = join(run.root, version, 'dsh')
+  await verifyDesktopRuntime(dsh, version, { platform: 'win32', arch: 'x64' })
+  const config = createElectronBuilderConfig({ ...environment, DSH_DESKTOP_APP_ID: run.appId,
+    DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: '0' }, 'win32', 'x64', dsh)
+  return { ...config,
+    productName: run.productName,
+    directories: { ...config.directories, output: join(run.root, version, 'installer') },
+    extraMetadata: { ...config.extraMetadata, name: `dsh-update-test-${run.id}`, version, main: 'qualification-bootstrap.mjs' },
+    files: [
+      { from: application, to: '.', filter: ['lib/*.js', 'lib/*.cjs', 'renderer/**/*', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'] },
+      'package.json',
+      { from: dsh, to: 'dsh', filter: ['**/*'] },
+      { from: join(dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] },
+    ],
+    publish: [{ provider: 'generic' as const, url: `${run.origin}/${run.feedKey.slice(0, -'nightly.yml'.length)}`, channel: 'nightly' }],
+  }
+}

+ 98 - 0
apps/desktop/scripts/installed-update-cos.ts

@@ -0,0 +1,98 @@
+/** Fixed test-COS transport; callers authorize writes separately from local planning. */
+import { createReadStream } from 'node:fs'
+import { createHash } from 'node:crypto'
+import { GetBucketVersioningCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
+import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
+import type { InstalledUpdatePublicationStore, InstalledUpdateRemoteObject } from './installed-update-publication.ts'
+
+const BUCKET = 'bj-toc-download-test-1320056602'
+const ORIGIN = 'https://download-test.deepseek.com'
+
+async function hashStream(stream: AsyncIterable<Uint8Array>): Promise<InstalledUpdateRemoteObject> {
+  const hash = createHash('sha512')
+  let size = 0
+  for await (const bytes of stream) { hash.update(bytes); size += bytes.length }
+  return { sha512: hash.digest('base64'), size }
+}
+
+/**
+ * Create a fixed test transport from .env.windows, passing only test upload credentials to the SDK.
+ * @returns Store and explicit disposal; no request is sent by construction and no signing process is launched.
+ */
+export function createInstalledUpdateCos(): InstalledUpdatePublicationStore & { dispose(): void } {
+  const environment = loadDesktopPackageEnvironment('win32')
+  if (environment.DSH_DESKTOP_AUTO_UPDATE_ENV !== 'test' || environment.DOWNLOAD_TEST_ORIGIN !== ORIGIN
+    || environment.DOWNLOAD_TEST_COS_BUCKET !== BUCKET || !environment.DOWNLOAD_TEST_COS_SECRET_ID?.trim()
+    || !environment.DOWNLOAD_TEST_COS_SECRET_KEY?.trim()) throw new Error('installed update: complete test upload settings are required')
+  const client = new S3Client({ region: 'Auto', endpoint: 'https://cos.ap-beijing.myqcloud.com', maxAttempts: 1,
+    requestChecksumCalculation: 'WHEN_REQUIRED', responseChecksumValidation: 'WHEN_REQUIRED',
+    credentials: { accessKeyId: environment.DOWNLOAD_TEST_COS_SECRET_ID, secretAccessKey: environment.DOWNLOAD_TEST_COS_SECRET_KEY } })
+  const keyAllowed = (key: string): void => {
+    if (!/^dsh-desk\/(?:bin|feeds)\/qualification\/[a-f0-9]{24}\/win-x64\/[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(key)) {
+      throw new Error('installed update: COS key must stay in the Windows qualification namespace')
+    }
+  }
+  return {
+    async versioningDisabled() {
+      const response = await client.send(new GetBucketVersioningCommand({ Bucket: BUCKET }), { abortSignal: AbortSignal.timeout(30_000) })
+      return response.$metadata.httpStatusCode === 200 && response.Status === undefined
+    },
+    async read(key) {
+      keyAllowed(key)
+      try {
+        const response = await client.send(new GetObjectCommand({ Bucket: BUCKET, Key: key }), {
+          abortSignal: AbortSignal.timeout(900_000),
+        })
+        if (!response.Body) throw new Error('installed update: missing COS response body')
+        // This transport uses the Node S3 handler, whose body is an async-iterable stream.
+        return await hashStream(response.Body as AsyncIterable<Uint8Array>)
+      } catch (error) {
+        if (typeof error === 'object' && error !== null && 'name' in error && error.name === 'NoSuchKey') return null
+        throw error
+      }
+    },
+    async publicRead(url) {
+      const parsed = new URL(url)
+      if (parsed.origin !== ORIGIN || parsed.search || parsed.hash) throw new Error('installed update: exact test public URL is required')
+      keyAllowed(parsed.pathname.slice(1))
+      const response = await fetch(url, { redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(900_000) })
+      if (response.status === 404) { await response.body?.cancel(); return null }
+      if (!response.ok || !response.body) { await response.body?.cancel(); throw new Error('installed update: public object read failed') }
+      const reader = response.body.getReader()
+      async function* bytes() {
+        try {
+          for (;;) { const next = await reader.read(); if (next.done) return; yield next.value }
+        } finally { try { await reader.cancel() } finally { reader.releaseLock() } }
+      }
+      return hashStream(bytes())
+    },
+    async put(key, object) {
+      keyAllowed(key)
+      const md5 = createHash('md5')
+      const sha512 = createHash('sha512')
+      let size = 0
+      const add = (bytes: Buffer): void => { md5.update(bytes); sha512.update(bytes); size += bytes.length }
+      if ('path' in object.source) {
+        for await (const bytes of createReadStream(object.source.path)) add(bytes as Buffer)
+      } else add(Buffer.from(object.source.contents))
+      if (size !== object.size || sha512.digest('base64') !== object.sha512) {
+        throw new Error('installed update: upload input bytes changed')
+      }
+      const body = 'path' in object.source ? createReadStream(object.source.path) : object.source.contents
+      const command = new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: body, ContentLength: object.size,
+        ContentMD5: md5.digest('base64'), ContentType: key.endsWith('.yml') ? 'application/yaml' : 'application/octet-stream',
+        CacheControl: 'no-store' })
+      if (object.forbidOverwrite) command.middlewareStack.add(next => async (args) => {
+        // S3's serialize step supplies the HTTP request before this build middleware.
+        const request = args.request as { headers: Record<string, string> }
+        request.headers['x-cos-forbid-overwrite'] = 'true'
+        return next(args)
+      }, { step: 'build', name: 'qualificationForbidOverwrite' })
+      try {
+        const response = await client.send(command, { abortSignal: AbortSignal.timeout(900_000) })
+        return response.$metadata.requestId === undefined ? {} : { requestId: response.$metadata.requestId }
+      } finally { if (typeof body !== 'string') body.destroy() }
+    },
+    dispose() { client.destroy() },
+  }
+}

+ 74 - 0
apps/desktop/scripts/installed-update-distribution.ts

@@ -0,0 +1,74 @@
+/** Validate local qualification payload bytes and prepare separate binary and fixed-feed operations. */
+import { createHash } from 'node:crypto'
+import { createReadStream } from 'node:fs'
+import { readFile, stat } from 'node:fs/promises'
+import { join } from 'node:path'
+import { dump, load } from 'js-yaml'
+import { readInstalledUpdateRun } from './installed-update-qualification.ts'
+
+/** A local immutable object, with its final test-only destination and digest. */
+export interface InstalledUpdateBinary {
+  readonly path: string
+  readonly key: string
+  readonly size: number
+  readonly sha512: string
+}
+
+/** File integrity does not establish signature, application identity, or authority to publish. */
+export interface InstalledUpdateDistribution {
+  readonly version: string
+  readonly bucket: string
+  readonly binaries: readonly InstalledUpdateBinary[]
+  readonly feed: { readonly key: string; readonly url: string; readonly contents: string; readonly sha512: string }
+  readonly verified: 'file-integrity-only'
+  readonly publicationAuthorized: false
+}
+
+async function binary(path: string, key: string): Promise<InstalledUpdateBinary> {
+  const file = await stat(path)
+  if (!file.isFile() || file.size === 0) throw new Error('installed update: missing or empty binary material')
+  const hash = createHash('sha512')
+  for await (const chunk of createReadStream(path)) hash.update(chunk)
+  return { path, key, size: file.size, sha512: hash.digest('base64') }
+}
+
+function record(value: unknown): Record<string, unknown> {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('installed update: invalid feed metadata')
+  return value as Record<string, unknown>
+}
+
+/**
+ * Check generated YAML against installer bytes and prepare one independent fixed-feed body per version.
+ * @param manifest Original qualification run.json; test identity and destinations are revalidated.
+ * @param version One of the run's two versions, never a version inferred from YAML.
+ * @returns Read-only binary and feed plans; no credentials, network, signing, or installation are involved.
+ */
+export async function planInstalledUpdateDistribution(manifest: string, version: string): Promise<InstalledUpdateDistribution> {
+  const run = await readInstalledUpdateRun(manifest)
+  if (!run.versions.includes(version)) throw new Error('installed update: version is outside the qualification run')
+  const directory = join(run.root, version, 'installer')
+  const metadata = record(load(await readFile(join(directory, 'nightly.yml'), 'utf8')))
+  if (metadata.version !== version || !Array.isArray(metadata.files) || metadata.files.length !== 1) {
+    throw new Error('installed update: feed must describe exactly the selected version and installer')
+  }
+  const info = record(metadata.files[0])
+  const filename = `deepseek-harness-${version}-win-x64.exe`
+  if (info.url !== filename || (metadata.path !== undefined && metadata.path !== filename)) {
+    throw new Error('installed update: feed filename must identify the selected local Windows installer')
+  }
+  const installer = await binary(join(directory, filename), `${run.binPrefix}/${filename}`)
+  if (info.size !== installer.size || info.sha512 !== installer.sha512
+    || (metadata.sha512 !== undefined && metadata.sha512 !== installer.sha512)) {
+    throw new Error('installed update: installer size or SHA-512 differs from the generated feed')
+  }
+  const blockmap = await binary(join(directory, `${filename}.blockmap`), `${run.binPrefix}/${filename}.blockmap`)
+  if (metadata.releaseDate !== undefined && (typeof metadata.releaseDate !== 'string'
+    || !Number.isFinite(Date.parse(metadata.releaseDate)))) throw new Error('installed update: invalid feed release date')
+  const url = `${run.origin}/${installer.key}`
+  const contents = dump({ version, files: [{ url, size: installer.size, sha512: installer.sha512 }],
+    path: url, sha512: installer.sha512, ...(metadata.releaseDate === undefined ? {} : { releaseDate: metadata.releaseDate }) })
+  return { version, bucket: run.bucket, binaries: [installer, blockmap],
+    feed: { key: run.feedKey, url: `${run.origin}/${run.feedKey}`, contents,
+      sha512: createHash('sha512').update(contents).digest('base64') },
+    verified: 'file-integrity-only', publicationAuthorized: false }
+}

+ 7 - 0
apps/desktop/scripts/installed-update-identity.d.mts

@@ -0,0 +1,7 @@
+/** Configure per-run Electron data and journal paths before production imports. */
+export function configureInstalledUpdateIdentity(
+  app: { getPath(name: string): string; setPath(name: string, path: string): void },
+  run: { id: string; versions: readonly string[] },
+  metadata: { version?: string; dshDesktopAppId?: string },
+  environment: NodeJS.ProcessEnv,
+): { root: string; userData: string; harnessHome: string; journals: string }

+ 27 - 0
apps/desktop/scripts/installed-update-identity.mjs

@@ -0,0 +1,27 @@
+/** Configure only a qualification package's identity before the production main entry loads. */
+import { mkdirSync } from 'node:fs'
+import { join } from 'node:path'
+
+/**
+ * Keep test application data and journals outside the replaceable installation tree.
+ * @param {{ getPath(name: string): string, setPath(name: string, path: string): void }} app Electron path API before readiness.
+ * @param {{ id: string, versions: readonly string[] }} run Embedded qualification identity and version pair.
+ * @param {{ version?: string, dshDesktopAppId?: string }} metadata Installed package metadata.
+ * @param {NodeJS.ProcessEnv} environment Main-process environment modified before production imports.
+ * @returns {{ root: string, userData: string, harnessHome: string, journals: string }} Shared paths for both versions.
+ */
+export function configureInstalledUpdateIdentity(app, run, metadata, environment) {
+  if (!/^[a-f0-9]{24}$/u.test(run.id) || run.versions.length !== 2
+    || metadata.dshDesktopAppId !== `com.deepseek.dsh.qualification.q${run.id}`
+    || !run.versions.includes(metadata.version)) {
+    throw new Error('installed update: qualification package identity does not match its bootstrap')
+  }
+  const root = join(app.getPath('appData'), 'dsh-update-qualification', run.id)
+  const paths = { root, userData: join(root, 'user-data'), harnessHome: join(root, 'dsh-home'), journals: join(root, 'journals') }
+  for (const directory of [paths.userData, paths.harnessHome, paths.journals]) mkdirSync(directory, { recursive: true })
+  app.setPath('userData', paths.userData)
+  app.setPath('sessionData', paths.userData)
+  environment.DSH_HOME = paths.harnessHome
+  environment.DSH_DESKTOP_UPDATE_JOURNAL_DIR = paths.journals
+  return paths
+}

+ 89 - 0
apps/desktop/scripts/installed-update-network.ps1

@@ -0,0 +1,89 @@
+# Operator-owned test-application fault. Status is read-only; Block and Restore require typed confirmation.
+param(
+    [Parameter(Mandatory = $true)][string]$Plan,
+    [ValidateSet('Status', 'Block', 'Restore')][string]$Action = 'Status'
+)
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+$planPath = (Resolve-Path -LiteralPath $Plan).Path
+$spec = Get-Content -LiteralPath $planPath -Raw -Encoding UTF8 | ConvertFrom-Json
+if ($spec.schemaVersion -ne 1 -or $spec.runId -cnotmatch '^[a-f0-9]{24}$' -or
+    $spec.ruleName -cne "DSH-Update-Qualification-$($spec.runId)" -or
+    $spec.sha512Hex -cnotmatch '^[A-F0-9]{128}$' -or
+    $spec.executable -notmatch '^[A-Za-z]:\\' -or
+    [IO.Path]::GetFileName($spec.executable) -cne "DSH Update Test $($spec.runId).exe" -or
+    [IO.Path]::GetFullPath($spec.executable) -cne $spec.executable) {
+    throw 'Invalid test-only network plan; no network changes were made.'
+}
+$owner = "dsh-update-qualification:$($spec.runId):$($spec.sha512Hex)"
+$recordParent = Join-Path (Split-Path -Parent $planPath) 'records'
+[IO.Directory]::CreateDirectory($recordParent) | Out-Null
+$record = Join-Path $recordParent ([Guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Path $record | Out-Null
+$events = Join-Path $record 'events.jsonl'
+function Save-Event([string]$Stage, [object]$Data) {
+    $line = @{ time = [DateTime]::UtcNow.ToString('o'); action = $Action; stage = $Stage; data = $Data } | ConvertTo-Json -Depth 6 -Compress
+    $bytes = [Text.Encoding]::UTF8.GetBytes($line + "`n")
+    $stream = [IO.File]::Open($events, [IO.FileMode]::Append, [IO.FileAccess]::Write, [IO.FileShare]::Read)
+    try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() }
+}
+function Find-OwnedRule {
+    $rules = @(Get-NetFirewallRule -PolicyStore PersistentStore | Where-Object { $_.Name -ceq $spec.ruleName })
+    if ($rules.Count -gt 1) { throw 'Multiple rules match this run; manual inspection is required.' }
+    if ($rules.Count -eq 0) { return $null }
+    $rule = $rules[0]
+    $filters = @($rule | Get-NetFirewallApplicationFilter)
+    if ($rule.Description -cne $owner -or [string]$rule.Direction -ne 'Outbound' -or [string]$rule.Action -ne 'Block' -or
+        $filters.Count -ne 1 -or $filters[0].Program -ine $spec.executable) {
+        throw 'The existing rule is not owned by this exact test plan; it was not modified.'
+    }
+    return $rule
+}
+$success = $false
+Write-Output "NETWORK_FAULT_RECORD $record"
+try {
+    Save-Event 'started' @{ runId = $spec.runId; executable = $spec.executable; ruleName = $spec.ruleName }
+    $existing = Find-OwnedRule
+    Save-Event 'before' @{ present = ($null -ne $existing) }
+    if ($Action -eq 'Block') {
+        if ($null -ne $existing) { throw 'A test rule already exists. Restore it before starting another fault.' }
+        if ((Get-FileHash -LiteralPath $spec.executable -Algorithm SHA512).Hash -cne $spec.sha512Hex) {
+            throw 'The test executable changed. No rule was created.'
+        }
+        Write-Output "Only this executable will be blocked: $($spec.executable)"
+        Write-Output 'Keep a second administrator terminal ready to run Restore. Do not disable the adapter, VPN, or proxy.'
+        if ((Read-Host "Type BLOCK $($spec.runId) after download progress begins") -cne "BLOCK $($spec.runId)") {
+            throw 'Confirmation declined; no rule was created.'
+        }
+        if ($null -ne (Find-OwnedRule)) { throw 'A rule appeared during confirmation. No rule was created.' }
+        if ((Get-FileHash -LiteralPath $spec.executable -Algorithm SHA512).Hash -cne $spec.sha512Hex) {
+            throw 'The test executable changed during confirmation. No rule was created.'
+        }
+        Save-Event 'creating-rule' @{ name = $spec.ruleName }
+        New-NetFirewallRule -Name $spec.ruleName -DisplayName $spec.ruleName -Description $owner `
+            -Direction Outbound -Program $spec.executable -Action Block -Profile Any -Enabled True -PolicyStore PersistentStore | Out-Null
+        $created = Find-OwnedRule
+        if ($null -eq $created -or [string]$created.Enabled -ne 'True') { throw 'The rule was not confirmed enabled. Use Restore.' }
+        Save-Event 'blocked-rule-present' @{ trafficInterruptionVerified = $false }
+    } elseif ($Action -eq 'Restore') {
+        if ($null -ne $existing) {
+            if ((Read-Host "Type RESTORE $($spec.runId)") -cne "RESTORE $($spec.runId)") { throw 'Restoration declined.' }
+            $existing = Find-OwnedRule
+            if ($null -ne $existing) {
+                Save-Event 'removing-rule' @{ name = $spec.ruleName }
+                $existing | Remove-NetFirewallRule
+            }
+        }
+        if ($null -ne (Find-OwnedRule)) { throw 'The test rule still exists; restoration is incomplete.' }
+        Save-Event 'rule-absent' @{ downloadRecoveryVerified = $false }
+    } else {
+        Save-Event 'status' @{ present = ($null -ne $existing); enabled = $(if ($null -ne $existing) { [string]$existing.Enabled } else { $null }) }
+        Write-Output "Test rule present: $($null -ne $existing)"
+    }
+    $success = $true
+} catch {
+    Save-Event 'failed' @{ message = $_.Exception.Message; automaticRetry = $false }
+    throw
+} finally {
+    Save-Event 'finished' @{ success = $success; rulePresenceIsNotTrafficEvidence = $true }
+}

+ 87 - 0
apps/desktop/scripts/installed-update-package-content.ts

@@ -0,0 +1,87 @@
+/** Verify identity and update configuration from an extracted installer payload, not a neighboring unpacked build. */
+import { createHash } from 'node:crypto'
+import { readFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { join } from 'node:path'
+import { load } from 'js-yaml'
+import { readAsar, type Node as AsarNode } from 'app-builder-lib/out/asar/asar.js'
+import { readInstalledUpdateRun } from './installed-update-qualification.ts'
+import { verifyInstalledUpdateApplication } from './prepare-installed-update-application.ts'
+import { verifyDesktopRuntime } from '../src/runtime-tree.ts'
+
+const require = createRequire(import.meta.url)
+
+function object(value: unknown): Record<string, unknown> {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('installed update: invalid package metadata')
+  return value as Record<string, unknown>
+}
+
+/**
+ * Check the actual payload's ASAR, bundled runtime, private identity, feed, cache, and publisher metadata.
+ * @param manifest Retained qualification manifest.
+ * @param version Exact selected version.
+ * @param payload Extracted installer payload in a private verification directory.
+ * @param publisher Expected DN derived from the public release certificate.
+ * @returns Content observations only; signatures, installation, and restart are separate checks.
+ */
+export async function verifyInstalledUpdatePackageContent(manifest: string, version: string, payload: string, publisher: string) {
+  const run = await readInstalledUpdateRun(manifest)
+  if (!run.versions.includes(version)) throw new Error('installed update: payload version is outside the run')
+  await verifyInstalledUpdateApplication(run.root)
+  const archive = await readAsar(join(payload, 'resources/app.asar'))
+  const metadata = object(await archive.readJson('package.json'))
+  if (metadata.name !== `dsh-update-test-${run.id}` || metadata.version !== version
+    || metadata.dshDesktopAppId !== run.appId || metadata.main !== 'qualification-bootstrap.mjs'
+    || metadata.type !== 'module' || metadata.dshMandatoryUpdatePolicy !== undefined) {
+    throw new Error('installed update: packaged application identity, version, entry, or policy differs')
+  }
+  const inventory = JSON.parse(await readFile(join(run.root, 'application/result.json'), 'utf8')) as {
+    files: { path: string; sha256: string }[]
+  }
+  const expectedPaths = new Set(inventory.files.map(file => file.path))
+  const inspect = (node: AsarNode, path = ''): void => {
+    if (path === 'node_modules' || path === 'package.json') return
+    if (node.link !== undefined || node.unpacked === true) throw new Error('installed update: application archive contains external entries')
+    if (node.files !== undefined) {
+      for (const [name, child] of Object.entries(node.files)) inspect(child, path === '' ? name : `${path}/${name}`)
+    } else if (!expectedPaths.has(path)) throw new Error('installed update: application archive contains additional files')
+  }
+  inspect(archive.header)
+  for (const file of inventory.files) {
+    const path = file.path.split('/').join(process.platform === 'win32' ? '\\' : '/')
+    const node = archive.getFile(path, false)
+    if (node.link !== undefined || node.unpacked === true
+      || createHash('sha256').update(await archive.readFile(path)).digest('hex') !== file.sha256) {
+      throw new Error('installed update: packaged application file differs from frozen inputs')
+    }
+  }
+  const dependencies = []
+  for (const name of ['electron-updater', 'semver']) {
+    const actual = object(await archive.readJson(join('node_modules', name, 'package.json')))
+    const expected = object(JSON.parse(await readFile(require.resolve(`${name}/package.json`), 'utf8')))
+    if (actual.name !== name || actual.version !== expected.version) throw new Error('installed update: packaged updater dependency differs from verification tools')
+    dependencies.push({ name, version: actual.version })
+  }
+  const update = object(load(await readFile(join(payload, 'resources/app-update.yml'), 'utf8')))
+  const url = `${run.origin}/${run.feedKey.slice(0, -'nightly.yml'.length)}`
+  if (update.provider !== 'generic' || update.url !== url || update.channel !== 'nightly'
+    || update.updaterCacheDirName !== `dsh-update-test-${run.id}-updater`
+    || !Array.isArray(update.publisherName) || update.publisherName.length !== 1 || update.publisherName[0] !== publisher) {
+    throw new Error('installed update: packaged feed, cache identity, channel, or publisher differs')
+  }
+  const runtime = await verifyDesktopRuntime(join(payload, 'resources/dsh'), version, { platform: 'win32', arch: 'x64' })
+  const prepared = await verifyDesktopRuntime(join(run.root, version, 'dsh'), version, { platform: 'win32', arch: 'x64' })
+  if (JSON.stringify(runtime.release) !== JSON.stringify(prepared.release)
+    || JSON.stringify(runtime.sharedPackages) !== JSON.stringify(prepared.sharedPackages)
+    || JSON.stringify(runtime.files.map(file => file.path)) !== JSON.stringify(prepared.files.map(file => file.path))) {
+    throw new Error('installed update: packaged runtime does not describe the prepared release')
+  }
+  for (const [index, file] of runtime.files.entries()) {
+    if (!file.path.endsWith('.exe') && (file.sha256 !== prepared.files[index]!.sha256 || file.bytes !== prepared.files[index]!.bytes)) {
+      throw new Error('installed update: non-executable runtime bytes differ from prepared inputs')
+    }
+  }
+  return { version, appId: run.appId, applicationFiles: inventory.files.length, dependencies, dependenciesFrozen: false,
+    runtimeFiles: runtime.files.length, feedUrl: `${run.origin}/${run.feedKey}`, installed: false,
+    resignedExecutables: runtime.files.filter(file => file.path.endsWith('.exe')).map(file => join(payload, 'resources/dsh', file.path)) }
+}

+ 122 - 0
apps/desktop/scripts/installed-update-packaging.ts

@@ -0,0 +1,122 @@
+/** Supervise one explicitly confirmed qualification build; checks never launch a child or clear a signing interlock. */
+import { createHash } from 'node:crypto'
+import { execFileSync } from 'node:child_process'
+import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { readInstalledUpdateRun } from './installed-update-qualification.ts'
+import { createInstalledUpdateBuilderConfig } from './installed-update-builder.ts'
+import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
+import { desktopElectronBuilderEnvironment } from './package-target.ts'
+import { createPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
+import { planInstalledUpdateDistribution } from './installed-update-distribution.ts'
+
+const APP_ROOT = resolve(import.meta.dirname, '..')
+const REPOSITORY = resolve(APP_ROOT, '../..')
+const SIGNING_FIELDS = new Set(['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL',
+  'DSH_DESKTOP_WINDOWS_KEY_CONTAINER', 'DSH_DESKTOP_WINDOWS_TOKEN_PIN'])
+
+/** Public refusal without credential values or contents of the incident record. */
+export class InstalledUpdateSigningHoldError extends Error {
+  constructor() {
+    super('installed update: signing interlock exists; administrator-reviewed recovery is required; do not clear it or retry automatically')
+  }
+}
+
+async function absent(path: string): Promise<boolean> {
+  try { await lstat(path); return false }
+  catch (error) {
+    if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true
+    throw error
+  }
+}
+
+/**
+ * Reject a retained or active hardware attempt without reading its contents or changing it.
+ * @param stateFile Per-user signing interlock; an isolated file may be supplied by tests, not the CLI.
+ * @returns Nothing when absent; an inaccessible or existing file rejects before credentials are loaded.
+ */
+export async function assertInstalledUpdateSigningClear(stateFile = join(homedir(), '.dsh-desktop-signing', 'attempt.json')): Promise<void> {
+  if (!await absent(stateFile)) throw new InstalledUpdateSigningHoldError()
+}
+
+/**
+ * Restrict builder children to ordinary tool settings and the four file-owned signing inputs.
+ * @param environment Loaded .env.windows settings, never raw log data.
+ * @returns A new environment without upload/LLM credentials or Node preload overrides.
+ */
+export function installedUpdatePackagingEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
+  return desktopElectronBuilderEnvironment(Object.fromEntries(Object.entries(environment)
+    .filter(([name]) => SIGNING_FIELDS.has(name) || !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$/iu.test(name))), false)
+}
+
+async function inputHashes(manifest: string, version: string, environment: NodeJS.ProcessEnv): Promise<object> {
+  const run = await readInstalledUpdateRun(manifest)
+  const paths = [manifest, join(run.root, 'application/result.json'), join(run.root, version, 'dsh/desktop-runtime.json'),
+    join(REPOSITORY, 'pnpm-lock.yaml'), join(APP_ROOT, 'package.json'),
+    ...['electron-builder-config.mjs', 'installed-update-builder.ts', 'build-installed-update-worker.mjs',
+      'windows-sign.mjs', 'windows-sign.cmd', 'windows-signing-state.mjs', 'windows-directory-installer.mjs',
+      'installer.nsh', 'prepare-windows-installer.ps1'].map(path => join(import.meta.dirname, path)),
+    environment.DSH_DESKTOP_WINDOWS_CER_FILE!, environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!]
+  const files = []
+  for (const path of paths) {
+    files.push({ path, sha256: createHash('sha256').update(await readFile(path)).digest('hex') })
+  }
+  const git = (args: string[]): string => execFileSync('git', args, { cwd: REPOSITORY, windowsHide: true, encoding: 'utf8' }).trim()
+  return { files, sourceCommit: git(['rev-parse', 'HEAD']),
+    dirtyFiles: git(['status', '--porcelain=v1', '--untracked-files=normal']).split('\n').filter(Boolean),
+    dependenciesByteFrozen: false }
+}
+
+/**
+ * Check, or after explicit confirmation build, exactly one version with retained fail-stop records.
+ * @param manifest Existing test-only run manifest.
+ * @param version One exact run version; an existing packaging attempt or installer refuses reuse.
+ * @param options Execute selection and operator confirmation; the CLI has no noninteractive approval shortcut.
+ * @returns Check/build observations only. A successful builder is not package verification or installation acceptance.
+ */
+export async function packageInstalledUpdate(
+  manifest: string, version: string, options: { execute: boolean; confirm: (id: string, version: string) => Promise<boolean> },
+): Promise<object> {
+  const run = await readInstalledUpdateRun(manifest)
+  if (!run.versions.includes(version)) throw new Error('installed update: package version is outside the run')
+  await assertInstalledUpdateSigningClear()
+  const preparation = join(run.root, version, 'packaging')
+  const output = join(run.root, version, 'installer')
+  if (!await absent(preparation) || !await absent(output)) throw new Error('installed update: existing packaging attempt or output requires a new run')
+  const environment = installedUpdatePackagingEnvironment({ ...loadDesktopPackageEnvironment('win32'),
+    DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64' })
+  await createInstalledUpdateBuilderConfig(manifest, version, environment)
+  const before = await inputHashes(manifest, version, environment)
+  if (!options.execute) return { mode: 'check', version, childLaunched: false, signed: false, publicationAuthorized: false }
+  if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('installed update: execution requires Windows x64')
+  if (!await options.confirm(run.id, version)) throw new Error('installed update: operator did not confirm this version')
+  await assertInstalledUpdateSigningClear()
+  await mkdir(preparation)
+  const record = createPackagingRun(preparation, { mode: 'operator-authorized-single-version', id: run.id, version })
+  console.log(`INSTALLED_UPDATE_PACKAGING_RECORD ${record.directory}`)
+  let success = false
+  try {
+    await mkdir(output)
+    await writeFile(join(record.directory, 'inputs.json'), `${JSON.stringify(before, null, 2)}\n`, { flag: 'wx', flush: true })
+    if (JSON.stringify(await inputHashes(manifest, version, environment)) !== JSON.stringify(before)) {
+      throw new Error('installed update: recorded source or tool inputs changed before packaging')
+    }
+    await record.run('signed-installer', process.execPath,
+      ['--import', 'tsx', join(import.meta.dirname, 'build-installed-update-worker.mjs'), resolve(manifest), version],
+      { cwd: REPOSITORY, env: environment, timeoutMs: 15 * 60_000 })
+    await createInstalledUpdateBuilderConfig(manifest, version, environment)
+    if (JSON.stringify(await inputHashes(manifest, version, environment)) !== JSON.stringify(before)) {
+      throw new Error('installed update: recorded source or tool inputs changed during packaging')
+    }
+    const files = await planInstalledUpdateDistribution(manifest, version)
+    await writeFile(join(record.directory, 'artifact-files.json'), `${JSON.stringify(files, null, 2)}\n`, { flag: 'wx', flush: true })
+    const result = { version, builderCompleted: true, packageVerification: 'pending', installerExecuted: false, published: false }
+    await writeFile(join(record.directory, 'builder-result.json'), `${JSON.stringify(result)}\n`, { flag: 'wx', flush: true })
+    success = true
+    return result
+  } catch (error) {
+    recordPackagingEvent(record.directory, { type: 'qualification-failed', retryAllowed: false })
+    throw error
+  } finally { record.finish(success) }
+}

+ 217 - 0
apps/desktop/scripts/installed-update-publication.ts

@@ -0,0 +1,217 @@
+/** Separate immutable qualification uploads from explicitly authorized fixed-feed publication. */
+import { mkdir, mkdtemp, readFile, readdir, rmdir, writeFile } from 'node:fs/promises'
+import { dirname, join, relative, resolve } from 'node:path'
+import { inspectInstalledUpdateJournals, readInstalledUpdateRun } from './installed-update-qualification.ts'
+import { planInstalledUpdateDistribution } from './installed-update-distribution.ts'
+import { installedUpdateFileHash } from './installed-update-signature.mjs'
+import { recordPackagingEvent } from './packaging-run.mjs'
+
+/** One fully read object; digest and byte count describe actual received bytes. */
+export interface InstalledUpdateRemoteObject {
+  readonly sha512: string
+  readonly size: number
+}
+
+/** Transport-owned authentication is never included in publication records. */
+export interface InstalledUpdatePublicationStore {
+  /** @returns True only after authoritative bucket configuration confirms versioning is disabled. */
+  versioningDisabled(): Promise<boolean>
+  /** @param key Exact run-owned object key. @returns Actual object digest, or null only for a confirmed missing object. */
+  read(key: string): Promise<InstalledUpdateRemoteObject | null>
+  /** @param url Exact public URL, without cache-busting parameters. @returns Actual public bytes, or confirmed absence. */
+  publicRead(url: string): Promise<InstalledUpdateRemoteObject | null>
+  /**
+   * Write once without automatic retries; forbidOverwrite must reach the server for immutable objects.
+   * @param key Exact run-owned destination.
+   * @param object Local binary or retained feed, with declared integrity and overwrite policy.
+   * @returns Public server receipt, never authorization headers or credentials.
+   */
+  put(key: string, object: {
+    readonly source: { readonly path: string } | { readonly contents: string }
+    readonly size: number
+    readonly sha512: string
+    readonly forbidOverwrite: boolean
+  }): Promise<{ readonly requestId?: string }>
+}
+
+/** Selected operator action; upload never advertises either version to clients. */
+export type InstalledUpdatePublicationAction = 'upload-binaries' | 'publish-feed'
+
+/**
+ * Revalidate a successful local package check against current bytes, without credentials or network access.
+ * @param manifest Original test manifest.
+ * @param version Exact selected version.
+ * @param receipt That version's successful package-verification result.
+ * @returns Current distribution plan bound to the retained verification evidence.
+ */
+export async function verifiedInstalledUpdateDistribution(manifest: string, version: string, receipt: string) {
+  const run = await readInstalledUpdateRun(manifest)
+  const path = relative(join(run.root, version, 'verification'), resolve(receipt)).replaceAll('\\', '/')
+  if (!/^check-[^/]+\/result\.json$/u.test(path)) throw new Error('installed update: matching package verification receipt is required')
+  const result = JSON.parse(await readFile(receipt, 'utf8')) as {
+    schemaVersion?: unknown
+    runId?: unknown
+    version?: unknown
+    stage?: unknown
+    passed?: unknown
+    installerSignature?: { valid?: unknown; timestamped?: unknown; sha512?: unknown; updaterVerificationInvoked?: unknown }
+    contents?: { appId?: unknown; version?: unknown }
+  }
+  const distribution = await planInstalledUpdateDistribution(manifest, version)
+  const inputs = JSON.parse(await readFile(join(dirname(receipt), 'inputs.json'), 'utf8')) as {
+    manifestSha512?: unknown
+    distribution?: unknown
+  }
+  if (result.schemaVersion !== 1 || result.runId !== run.id || result.version !== version || result.stage !== 'complete' || result.passed !== true
+    || result.contents?.appId !== run.appId || result.contents.version !== version
+    || result.installerSignature?.valid !== true || result.installerSignature.timestamped !== true
+    || result.installerSignature.updaterVerificationInvoked !== true
+    || result.installerSignature.sha512 !== distribution.binaries[0]!.sha512
+    || inputs.manifestSha512 !== await installedUpdateFileHash(manifest)
+    || JSON.stringify(inputs.distribution) !== JSON.stringify(distribution)) {
+    throw new Error('installed update: current files do not match successful package verification')
+  }
+  return { run, distribution, receiptSha512: await installedUpdateFileHash(receipt) }
+}
+
+function matches(actual: InstalledUpdateRemoteObject | null, expected: InstalledUpdateRemoteObject): boolean {
+  return actual?.sha512 === expected.sha512 && actual.size === expected.size
+}
+
+async function verifiedUploadReceipt(prepared: Awaited<ReturnType<typeof verifiedInstalledUpdateDistribution>>) {
+  const parent = join(prepared.run.root, 'publication-records')
+  for (const entry of await readdir(parent, { withFileTypes: true })) {
+    if (!entry.isDirectory() || !entry.name.startsWith('operation-')) continue
+    const directory = join(parent, entry.name)
+    if (!(await readdir(directory)).includes('result.json')) continue
+    const path = join(directory, 'result.json')
+    const result: unknown = JSON.parse(await readFile(path, 'utf8'))
+    if (typeof result !== 'object' || result === null) continue
+    const fields = result as Record<string, unknown>
+    if (fields.schemaVersion !== 1 || fields.success !== true || fields.stage !== 'complete'
+      || fields.action !== 'upload-binaries' || fields.runId !== prepared.run.id || fields.version !== prepared.distribution.version) continue
+    const planPath = join(directory, 'plan.json')
+    const plan: unknown = JSON.parse(await readFile(planPath, 'utf8'))
+    if (JSON.stringify(plan) !== JSON.stringify(prepared)) continue
+    return { path, sha512: await installedUpdateFileHash(path), planSha512: await installedUpdateFileHash(planPath) }
+  }
+  throw new Error('matching successful binary upload receipt is required')
+}
+
+async function startupEvidence(manifest: string, directory: string | undefined) {
+  const run = await readInstalledUpdateRun(manifest)
+  if (!directory || !resolve(directory).replaceAll('\\', '/').endsWith(`/dsh-update-qualification/${run.id}/journals`)) {
+    throw new Error('installed update: original installed application journal directory is required before successor publication')
+  }
+  const report = await inspectInstalledUpdateJournals(directory, run.versions)
+  const ready = report.milestones['original-workspace']
+  if (!ready || Date.parse(ready.time) > Date.now()) throw new Error('installed update: original workspace startup is not recorded')
+  return ready
+}
+
+/**
+ * Execute one separately authorized upload or feed publication with local exclusion and retained evidence.
+ * @param manifest Original qualification manifest.
+ * @param version Version to upload or advertise.
+ * @param receipt Matching successful package verification.
+ * @param action Upload objects without a feed, or publish only after public object verification.
+ * @param store Explicit transport, supplied only after operator authorization; writes must not retry.
+ * @param journalDirectory Original installed-app journal directory, required for successor publication.
+ * @returns Retained operation result path; any error stops subsequent writes and preserves partial evidence.
+ */
+export async function executeInstalledUpdatePublication(
+  manifest: string, version: string, receipt: string, action: InstalledUpdatePublicationAction,
+  store: InstalledUpdatePublicationStore, journalDirectory?: string,
+): Promise<string> {
+  const prepared = await verifiedInstalledUpdateDistribution(manifest, version, receipt)
+  const { run, distribution } = prepared
+  const successor = action === 'publish-feed' && version === run.versions[1]
+  const startup = successor ? await startupEvidence(manifest, journalDirectory) : undefined
+  const lock = join(run.root, 'publication.lock')
+  await mkdir(lock)
+  let record: string | undefined
+  const result: Record<string, unknown> = { schemaVersion: 1, runId: run.id, version, action, success: false, startup,
+    startedAt: new Date().toISOString(), singlePublisherRequired: true }
+  const stage = (name: string, data: object = {}): void => {
+    result.stage = name
+    recordPackagingEvent(record!, { type: 'publication-stage', stage: name, ...data })
+    console.log(`INSTALLED_UPDATE_PUBLICATION_STAGE ${name}`)
+  }
+  try {
+    const parent = join(run.root, 'publication-records')
+    await mkdir(parent, { recursive: true })
+    record = await mkdtemp(join(parent, 'operation-'))
+    console.log(`INSTALLED_UPDATE_PUBLICATION_RECORD ${record}`)
+    await writeFile(join(record, 'plan.json'), `${JSON.stringify(prepared, null, 2)}\n`, { flag: 'wx', flush: true })
+    if (action === 'publish-feed') {
+      stage('binary-upload-receipt')
+      result.binaryUploadReceipt = await verifiedUploadReceipt(prepared)
+      stage('binary-upload-receipt-verified', result.binaryUploadReceipt as object)
+    } else {
+      stage('bucket-versioning')
+      if (!await store.versioningDisabled()) throw new Error('bucket versioning is not confirmed disabled')
+    }
+    for (const binary of action === 'upload-binaries' ? distribution.binaries : []) {
+      stage('binary-origin-read', { key: binary.key })
+      const existing = await store.read(binary.key)
+      if (existing !== null && !matches(existing, binary)) throw new Error('existing binary differs')
+      if (existing === null) {
+        if (action !== 'upload-binaries') throw new Error('binary upload must precede feed publication')
+        if (JSON.stringify(await verifiedInstalledUpdateDistribution(manifest, version, receipt)) !== JSON.stringify(prepared)) {
+          throw new Error('local inputs changed')
+        }
+        stage('binary-put', { key: binary.key })
+        const response = await store.put(binary.key, { source: { path: binary.path }, size: binary.size,
+          sha512: binary.sha512, forbidOverwrite: true })
+        stage('binary-put-response', { key: binary.key, ...response })
+      }
+      stage('binary-public-read', { key: binary.key })
+      if (!matches(await store.publicRead(`${run.origin}/${binary.key}`), binary)) throw new Error('public binary differs')
+    }
+    if (action === 'publish-feed') {
+      stage('feed-origin-read')
+      const previous = await store.read(distribution.feed.key)
+      const previousPlan = successor ? await planInstalledUpdateDistribution(manifest, run.versions[0]) : undefined
+      const expectedPrevious = previousPlan === undefined ? null
+        : { sha512: previousPlan.feed.sha512, size: Buffer.byteLength(previousPlan.feed.contents) }
+      const alreadyPublished = matches(previous, { sha512: distribution.feed.sha512, size: Buffer.byteLength(distribution.feed.contents) })
+      if (!alreadyPublished && (expectedPrevious === null ? previous !== null : !matches(previous, expectedPrevious))) {
+        throw new Error('unexpected previous feed')
+      }
+      if (successor) {
+        const evidence = await startupEvidence(manifest, journalDirectory)
+        if (JSON.stringify(evidence) !== JSON.stringify(startup)) throw new Error('startup evidence changed')
+      }
+      if (JSON.stringify(await verifiedInstalledUpdateDistribution(manifest, version, receipt)) !== JSON.stringify(prepared)) {
+        throw new Error('local inputs changed')
+      }
+      await writeFile(join(record, 'feed.yml'), distribution.feed.contents, { flag: 'wx', flush: true })
+      result.alreadyPublished = alreadyPublished
+      if (!alreadyPublished) {
+        stage('feed-put', { key: distribution.feed.key, previous, startedAfterOriginal: successor })
+        result.putResponse = await store.put(distribution.feed.key, { source: { contents: distribution.feed.contents },
+          size: Buffer.byteLength(distribution.feed.contents), sha512: distribution.feed.sha512, forbidOverwrite: !successor })
+      }
+      stage('feed-public-read')
+      if (!matches(await store.publicRead(distribution.feed.url), { sha512: distribution.feed.sha512,
+        size: Buffer.byteLength(distribution.feed.contents) })) throw new Error('public feed differs')
+    }
+    stage('complete')
+    result.success = true
+    return join(record, 'result.json')
+  } catch (error) {
+    result.failure = 'operation stopped; inspect the retained stage and remote state before any further publication'
+    if (typeof error === 'object' && error !== null && '$metadata' in error && typeof error.$metadata === 'object' && error.$metadata !== null) {
+      const metadata = error.$metadata as { httpStatusCode?: unknown }
+      if (typeof metadata.httpStatusCode === 'number' && metadata.httpStatusCode >= 100 && metadata.httpStatusCode <= 599) {
+        result.httpStatus = metadata.httpStatusCode
+      }
+    }
+    throw new Error(`installed update: publication stopped; record: ${record ?? 'not allocated'}`)
+  } finally {
+    try {
+      if (record !== undefined) await writeFile(join(record, 'result.json'), `${JSON.stringify({ ...result,
+        finishedAt: new Date().toISOString() }, null, 2)}\n`, { flag: 'wx', flush: true })
+    } finally { await rmdir(lock) }
+  }
+}

+ 277 - 0
apps/desktop/scripts/installed-update-qualification.ts

@@ -0,0 +1,277 @@
+/** Local-only material allocation and journal inspection for operator-driven installed updates. */
+import { createHash, randomBytes } from 'node:crypto'
+import { mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises'
+import { dirname, join, resolve } from 'node:path'
+import { gt, valid } from 'semver'
+
+/** Source identifiers exclude file contents, configuration values, and credentials. */
+export interface InstalledUpdateSource {
+  readonly version: string
+  readonly commit: string
+  readonly dirtyFiles: readonly string[]
+}
+
+/** A private test namespace; creating it performs no signing, installation, or remote operation. */
+export interface InstalledUpdateRun {
+  readonly schemaVersion: 1
+  readonly id: string
+  readonly root: string
+  readonly createdAt: string
+  readonly source: InstalledUpdateSource
+  readonly versions: readonly [string, string]
+  readonly appId: string
+  readonly productName: string
+  readonly environment: 'test'
+  readonly origin: string
+  readonly bucket: string
+  readonly feedKey: string
+  readonly binPrefix: string
+}
+
+/**
+ * Allocate a new local run and retain its manifest without reading release credentials.
+ * @param parent Ignored material directory; each invocation acquires a separate child atomically.
+ * @param versions Explicit original and successor Nightly versions, in increasing order.
+ * @param source Source version, Git commit, and dirty-file list captured before material preparation.
+ * @returns The retained run manifest; no package or publication is implied by its presence.
+ */
+export async function createInstalledUpdateRun(
+  parent: string, versions: readonly [string, string], source: InstalledUpdateSource,
+): Promise<InstalledUpdateRun> {
+  validateVersions(versions)
+  if (valid(source.version) === null || !/^[a-f0-9]{40,64}$/u.test(source.commit)) {
+    throw new Error('installed update: valid source version and Git commit are required')
+  }
+  await mkdir(parent, { recursive: true })
+  const root = await mkdtemp(join(resolve(parent), 'installed-update-'))
+  const id = randomBytes(12).toString('hex')
+  const run: InstalledUpdateRun = {
+    schemaVersion: 1, id, root, createdAt: new Date().toISOString(), source, versions,
+    appId: `com.deepseek.dsh.qualification.q${id}`, productName: `DSH Update Test ${id}`,
+    environment: 'test', origin: 'https://download-test.deepseek.com', bucket: 'bj-toc-download-test-1320056602',
+    feedKey: `dsh-desk/feeds/qualification/${id}/win-x64/nightly.yml`,
+    binPrefix: `dsh-desk/bin/qualification/${id}/win-x64`,
+  }
+  await writeFile(join(root, 'run.json'), `${JSON.stringify(run, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true })
+  return run
+}
+
+function validateVersions(versions: readonly [string, string]): void {
+  if (versions.some(version => valid(version) !== version || !/^\d+\.\d+\.\d+-nightly\.[0-9.]+$/u.test(version))
+    || !gt(versions[1], versions[0])) {
+    throw new Error('installed update: two increasing numeric Nightly versions are required')
+  }
+}
+
+/**
+ * Load a retained manifest and reject altered destinations or application identities.
+ * @param path Local run.json produced by the allocator.
+ * @returns Validated test-only run; the directory must still be the original manifest location.
+ */
+export async function readInstalledUpdateRun(path: string): Promise<InstalledUpdateRun> {
+  const value: unknown = JSON.parse(await readFile(path, 'utf8'))
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('installed update: invalid run manifest')
+  const row = value as Record<string, unknown>
+  if (row.schemaVersion !== 1 || typeof row.id !== 'string' || !/^[a-f0-9]{24}$/u.test(row.id)
+    || row.root !== resolve(dirname(path)) || row.environment !== 'test'
+    || row.origin !== 'https://download-test.deepseek.com' || row.bucket !== 'bj-toc-download-test-1320056602'
+    || row.appId !== `com.deepseek.dsh.qualification.q${row.id}` || row.productName !== `DSH Update Test ${row.id}`
+    || row.feedKey !== `dsh-desk/feeds/qualification/${row.id}/win-x64/nightly.yml`
+    || row.binPrefix !== `dsh-desk/bin/qualification/${row.id}/win-x64`
+    || !Array.isArray(row.versions) || row.versions.length !== 2 || row.versions.some(version => typeof version !== 'string')) {
+    throw new Error('installed update: manifest identity, location, versions, or test destination changed')
+  }
+  validateVersions(row.versions as [string, string])
+  if (typeof row.source !== 'object' || row.source === null || Array.isArray(row.source)) {
+    throw new Error('installed update: missing source version and commit')
+  }
+  const source = row.source as Record<string, unknown>
+  if (typeof source.version !== 'string' || valid(source.version) === null
+    || typeof source.commit !== 'string' || !/^[a-f0-9]{40,64}$/u.test(source.commit)
+    || !Array.isArray(source.dirtyFiles) || source.dirtyFiles.some(file => typeof file !== 'string')
+    || typeof row.createdAt !== 'string' || !Number.isFinite(Date.parse(row.createdAt))) {
+    throw new Error('installed update: invalid source version, commit, file list, or creation time')
+  }
+  return row as unknown as InstalledUpdateRun
+}
+
+interface JournalRecord {
+  readonly pid: number
+  readonly sequence: number
+  readonly time: string
+  readonly version: string
+  readonly event: string
+  readonly phase?: string
+  readonly targetVersion?: string
+  readonly failedOperation?: string
+  readonly percent?: number
+}
+
+/** A location in retained evidence, never a copy of raw diagnostics. */
+export interface InstalledUpdateObservation {
+  readonly file: string
+  readonly sequence: number
+  readonly time: string
+}
+
+/** Observed journal sequence is deliberately separate from operator acceptance. */
+export interface InstalledUpdateEvidence {
+  readonly schemaVersion: 1
+  readonly versions: readonly [string, string]
+  readonly filesRead: number
+  readonly recordedFlow: 'complete' | 'incomplete'
+  readonly milestones: Readonly<Record<string, InstalledUpdateObservation>>
+  readonly missing: readonly string[]
+  readonly operatorVerificationRequired: readonly string[]
+}
+
+const FIELDS = new Set(['schemaVersion', 'sequence', 'time', 'pid', 'version', 'event', 'phase',
+  'targetVersion', 'percent', 'failedOperation', 'errorCode'])
+const ACTIONS = new Set(['started', 'workspace-ready', 'workspace-failed', 'check-requested',
+  'download-requested', 'install-confirmed', 'quit-requested', 'state'])
+const PHASES = new Set(['idle', 'checking', 'available', 'downloading', 'verifying', 'installing', 'ready', 'error'])
+const OPERATIONS = new Set(['check', 'download', 'install'])
+const ERROR_CODES = new Set(['ETIMEDOUT', 'ENOSPC', 'ERR_INTERNET_DISCONNECTED', 'ERR_CONNECTION_RESET',
+  'ERR_CONNECTION_CLOSED', 'ERR_NAME_NOT_RESOLVED', 'ERR_UPDATER_INVALID_SIGNATURE', 'ERR_UPDATER_CHECKSUM_MISMATCH', 'UNCLASSIFIED'])
+const STAGES = ['original-workspace', 'first-download', 'transfer-started', 'download-failed',
+  'manual-retry', 'download-ready', 'install-confirmed', 'original-quit', 'successor-started', 'successor-workspace'] as const
+
+function parseRecord(line: string, sequence: number): JournalRecord {
+  let value: unknown
+  try { value = JSON.parse(line) }
+  catch { throw new Error('installed update: invalid journal JSON; raw input is withheld') }
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('installed update: invalid journal record')
+  const row = value as Record<string, unknown>
+  if (Object.keys(row).some(key => !FIELDS.has(key)) || row.schemaVersion !== 1 || row.sequence !== sequence
+    || !Number.isSafeInteger(row.pid) || (row.pid as number) <= 0
+    || typeof row.time !== 'string' || !Number.isFinite(Date.parse(row.time))
+    || new Date(row.time).toISOString() !== row.time || typeof row.version !== 'string' || valid(row.version) !== row.version
+    || typeof row.event !== 'string' || !ACTIONS.has(row.event)
+    || (sequence === 0 && row.event !== 'started') || (sequence > 0 && row.event === 'started')
+    || (row.event === 'state' && (typeof row.phase !== 'string' || !PHASES.has(row.phase)))
+    || (row.phase !== undefined && (typeof row.phase !== 'string' || !PHASES.has(row.phase)))
+    || (row.failedOperation !== undefined && (typeof row.failedOperation !== 'string' || !OPERATIONS.has(row.failedOperation)))
+    || (row.errorCode !== undefined && (typeof row.errorCode !== 'string' || !ERROR_CODES.has(row.errorCode)))
+    || (row.percent !== undefined && (typeof row.percent !== 'number' || !Number.isInteger(row.percent)
+      || row.percent < 0 || row.percent > 100))
+    || (row.targetVersion !== undefined && (typeof row.targetVersion !== 'string' || valid(row.targetVersion) !== row.targetVersion))) {
+    throw new Error('installed update: unsupported or inconsistent journal record; raw input is withheld')
+  }
+  return row as unknown as JournalRecord
+}
+
+/**
+ * Inspect a private evidence directory without changing files or declaring the installation successful.
+ * @param directory Directory containing only the qualification run's per-process JSONL journals.
+ * @param versions Expected installed original and successor versions.
+ * @returns Ordered failure/retry/restart observations plus required independent operator verification.
+ */
+export async function inspectInstalledUpdateJournals(
+  directory: string, versions: readonly [string, string],
+): Promise<InstalledUpdateEvidence> {
+  return inspectJournalRuns(await readJournalRuns(directory, versions), versions)
+}
+
+interface JournalRun { readonly file: string; readonly text: string; readonly records: JournalRecord[] }
+
+async function readJournalRuns(directory: string, versions: readonly [string, string]): Promise<JournalRun[]> {
+  validateVersions(versions)
+  const files = (await readdir(directory)).filter(file => file.endsWith('.jsonl')).sort()
+  const runs: JournalRun[] = []
+  let totalBytes = 0
+  for (const file of files) {
+    if (!/^\d+-[a-f0-9-]{36}\.jsonl$/u.test(file)) throw new Error('installed update: unexpected journal filename')
+    if ((await stat(join(directory, file))).size > 10 * 1024 * 1024) throw new Error('installed update: journal exceeds 10 MiB inspection limit')
+    const text = await readFile(join(directory, file), 'utf8')
+    const bytes = Buffer.byteLength(text)
+    totalBytes += bytes
+    if (bytes > 10 * 1024 * 1024 || totalBytes > 50 * 1024 * 1024) throw new Error('installed update: journal snapshot exceeds inspection limit')
+    if (!text.endsWith('\n')) throw new Error('installed update: incomplete journal tail')
+    const records = text.slice(0, -1).split('\n').map(parseRecord)
+    if (records.some(row => row.version !== records[0]!.version || row.pid !== records[0]!.pid || !versions.includes(row.version))) {
+      throw new Error('installed update: mixed or unexpected installed versions')
+    }
+    runs.push({ file, text, records })
+  }
+  return runs
+}
+
+function inspectJournalRuns(runs: readonly JournalRun[], versions: readonly [string, string]): InstalledUpdateEvidence {
+  let milestones: Record<string, InstalledUpdateObservation> = {}
+  // A retry and installation authorization must belong to the same original process.
+  for (const run of runs.filter(run => run.records[0]!.version === versions[0])) {
+    const candidate: Record<string, InstalledUpdateObservation> = {}
+    let stage = 0
+    for (const record of run.records) {
+      if (stage >= 6 && (record.event === 'download-requested' || (record.event === 'state' && record.phase === 'error'))) {
+        for (const key of STAGES.slice(4)) delete candidate[key]
+        stage = 4
+      }
+      const matches = [record.event === 'workspace-ready', record.event === 'download-requested',
+        record.event === 'state' && record.phase === 'downloading' && record.targetVersion === versions[1]
+          && record.percent !== undefined && record.percent > 0,
+        record.event === 'state' && record.phase === 'error' && record.failedOperation === 'download'
+          && record.targetVersion === versions[1],
+        record.event === 'download-requested',
+        record.event === 'state' && record.phase === 'ready' && record.targetVersion === versions[1],
+        record.event === 'install-confirmed', record.event === 'quit-requested']
+      if (stage < 8 && matches[stage]) {
+        candidate[STAGES[stage]!] = { file: run.file, sequence: record.sequence, time: record.time }
+        stage++
+      }
+    }
+    if (Object.keys(candidate).length > Object.keys(milestones).length) milestones = candidate
+  }
+  const quit = milestones['original-quit']
+  if (quit !== undefined) {
+    for (const run of runs.filter(run => run.records[0]!.version === versions[1])) {
+      const started = run.records[0]!
+      if (Date.parse(started.time) < Date.parse(quit.time)) continue
+      const ready = run.records.find(record => record.event === 'workspace-ready')
+      if (ready === undefined) continue
+      milestones['successor-started'] = { file: run.file, sequence: started.sequence, time: started.time }
+      milestones['successor-workspace'] = { file: run.file, sequence: ready.sequence, time: ready.time }
+      break
+    }
+  }
+  const missing = STAGES.filter(stage => milestones[stage] === undefined)
+  return { schemaVersion: 1, versions, filesRead: runs.length,
+    recordedFlow: missing.length === 0 ? 'complete' : 'incomplete', milestones, missing,
+    operatorVerificationRequired: ['feed-publication-after-original-startup', 'network-fault-and-recovery',
+      'installer-completion-and-installed-path', 'test-data-preserved', 'screenshots-and-user-confirmations'] }
+}
+
+/**
+ * Retain validated journal bytes and a report from that same snapshot, without copying application data.
+ * @param manifest Original test run manifest.
+ * @param directory The run's installed-app journals directory; collection never changes its files.
+ * @returns Independent local collection directory; incomplete flow is retained as incomplete, not acceptance.
+ */
+export async function collectInstalledUpdateJournals(manifest: string, directory: string): Promise<string> {
+  const run = await readInstalledUpdateRun(manifest)
+  if (!resolve(directory).replaceAll('\\', '/').endsWith(`/dsh-update-qualification/${run.id}/journals`)) {
+    throw new Error('installed update: matching installed-app journal directory is required')
+  }
+  const snapshots = await readJournalRuns(directory, run.versions)
+  const evidence = inspectJournalRuns(snapshots, run.versions)
+  const parent = join(run.root, 'evidence')
+  await mkdir(parent, { recursive: true })
+  const collection = await mkdtemp(join(parent, 'collection-'))
+  try {
+    await writeFile(join(collection, 'started.json'), `${JSON.stringify({ time: new Date().toISOString(), runId: run.id })}\n`,
+      { flag: 'wx', mode: 0o600, flush: true })
+    await mkdir(join(collection, 'journals'))
+    for (const snapshot of snapshots) {
+      await writeFile(join(collection, 'journals', snapshot.file), snapshot.text, { flag: 'wx', mode: 0o600, flush: true })
+    }
+    const result = { schemaVersion: 1, runId: run.id, collectedAt: new Date().toISOString(), sourceDirectory: resolve(directory),
+      evidence, operatorAcceptance: 'pending', files: snapshots.map(snapshot => ({ path: `journals/${snapshot.file}`,
+        bytes: Buffer.byteLength(snapshot.text), sha256: createHash('sha256').update(snapshot.text).digest('hex') })) }
+    await writeFile(join(collection, 'report.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true })
+    return collection
+  } catch {
+    await writeFile(join(collection, 'failed.json'), `${JSON.stringify({ failed: true, time: new Date().toISOString() })}\n`,
+      { flag: 'wx', mode: 0o600, flush: true })
+    throw new Error(`installed update: journal collection failed; partial evidence retained at ${collection}`)
+  }
+}

+ 10 - 0
apps/desktop/scripts/installed-update-signature.d.mts

@@ -0,0 +1,10 @@
+/** Hash a local file without executing it. @param path File path. @returns Base64 SHA-512. */
+export function installedUpdateFileHash(path: string): Promise<string>
+/**
+ * Verify with the updater and Authenticode, including timestamp presence, without private-key access.
+ * @param file Executable inspected as data.
+ * @param publisher Trusted public certificate DN.
+ * @param directory New private evidence directory.
+ * @returns Public signature attributes; rejects skipped, invalid, untimestamped, or changed inputs.
+ */
+export function verifyInstalledUpdateSignature(file: string, publisher: string, directory: string): Promise<object>

+ 77 - 0
apps/desktop/scripts/installed-update-signature.mjs

@@ -0,0 +1,77 @@
+/** Verify supplied executable files without invoking SignTool, private keys, or installer entry points. */
+import { execFile } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { createReadStream } from 'node:fs'
+import { writeFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { join, resolve } from 'node:path'
+import { promisify } from 'node:util'
+
+const require = createRequire(import.meta.url)
+
+/**
+ * Hash a file without loading its complete contents into memory.
+ * @param {string} path Local file.
+ * @returns {Promise<string>} Base64 SHA-512.
+ */
+export async function installedUpdateFileHash(path) {
+  const hash = createHash('sha512')
+  for await (const bytes of createReadStream(path)) hash.update(bytes)
+  return hash.digest('base64')
+}
+
+/**
+ * Require the real updater verifier, a valid Authenticode signature, and a timestamp.
+ * @param {string} file Executable inspected as data, never executed.
+ * @param {string} publisher Expected DN from the trusted public release certificate, not downloaded YAML.
+ * @param {string} directory New private evidence directory owned by this signature check.
+ * @returns {Promise<object>} Public signature attributes and unchanged-file SHA-512; no installation claim.
+ */
+export async function verifyInstalledUpdateSignature(file, publisher, directory) {
+  if (process.platform !== 'win32') throw new Error('installed update: signature verification requires Windows')
+  const before = await installedUpdateFileHash(file)
+  const config = join(directory, 'signature-config.json')
+  await writeFile(config, `${JSON.stringify({ publisherName: [publisher] })}\n`, { flag: 'wx', flush: true })
+  const environment = Object.fromEntries(Object.entries(process.env)
+    .filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$|^PSModulePath$/iu.test(name)))
+  const options = { env: environment, encoding: 'utf8', windowsHide: true, timeout: 60_000, maxBuffer: 64 * 1024 }
+  const verification = await promisify(execFile)(process.execPath, [import.meta.filename, '--updater', config, file], options)
+  if (JSON.parse(verification.stdout).updaterVerificationInvoked !== true) {
+    throw new Error('installed update: updater signature verification was not confirmed')
+  }
+  const { stdout, stderr } = await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
+    '$ErrorActionPreference="Stop"; $s=Get-AuthenticodeSignature -LiteralPath $env:DSH_VERIFY_FILE; [pscustomobject]@{valid=($s.Status -eq "Valid");timestamped=($null -ne $s.TimeStamperCertificate);signer=$s.SignerCertificate.Thumbprint;timestamp=$s.TimeStamperCertificate.Thumbprint}|ConvertTo-Json -Compress'],
+  { ...options, env: { ...environment, DSH_VERIFY_FILE: file } })
+  const details = JSON.parse(stdout)
+  if (stderr || details.valid !== true || details.timestamped !== true || !/^[A-Fa-f0-9]{40}$/u.test(details.signer)
+    || !/^[A-Fa-f0-9]{40}$/u.test(details.timestamp)) throw new Error('installed update: valid timestamped signature is required')
+  if (await installedUpdateFileHash(file) !== before) throw new Error('installed update: file changed during signature verification')
+  return { sha512: before, valid: true, timestamped: true, signerThumbprint: details.signer,
+    timestampThumbprint: details.timestamp, updaterVerificationInvoked: true }
+}
+
+async function verifyWithUpdater(config, file) {
+  const { NsisUpdater } = require('electron-updater')
+  const updater = new NsisUpdater(null, { version: '0.0.0', isPackaged: true })
+  updater.autoInstallOnAppQuit = false
+  updater.updateConfigPath = config
+  const logs = []
+  updater.logger = Object.fromEntries(['info', 'warn', 'error', 'debug'].map(level => [level, value => logs.push(String(value))]))
+  try {
+    const result = await updater.verifySignature(file)
+    if (result !== null || !logs.some(line => line.startsWith('Verifying signature '))
+      || logs.some(line => line.includes('Ignoring signature validation'))) {
+      throw new Error('installed update: updater signature verification rejected the file or was skipped')
+    }
+    return { updaterVerificationInvoked: true }
+  } finally { updater.removeAllListeners() }
+}
+
+if (process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(import.meta.filename)) {
+  const [mode, config, file, ...extra] = process.argv.slice(2)
+  if (mode !== '--updater' || !config || !file || extra.length) process.exitCode = 1
+  else verifyWithUpdater(config, file).then(result => console.log(JSON.stringify(result))).catch(() => {
+    console.error('installed update: updater signature verification failed')
+    process.exitCode = 1
+  })
+}

+ 35 - 0
apps/desktop/scripts/macos-app-update-config.d.mts

@@ -0,0 +1,35 @@
+/** Resolved fields required to embed a macOS updater feed. */
+export interface MacOSAppUpdateFeed {
+  readonly publicUrl: string
+}
+
+/** Packaged electron-updater configuration for macOS. */
+export interface MacOSAppUpdateConfig {
+  readonly provider: 'generic'
+  readonly url: string
+  readonly channel: 'nightly'
+  readonly updaterCacheDirName: string
+}
+
+/** Resolve the one generic macOS feed from the final electron-builder configuration. */
+export function resolveMacOSAppUpdateFeed(publish: unknown): MacOSAppUpdateFeed
+
+/** Create the electron-updater configuration embedded before code signing. */
+export function createMacOSAppUpdateConfig(
+  update: MacOSAppUpdateFeed,
+  updaterCacheDirName: string,
+): MacOSAppUpdateConfig
+
+/** Write the updater configuration into an assembled App before signing. */
+export function writeMacOSAppUpdateConfig(
+  resourcesDir: string,
+  update: MacOSAppUpdateFeed,
+  updaterCacheDirName: string,
+): Promise<void>
+
+/** Verify the updater configuration inside an assembled macOS App. */
+export function verifyMacOSAppUpdateConfig(
+  appPath: string,
+  update: MacOSAppUpdateFeed,
+  updaterCacheDirName?: string,
+): Promise<void>

+ 91 - 0
apps/desktop/scripts/macos-app-update-config.mjs

@@ -0,0 +1,91 @@
+/** Write and verify the updater configuration sealed into a macOS application. */
+
+import { readFile, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { dump, load } from 'js-yaml'
+
+const CONFIG_FILENAME = 'app-update.yml'
+const CHANNEL = 'nightly'
+
+function object(value, label) {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+    throw new Error(`desktop macOS update config: ${label} must be an object`)
+  }
+  return value
+}
+
+function nonEmptyString(value, label) {
+  if (typeof value !== 'string' || value === '') {
+    throw new Error(`desktop macOS update config: ${label} must be a non-empty string`)
+  }
+  return value
+}
+
+/**
+ * Resolve the one generic macOS feed from the final electron-builder configuration.
+ * @param {unknown} publish - Final electron-builder publish setting.
+ * @returns {{ publicUrl: string }} Resolved feed used by the packaged App.
+ */
+export function resolveMacOSAppUpdateFeed(publish) {
+  if (!Array.isArray(publish) || publish.length !== 1) {
+    throw new Error('desktop macOS update config: publish must contain exactly one provider')
+  }
+  const provider = object(publish[0], 'publish provider')
+  if (provider.provider !== 'generic' || provider.channel !== CHANNEL) {
+    throw new Error('desktop macOS update config: publish provider must be generic Nightly')
+  }
+  return { publicUrl: nonEmptyString(provider.url, 'publish provider URL') }
+}
+
+/**
+ * Create the electron-updater configuration embedded before code signing.
+ * @param {{ publicUrl: string }} update - Resolved update feed.
+ * @param {string} updaterCacheDirName - electron-builder application cache directory.
+ * @returns {{ provider: 'generic', url: string, channel: 'nightly', updaterCacheDirName: string }} Packaged updater fields.
+ */
+export function createMacOSAppUpdateConfig(update, updaterCacheDirName) {
+  return {
+    provider: 'generic',
+    url: nonEmptyString(update.publicUrl, 'public URL'),
+    channel: CHANNEL,
+    updaterCacheDirName: nonEmptyString(updaterCacheDirName, 'updater cache directory'),
+  }
+}
+
+/**
+ * Write the updater configuration into an assembled App before signing.
+ * @param {string} resourcesDir - App Contents/Resources directory.
+ * @param {{ publicUrl: string }} update - Resolved update feed.
+ * @param {string} updaterCacheDirName - electron-builder application cache directory.
+ * @returns {Promise<void>} Resolves after the configuration is durable.
+ */
+export async function writeMacOSAppUpdateConfig(resourcesDir, update, updaterCacheDirName) {
+  const config = createMacOSAppUpdateConfig(update, updaterCacheDirName)
+  await writeFile(join(resourcesDir, CONFIG_FILENAME), dump(config, { lineWidth: -1, noRefs: true }))
+}
+
+/**
+ * Verify the updater configuration inside an assembled macOS App.
+ * @param {string} appPath - Application bundle path.
+ * @param {{ publicUrl: string }} update - Expected update feed.
+ * @param {string | undefined} updaterCacheDirName - Exact cache directory when known.
+ * @returns {Promise<void>} Resolves when the packaged configuration matches the release destination.
+ */
+export async function verifyMacOSAppUpdateConfig(appPath, update, updaterCacheDirName = undefined) {
+  const path = join(appPath, 'Contents', 'Resources', CONFIG_FILENAME)
+  let parsed
+  try {
+    parsed = load(await readFile(path, 'utf8'))
+  }
+  catch (error) {
+    throw new Error(`desktop macOS update config: cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`)
+  }
+  const config = object(parsed, CONFIG_FILENAME)
+  if (config.provider !== 'generic' || config.url !== update.publicUrl || config.channel !== CHANNEL) {
+    throw new Error(`desktop macOS update config: ${path} does not match ${update.publicUrl}`)
+  }
+  const actualCacheDirName = nonEmptyString(config.updaterCacheDirName, `${CONFIG_FILENAME}.updaterCacheDirName`)
+  if (updaterCacheDirName !== undefined && actualCacheDirName !== updaterCacheDirName) {
+    throw new Error(`desktop macOS update config: ${path} has updater cache directory ${actualCacheDirName}; expected ${updaterCacheDirName}`)
+  }
+}

+ 30 - 0
apps/desktop/scripts/package-installed-update.ts

@@ -0,0 +1,30 @@
+/** Operator-only signed packaging; default checks do not launch children, sign, install, or publish. */
+import { createInterface } from 'node:readline/promises'
+import { stdin, stdout } from 'node:process'
+import { InstalledUpdateSigningHoldError, packageInstalledUpdate } from './installed-update-packaging.ts'
+
+async function main(): Promise<void> {
+  const [manifest, version, mode, ...extra] = process.argv.slice(2)
+  if (!manifest || !version || extra.length !== 0 || (mode !== undefined && mode !== '--check' && mode !== '--execute')) {
+    throw new Error('invalid invocation')
+  }
+  const result = await packageInstalledUpdate(manifest, version, { execute: mode === '--execute',
+    confirm: async (id, selectedVersion) => {
+      if (!stdin.isTTY || !stdout.isTTY) return false
+      const terminal = createInterface({ input: stdin, output: stdout })
+      try {
+        console.log('仅在管理员已审核并恢复签名保护后继续。确认已注销令牌、剩余 5/5、PIN 正确且无其他签名任务。')
+        console.log('本次打包会有多次签名;不重试失败操作,驱动内部认证次数无法保证。出现密码弹窗请取消,不要补输。')
+        console.log('PIN 只从 .env.windows 读取;签名接口要求它短暂出现在 SignTool 参数中。不会安装或上传。')
+        const expected = `PACKAGE ${selectedVersion} ${id}`
+        return (await terminal.question(`仅授权这个版本,请输入 ${expected}:`)) === expected
+      } finally { terminal.close() }
+    } })
+  console.log(JSON.stringify(result, null, 2))
+}
+
+main().catch((error: unknown) => {
+  console.error(error instanceof InstalledUpdateSigningHoldError ? error.message
+    : 'installed update packaging stopped. Check prepared files and the retained record; do not retry or remove protection automatically.')
+  process.exitCode = 1
+})

+ 14 - 3
apps/desktop/scripts/package-macos.ts

@@ -4,13 +4,16 @@ import { execFile } from 'node:child_process'
 import { mkdtemp, rename, rm, stat } from 'node:fs/promises'
 import { basename, dirname, join } from 'node:path'
 import { promisify } from 'node:util'
-import { Arch, getArchSuffix } from 'electron-builder'
 import { notarize } from '@electron/notarize'
 import {
   resolveMacOSNotarizationEnvironment,
   resolveMacOSSigningEnvironment,
 } from './desktop-release-environment.mjs'
-import { desktopUpdateMetadataFilename } from './desktop-auto-update-environment.mjs'
+import {
+  desktopUpdateMetadataFilename,
+  resolveDesktopAutoUpdateConfig,
+} from './desktop-auto-update-environment.mjs'
+import { verifyMacOSAppUpdateConfig } from './macos-app-update-config.mjs'
 import { verifyMacOSNotarizedApplication, verifyMacOSSignature } from './verify-macos-signature.mjs'
 
 const execute = promisify(execFile)
@@ -72,15 +75,19 @@ export async function packageMacOSArtifacts(
   const { arch, version, artifactsRoot, environment } = request
   const expected = resolveMacOSSigningEnvironment(environment)
   const credentials = resolveMacOSNotarizationEnvironment(environment)
-  const appPath = join(artifactsRoot, `mac${getArchSuffix(Arch[arch])}`, 'DeepSeek Harness.app')
+  const update = resolveDesktopAutoUpdateConfig(environment, 'darwin', arch)
+  const appPath = join(artifactsRoot, arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app')
   const root = await mkdtemp(join(dirname(artifactsRoot), 'notarization-'))
   const zipApp = join(root, 'zip', basename(appPath))
   const dmgApp = join(root, 'dmg', basename(appPath))
   const zipOutput = join(root, 'zip-artifacts')
   const dmgOutput = join(root, 'dmg-artifacts')
   try {
+    await verifyMacOSAppUpdateConfig(appPath, update)
     await apple.copyApp(appPath, zipApp)
     await apple.copyApp(appPath, dmgApp)
+    await verifyMacOSAppUpdateConfig(zipApp, update)
+    await verifyMacOSAppUpdateConfig(dmgApp, update)
     apple.verifySignature(zipApp, expected)
     apple.verifySignature(dmgApp, expected)
     const results = await Promise.allSettled([
@@ -97,6 +104,10 @@ export async function packageMacOSArtifacts(
     if (failures.length > 0) {
       throw new AggregateError(failures.map(result => result.reason), 'desktop macOS packaging: artifact lanes failed')
     }
+    await verifyMacOSAppUpdateConfig(zipApp, update)
+    await verifyMacOSAppUpdateConfig(dmgApp, update)
+    apple.verifySignature(zipApp, expected)
+    apple.verifySignature(dmgApp, expected)
     const base = `deepseek-harness-${version}-mac-${arch}`
     const artifacts = [
       [dmgOutput, `${base}.dmg`],

+ 34 - 12
apps/desktop/scripts/package-target.ts

@@ -11,6 +11,7 @@ import {
 import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
 import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts'
 import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
+import { createPackagingRun } from './packaging-run.mjs'
 
 const APP_ROOT = resolve(import.meta.dirname, '..')
 const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
@@ -253,11 +254,13 @@ function runPnpm(
   args: readonly string[],
   env: NodeJS.ProcessEnv = process.env,
   cwd: string = APP_ROOT,
+  run?: ReturnType<typeof createPackagingRun>,
 ): Promise<void> {
   const pnpmEntry = process.env.npm_execpath
   if (pnpmEntry === undefined || pnpmEntry === '') {
     throw new Error('desktop package: invoke this script through a pnpm package command')
   }
+  if (run !== undefined) return run.run(args.join(' '), process.execPath, [pnpmEntry, ...args], { cwd, env })
   return new Promise((resolvePromise, reject) => {
     const child = spawn(process.execPath, [pnpmEntry, ...args], {
       cwd,
@@ -281,6 +284,25 @@ async function main(): Promise<void> {
     process.stdout.write(`desktop package: ${target.name} local configuration valid; signing and notarization were not attempted\n`)
     return
   }
+  const run = target.platform === 'win32'
+    ? createPackagingRun(join(APP_ROOT, '.desktop-build', 'packaging-runs'), {
+      target: target.name, unsigned: invocation.unsigned, version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
+    }) : undefined
+  if (run !== undefined) console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`)
+  let success = false
+  try {
+    await packageTarget(invocation, environment, run)
+    success = true
+  } finally { run?.finish(success) }
+}
+
+async function packageTarget(
+  invocation: DesktopPackageInvocation,
+  environment: NodeJS.ProcessEnv,
+  run: ReturnType<typeof createPackagingRun> | undefined,
+): Promise<void> {
+  const { target } = invocation
+  const execute = (args: readonly string[], env: NodeJS.ProcessEnv, cwd: string = APP_ROOT) => runPnpm(args, env, cwd, run)
   const buildPaths = desktopTargetBuildPaths(target.name)
   const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
   if (!invocation.prepareOnly && !invocation.unsigned) {
@@ -297,32 +319,32 @@ async function main(): Promise<void> {
   for (const name of WINDOWS_SIGNING_ENV_NAMES) {
     if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name]
   }
-  await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
-  await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
-  await runPnpm([
+  await execute(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
+  await execute(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
+  await execute([
     '--dir',
     'apps/desktop-host',
     'pack',
     '--pack-destination',
     buildPaths.packedDsh,
   ], buildEnv, REPOSITORY_ROOT)
-  await runPnpm(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT)
+  await execute(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT)
   rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
   mkdirSync(buildPaths.packedLandlock, { recursive: true })
-  await runPnpm(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
-  await runPnpm([
+  await execute(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
+  await execute([
     '--dir',
     'native/system/packages/entry',
     'pack',
     '--pack-destination',
     buildPaths.packedLandlock,
   ], buildEnv, REPOSITORY_ROOT)
-  await runPnpm(['run', 'prepare:runtime'], targetEnv)
-  await runPnpm(['run', 'prepare:packages'], targetEnv)
-  await runPnpm(['run', 'prepare:dsh'], targetEnv)
+  await execute(['run', 'prepare:runtime'], targetEnv)
+  await execute(['run', 'prepare:packages'], targetEnv)
+  await execute(['run', 'prepare:dsh'], targetEnv)
   if (invocation.prepareOnly) return
   if (target.platform === 'darwin' && !invocation.directory) {
-    await runPnpm([
+    await execute([
       ...desktopElectronBuilderArguments(target, true),
       '--config.mac.notarize=false',
     ], electronBuilderEnv)
@@ -331,9 +353,9 @@ async function main(): Promise<void> {
       version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
       artifactsRoot: buildPaths.artifacts,
       environment: electronBuilderEnv,
-    }, artifact => runPnpm(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv))
+    }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv))
   } else {
-    await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
+    await execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
   }
   if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
 }

+ 40 - 0
apps/desktop/scripts/packaging-run.d.mts

@@ -0,0 +1,40 @@
+/**
+ * Persist one credential-free packaging event before its operation.
+ * @param directory Private run directory.
+ * @param event Whitelisted fields, excluding arguments and environments.
+ * @returns Nothing.
+ */
+export function recordPackagingEvent(directory: string, event: object): void
+/**
+ * Publish a fatal marker that stops the supervised stage tree.
+ * @param directory Private run directory.
+ * @param reason Credential-free failure category.
+ * @returns Nothing.
+ */
+export function failPackagingRun(directory: string, reason: string): void
+/**
+ * Redact inherited secret values across arbitrary output chunk boundaries.
+ * @param secrets Exact credential values to remove.
+ * @param emit Redacted output sink.
+ * @returns A UTF-8 stream consumer that flushes its suffix on end.
+ */
+export function packagingOutputRedactor(secrets: readonly string[], emit: (text: string) => void): {
+  write(chunk: Buffer): void
+  end(): void
+}
+/**
+ * Allocate retained evidence and supervise sequential child-process stages.
+ * @param root Parent directory for retained records.
+ * @param metadata Public target/version metadata only.
+ * @returns A run that blocks later stages after failure and awaits owned process termination.
+ */
+export function createPackagingRun(root: string, metadata: object): {
+  directory: string
+  run(stage: string, executable: string, args: readonly string[], options: {
+    cwd: string
+    env: NodeJS.ProcessEnv
+    /** Optional stage deadline; timeout fails the run even if the child reports exit zero. */
+    timeoutMs?: number
+  }): Promise<void>
+  finish(success: boolean): void
+}

+ 171 - 0
apps/desktop/scripts/packaging-run.mjs

@@ -0,0 +1,171 @@
+/** Persist redacted packaging evidence and terminate the owned stage tree on fatal signing failures. */
+import { spawn } from 'node:child_process'
+import { appendFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { StringDecoder } from 'node:string_decoder'
+
+const FATAL_NOTIFICATION = 'DSH_DESKTOP_PACKAGING_FATAL'
+
+/**
+ * Append one credential-free event before its corresponding operation starts.
+ * @param {string} directory Private run directory.
+ * @param {object} event Whitelisted event fields; never pass command arguments or environments.
+ * @returns {void}
+ */
+export function recordPackagingEvent(directory, event) {
+  appendFileSync(join(directory, 'events.jsonl'), `${JSON.stringify({ time: new Date().toISOString(), pid: process.pid, ...event })}\n`, { flush: true })
+}
+
+/**
+ * Publish the fatal marker before rejecting a signing operation.
+ * @param {string} directory Private run directory.
+ * @param {string} reason Credential-free failure category.
+ * @returns {void}
+ */
+export function failPackagingRun(directory, reason) {
+  try {
+    writeFileSync(join(directory, 'fatal.json'), `${JSON.stringify({ time: new Date().toISOString(), pid: process.pid, reason })}\n`, { flush: true })
+  } finally { process.stderr.write(`\n${FATAL_NOTIFICATION}\n`) }
+}
+
+/**
+ * Redact complete secrets even when process output splits them across chunks.
+ * @param {readonly string[]} secrets Exact inherited credential values.
+ * @param {(text: string) => void} emit Redacted output sink.
+ * @returns {{write: (chunk: Buffer) => void, end: () => void}} Bounded streaming redactor.
+ */
+export function packagingOutputRedactor(secrets, emit) {
+  const values = [...new Set(secrets.filter(Boolean))].sort((a, b) => b.length - a.length)
+  const decoder = new StringDecoder('utf8')
+  let buffer = ''
+  function drain(final) {
+    let output = ''
+    while (buffer.length > 0) {
+      const match = values.find(value => buffer.startsWith(value))
+      if (match !== undefined) { output += '[REDACTED]'; buffer = buffer.slice(match.length); continue }
+      if (!final && values.some(value => value.startsWith(buffer))) break
+      output += buffer[0]
+      buffer = buffer.slice(1)
+    }
+    if (output !== '') emit(output)
+  }
+  return {
+    write(chunk) { buffer += decoder.write(chunk); drain(false) },
+    end() { buffer += decoder.end(); drain(true) },
+  }
+}
+
+/**
+ * Allocate a run whose failures never become release completion records.
+ * @param {string} root Parent for retained packaging records.
+ * @param {object} metadata Public target/version metadata only.
+ * @returns {{directory: string, run: (stage: string, executable: string, args: readonly string[], options: {cwd: string, env: NodeJS.ProcessEnv, timeoutMs?: number}) => Promise<void>, finish: (success: boolean) => void}} Owned run supervisor; an optional stage deadline records timeout independently of exit status and awaits termination.
+ */
+export function createPackagingRun(root, metadata) {
+  mkdirSync(root, { recursive: true })
+  const directory = realpathSync(mkdtempSync(join(resolve(root), `${new Date().toISOString().replaceAll(':', '-')}-`)))
+  for (const name of ['events.jsonl', 'stdout.log', 'stderr.log']) {
+    writeFileSync(join(directory, name), '', { flag: 'wx', mode: 0o600 })
+  }
+  writeFileSync(join(directory, 'run.json'), `${JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid, ...metadata })}\n`, { flag: 'wx', flush: true })
+  let failed = false
+  let active = false
+  const fatal = join(directory, 'fatal.json')
+  async function run(stage, executable, args, options) {
+    if (failed || existsSync(fatal)) throw new Error(`desktop package: run is blocked; see ${directory}`)
+    if (active) throw new Error('desktop package: supervised stages must run sequentially')
+    active = true
+    let child
+    let fatalObserved = false
+    let launchError = false
+    let outputError = false
+    let termination
+    let terminationCode
+    let terminationError = false
+    let stageClosed = false
+    let timedOut = false
+    let deadline
+    let closed
+    const safeEnvironment = Object.fromEntries(Object.entries(options.env).filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$/iu.test(name)))
+    function stop() {
+      fatalObserved = true
+      failed = true
+      if (termination !== undefined || child?.pid === undefined || stageClosed) return
+      if (process.platform === 'win32') {
+        const killer = spawn(join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], {
+          env: safeEnvironment, windowsHide: true, stdio: 'ignore',
+        })
+        termination = new Promise(resolveTermination => {
+          killer.once('error', () => { terminationError = true })
+          killer.once('close', code => {
+            terminationCode = code
+            if (code !== 0) { terminationError = true; child.kill() }
+            resolveTermination()
+          })
+        })
+      } else {
+        try { process.kill(-child.pid, 'SIGKILL') } catch (error) { if (error.code !== 'ESRCH') terminationError = true }
+        termination = Promise.resolve()
+      }
+    }
+    const interrupted = () => stop()
+    process.once('SIGINT', interrupted)
+    process.once('SIGTERM', interrupted)
+    try {
+      recordPackagingEvent(directory, { type: 'stage-start', stage })
+      child = spawn(executable, [...args], { cwd: options.cwd, env: { ...options.env, DSH_DESKTOP_PACKAGING_RUN_DIR: directory },
+        windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] })
+      closed = new Promise(resolveClose => {
+        child.once('error', () => { launchError = true })
+        child.once('close', (code, signal) => { stageClosed = true; resolveClose({ code, signal }) })
+      })
+      recordPackagingEvent(directory, { type: 'stage-spawn', stage, childPid: child.pid })
+      if (options.timeoutMs !== undefined) deadline = setTimeout(() => { timedOut = true; stop() }, options.timeoutMs)
+      const secrets = Object.entries(options.env).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD/iu.test(name)).map(([, value]) => value ?? '')
+      const streams = [['stdout', child.stdout, process.stdout], ['stderr', child.stderr, process.stderr]]
+      for (const [name, stream, consoleStream] of streams) {
+        let notification = ''
+        const redactor = packagingOutputRedactor(secrets, text => {
+          try {
+            appendFileSync(join(directory, `${name}.log`), text, { flush: true })
+            consoleStream.write(text)
+          } catch { outputError = true; stop() }
+          notification += text
+          if (notification.includes(FATAL_NOTIFICATION)) stop()
+          notification = notification.slice(-FATAL_NOTIFICATION.length)
+        })
+        stream.on('data', chunk => redactor.write(chunk))
+        stream.once('end', () => redactor.end())
+        stream.once('error', () => { outputError = true; stop() })
+      }
+      if (existsSync(fatal)) stop()
+      const result = await closed
+      await termination
+      fatalObserved ||= existsSync(fatal)
+      recordPackagingEvent(directory, { type: 'stage-end', stage, ...result, timedOut, fatalObserved, launchError, outputError, terminationCode, terminationError })
+      if (result.code !== 0 || result.signal !== null || fatalObserved || launchError || outputError || terminationError) {
+        failed = true
+        throw new Error(`desktop package: ${stage} failed; evidence: ${directory}`)
+      }
+    } catch (error) {
+      failed = true
+      stop()
+      await closed
+      await termination
+      throw error
+    } finally {
+      clearTimeout(deadline)
+      process.removeListener('SIGINT', interrupted)
+      process.removeListener('SIGTERM', interrupted)
+      active = false
+    }
+  }
+  return {
+    directory,
+    run,
+    finish(success) {
+      if (active) throw new Error('desktop package: cannot finish an active run')
+      writeFileSync(join(directory, 'result.json'), `${JSON.stringify({ completedAt: new Date().toISOString(), success: success && !failed && !existsSync(fatal) })}\n`, { flag: 'wx', flush: true })
+    },
+  }
+}

+ 94 - 0
apps/desktop/scripts/prepare-installed-update-application.ts

@@ -0,0 +1,94 @@
+/** Freeze the built main, preload, renderer, and bootstrap files shared by a qualification version pair. */
+import { createHash } from 'node:crypto'
+import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { readInstalledUpdateRun } from './installed-update-qualification.ts'
+
+interface ApplicationFile { readonly path: string; readonly sha256: string }
+
+async function applicationFiles(root: string, relative = ''): Promise<string[]> {
+  const files: string[] = []
+  for (const entry of await readdir(join(root, relative), { withFileTypes: true })) {
+    const path = relative === '' ? entry.name : `${relative}/${entry.name}`
+    if (entry.isDirectory()) files.push(...await applicationFiles(root, path))
+    else if (entry.isFile()) files.push(path)
+    else throw new Error('installed update: application links and special files cannot be frozen')
+  }
+  return files
+}
+
+/**
+ * Copy only shipped application files and record their hashes; existing output refuses reuse.
+ * @param manifest Original run manifest with completed bootstrap preparation.
+ * @param source Desktop app directory containing freshly built lib and renderer files.
+ * @returns Hash record, not dependency, signed-package, or boot qualification evidence.
+ */
+export async function prepareInstalledUpdateApplication(manifest: string, source: string): Promise<object> {
+  const run = await readInstalledUpdateRun(manifest)
+  const directory = join(run.root, 'application')
+  await mkdir(directory)
+  await writeFile(join(directory, 'started.json'), `${JSON.stringify({ time: new Date().toISOString() })}\n`, { flag: 'wx', flush: true })
+  try {
+    const modules = (await readdir(join(source, 'lib'), { withFileTypes: true }))
+      .filter(entry => /\.(?:js|cjs)$/u.test(entry.name))
+    if (modules.some(entry => !entry.isFile())) throw new Error('installed update: application modules must be regular files')
+    const paths = [...modules.map(entry => `lib/${entry.name}`), ...await applicationFiles(source, 'renderer')].sort()
+    for (const required of ['lib/main.js', 'lib/preload.cjs', 'lib/preload-app.cjs', 'lib/preload-mandatory.cjs', 'lib/preload-update-dialog.cjs']) {
+      if (!paths.includes(required)) throw new Error('installed update: rebuild Desktop before freezing application files')
+    }
+    const files: ApplicationFile[] = []
+    const inputs = [...paths.map(path => ({ path, source: join(source, path) })),
+      ...['qualification-bootstrap.mjs', 'installed-update-identity.mjs'].map(path => ({ path, source: join(run.root, 'bootstrap', path) }))]
+    for (const input of inputs) {
+      const bytes = await readFile(input.source)
+      const target = join(directory, 'files', input.path)
+      await mkdir(dirname(target), { recursive: true })
+      await writeFile(target, bytes, { flag: 'wx', flush: true })
+      files.push({ path: input.path, sha256: createHash('sha256').update(bytes).digest('hex') })
+    }
+    for (const [index, input] of inputs.entries()) {
+      if (createHash('sha256').update(await readFile(input.source)).digest('hex') !== files[index]!.sha256) {
+        throw new Error('installed update: application input changed while freezing files')
+      }
+    }
+    const result = { schemaVersion: 1, files, dependenciesFrozen: false, signed: false, bootTested: false }
+    await writeFile(join(directory, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx', flush: true })
+    return result
+  } catch (error) {
+    await writeFile(join(directory, 'failed.json'), `${JSON.stringify({ failed: true, retryAllowed: false })}\n`, { flag: 'wx', flush: true })
+    throw error
+  }
+}
+
+/**
+ * Verify frozen application bytes before selecting them for a package.
+ * @param root Validated run directory.
+ * @returns The application file directory; node_modules and build hooks remain separate inputs.
+ */
+export async function verifyInstalledUpdateApplication(root: string): Promise<string> {
+  const directory = join(root, 'application')
+  const record = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8')) as { schemaVersion?: unknown; files?: unknown }
+  if (record.schemaVersion !== 1 || !Array.isArray(record.files) || record.files.length === 0) {
+    throw new Error('installed update: missing application file inventory')
+  }
+  const paths = new Set<string>()
+  for (const entry of record.files as unknown[]) {
+    if (typeof entry !== 'object' || entry === null || !('path' in entry) || !('sha256' in entry)
+      || typeof entry.path !== 'string' || typeof entry.sha256 !== 'string'
+      || entry.path.includes('\\') || entry.path.includes(':') || entry.path.split('/').some(part => ['', '.', '..'].includes(part))
+      || !/^(?:lib\/[^/]+\.(?:js|cjs)|renderer\/.+|qualification-bootstrap\.mjs|installed-update-identity\.mjs)$/u.test(entry.path)
+      || !/^[a-f0-9]{64}$/u.test(entry.sha256) || paths.has(entry.path)) {
+      throw new Error('installed update: invalid application file inventory')
+    }
+    paths.add(entry.path)
+    if (createHash('sha256').update(await readFile(join(directory, 'files', entry.path))).digest('hex') !== entry.sha256) {
+      throw new Error('installed update: frozen application file changed')
+    }
+  }
+  const actual = await applicationFiles(join(directory, 'files'))
+  if (actual.length !== paths.size || actual.some(path => !paths.has(path))
+    || ['lib/main.js', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'].some(path => !paths.has(path))) {
+    throw new Error('installed update: frozen application inventory has missing or additional files')
+  }
+  return join(directory, 'files')
+}

+ 29 - 0
apps/desktop/scripts/prepare-installed-update-bootstrap.ts

@@ -0,0 +1,29 @@
+/** Prepare immutable per-run bootstrap files without launching the application or loading credentials. */
+import { copyFile, constants, mkdir, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { readInstalledUpdateRun } from './installed-update-qualification.ts'
+
+/**
+ * Write the shared qualification entry for both versions; refuse to overwrite an existing preparation.
+ * @param manifest Original run.json in its allocated directory.
+ * @returns The private directory whose two modules must be included in each package's files.
+ */
+export async function prepareInstalledUpdateBootstrap(manifest: string): Promise<string> {
+  const run = await readInstalledUpdateRun(manifest)
+  const directory = join(run.root, 'bootstrap')
+  await mkdir(directory)
+  await copyFile(join(import.meta.dirname, 'installed-update-identity.mjs'),
+    join(directory, 'installed-update-identity.mjs'), constants.COPYFILE_EXCL)
+  const entry = `import { app } from 'electron'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { configureInstalledUpdateIdentity } from './installed-update-identity.mjs'
+
+const run = ${JSON.stringify({ id: run.id, versions: run.versions })}
+const metadata = JSON.parse(readFileSync(join(app.getAppPath(), 'package.json'), 'utf8'))
+configureInstalledUpdateIdentity(app, run, metadata, process.env)
+await import('./lib/main.js')
+`
+  await writeFile(join(directory, 'qualification-bootstrap.mjs'), entry, { flag: 'wx', mode: 0o600, flush: true })
+  return directory
+}

+ 62 - 0
apps/desktop/scripts/prepare-installed-update-network.ts

@@ -0,0 +1,62 @@
+/** Prepare one operator-owned firewall fault for an installed, independently verified qualification executable. */
+import { lstat, mkdir, readFile, realpath, writeFile } from 'node:fs/promises'
+import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
+import { readInstalledUpdateRun } from './installed-update-qualification.ts'
+import { installedUpdateFileHash } from './installed-update-signature.mjs'
+
+/**
+ * Bind a local recovery plan to the version 1 executable hash from retained package verification.
+ * @param manifest Original qualification run.json.
+ * @param executable Absolute installed test application path, not the extracted verification payload.
+ * @param receipt Successful version 1 package verification result.json.
+ * @returns Exclusively created plan path; no credentials, subprocess, or firewall operation is involved.
+ */
+export async function prepareInstalledUpdateNetwork(manifest: string, executable: string, receipt: string): Promise<string> {
+  const run = await readInstalledUpdateRun(manifest)
+  const receiptRelative = relative(join(run.root, run.versions[0], 'verification'), resolve(receipt)).replaceAll('\\', '/')
+  if (!/^check-[^/]+\/result\.json$/u.test(receiptRelative)) throw new Error('installed update: version 1 verification receipt is required')
+  const result = JSON.parse(await readFile(receipt, 'utf8')) as {
+    schemaVersion?: unknown
+    runId?: unknown
+    version?: unknown
+    passed?: unknown
+    applicationSignature?: { sha512?: unknown; valid?: unknown; timestamped?: unknown; updaterVerificationInvoked?: unknown }
+    contents?: { appId?: unknown; version?: unknown }
+  }
+  const signature = result.applicationSignature
+  if (result.schemaVersion !== 1 || result.runId !== run.id || result.version !== run.versions[0] || result.passed !== true
+    || result.contents?.appId !== run.appId || result.contents.version !== run.versions[0]
+    || signature?.valid !== true || signature.timestamped !== true || signature.updaterVerificationInvoked !== true
+    || typeof signature.sha512 !== 'string' || !/^[A-Za-z0-9+/]{86}==$/u.test(signature.sha512)) {
+    throw new Error('installed update: successful identity and signature verification is required')
+  }
+  if (!isAbsolute(executable) || basename(executable) !== `${run.productName}.exe`
+    || !(await lstat(executable)).isFile()) {
+    throw new Error('installed update: installed test executable path or bytes do not match verification')
+  }
+  const installed = await realpath(executable)
+  const insideRun = relative(await realpath(run.root), installed)
+  if ((!insideRun.startsWith(`..${sep}`) && !isAbsolute(insideRun))
+    || await installedUpdateFileHash(installed) !== signature.sha512) {
+    throw new Error('installed update: installed test executable path or bytes do not match verification')
+  }
+  const directory = join(run.root, 'network-fault')
+  await mkdir(directory)
+  const path = join(directory, 'plan.json')
+  await writeFile(path, `${JSON.stringify({ schemaVersion: 1, runId: run.id, executable: installed,
+    sha512Hex: Buffer.from(signature.sha512, 'base64').toString('hex').toUpperCase(),
+    ruleName: `DSH-Update-Qualification-${run.id}`, manifest: resolve(manifest), receipt: resolve(receipt),
+    receiptSha512: await installedUpdateFileHash(receipt), networkChanged: false }, null, 2)}\n`, { flag: 'wx', flush: true })
+  return path
+}
+
+if (process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(import.meta.filename)) {
+  const [manifest, executable, receipt, ...extra] = process.argv.slice(2)
+  if (!manifest || !executable || !receipt || extra.length) {
+    console.error('usage: prepare-installed-update-network.ts <run.json> <installed-test.exe> <verification/result.json>')
+    process.exitCode = 1
+  } else prepareInstalledUpdateNetwork(manifest, executable, receipt).then(path => console.log(path)).catch(() => {
+    console.error('installed update: network plan preparation failed; no network changes were made')
+    process.exitCode = 1
+  })
+}

+ 71 - 0
apps/desktop/scripts/prepare-installed-update-runtime.ts

@@ -0,0 +1,71 @@
+/** Copy a verified runtime into isolated, version-bound qualification resources without signing. */
+import { createHash } from 'node:crypto'
+import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { runtimePath, verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts'
+import { readInstalledUpdateRun } from './installed-update-qualification.ts'
+
+interface PackageMetadata {
+  name?: string
+  version?: string
+  dependencies?: Record<string, string>
+  devDependencies?: Record<string, string>
+  peerDependencies?: Record<string, string>
+  optionalDependencies?: Record<string, string>
+}
+
+/**
+ * Clone one verified source tree into two synthetic release versions, retaining source bytes unchanged.
+ * @param manifest The existing test run manifest; both version directories must be absent.
+ * @param sourceRoot Fresh prepared dsh runtime, never the user's installed application.
+ * @returns Completion record with descriptor hashes; this is not signed or boot-tested artifact evidence.
+ */
+export async function prepareInstalledUpdateRuntime(manifest: string, sourceRoot: string): Promise<object> {
+  const run = await readInstalledUpdateRun(manifest)
+  const receipt = join(run.root, 'runtime-preparation')
+  await mkdir(receipt)
+  await writeFile(join(receipt, 'started.json'), `${JSON.stringify({ sourceRoot, time: new Date().toISOString() })}\n`,
+    { flag: 'wx', mode: 0o600, flush: true })
+  const results: object[] = []
+  try {
+    const source = await verifyDesktopRuntime(sourceRoot, run.source.version)
+    const sourceHash = createHash('sha256').update(await readFile(join(sourceRoot, 'desktop-runtime.json'))).digest('hex')
+    await writeFile(join(receipt, 'source.json'), `${JSON.stringify({ sourceHash, version: run.source.version })}\n`,
+      { flag: 'wx', mode: 0o600, flush: true })
+    const releaseNames = new Set(source.sharedPackages.filter(entry => entry.version === run.source.version
+      && (entry.name === '@deepseek-ai/dsh' || entry.name.startsWith('@deepseek-ai/dsh-'))).map(entry => entry.name))
+    for (const version of run.versions) {
+      const directory = join(run.root, version)
+      await mkdir(directory)
+      const runtime = join(directory, 'dsh')
+      await cp(sourceRoot, runtime, { recursive: true, force: false, errorOnExist: true })
+      const paths = [join(runtime, 'package.json'), ...source.sharedPackages.filter(entry => releaseNames.has(entry.name))
+        .map(entry => join(runtimePath(runtime, entry.path), 'package.json'))]
+      for (const path of paths) {
+        const metadata = JSON.parse(await readFile(path, 'utf8')) as PackageMetadata
+        metadata.version = version
+        for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const) {
+          for (const [name, value] of Object.entries(metadata[field] ?? {})) {
+            if (releaseNames.has(name) && value === run.source.version) metadata[field]![name] = version
+          }
+        }
+        await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`)
+      }
+      writeDesktopRuntime(runtime, { ...source.release, version }, source.sharedPackages.map(entry => entry.name), source)
+      const verified = await verifyDesktopRuntime(runtime, version, source)
+      results.push({ version, runtime, files: verified.files.length, sharedPackages: verified.sharedPackages.length,
+        descriptorSha256: createHash('sha256').update(await readFile(join(runtime, 'desktop-runtime.json'))).digest('hex') })
+    }
+    await verifyDesktopRuntime(sourceRoot, run.source.version, source)
+    if (createHash('sha256').update(await readFile(join(sourceRoot, 'desktop-runtime.json'))).digest('hex') !== sourceHash) {
+      throw new Error('installed update: source runtime changed during qualification preparation')
+    }
+    const result = { schemaVersion: 1, sourceHash, versions: results, signed: false, bootTested: false }
+    await writeFile(join(receipt, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true })
+    return result
+  } catch (error) {
+    await writeFile(join(receipt, 'failed.json'), `${JSON.stringify({ failed: true, time: new Date().toISOString(),
+      completedVersions: results.length, retryAllowed: false })}\n`, { flag: 'wx', mode: 0o600, flush: true })
+    throw error
+  }
+}

+ 60 - 0
apps/desktop/scripts/prepare-installed-update.ts

@@ -0,0 +1,60 @@
+/** Allocate operator qualification materials or inspect journals; never signs, uploads, or installs. */
+import { execFileSync } from 'node:child_process'
+import { readFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import { collectInstalledUpdateJournals, createInstalledUpdateRun, inspectInstalledUpdateJournals } from './installed-update-qualification.ts'
+import { prepareInstalledUpdateBootstrap } from './prepare-installed-update-bootstrap.ts'
+import { prepareInstalledUpdateRuntime } from './prepare-installed-update-runtime.ts'
+import { planInstalledUpdateDistribution } from './installed-update-distribution.ts'
+import { prepareInstalledUpdateApplication } from './prepare-installed-update-application.ts'
+
+const repository = resolve(import.meta.dirname, '../../..')
+
+async function main(): Promise<void> {
+  const [command, original, successor, directory, ...extra] = process.argv.slice(2)
+  if (command === 'collect' && original !== undefined && successor !== undefined && directory === undefined) {
+    console.log(JSON.stringify({ collection: await collectInstalledUpdateJournals(original, successor), operatorAcceptance: 'pending' }, null, 2))
+    return
+  }
+  if (command === 'application' && original !== undefined && successor === undefined) {
+    console.log(JSON.stringify(await prepareInstalledUpdateApplication(original, resolve(repository, 'apps/desktop')), null, 2))
+    return
+  }
+  if (command === 'files' && original !== undefined && successor !== undefined && directory === undefined) {
+    console.log(JSON.stringify(await planInstalledUpdateDistribution(original, successor), null, 2))
+    return
+  }
+  if (command === 'bootstrap' && original !== undefined && successor === undefined) {
+    console.log(JSON.stringify({ bootstrap: await prepareInstalledUpdateBootstrap(original), launched: false }, null, 2))
+    return
+  }
+  if (command === 'runtime' && original !== undefined && successor === undefined) {
+    console.log(JSON.stringify(await prepareInstalledUpdateRuntime(original,
+      resolve(repository, 'apps/desktop/.desktop-build/targets/win-x64/dsh')), null, 2))
+    return
+  }
+  if ((command !== 'init' && command !== 'inspect') || original === undefined || successor === undefined
+    || extra.length !== 0 || (command === 'init' && directory !== undefined) || (command === 'inspect' && directory === undefined)) {
+    throw new Error('usage: prepare-installed-update.ts init <original-nightly> <successor-nightly> | bootstrap <run.json> | runtime <run.json> | application <run.json> | files <run.json> <version> | collect <run.json> <journal-directory> | inspect <original-nightly> <successor-nightly> <journal-directory>')
+  }
+  if (command === 'inspect') {
+    const evidence = await inspectInstalledUpdateJournals(directory!, [original, successor])
+    console.log(JSON.stringify(evidence, null, 2))
+    if (evidence.recordedFlow === 'incomplete') process.exitCode = 2
+    return
+  }
+  const metadata = JSON.parse(await readFile(resolve(repository, 'package.json'), 'utf8')) as { version: string }
+  const git = (args: string[]): string => execFileSync('git', args, { cwd: repository, encoding: 'utf8', windowsHide: true }).trim()
+  const run = await createInstalledUpdateRun(resolve(repository, 'apps/desktop/.desktop-build/qualification'), [original, successor], {
+    version: metadata.version, commit: git(['rev-parse', 'HEAD']),
+    dirtyFiles: git(['status', '--porcelain=v1', '--untracked-files=normal']).split('\n').filter(Boolean),
+  })
+  console.log(JSON.stringify({ manifest: resolve(run.root, 'run.json'), appId: run.appId,
+    versions: run.versions, feedUrl: `${run.origin}/${run.feedKey}`, artifactsPrepared: false, published: false }, null, 2))
+}
+
+main().catch(() => {
+  // Inputs may be logs or local configuration; do not echo arbitrary exception details.
+  console.error('installed update preparation failed; verify arguments, Git checkout, versions, and local evidence files. No remote operation was attempted.')
+  process.exitCode = 1
+})

+ 43 - 0
apps/desktop/scripts/publish-installed-update.ts

@@ -0,0 +1,43 @@
+/** Operator entry for separate test binary uploads and fixed-feed publication; default mode is local-only. */
+import { parseArgs } from 'node:util'
+import { createInterface } from 'node:readline/promises'
+import { stdin, stdout } from 'node:process'
+import { verifiedInstalledUpdateDistribution, executeInstalledUpdatePublication } from './installed-update-publication.ts'
+import { createInstalledUpdateCos } from './installed-update-cos.ts'
+
+async function main(): Promise<void> {
+  const { values, positionals } = parseArgs({ allowPositionals: true, options: {
+    execute: { type: 'boolean', default: false }, journals: { type: 'string' },
+  } })
+  const [action, manifest, version, receipt, ...extra] = positionals
+  if ((action !== 'upload-binaries' && action !== 'publish-feed') || !manifest || !version || !receipt || extra.length) {
+    throw new Error('invalid invocation')
+  }
+  const prepared = await verifiedInstalledUpdateDistribution(manifest, version, receipt)
+  console.log(JSON.stringify({ action, version, runId: prepared.run.id, destination: prepared.distribution,
+    mode: values.execute ? 'awaiting-operator' : 'local-check', networkStarted: false }, null, 2))
+  if (!values.execute) return
+  if (!stdin.isTTY || !stdout.isTTY) throw new Error('operator terminal required')
+  const expected = `${action === 'upload-binaries' ? 'UPLOAD' : 'PUBLISH'} ${version} ${prepared.run.id}`
+  const terminal = createInterface({ input: stdin, output: stdout })
+  let confirmed = false
+  try {
+    console.log(action === 'upload-binaries'
+      ? '仅授权此批次 test 二进制上传。确认没有其他发布者;会完整回读安装包,可能产生较大下载流量。'
+      : '仅授权此批次 test 清单发布。确认没有其他发布者;复用成功上传回执,仅回读清单,不重复下载安装包。')
+    if (action === 'publish-feed' && version === prepared.run.versions[1]) {
+      console.log('确认版本 1 已通过安装后的入口启动并仍在运行;必须提供该批次 journals 目录。')
+    }
+    confirmed = (await terminal.question(`请输入 ${expected}:`)) === expected
+  } finally { terminal.close() }
+  if (!confirmed) throw new Error('operator declined')
+  const store = createInstalledUpdateCos()
+  try {
+    console.log(await executeInstalledUpdatePublication(manifest, version, receipt, action, store, values.journals))
+  } finally { store.dispose() }
+}
+
+main().catch(() => {
+  console.error('installed update: upload/publication stopped. Review local prerequisites and any retained operation record; no automatic retry.')
+  process.exitCode = 1
+})

+ 54 - 0
apps/desktop/scripts/test-host-updates.ts

@@ -0,0 +1,54 @@
+/** Run built Desktop Host task protection in a private development profile without Electron or installation. */
+import { spawn } from 'node:child_process'
+import { mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises'
+import { join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { createDevelopmentProjectMetadata } from '../src/project-manager.ts'
+import { removeOwnedDirectory } from '../src/owned-directory.ts'
+import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
+
+const repo = resolve(import.meta.dirname, '../../..')
+const evidenceRoot = join(repo, 'apps/desktop/.desktop-build/qualification')
+await mkdir(evidenceRoot, { recursive: true })
+const root = await mkdtemp(join(evidenceRoot, 'host-updates-'))
+const project = join(root, 'project')
+const manifest = JSON.parse(await readFile(join(repo, 'apps/desktop/package.json'), 'utf8')) as { version: string }
+const pnpm = JSON.parse(await readFile(join(repo, 'apps/desktop/node_modules/pnpm/package.json'), 'utf8')) as { version: string }
+try {
+  createDevelopmentProjectMetadata(project, {
+    schemaVersion: 1, version: manifest.version, nodeVersion: process.versions.node,
+    pnpmVersion: pnpm.version, hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
+  })
+  // This suite tests the Host, not dev.ts's dependency projection. Preserve pnpm's existing graph verbatim.
+  await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(project, 'node_modules'),
+    process.platform === 'win32' ? 'junction' : 'dir')
+  await writeFile(join(project, 'cordis.patch.yml'), JSON.stringify([
+    { id: 'webserver', config: { host: '127.0.0.1', port: 0 } },
+    { id: 'llm-deepseek', disabled: true },
+    { id: 'session-title-llm', disabled: true },
+    { id: 'session-telemetry-otel', disabled: true },
+    { id: 'agent-instructions', disabled: true },
+    { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } },
+    { insert: [{ id: 'update-qualification', name: new URL('../tests/fixtures/host-update-control.mjs', import.meta.url).href }] },
+  ]))
+  const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) =>
+    /^(?:path|systemroot|windir|comspec|pathext)$/iu.test(name)))
+  const child = spawn(process.execPath, [fileURLToPath(new URL('../tests/fixtures/host-update-qualification.mjs', import.meta.url)), root], {
+    cwd: root, env: { ...environment, DSH_HOME: join(root, 'home'), USERPROFILE: root, HOME: root,
+      TEMP: root, TMP: root, TMPDIR: root }, stdio: 'inherit', windowsHide: true,
+  })
+  let timedOut = false
+  const timeout = setTimeout(() => { timedOut = true; child.kill() }, 120_000)
+  try {
+    const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((done, reject) => {
+      child.once('error', reject)
+      child.once('close', (code, signal) => { done({ code, signal }) })
+    })
+    if (timedOut || result.signal !== null || result.code !== 0) {
+      throw new Error(`Host update qualification failed: timeout=${String(timedOut)}, signal=${String(result.signal)}, code=${String(result.code)}`)
+    }
+    console.log(`Host update evidence: ${root}`)
+  } finally { clearTimeout(timeout) }
+} finally {
+  removeOwnedDirectory(project)
+}

+ 38 - 0
apps/desktop/scripts/test-local-updater.mjs

@@ -0,0 +1,38 @@
+/** Run the built Desktop coordinator against real Electron downloads without installing software. */
+import { spawn } from 'node:child_process'
+import { copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import electron from 'electron'
+
+if (process.platform !== 'win32') throw new Error('Local NSIS updater qualification requires Windows')
+const root = await mkdtemp(join(tmpdir(), 'dsh-local-updater-'))
+let timedOut = false
+try {
+  const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) =>
+    !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^ELECTRON_RUN_AS_NODE$/iu.test(name)))
+  const child = spawn(electron, [fileURLToPath(new URL('../tests/fixtures/local-updater.mjs', import.meta.url))], {
+    env: { ...environment, DSH_LOCAL_UPDATE_TEST_ROOT: root }, stdio: 'inherit', windowsHide: true,
+  })
+  const timeout = setTimeout(() => { timedOut = true; child.kill() }, 120_000)
+  try {
+    const result = await new Promise((resolve, reject) => {
+      child.once('error', reject)
+      child.once('close', (code, signal) => resolve({ code, signal }))
+    })
+    if (timedOut) throw new Error('Local updater qualification exceeded its 120-second deadline')
+    if (result.signal !== null) throw new Error(`Electron exited on ${result.signal}`)
+    if (result.code !== 0) throw new Error(`Local updater qualification failed: exit=${result.code}`)
+    const evidenceRoot = fileURLToPath(new URL('../.desktop-build/qualification/', import.meta.url))
+    await mkdir(evidenceRoot, { recursive: true })
+    const evidence = await mkdtemp(join(evidenceRoot, 'local-updater-'))
+    const report = JSON.parse(await readFile(join(root, 'result.json'), 'utf8'))
+    await copyFile(join(root, 'result.json'), join(evidence, 'result.json'))
+    if (report.screenshot.captured) await copyFile(join(root, 'mandatory-update.png'), join(evidence, 'mandatory-update.png'))
+    for (const name of report.dialogScreenshots) await copyFile(join(root, name), join(evidence, name))
+    console.log(`Local updater evidence: ${evidence}`)
+  } finally { clearTimeout(timeout) }
+} finally {
+  await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
+}

+ 72 - 0
apps/desktop/scripts/test-signed-updates.mjs

@@ -0,0 +1,72 @@
+/** Run real Electron downloads from local executable inputs without installing or publishing them. */
+import assert from 'node:assert/strict'
+import { spawn } from 'node:child_process'
+import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import electron from 'electron'
+
+assert.equal(process.platform, 'win32', 'Signed NSIS download qualification requires Windows')
+const [certificate, signed, unsigned, old] = process.argv.slice(2)
+assert.ok(certificate && signed && unsigned,
+  'Usage: node apps/desktop/scripts/test-signed-updates.mjs <public.cer> <signed.exe> <unsigned.exe> [old.exe]')
+const paths = await Promise.all([certificate, signed, unsigned, ...(old ? [old, `${signed}.blockmap`, `${old}.blockmap`] : [])]
+  .map(file => realpath(file)))
+const evidence = fileURLToPath(new URL('../.desktop-build/qualification/', import.meta.url))
+await mkdir(evidence, { recursive: true })
+const root = await mkdtemp(join(evidence, 'signed-downloads-'))
+await mkdir(join(root, 'runtime'))
+const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) =>
+  !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^ELECTRON_RUN_AS_NODE$/iu.test(name)))
+let timedOut = false
+const timeoutMs = Number(process.env.DSH_SIGNED_UPDATE_TEST_TIMEOUT_MS ?? 180_000)
+assert.ok(Number.isSafeInteger(timeoutMs) && timeoutMs >= 1000 && timeoutMs <= 180_000,
+  'DSH_SIGNED_UPDATE_TEST_TIMEOUT_MS must be an integer from 1000 to 180000')
+let termination = Promise.resolve()
+let terminationError
+let launchError
+const startedAt = new Date().toISOString()
+const child = spawn(electron, [fileURLToPath(new URL('../tests/fixtures/signed-updates.mjs', import.meta.url))], {
+  env: { ...environment, DSH_SIGNED_UPDATE_TEST_ROOT: root,
+    DSH_SIGNED_UPDATE_CERTIFICATE: paths[0], DSH_SIGNED_UPDATE_SIGNED: paths[1], DSH_SIGNED_UPDATE_UNSIGNED: paths[2],
+    DSH_SIGNED_UPDATE_OLD: paths[3] ?? '' },
+  stdio: 'inherit', windowsHide: true,
+})
+child.once('error', error => { launchError = error.message })
+const deadline = setTimeout(() => {
+  timedOut = true
+  // Authenticode starts PowerShell; stop the complete owned process tree before deleting downloads.
+  termination = new Promise((resolve) => {
+    const killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
+      env: environment, stdio: 'ignore', windowsHide: true,
+    })
+    killer.once('error', error => { terminationError = error.message })
+    killer.once('close', (code) => {
+      if (code !== 0) terminationError ??= `taskkill exited ${code}`
+      resolve()
+    })
+  })
+}, timeoutMs)
+try {
+  const result = await new Promise((resolve) => {
+    child.once('close', (code, signal) => resolve({ code, signal }))
+  })
+  clearTimeout(deadline)
+  await termination
+  await writeFile(join(root, 'process.json'), `${JSON.stringify({ ...result, timedOut, pid: child.pid,
+    startedAt, completedAt: new Date().toISOString(), launchError, terminationError }, null, 2)}\n`)
+  assert.equal(launchError, undefined)
+  assert.equal(terminationError, undefined)
+  assert.equal(timedOut, false, `Signed download qualification exceeded ${timeoutMs} ms`)
+  assert.equal(result.signal, null, `Electron exited on ${result.signal}`)
+  assert.equal(result.code, 0, `Signed download qualification failed; see ${root}`)
+  const report = JSON.parse(await readFile(join(root, 'result.json'), 'utf8'))
+  assert.equal(report.passed, true)
+}
+finally {
+  clearTimeout(deadline)
+  await termination
+  // Only this invocation's runtime directory is removed after process exit; reports remain.
+  await rm(join(root, 'runtime'), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
+  console.log(`SIGNED_UPDATE_DOWNLOAD_RESULT ${root}`)
+}

+ 80 - 0
apps/desktop/scripts/test-windows-update-signature.mjs

@@ -0,0 +1,80 @@
+/** Real NSIS publisher verification against supplied files; never signs or executes an installer. */
+import assert from 'node:assert/strict'
+import { createHash } from 'node:crypto'
+import { createReadStream } from 'node:fs'
+import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { resolveWindowsUpdatePublisher } from './windows-sign.mjs'
+
+assert.equal(process.platform, 'win32', 'Authenticode qualification requires Windows')
+const [certificateFile, signedFile, unsignedFile] = process.argv.slice(2)
+assert.ok(certificateFile && signedFile && unsignedFile,
+  'Usage: node apps/desktop/scripts/test-windows-update-signature.mjs <public.cer> <signed.exe> <unsigned.exe>')
+const require = createRequire(import.meta.url)
+const { NsisUpdater } = require('electron-updater')
+const { WindowsSignToolManager } = require('app-builder-lib/out/codeSign/windowsSignToolManager.js')
+const { getAppUpdatePublishConfiguration } = require('app-builder-lib/out/publish/PublishManager.js')
+const { Platform } = require('app-builder-lib')
+const publisherName = resolveWindowsUpdatePublisher(certificateFile)
+const signed = await realpath(signedFile)
+const unsigned = await realpath(unsignedFile)
+assert.notEqual(signed, unsigned, 'Signed and unsigned controls must be distinct files')
+const qualification = fileURLToPath(new URL('../.desktop-build/qualification/', import.meta.url))
+await mkdir(qualification, { recursive: true })
+const root = await mkdtemp(join(qualification, 'windows-update-signature-'))
+const report = { publisherName, installerExecuted: false, cases: [], passed: false }
+
+async function digest(file) {
+  const hash = createHash('sha512')
+  for await (const chunk of createReadStream(file)) hash.update(chunk)
+  return hash.digest('base64')
+}
+
+try {
+  const before = { signed: await digest(signed), unsigned: await digest(unsigned) }
+  const platformSpecificBuildOptions = { signtoolOptions: { publisherName } }
+  const manager = new WindowsSignToolManager({ platformSpecificBuildOptions })
+  // Keep builder/updater metadata code real; no packaging, key lookup, HTTP, or installer entry runs.
+  const metadata = await getAppUpdatePublishConfiguration({
+    platform: Platform.WINDOWS,
+    platformSpecificBuildOptions,
+    appInfo: { updaterCacheDirName: 'private-signature-qualification' },
+    signingManager: { value: Promise.resolve(manager) },
+    isForceCodeSigningVerification: true,
+    info: {},
+    config: { publish: [{ provider: 'generic', url: 'https://unused.invalid/', channel: 'nightly' }] },
+    expandMacro: value => value,
+  }, null, 1, true)
+  assert.deepEqual(metadata.publisherName, [publisherName])
+  for (const [name, file, config, accepted] of [
+    ['missing-publisher-negative-control', signed, { ...metadata, publisherName: undefined }, true],
+    ['matching-publisher', signed, metadata, true],
+    ['wrong-publisher', signed, { ...metadata, publisherName: ['CN=Not the release publisher'] }, false],
+    ['unsigned-file', unsigned, metadata, false],
+  ]) {
+    const configFile = join(root, `${name}.yml`)
+    await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, { flag: 'wx' })
+    const updater = new NsisUpdater(null, { version: '0.1.0', isPackaged: true })
+    updater.autoInstallOnAppQuit = false
+    updater.updateConfigPath = configFile
+    const logs = []
+    updater.logger = Object.fromEntries(['info', 'warn', 'error', 'debug'].map(level => [level, value => logs.push(String(value))]))
+    const result = await updater.verifySignature(file)
+    assert.equal(result === null, accepted, `${name}: unexpected signature result`)
+    assert.ok(!logs.some(line => line.includes('Ignoring signature validation')), 'PowerShell verification must not be skipped')
+    const verificationInvoked = logs.some(line => line.startsWith('Verifying signature '))
+    assert.equal(verificationInvoked, name !== 'missing-publisher-negative-control')
+    if (name === 'wrong-publisher') assert.match(result, /"Status": 0/u, 'Wrong-publisher control must have a valid signature')
+    report.cases.push({ name, file, accepted, verificationInvoked, sha512: file === signed ? before.signed : before.unsigned })
+    updater.removeAllListeners()
+  }
+  assert.equal(await digest(signed), before.signed, 'Signed input changed during verification')
+  assert.equal(await digest(unsigned), before.unsigned, 'Unsigned input changed during verification')
+  report.passed = true
+}
+finally {
+  await writeFile(join(root, 'result.json'), `${JSON.stringify(report, null, 2)}\n`, { flag: 'wx' })
+  console.log(`WINDOWS_UPDATE_SIGNATURE_RESULT ${join(root, 'result.json')}`)
+}

+ 75 - 0
apps/desktop/scripts/test-workspace-updates.ts

@@ -0,0 +1,75 @@
+/** Run the compiled Electron main entry in a private app with local update delivery and real Host IPC. */
+import { execFileSync, spawn } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
+import { cp, mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { createDevelopmentProjectMetadata, createPluginProfile } from '../src/project-manager.ts'
+import { removeOwnedDirectory } from '../src/owned-directory.ts'
+import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
+
+const repo = resolve(import.meta.dirname, '../../..')
+const interactive = process.argv.includes('--interactive')
+if (process.argv.slice(2).some(argument => argument !== '--interactive')) throw new Error('Expected only --interactive')
+const evidence = join(repo, 'apps/desktop/.desktop-build/qualification')
+await mkdir(evidence, { recursive: true })
+const root = await mkdtemp(join(evidence, 'electron-workspace-updates-'))
+const application = join(root, 'app')
+const project = join(application, '.desktop-build/development/project')
+const profile = join(root, 'home/profiles/desktop')
+const manifest = JSON.parse(await readFile(join(repo, 'apps/desktop/package.json'), 'utf8')) as { version: string }
+const pnpm = JSON.parse(await readFile(join(repo, 'apps/desktop/node_modules/pnpm/package.json'), 'utf8')) as { version: string }
+try {
+  const require = createRequire(import.meta.url)
+  const electron = require('electron') as string
+  const release = { schemaVersion: 1 as const, version: manifest.version,
+    nodeVersion: execFileSync(electron, ['-p', 'process.versions.node'],
+      { encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, windowsHide: true }).trim(),
+    pnpmVersion: pnpm.version, hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION }
+  createDevelopmentProjectMetadata(project, release)
+  createPluginProfile(profile)
+  await writeFile(join(project, 'desktop-runtime.json'), JSON.stringify({
+    schemaVersion: 1, release, platform: process.platform, arch: process.arch, files: [],
+    sharedPackages: ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host']
+      .map(name => ({ name, version: manifest.version, path: `node_modules/${name}` })),
+  }))
+  await cp(join(repo, 'apps/desktop/lib/types'), join(application, 'lib'), { recursive: true })
+  await cp(join(repo, 'apps/desktop/renderer'), join(application, 'renderer'), { recursive: true })
+  for (const name of ['preload-app', 'preload', 'preload-mandatory', 'preload-update-dialog']) {
+    await cp(join(repo, `apps/desktop/lib/${name}.cjs`), join(application, `lib/${name}.cjs`))
+  }
+  await writeFile(join(application, 'package.json'), JSON.stringify({ name: 'desktop-update-qualification', version: manifest.version, type: 'module' }))
+  for (const owner of [application, project]) {
+    await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(owner, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
+  }
+  await writeFile(join(profile, 'cordis.patch.yml'), JSON.stringify([
+    { id: 'webserver', config: { host: '127.0.0.1', port: 0 } },
+    { id: 'llm-deepseek', disabled: true }, { id: 'session-title-llm', disabled: true },
+    { id: 'session-telemetry-otel', disabled: true },
+    { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } },
+    { insert: [{ id: 'update-control', name: new URL('../tests/fixtures/workspace-update-host.mjs', import.meta.url).href }] },
+  ]))
+  const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) =>
+    /^(?:path|systemroot|windir|comspec|pathext)$/iu.test(name)))
+  const child = spawn(electron, [fileURLToPath(new URL('../tests/fixtures/workspace-updates.mjs', import.meta.url)), '--lang=zh-CN',
+    ...(interactive ? ['--interactive'] : [])], {
+    cwd: root, env: { ...environment, DSH_HOME: join(root, 'home'), USERPROFILE: root, HOME: root,
+      TEMP: root, TMP: root, TMPDIR: root, DSH_WORKSPACE_UPDATE_ROOT: root, DSH_WORKSPACE_UPDATE_TOKEN: randomUUID(),
+      DSH_DESKTOP_OPEN_DEVTOOLS: '0' },
+    // Hiding the GUI process suppresses its first window and can suspend renderer frame callbacks.
+    stdio: 'inherit', windowsHide: false,
+  })
+  let timedOut = false
+  const timer = interactive ? undefined : setTimeout(() => { timedOut = true; child.kill() }, 120_000)
+  try {
+    const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((done, reject) => {
+      child.once('error', reject)
+      child.once('close', (code, signal) => { done({ code, signal }) })
+    })
+    if (timedOut || result.signal !== null || result.code !== 0) {
+      throw new Error(`Workspace qualification failed: timeout=${String(timedOut)}, signal=${String(result.signal)}, exit=${String(result.code)}; evidence=${root}`)
+    }
+    console.log(`Electron workspace evidence: ${root}`)
+  } finally { clearTimeout(timer) }
+} finally { removeOwnedDirectory(application) }

+ 7 - 33
apps/desktop/scripts/upload-target.ts

@@ -1,16 +1,12 @@
 /** Upload one validated Desktop release to its Tencent COS update directory. */
 
-import { createReadStream } from 'node:fs'
-import { stat } from 'node:fs/promises'
 import { resolve } from 'node:path'
 import { parseArgs } from 'node:util'
-import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
+import { S3Client } from '@aws-sdk/client-s3'
 import type { DesktopPackageTargetName } from './package-target.ts'
 import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
-import {
-  createDesktopUploadPlan,
-  type DesktopUploadArtifact,
-} from './desktop-upload-plan.ts'
+import { createDesktopUploadPlan } from './desktop-upload-plan.ts'
+import { uploadDesktopRelease } from './desktop-upload-run.ts'
 
 const SUPPORTED_TARGETS = new Set<DesktopPackageTargetName>(['mac-arm64', 'mac-x64', 'win-x64'])
 
@@ -29,29 +25,6 @@ function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, name: string):
   return value
 }
 
-async function putArtifact(
-  client: S3Client,
-  bucket: string,
-  artifact: DesktopUploadArtifact,
-): Promise<void> {
-  const details = await stat(artifact.path)
-  const body = createReadStream(artifact.path)
-  try {
-    await client.send(new PutObjectCommand({
-      Bucket: bucket,
-      Key: artifact.key,
-      Body: body,
-      ContentLength: details.size,
-      ContentType: artifact.contentType,
-      CacheControl: artifact.cacheControl,
-    }))
-  }
-  finally {
-    body.destroy()
-  }
-  process.stdout.write(`desktop upload: uploaded ${artifact.key}\n`)
-}
-
 async function main(): Promise<void> {
   const { positionals } = parseArgs({ args: process.argv.slice(2), allowPositionals: true })
   const target = positionals[0]
@@ -62,6 +35,7 @@ async function main(): Promise<void> {
   const environment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin')
   const plan = await createDesktopUploadPlan(name, { environment })
   const client = new S3Client({
+    maxAttempts: 1,
     region: 'Auto',
     endpoint: 'https://cos.ap-beijing.myqcloud.com',
     credentials: {
@@ -71,7 +45,7 @@ async function main(): Promise<void> {
   })
   process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`)
   try {
-    for (const artifact of plan.artifacts) await putArtifact(client, plan.bucket, artifact)
+    await uploadDesktopRelease(plan, client, resolve(import.meta.dirname, '../.desktop-build/upload-records'))
   }
   finally {
     client.destroy()
@@ -79,8 +53,8 @@ async function main(): Promise<void> {
 }
 
 if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
-  main().catch((error: unknown) => {
-    process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`)
+  main().catch(() => {
+    process.stderr.write('desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.\n')
     process.exitCode = 1
   })
 }

+ 132 - 0
apps/desktop/scripts/upload-with-credentials.ps1

@@ -0,0 +1,132 @@
+#requires -Version 5.1
+<#
+.SYNOPSIS
+Checks Windows DPAPI credentials, or explicitly uploads one completed Desktop target.
+.DESCRIPTION
+Imports encrypted SecretId and SecretKey fields from an external CLIXML file.
+Only the Node child receives plaintext COS credentials. The default check starts
+a keyless-code probe without contacting COS; it does not verify cloud permissions.
+.PARAMETER CredentialFile
+Path to the CLIXML file created by the current Windows user on this machine.
+.PARAMETER Environment
+Deployment that owns the credential pair; never inferred from the filename.
+.PARAMETER Target
+Completed Desktop target to upload when Upload is explicitly selected.
+.PARAMETER Bucket
+COS bucket for an explicit upload. Not needed for the local credential check.
+.PARAMETER Upload
+Authorize the existing target upload entry. Omit to check credentials locally.
+#>
+[CmdletBinding(DefaultParameterSetName = 'Check')]
+param(
+    [Parameter(Mandatory = $true)]
+    [string]$CredentialFile,
+    [Parameter(Mandatory = $true)]
+    [ValidateSet('test', 'production')]
+    [string]$Environment,
+    [Parameter(ParameterSetName = 'Publish', Mandatory = $true)]
+    [ValidateSet('win-x64', 'mac-x64', 'mac-arm64')]
+    [string]$Target,
+    [Parameter(ParameterSetName = 'Publish', Mandatory = $true)]
+    [ValidateNotNullOrEmpty()]
+    [string]$Bucket,
+    [Parameter(ParameterSetName = 'Publish', Mandatory = $true)]
+    [switch]$Upload
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+$child = $null
+$started = $false
+$secretId = $null
+$secretKey = $null
+$credentials = $null
+$startInfo = $null
+$stage = 'decrypt-file'
+
+try {
+    if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
+        throw 'Windows DPAPI is required.'
+    }
+    try {
+        $credentials = Import-Clixml -LiteralPath $CredentialFile
+        foreach ($field in @('SecretId', 'SecretKey')) {
+            if ($credentials.$field -isnot [Security.SecureString] -or $credentials.$field.Length -eq 0) {
+                throw 'Expected non-empty encrypted fields.'
+            }
+        }
+    } catch {
+        throw 'Cannot read encrypted COS credentials. Check the file and use its original Windows user and machine.'
+    }
+
+    $stage = 'prepare-node'
+    $startInfo = New-Object Diagnostics.ProcessStartInfo
+    $startInfo.FileName = (Get-Command node -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
+    $startInfo.WorkingDirectory = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../..'))
+    $startInfo.UseShellExecute = $false
+    $startInfo.CreateNoWindow = $true
+    $startInfo.RedirectStandardOutput = $true
+    $startInfo.RedirectStandardError = $true
+    foreach ($name in @($startInfo.EnvironmentVariables.Keys)) {
+        # Node preload hooks and unrelated release secrets must not reach this credential-bearing process.
+        if ($name -match 'KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^DSH_DESKTOP_WINDOWS_|^APPLE_|^CSC_') {
+            $startInfo.EnvironmentVariables.Remove($name)
+        }
+    }
+    $stage = 'prepare-credentials'
+    $prefix = if ($Environment -eq 'production') { 'DOWNLOAD_PROD_COS' } else { 'DOWNLOAD_TEST_COS' }
+    $secretId = [Net.NetworkCredential]::new('', $credentials.SecretId).Password
+    $secretKey = [Net.NetworkCredential]::new('', $credentials.SecretKey).Password
+    if ([string]::IsNullOrWhiteSpace($secretId) -or [string]::IsNullOrWhiteSpace($secretKey)) {
+        throw 'COS credential fields must not be blank.'
+    }
+    $startInfo.EnvironmentVariables["${prefix}_SECRET_ID"] = $secretId
+    $startInfo.EnvironmentVariables["${prefix}_SECRET_KEY"] = $secretKey
+    $startInfo.EnvironmentVariables['DSH_DESKTOP_AUTO_UPDATE_ENV'] = $Environment
+    if ($Upload) {
+        $startInfo.EnvironmentVariables["${prefix}_BUCKET"] = $Bucket
+        $startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target"
+        Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes."
+    } else {
+        $probe = "const id=process.env.${prefix}_SECRET_ID;const key=process.env.${prefix}_SECRET_KEY;process.exit(id?.trim()&&key?.trim()?0:1)"
+        $startInfo.Arguments = "-e `"$probe`""
+    }
+
+    $stage = 'run-node'
+    $child = New-Object Diagnostics.Process
+    $child.StartInfo = $startInfo
+    $started = $child.Start()
+    $stderr = $child.StandardError.ReadToEndAsync()
+    while ($null -ne ($line = $child.StandardOutput.ReadLine())) {
+        Write-Output $line.Replace($secretId, '[REDACTED]').Replace($secretKey, '[REDACTED]')
+    }
+    $child.WaitForExit()
+    # SDK exception objects can include signed request details; do not forward raw stderr.
+    $null = $stderr.GetAwaiter().GetResult()
+    if ($child.ExitCode -ne 0) {
+        throw "Node upload/check failed (exit $($child.ExitCode)); private diagnostics suppressed."
+    }
+    if (-not $Upload) {
+        Write-Output 'desktop credentials: encrypted fields loaded; child environment verified; no network request made.'
+    }
+} catch {
+    # Import and process exceptions are not safe credential diagnostics.
+    Write-Output "desktop credentials: failed; stage=$stage; line=$($_.InvocationInfo.ScriptLineNumber). Verify the encrypted file, Windows account, Node, and release inputs. No secrets printed."
+    exit 1
+} finally {
+    if ($started -and -not $child.HasExited) {
+        $child.Kill()
+        $child.WaitForExit()
+    }
+    if ($null -ne $child) { $child.Dispose() }
+    if ($null -ne $startInfo) { $startInfo.EnvironmentVariables.Clear() }
+    if ($null -ne $credentials) {
+        foreach ($field in @('SecretId', 'SecretKey')) {
+            if ($credentials.PSObject.Properties[$field] -and $credentials.$field -is [Security.SecureString]) {
+                $credentials.$field.Dispose()
+            }
+        }
+    }
+    $secretId = $null
+    $secretKey = $null
+}

+ 125 - 0
apps/desktop/scripts/verify-installed-update-package.ts

@@ -0,0 +1,125 @@
+/** Verify signed installer bytes and extract their payload without running any installer or signing operation. */
+import { execFile } from 'node:child_process'
+import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
+import { join, resolve } from 'node:path'
+import { promisify } from 'node:util'
+import { readInstalledUpdateRun } from './installed-update-qualification.ts'
+import { planInstalledUpdateDistribution } from './installed-update-distribution.ts'
+import { installedUpdateFileHash, verifyInstalledUpdateSignature } from './installed-update-signature.mjs'
+import { resolveWindowsUpdatePublisher } from './windows-sign.mjs'
+import { verifyInstalledUpdatePackageContent } from './installed-update-package-content.ts'
+import { recordPackagingEvent } from './packaging-run.mjs'
+
+/**
+ * Reject nonrelative archive names and link entries before extraction into a new directory.
+ * @param listing The pinned 7-Zip's UTF-8 technical listing, with archive headers suppressed.
+ * @returns Number of relative archive entries; rejects empty or unsafe listings.
+ */
+export function validateInstalledUpdateArchivePaths(listing: string): number {
+  const paths = [...listing.matchAll(/^Path = (.+)\r?$/gmu)].map(match => match[1]!.replace(/\r$/u, '').replaceAll('\\', '/'))
+  if (paths.length === 0 || /^(?:Symbolic Link|Hard Link|Reparse Point) = .+/mu.test(listing)
+    || /^Attributes = .*\blrwx/mu.test(listing)) throw new Error('installed update: archive links or missing entries are not accepted')
+  const seen = new Set<string>()
+  for (const path of paths) {
+    if (seen.has(path.toLowerCase())) throw new Error('installed update: archive contains duplicate Windows paths')
+    seen.add(path.toLowerCase())
+    if (/[\x00-\x1f:*?"<>|]/u.test(path) || path.split('/').some(part => part === '' || part === '.' || part === '..'
+      || /[. ]$/u.test(part) || /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/iu.test(part))) {
+      throw new Error('installed update: archive contains an unsafe path')
+    }
+  }
+  return paths.length
+}
+
+/**
+ * Produce retained signature, extraction, and content evidence for one final installer.
+ * @param manifest Original test run.
+ * @param version One exact run version.
+ * @param certificate Trusted public certificate; no .env or PIN is read.
+ * @param archiveTool Reviewed local 7-Zip executable, never an executable extracted from this installer.
+ * @returns Result path; failure retains partial records and never reports a passed package.
+ */
+export async function verifyInstalledUpdatePackage(
+  manifest: string, version: string, certificate: string, archiveTool: string,
+): Promise<string> {
+  const run = await readInstalledUpdateRun(manifest)
+  if (!run.versions.includes(version)) throw new Error('installed update: verification version is outside the run')
+  const parent = join(run.root, version, 'verification')
+  await mkdir(parent, { recursive: true })
+  const record = await mkdtemp(join(parent, 'check-'))
+  const result: Record<string, unknown> = { schemaVersion: 1, runId: run.id, version, passed: false,
+    installerExecuted: false, published: false, manualChecks: ['installer-registration', 'startup', 'upgrade', 'data-retention'] }
+  const stage = (name: string): void => { result.stage = name; recordPackagingEvent(record, { type: 'verification-stage', stage: name }) }
+  console.log(`INSTALLED_UPDATE_VERIFICATION_RECORD ${record}`)
+  try {
+    stage('local-file-plan')
+    const plan = await planInstalledUpdateDistribution(manifest, version)
+    const installer = plan.binaries[0]!.path
+    const publisher = resolveWindowsUpdatePublisher(certificate)
+    const toolHash = await installedUpdateFileHash(archiveTool)
+    const certificateSha512 = await installedUpdateFileHash(certificate)
+    const manifestSha512 = await installedUpdateFileHash(manifest)
+    await writeFile(join(record, 'inputs.json'), `${JSON.stringify({ manifestSha512, distribution: plan,
+      certificate, certificateSha512, archiveTool, toolHash })}\n`, { flag: 'wx', flush: true })
+    await mkdir(join(record, 'installer-signature'))
+    stage('installer-signature')
+    result.installerSignature = await verifyInstalledUpdateSignature(installer, publisher, join(record, 'installer-signature'))
+    const environment = Object.fromEntries(Object.entries(process.env)
+      .filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$/iu.test(name)))
+    const execute = (args: string[]) => promisify(execFile)(archiveTool, args, {
+      env: environment, cwd: record, windowsHide: true, encoding: 'utf8' as const, timeout: 120_000, maxBuffer: 16 * 1024 * 1024,
+    })
+    stage('archive-paths')
+    const listing = await execute(['l', '-slt', '-ba', '-sccUTF-8', '--', installer])
+    await writeFile(join(record, 'archive-list.txt'), listing.stdout, { flag: 'wx', flush: true })
+    result.archiveEntries = validateInstalledUpdateArchivePaths(listing.stdout)
+    const payload = join(record, 'payload')
+    await mkdir(payload)
+    stage('extraction')
+    const extraction = await execute(['x', '-y', '-bd', '-bso0', '-bsp0', `-o${payload}`, '--', installer])
+    await writeFile(join(record, 'extraction.log'), `${extraction.stdout}\n${extraction.stderr}`, { flag: 'wx', flush: true })
+    stage('payload-content')
+    const contents = await verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)
+    result.contents = contents
+    await mkdir(join(record, 'application-signature'))
+    stage('application-signature')
+    result.applicationSignature = await verifyInstalledUpdateSignature(join(payload, `${run.productName}.exe`),
+      publisher, join(record, 'application-signature'))
+    const runtimeSignatures: object[] = []
+    result.runtimeSignatures = runtimeSignatures
+    for (const [index, path] of contents.resignedExecutables.entries()) {
+      stage(`runtime-signature-${index}`)
+      const directory = join(record, `runtime-signature-${index}`)
+      await mkdir(directory)
+      runtimeSignatures.push({ path, ...await verifyInstalledUpdateSignature(path, publisher, directory) })
+    }
+    stage('unchanged-inputs')
+    if (await installedUpdateFileHash(installer) !== plan.binaries[0]!.sha512
+      || await installedUpdateFileHash(archiveTool) !== toolHash || await installedUpdateFileHash(certificate) !== certificateSha512
+      || await installedUpdateFileHash(manifest) !== manifestSha512
+      || JSON.stringify(await planInstalledUpdateDistribution(manifest, version)) !== JSON.stringify(plan)) {
+      throw new Error('installed update: verification input changed')
+    }
+    stage('complete')
+    result.passed = true
+    return join(record, 'result.json')
+  } catch (error) {
+    result.failure = error instanceof Error ? error.message : 'verification failed'
+    throw error
+  } finally {
+    await writeFile(join(record, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx', flush: true })
+  }
+}
+
+if (process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(import.meta.filename)) {
+  const [manifest, version, certificate, archiveTool, ...extra] = process.argv.slice(2)
+  if (!manifest || !version || !certificate || !archiveTool || extra.length !== 0) {
+    console.error('usage: verify-installed-update-package.ts <run.json> <version> <public.cer> <reviewed-7za.exe>')
+    process.exitCode = 1
+  } else {
+    verifyInstalledUpdatePackage(manifest, version, certificate, archiveTool).then(path => console.log(path)).catch(() => {
+      console.error('installed update: package verification failed; inspect the retained record. No installer was executed.')
+      process.exitCode = 1
+    })
+  }
+}

+ 9 - 0
apps/desktop/scripts/windows-sign.d.mts

@@ -14,6 +14,13 @@ export function buildWindowsSigningEnvironment(environment: NodeJS.ProcessEnv, i
   keyContainer: string
 }): NodeJS.ProcessEnv
 
+/**
+ * Pin updater verification to the release certificate's organization, country, and common name.
+ * @param certificateFile Public Windows Code Signing certificate file.
+ * @returns Distinguished-name attributes consumed by electron-updater.
+ */
+export function resolveWindowsUpdatePublisher(certificateFile: string | undefined): string
+
 /**
  * Serialize hardware-token signing and stop all queued tasks after the first failure.
  *
@@ -26,6 +33,8 @@ export function createWindowsTokenSigner(options: {
   tokenPin?: string | undefined
   keyContainer?: string | undefined
   commandInterpreter?: string | undefined
+  runDirectory?: string | undefined
+  stateDirectory?: string | undefined
 }): (
   configuration: {
     path: string

+ 34 - 5
apps/desktop/scripts/windows-sign.mjs

@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { promisify } from 'node:util'
 import wineVmModule from 'app-builder-lib/out/vm/WineVm.js'
+import { beginWindowsSigningAttempt } from './windows-signing-state.mjs'
 
 const execFileAsync = promisify(execFile)
 const { WineVmManager } = wineVmModule
@@ -67,7 +68,28 @@ function resolveCertificateFile(value) {
   if (certificate.ca || !certificate.keyUsage?.includes(CODE_SIGNING_EKU)) {
     throw new Error(`Windows code-signing certificate file must contain a non-CA Code Signing certificate: ${path}`)
   }
-  return path
+  return { path, certificate }
+}
+
+/**
+ * Pin updater verification to the release certificate's organization, country, and common name.
+ *
+ * @param {string | undefined} certificateFile Public Windows Code Signing certificate file.
+ * @returns {string} Distinguished-name attributes consumed by electron-updater.
+ */
+export function resolveWindowsUpdatePublisher(certificateFile) {
+  const { certificate } = resolveCertificateFile(certificateFile)
+  const subject = certificate.toLegacyObject().subject
+  // These attributes have identical names in OpenSSL and Windows certificate subjects.
+  return ['CN', 'O', 'C'].map((key) => {
+    const value = subject[key]
+    if (typeof value !== 'string' || value.trim() === '') {
+      throw new Error(`Windows update publisher requires one nonempty ${key} certificate attribute`)
+    }
+    const escaped = value.replace(/[\\",;+\x00-\x20]/gu,
+      character => `\\${character.charCodeAt(0).toString(16).padStart(2, '0')}`)
+    return `${key}=${escaped}`
+  }).join(',')
 }
 
 function resolveSignTool(value) {
@@ -136,11 +158,11 @@ export function buildWindowsSigningEnvironment(environment, input) {
 /**
  * Serialize SafeNet signing and stop all queued tasks after the first failure.
  *
- * @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string }} options Release signing configuration.
+ * @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string, runDirectory?: string, stateDirectory?: string }} options Release identity, supervised run, and test-only isolated interlock directory.
  * @returns {(configuration: { path: string, hash: string, isNest: boolean }) => Promise<void>} The signing hook.
  */
 export function createWindowsTokenSigner(options) {
-  const certificateFile = resolveCertificateFile(options.certificateFile)
+  const { path: certificateFile } = resolveCertificateFile(options.certificateFile)
   const signTool = resolveSignTool(options.signTool)
   const { keyContainer, tokenPin } = resolveTokenIdentity(options)
   const commandInterpreter = options.commandInterpreter
@@ -154,9 +176,11 @@ export function createWindowsTokenSigner(options) {
       }
       await repairDanglingAuthenticodeDirectory(configuration.path)
       const secrets = [tokenPin]
+      const attempt = beginWindowsSigningAttempt({ runDirectory: options.runDirectory,
+        stateDirectory: options.stateDirectory, target: configuration.path })
       let result
       try {
-        result = await execFileAsync(commandInterpreter, [
+        const operation = execFileAsync(commandInterpreter, [
           '/d',
           '/v:off',
           '/c',
@@ -173,10 +197,15 @@ export function createWindowsTokenSigner(options) {
           }),
           windowsHide: true,
         })
+        attempt.started(operation.child?.pid ?? null)
+        result = await operation
       }
       catch (error) {
-        throw createRedactedWindowsSigningError(error, configuration.path, secrets)
+        const failure = createRedactedWindowsSigningError(error, configuration.path, secrets)
+        attempt.failure(typeof error.code === 'number' || typeof error.code === 'string' ? error.code : null, failure.message)
+        throw failure
       }
+      attempt.success()
       const stdout = redactedSigningOutput(result.stdout, secrets)
       const stderr = redactedSigningOutput(result.stderr, secrets)
       if (stdout !== '') process.stdout.write(stdout)

+ 14 - 0
apps/desktop/scripts/windows-signing-state.d.mts

@@ -0,0 +1,14 @@
+/**
+ * Acquire durable evidence and the shared signing interlock before any hardware operation.
+ * @param options Supervised run, target artifact, and test-only isolated state directory.
+ * @returns Process evidence and completion callbacks; only success releases the interlock.
+ */
+export function beginWindowsSigningAttempt(options: {
+  runDirectory?: string | undefined
+  stateDirectory?: string | undefined
+  target: string
+}): {
+  started(pid: number | null): void
+  success(): void
+  failure(code: number | string | null, diagnostic: string): void
+}

+ 49 - 0
apps/desktop/scripts/windows-signing-state.mjs

@@ -0,0 +1,49 @@
+/** Persist a per-user hardware-signing interlock; failures and interrupted attempts never unlock automatically. */
+import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join, isAbsolute } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
+
+/**
+ * Acquire the one hardware-signing attempt slot before launching SignTool.
+ * @param {{runDirectory?: string, stateDirectory?: string, target: string}} options Run evidence and test-only isolated state directory.
+ * @returns {{started: (pid: number|null) => void, success: () => void, failure: (code: number|string|null, diagnostic: string) => void}} Process evidence and completion callbacks; failure retains the interlock.
+ */
+export function beginWindowsSigningAttempt(options) {
+  const runDirectory = options.runDirectory ?? process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
+  if (!runDirectory || !isAbsolute(runDirectory) || !existsSync(join(runDirectory, 'run.json'))) {
+    throw new Error('Windows hardware signing requires a supervised packaging run with retained records')
+  }
+  if (existsSync(join(runDirectory, 'fatal.json'))) throw new Error('Windows signing refused: packaging run already failed')
+  const root = options.stateDirectory ?? join(homedir(), '.dsh-desktop-signing')
+  const lock = join(root, 'attempt.json')
+  const attemptId = randomUUID()
+  mkdirSync(root, { recursive: true })
+  let descriptor
+  try { descriptor = openSync(lock, 'wx', 0o600) }
+  catch (error) {
+    failPackagingRun(runDirectory, 'hardware-signing-interlock-unavailable')
+    throw new Error(`Windows signing refused: interlock unavailable at ${lock}; inspect the previous attempt before administrator-approved recovery (${error.code})`)
+  }
+  try {
+    writeFileSync(descriptor, `${JSON.stringify({ attemptId, runDirectory, pid: process.pid, startedAt: new Date().toISOString(), target: options.target })}\n`, { flush: true })
+  } finally { closeSync(descriptor) }
+  try { recordPackagingEvent(runDirectory, { type: 'sign-start', attemptId, target: options.target }) }
+  catch (error) { failPackagingRun(runDirectory, 'signing-audit-write-failed'); throw error }
+  return {
+    started(pid) {
+      recordPackagingEvent(runDirectory, { type: 'sign-command-start', attemptId, commandPid: pid })
+    },
+    success() {
+      recordPackagingEvent(runDirectory, { type: 'sign-success', attemptId, target: options.target })
+      if (JSON.parse(readFileSync(lock, 'utf8')).attemptId !== attemptId) throw new Error('Windows signing interlock ownership changed')
+      unlinkSync(lock)
+    },
+    failure(code, diagnostic) {
+      // The attempt file stays in place even if recording the failure or notifying the parent fails.
+      try { recordPackagingEvent(runDirectory, { type: 'sign-failure', attemptId, target: options.target, code, diagnostic }) }
+      finally { failPackagingRun(runDirectory, 'hardware-signing-failed') }
+    },
+  }
+}

+ 67 - 5
apps/desktop/src/host-process.ts

@@ -15,7 +15,12 @@ interface FatalEvent {
   readonly message: string
 }
 
-type DesktopHostEvent = ReadyEvent | FatalEvent
+type DesktopHostEvent = ReadyEvent | FatalEvent | { readonly type: 'shutdown-complete' } | {
+  readonly type: 'update-tasks'
+  readonly requestId: number
+  readonly active: boolean
+  readonly error?: string
+}
 
 const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 1024
 
@@ -23,10 +28,15 @@ function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
   if (typeof message !== 'object' || message === null || !('type' in message)) return false
   const candidate = message as Record<string, unknown>
   switch (candidate.type) {
+    case 'shutdown-complete':
+      return true
     case 'ready':
       return typeof candidate.url === 'string'
     case 'fatal':
       return typeof candidate.message === 'string'
+    case 'update-tasks':
+      return Number.isSafeInteger(candidate.requestId) && typeof candidate.active === 'boolean'
+        && (candidate.error === undefined || typeof candidate.error === 'string')
     default:
       return false
   }
@@ -51,6 +61,9 @@ export interface DesktopHostReady {
   readonly injections?: readonly unknown[] | undefined
 }
 
+/** The child has exited, but task teardown did not finish successfully. */
+export class DesktopHostUncleanExitError extends Error {}
+
 /** One Web backend running under the Electron executable in Node mode. */
 export class DesktopHostProcess {
   private child: ChildProcess | undefined
@@ -64,6 +77,9 @@ export class DesktopHostProcess {
   private stderr = ''
   private failureReported = false
   private stopping = false
+  private shutdownCompleted = false
+  private nextControlId = 1
+  private readonly taskQueries = new Map<number, { resolve: (active: boolean) => void; reject: (error: Error) => void }>()
 
   /**
    * @param node - Absolute Electron executable in Node mode.
@@ -117,7 +133,16 @@ export class DesktopHostProcess {
         return
       }
       if (message.type === 'ready') this.readyResolve({ url: message.url, injections: message.injections })
-      else this.fail(new Error(message.message))
+      else if (message.type === 'shutdown-complete') {
+        if (this.stopping) this.shutdownCompleted = true
+        else this.fail(new Error('dsh desktop host acknowledged an unrequested shutdown'))
+      }
+      else if (message.type === 'fatal') this.fail(new Error(message.message))
+      else {
+        const query = this.taskQueries.get(message.requestId)
+        if (message.error === undefined) query?.resolve(message.active)
+        else query?.reject(new Error(message.error))
+      }
     })
     child.once('error', (error) => { this.fail(error) })
     this.exitPromise = new Promise<void>((resolve) => {
@@ -131,14 +156,45 @@ export class DesktopHostProcess {
     return this.readyPromise
   }
 
-  /** Request graceful teardown and await exit, escalating termination when needed. */
-  async stop(): Promise<void> {
+  /**
+   * Inspect active work or lock request admission for update handoff.
+   * @param action - Read-only inspection, admission lock, or recovery unlock.
+   * @returns Whether live tasks would be affected. Locking drains admitted API requests before inspecting tasks;
+   * an unanswered drain fails at the control-request deadline without authorizing installation.
+   */
+  async updateTasks(action: 'inspect' | 'lock' | 'unlock'): Promise<boolean> {
+    const child = this.child
+    if (child === undefined || !child.connected || this.failureReported || this.stopping) {
+      throw new Error('desktop update: Host is unavailable')
+    }
+    const requestId = this.nextControlId++
+    let timer: ReturnType<typeof setTimeout> | undefined
+    try {
+      return await new Promise<boolean>((resolve, reject) => {
+        this.taskQueries.set(requestId, { resolve, reject })
+        timer = setTimeout(() => { reject(new Error('desktop update: task inspection timed out')) }, 10_000)
+        child.send({ type: 'update-tasks', requestId, action }, (error) => { if (error !== null) reject(error) })
+      })
+    } finally {
+      clearTimeout(timer)
+      this.taskQueries.delete(requestId)
+    }
+  }
+
+  /**
+   * Request teardown and await child exit, escalating termination when needed.
+   * @param requireGraceful - Reject update handoff after forced termination or unsuccessful child exit.
+   * @returns Completion of owned process teardown. DesktopHostUncleanExitError confirms exit but refuses installation;
+   * other failures do not confirm exit.
+   */
+  async stop(requireGraceful = false): Promise<void> {
     const child = this.child
     if (child === undefined) return
     this.stopping = true
     if (child.connected) child.send({ type: 'shutdown' }, (error) => { if (error !== null) this.fail(error) })
     const exited = this.exitPromise ?? Promise.resolve()
-    if (!await exitsWithin(exited, 10_000)) child.kill('SIGTERM')
+    const graceful = await exitsWithin(exited, 10_000)
+    if (!graceful) child.kill('SIGTERM')
     if (!await exitsWithin(exited, 5_000)) {
       child.kill('SIGKILL')
       if (!await exitsWithin(exited, 5_000)) {
@@ -146,10 +202,16 @@ export class DesktopHostProcess {
       }
     }
     this.child = undefined
+    if (requireGraceful && (!graceful || child.exitCode !== 0 || !this.shutdownCompleted)) {
+      // This diagnostic reaches expandable UI; arbitrary plugin stderr can contain credentials.
+      throw new DesktopHostUncleanExitError(`desktop update: Host did not complete graceful task teardown (exit ${String(child.exitCode)}, signal ${String(child.signalCode)}, shutdown acknowledged ${String(this.shutdownCompleted)}, graceful deadline exceeded ${String(!graceful)})`)
+    }
   }
 
   private fail(error: Error): void {
     this.readyReject(error)
+    for (const query of this.taskQueries.values()) query.reject(error)
+    this.taskQueries.clear()
     if (!this.failureReported && !this.stopping) {
       this.failureReported = true
       try { this.onFailure?.(error) } catch (listenerError) {

+ 38 - 1
apps/desktop/src/ipc.ts

@@ -16,14 +16,51 @@ export const DESKTOP_IPC = {
   updatesCheck: 'dsh-desktop:updates-check',
   updatesInstall: 'dsh-desktop:updates-install',
   updatesState: 'dsh-desktop:updates-state',
+  updatesStatus: 'dsh-desktop:updates-status',
+  updatesOpen: 'dsh-desktop:updates-open',
+  updatesPresentation: 'dsh-desktop:updates-presentation',
   nativeThemeSet: 'dsh-desktop:native-theme-set',
 } as const
 
 /** Desktop release update state rendered by desktop-owned UI. */
 export interface DesktopUpdateState {
-  readonly phase: 'idle' | 'checking' | 'available' | 'installing' | 'ready' | 'error'
+  readonly phase: 'idle' | 'checking' | 'available' | 'downloading' | 'verifying' | 'installing' | 'ready' | 'error'
   readonly version?: string
   readonly message?: string
+  /** Main-owned diagnostics without subprocess output or credentials; hidden until expanded. */
+  readonly technicalDetails?: string
+  readonly percent?: number
+  readonly failedOperation?: 'check' | 'download' | 'install'
+}
+
+/** Classified failure copy selected by the Web locale without exposing raw updater diagnostics. */
+export type DesktopUpdateFailureKind =
+  | 'check'
+  | 'check-network'
+  | 'download'
+  | 'download-network'
+  | 'install'
+  | 'install-network'
+  | 'stop-failed'
+  | 'tasks-changed'
+  | 'tasks-unavailable'
+
+/** Semantic status content; actions open main-process confirmation dialogs only. */
+export interface DesktopUpdatePresentation {
+  readonly phase: DesktopUpdateState['phase']
+  readonly version?: string
+  readonly percent?: number
+  readonly failure?: DesktopUpdateFailureKind
+}
+
+/** Product documents cannot supply update versions, package URLs, or installation authorization. */
+export interface DshDesktopProductApi {
+  readonly protocolVersion: 1
+  readonly updates: {
+    status(): Promise<DesktopUpdatePresentation>
+    open(): Promise<void>
+    subscribe(listener: (state: DesktopUpdatePresentation) => void): () => void
+  }
 }
 
 /** Narrow bridge exposed through context isolation. */

+ 110 - 2
apps/desktop/src/locale.ts

@@ -13,15 +13,69 @@ export const en = {
   pluginsMenu: 'Desktop Plugins…',
   checkUpdatesMenu: 'Check for Updates…',
   updateCheckFailedTitle: 'Update Check Failed',
+  updateCheckFailed: 'Could not check for updates. Please try again later.',
+  updateDownloadFailed: 'Could not download the update. Please try again.',
+  updateInstallFailed: 'Could not install the update. Please try again later.',
+  updateNetworkFailed: 'The connection was interrupted. Check your network and try again.',
   unknownError: 'Unknown error',
   updateCheckTitle: 'Check for Updates',
-  updateCurrent: 'You already have the latest version.',
+  updateCurrent: 'No updates available. Current version: V{version}',
+  updateChecking: 'Checking for updates…',
+  updateDownload: 'Download update',
+  updateDownloadedTitle: 'DeepSeek Harness v{version} downloaded',
+  updateDownloadedDetail: 'The update has downloaded. Select Install and Restart to restart the application and install it.',
+  updateClose: 'Close',
+  updateAcknowledge: 'OK',
+  updateLater: 'Update later',
+  updateDownloading: 'Downloading {percent}%…',
+  updateVerifying: 'Verifying update files…',
+  updateInstalling: 'Preparing to restart…',
+  updateRetry: 'Retry update',
+  updateReady: 'The update is ready to install.',
+  updateActiveTasks: 'Tasks are still in progress',
+  updateActiveTasksDetail: 'Restarting to update may interrupt these tasks. Continue updating?',
+  updateStopTasks: 'Stop tasks and update',
+  updateTasksChanged: 'New tasks started. Review the update confirmation again.',
+  updateTasksUnavailable: 'Task status is unavailable. Try updating again when the workspace is ready.',
+  updateStopFailed: 'Tasks could not be stopped safely. The update was not installed. Please try again later.',
+  updateTechnicalDetails: 'View technical details',
   updateTitle: 'DeepSeek Harness Update',
   updateAvailable: 'An update is available',
   updateDetail: 'DeepSeek Harness {version}\n\nThis release includes its matching dsh version. The application will restart after installation.',
   installAndRestart: 'Install and Restart',
   later: 'Later',
   updateFailedTitle: 'Update Failed',
+  mandatoryTitle: 'Update required',
+  mandatoryDetail: 'This version is no longer supported. Update to continue. Existing tasks can keep running until you approve a restart.',
+  mandatoryUnavailable: 'The update requirement could not be checked. Retry when the connection is available.',
+  policyLoginTitle: 'Sign in to the test environment',
+  policyLoginRequired: 'This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update.',
+  policyLogin: 'Sign in with Feishu',
+  policyLoginFailed: 'Test environment sign-in did not complete. Please check your connection and try again.',
+  mandatoryInvalidPolicy: 'Update information is incomplete. Retry the check or contact support.',
+  mandatoryNoRelease: 'No applicable update is available. Retry the check or visit the download page.',
+  mandatoryRefresh: 'Check again',
+  mandatoryPage: 'Open download page',
+  mandatoryCopy: 'Copy download address',
+  mandatoryQuit: 'Exit application',
+  mandatoryPageFailed: 'The download page could not be opened. Copy the address below and open it in your browser.',
+  mandatoryActionFailed: 'The update action failed. Retry; the update requirement remains active.',
+  mandatoryReady: 'Update ready',
+  mandatoryVersion: 'V{version}',
+  mandatoryReadyDetail: 'Installing the update will restart the application.',
+  mandatoryDeferred: 'Existing tasks can keep running. Update to continue using the application.',
+  mandatoryContinue: 'Continue installing update',
+  mandatoryInspecting: 'Checking tasks…',
+  mandatoryStopping: 'Safely stopping tasks in the application.',
+  mandatoryRestarting: 'The application will restart shortly. Please wait.',
+  mandatoryDownloadFailed: 'The update files could not be downloaded or prepared. Please retry.',
+  mandatoryInstallFailed: 'The update was not installed. Check tasks again and retry.',
+  mandatoryOpenHelp: 'If the page did not open, you can',
+  mandatoryReopen: 'Open download page again',
+  mandatoryCopied: 'Link copied',
+  mandatoryCopyFailed: 'Copy failed. Select and copy the address below manually.',
+  mandatoryAddress: 'Download address',
+  mandatoryNotification: 'Return to the application to confirm installation and restart.',
   pluginManagerTitle: 'Desktop Plugins',
   pluginWindowTitle: 'DeepSeek Harness — Desktop Plugins',
   pluginManagerDescription: 'Plugins are installed only in the Desktop node_modules and are managed by the bundled pnpm.',
@@ -63,15 +117,69 @@ export const zh = {
   pluginsMenu: '桌面插件…',
   checkUpdatesMenu: '检查更新…',
   updateCheckFailedTitle: '更新检查失败',
+  updateCheckFailed: '检查更新失败,请稍后重试。',
+  updateDownloadFailed: '下载更新失败,请重试。',
+  updateInstallFailed: '安装更新失败,请稍后重试。',
+  updateNetworkFailed: '网络连接异常,请检查网络后重试。',
   unknownError: '未知错误',
   updateCheckTitle: '检查更新',
-  updateCurrent: '当前已是最新版本。',
+  updateCurrent: '当前暂无可用更新。当前版本:V{version}',
+  updateChecking: '正在检查更新…',
+  updateDownload: '下载更新',
+  updateDownloadedTitle: 'DeepSeek Harness v{version} 下载完成',
+  updateDownloadedDetail: '安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。',
+  updateClose: '关闭',
+  updateAcknowledge: '确定',
+  updateLater: '稍后更新',
+  updateDownloading: '正在下载 {percent}%…',
+  updateVerifying: '正在校验更新文件…',
+  updateInstalling: '正在准备重启…',
+  updateRetry: '重试更新',
+  updateReady: '更新已准备就绪。',
+  updateActiveTasks: '仍有进行中的任务',
+  updateActiveTasksDetail: '重启更新可能中断这些任务,是否要继续更新?',
+  updateStopTasks: '停止任务并更新',
+  updateTasksChanged: '有新任务开始,请重新确认更新。',
+  updateTasksUnavailable: '无法确认任务状态,请在工作区就绪后重试更新。',
+  updateStopFailed: '未能安全停止任务,更新未安装。请稍后重试。',
+  updateTechnicalDetails: '查看技术详情',
   updateTitle: 'DeepSeek Harness 更新',
   updateAvailable: '发现可用更新',
   updateDetail: 'DeepSeek Harness {version}\n\n新版本绑定匹配的 dsh,安装后将重新启动。',
   installAndRestart: '安装并重启',
   later: '稍后',
   updateFailedTitle: '更新失败',
+  mandatoryTitle: '需要更新',
+  mandatoryDetail: '当前版本已停止支持,请更新后继续使用。在您确认重启之前,现有任务可以继续运行。',
+  mandatoryUnavailable: '暂时无法检查更新要求,请在网络恢复后重试。',
+  policyLoginTitle: '登录测试环境',
+  policyLoginRequired: '这是测试版应用,检查更新要求需要先通过飞书登录。登录不会下载或安装更新。',
+  policyLogin: '通过飞书登录',
+  policyLoginFailed: '测试环境登录未完成,请检查网络后重试。',
+  mandatoryInvalidPolicy: '更新信息不完整,请重新检查或联系支持人员。',
+  mandatoryNoRelease: '暂时没有可用的更新,请重新检查或前往下载页面。',
+  mandatoryRefresh: '重新检查',
+  mandatoryPage: '前往官网下载',
+  mandatoryCopy: '复制下载链接',
+  mandatoryQuit: '退出应用',
+  mandatoryPageFailed: '无法打开浏览器,请复制下载链接后手动打开。',
+  mandatoryActionFailed: '更新操作失败,请重试;应用仍需更新后才能继续使用。',
+  mandatoryReady: '更新已准备就绪',
+  mandatoryVersion: 'V{version}',
+  mandatoryReadyDetail: '安装后将重新启动应用。',
+  mandatoryDeferred: '现有任务可以继续运行。完成更新后才能继续操作应用。',
+  mandatoryContinue: '继续安装更新',
+  mandatoryInspecting: '正在检查任务状态…',
+  mandatoryStopping: '正在安全结束应用中的任务。',
+  mandatoryRestarting: '应用即将重启,请稍候。',
+  mandatoryDownloadFailed: '更新文件下载或准备失败,请重试。',
+  mandatoryInstallFailed: '更新尚未安装,请重新检查任务后重试。',
+  mandatoryOpenHelp: '若页面未打开,可',
+  mandatoryReopen: '重新打开官网',
+  mandatoryCopied: '已复制链接',
+  mandatoryCopyFailed: '复制失败,请手动选择下方地址复制。',
+  mandatoryAddress: '下载地址',
+  mandatoryNotification: '返回应用确认安装并重启。',
   pluginManagerTitle: '桌面插件',
   pluginWindowTitle: 'DeepSeek Harness — 桌面插件',
   pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。',

+ 307 - 43
apps/desktop/src/main.ts

@@ -9,6 +9,7 @@ import {
   dialog,
   ipcMain,
   Menu,
+  powerMonitor,
   nativeTheme,
   protocol,
   session,
@@ -18,7 +19,7 @@ import {
 } from 'electron'
 import { resolveDesktopPaths } from './paths.ts'
 import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
-import { DesktopHostProcess } from './host-process.ts'
+import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
 import { desktopNodeEnvironment } from './node-environment.ts'
 import { DesktopBackendController } from './backend-controller.ts'
 import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
@@ -27,6 +28,16 @@ import { claimDesktopSingleInstance } from './single-instance.ts'
 import { DesktopUpdateCoordinator } from './update-coordinator.ts'
 import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
 import { DesktopFatalRecovery } from './fatal-recovery.ts'
+import { DesktopUpdateJournal } from './update-journal.ts'
+import { DesktopUpdatePreparationError } from './update-error.ts'
+import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './update-schedule.ts'
+import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts'
+import { desktopErrorState } from './startup-error.ts'
+import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts'
+import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
+import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
+import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
+import { readDesktopRuntime } from './runtime-tree.ts'
 
 const SCHEME = 'dsh-app'
 let focusPrimaryWindow = (): void => {}
@@ -189,6 +200,8 @@ async function serveShellAsset(request: Request): Promise<Response> {
 }
 
 async function main(): Promise<void> {
+  const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR
+  const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion())
   const resources = runtimeResources()
   const paths = resolveDesktopPaths()
   const development = !app.isPackaged
@@ -199,15 +212,42 @@ async function main(): Promise<void> {
   let mainWindow: BrowserWindow | undefined
   let pluginWindow: BrowserWindow | undefined
   let shellInstallerOwnsQuit = false
+  let requireCleanStop = false
+  let updateStoppedHost = false
+  let updateStopFailure: DesktopHostUncleanExitError | undefined
   let updateState: DesktopUpdateState = { phase: 'idle' }
+  let mandatoryPolicy: DesktopMandatoryUpdatePolicy | undefined
+  let mandatoryUI: DesktopMandatoryUpdateWindow | undefined
+  let policyAuth: DesktopPolicyTestAuth | undefined
+  const isQuitting = (): boolean => quitting
+  const ordinaryDialogs = new Set<AbortController>()
   const locale = resolveDesktopLocale(app.getLocale())
   const messages = locale.messages
+  const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), locale)
+  const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true
+  const assertPolicyAllowsBusiness = (): void => { if (isMandatory()) throw new Error(messages.mandatoryTitle) }
+  const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise<Electron.MessageBoxReturnValue> => {
+    const controller = new AbortController()
+    ordinaryDialogs.add(controller)
+    try {
+      if (mainWindow === undefined) return { response: options.cancelId ?? 0, checkboxChecked: false }
+      return await updateDialog.show(mainWindow, { ...options, signal: controller.signal })
+    }
+    finally { ordinaryDialogs.delete(controller) }
+  }
   const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
   const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url))
   const applicationUrl = `${SCHEME}://app/`
   let hostUrl: string | undefined
   let hostCookie: string | undefined
   let injections: readonly unknown[] = []
+  const assertProductSender = (event: IpcMainInvokeEvent): void => {
+    assertDesktopSender(event, ['app'])
+    if (mainWindow === undefined || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents
+      || event.senderFrame === null || event.senderFrame !== mainWindow.webContents.mainFrame) {
+      throw new Error('dsh desktop: rejected IPC from an unowned renderer')
+    }
+  }
   let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
   const navigateMain = (url: string): Promise<void> => {
     const window = mainWindow
@@ -238,16 +278,51 @@ async function main(): Promise<void> {
         if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
         injections = ready.injections
       },
-      stop: () => host.stop(),
+      stop: async () => {
+        try { await host.stop(requireCleanStop) }
+        catch (error) {
+          if (!requireCleanStop || !(error instanceof DesktopHostUncleanExitError)) throw error
+          // Backend cleanup succeeded; installation still rejects the unsuccessful task teardown.
+          updateStopFailure = error
+        }
+      },
+      updateTasks: (action: 'inspect' | 'lock' | 'unlock') => host.updateTasks(action),
     }
   }, (state) => {
     if (state.phase === 'error') reportFatal(new Error(state.message))
   })
 
+  const updateErrors = new WeakMap<DesktopUpdateState, Promise<void>>()
+  const showUpdateFailure = (state: DesktopUpdateState): Promise<void> => {
+    if (state.phase !== 'error') return Promise.resolve()
+    if (isMandatory()) { mandatoryUI?.sync(); return Promise.resolve() }
+    let shown = updateErrors.get(state)
+    if (shown === undefined) {
+      shown = ordinaryMessageBox({ type: 'error', title: messages.updateFailedTitle,
+        message: desktopUpdateErrorSummary(state, messages),
+        technicalDetails: state.technicalDetails ?? state.message ?? '' }).then(() => {})
+      updateErrors.set(state, shown)
+    }
+    return shown
+  }
   const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => {
+    updateJournal?.state(state)
     updateState = state
+    mandatoryUI?.sync()
     for (const window of BrowserWindow.getAllWindows()) {
       window.webContents.send(DESKTOP_IPC.updatesState, state)
+      window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state, messages))
+    }
+    if (state.phase === 'error' && state.failedOperation !== 'check') {
+      const restoreHost = state.failedOperation === 'install' && updateStoppedHost && !quitting
+      shellInstallerOwnsQuit = false
+      updateStoppedHost = false
+      if (restoreHost) {
+        // Only confirmed process exit permits replacement before another installation confirmation.
+        startup ??= backend.start(async () => {}).then(() => navigateMain(applicationUrl))
+          .catch(reportFatal).finally(() => { startup = undefined })
+      }
+      void showUpdateFailure(state).catch((error: unknown) => { console.error(error) })
     }
     return state
   }
@@ -265,8 +340,10 @@ async function main(): Promise<void> {
       await backend.start(async () => {
         await manager.applyRelease(app.isPackaged)
       })
+      if (backend.host !== undefined) updateJournal?.action('workspace-ready')
       // The existing Web document resumes through the boot IPC response.
     })().catch((error: unknown) => {
+      updateJournal?.action('workspace-failed')
       reportFatal(error)
       throw error
     }).finally(() => { startup = undefined })
@@ -276,11 +353,59 @@ async function main(): Promise<void> {
   const updates = new DesktopUpdateCoordinator(
     publishUpdate,
     async () => {
-      shellInstallerOwnsQuit = true
-      await backend.stop()
+      await startup?.catch(() => undefined)
+      const host = backend.host
+      if (host === undefined) throw new Error(messages.updateTasksUnavailable)
+      const active = await host.updateTasks('inspect')
+      const confirmation: Electron.MessageBoxOptions = {
+        type: active ? 'warning' : 'info', title: messages.updateTitle,
+        message: active ? messages.updateActiveTasks : formatDesktopMessage(messages.updateDownloadedTitle, { version: updates.state.version ?? '' }),
+        detail: active ? messages.updateActiveTasksDetail
+          : messages.updateDownloadedDetail,
+        buttons: active ? [messages.updateStopTasks, messages.updateLater] : [messages.installAndRestart],
+        defaultId: 1, cancelId: 1,
+      }
+      if (isMandatory()) {
+        if (!await mandatoryUI?.confirm(updates.state.version ?? '', active)) return false
+      } else {
+        if (mainWindow === undefined) return false
+        const result = await updateDialog.show(mainWindow, confirmation)
+        if (result.response !== 0 || isMandatory()) return false
+      }
+      if (backend.host !== host) throw new Error(messages.updateTasksUnavailable)
+      try {
+        const stillActive = await host.updateTasks('lock')
+        if (stillActive && !active) throw new Error(messages.updateTasksChanged)
+        mandatoryUI?.preparingRestart(stillActive)
+        requireCleanStop = true
+        updateStopFailure = undefined
+        await backend.stop()
+        updateStoppedHost = true
+        // The backend's async cleanup callback can assign this after the reset above.
+        const stopFailure = updateStopFailure as DesktopHostUncleanExitError | undefined
+        if (stopFailure !== undefined) throw new DesktopUpdatePreparationError(messages.updateStopFailed, stopFailure.message)
+        updateJournal?.action('install-confirmed')
+        shellInstallerOwnsQuit = true
+      } catch (error) {
+        if (!updateStoppedHost) await host.updateTasks('unlock').catch((unlockError: unknown) => { console.error(unlockError) })
+        throw error
+      } finally {
+        requireCleanStop = false
+      }
+      return true
     },
   )
 
+  const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
+
+  const downloadUpdate = async (version: string): Promise<DesktopUpdateState> => {
+    updateJournal?.action('download-requested')
+    const state = await updates.download(version)
+    if (state.phase !== 'ready' || quitting) return state
+    // Only a completed user-driven download opens this prompt; cancelling installation does not reopen it.
+    return updates.install(version)
+  }
+
   protocol.handle(SCHEME, (request) => {
     const url = new URL(request.url)
     if (url.hostname === 'app') {
@@ -328,8 +453,11 @@ async function main(): Promise<void> {
 
   const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters<DesktopProjectManager['mutate']>[0]): Promise<void> => {
     assertDesktopSender(event, ['shell'])
+    assertPolicyAllowsBusiness()
+    if (updates.state.phase === 'installing') throw new Error(messages.updateInstalling)
     await startup?.catch(() => undefined)
     if (recovery.active) throw new Error(messages.fatalSummary)
+    assertPolicyAllowsBusiness()
     try {
       await manager.mutate(mutation, hooks)
     } finally {
@@ -373,56 +501,138 @@ async function main(): Promise<void> {
   })
   ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => {
     assertDesktopSender(event, ['shell'])
-    return updates.check()
+    updateJournal?.action('check-requested')
+    return updateSchedule.check(true)
   })
   ipcMain.handle(DESKTOP_IPC.updatesInstall, async (event) => {
     assertDesktopSender(event, ['shell'])
-    await updates.install()
+    await openUpdatePrompt()
+  })
+  ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => {
+    assertProductSender(event)
+    return presentDesktopUpdate(updates.state, messages)
+  })
+  ipcMain.handle(DESKTOP_IPC.updatesOpen, async (event) => {
+    assertProductSender(event)
+    await openUpdatePrompt()
   })
 
-  const checkAndPrompt = async (manual: boolean): Promise<void> => {
-    const state = await updates.check()
-    if (state.phase === 'error') {
-      if (manual) {
-        await dialog.showMessageBox({
-          type: 'error',
-          title: messages.updateCheckFailedTitle,
-          message: state.message ?? messages.unknownError,
-        })
-      }
-      return
+  let promptOperation: Promise<void> | undefined
+  let policyAuthenticationQueued = false
+  const openUpdatePrompt = (manual = false): Promise<void> => {
+    if (authenticationOperation !== undefined) {
+      policyAuth?.focus(); updateDialog.focus()
     }
-    if (state.phase !== 'available') {
+    promptOperation ??= Promise.resolve().then(async () => {
+      if (manual) updateJournal?.action('check-requested')
+      const joinedPolicyAuthentication = authenticationOperation !== undefined
+      if (joinedPolicyAuthentication) await authenticatePolicy()
+      if (isMandatory()) {
+        mandatoryUI?.focus()
+        if (manual) await Promise.all([checkPolicyManually(), updateSchedule.check(true)])
+        return
+      }
+      let state = updates.state
+      if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) {
+        const controller = new AbortController()
+        ordinaryDialogs.add(controller)
+        const progress = mainWindow === undefined ? Promise.resolve() : updateDialog.show(mainWindow, { type: 'info', title: messages.updateCheckTitle,
+          message: messages.updateChecking, buttons: [messages.later], cancelId: 0, signal: controller.signal })
+        try {
+          if (!joinedPolicyAuthentication) {
+            void checkPolicyManually('deferred').catch((error: unknown) => { console.error(error) })
+          }
+          state = await updateSchedule.check(true)
+        } finally { controller.abort(); ordinaryDialogs.delete(controller); await progress }
+      }
+      if (isMandatory()) { mandatoryUI?.focus(); return }
+      if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return }
+      if (state.phase === 'idle') {
+        await ordinaryMessageBox({ type: 'info', title: messages.updateCheckTitle,
+          message: formatDesktopMessage(messages.updateCurrent, { version: app.getVersion() }) })
+        return
+      }
+      if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) {
+        if (state.version !== undefined) await showUpdateFailure(await updates.install(state.version))
+        return
+      }
+      if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return
       if (manual) {
-        await dialog.showMessageBox({
-          type: 'info',
-          title: messages.updateCheckTitle,
-          message: state.message ?? messages.updateCurrent,
-        })
+        const result = await ordinaryMessageBox({ title: messages.updateCheckTitle, message: messages.updateAvailable,
+          detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
+          buttons: [messages.updateDownload], cancelId: 1 })
+        if (result.response !== 0) return
+      }
+      if (!isMandatory() && state.version !== undefined) {
+        await showUpdateFailure(await downloadUpdate(state.version))
       }
+    }).catch((error: unknown) => showUpdateFailure({ phase: 'error', message: desktopErrorState(error).message }))
+      .finally(() => { promptOperation = undefined; flushQueuedPolicyAuthentication() })
+    return promptOperation
+  }
+
+  let authenticationOperation: Promise<DesktopPolicyState | undefined> | undefined
+  const authenticatePolicy = () => {
+    if (authenticationOperation !== undefined) { policyAuth?.focus(); updateDialog.focus() }
+    authenticationOperation ??= runPolicyAuthentication().finally(() => { authenticationOperation = undefined })
+    return authenticationOperation
+  }
+  const flushQueuedPolicyAuthentication = (): void => {
+    if (!policyAuthenticationQueued || promptOperation !== undefined || authenticationOperation !== undefined
+      || isMandatory() || quitting) return
+    policyAuthenticationQueued = false
+    void authenticatePolicy().catch((error: unknown) => { console.error(error) })
+  }
+  const queuePolicyAuthentication = (): void => {
+    if (authenticationOperation !== undefined) {
+      policyAuth?.focus(); updateDialog.focus()
       return
     }
-    const result = await dialog.showMessageBox({
-      type: 'info',
-      title: messages.updateTitle,
-      message: messages.updateAvailable,
-      detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
-      buttons: [messages.installAndRestart, messages.later],
-      defaultId: 0,
-      cancelId: 1,
-    })
-    if (result.response !== 0) return
-    const installed = await updates.install()
-    if (installed.phase === 'error') {
-      await dialog.showMessageBox({
-        type: 'error',
-        title: messages.updateFailedTitle,
-        message: installed.message ?? messages.unknownError,
-      })
+    policyAuthenticationQueued = true
+    flushQueuedPolicyAuthentication()
+  }
+  const runPolicyAuthentication = async () => {
+    if (policyAuth === undefined || mandatoryPolicy === undefined || quitting) return undefined
+    const parent = mandatoryUI?.confirmationWindow ?? mainWindow
+    if (parent === undefined) return undefined
+    const consent = await updateDialog.show(parent, { type: 'info', title: messages.policyLoginTitle,
+      message: messages.policyLoginRequired, buttons: [messages.policyLogin, messages.later], cancelId: 1 })
+    if (consent.response !== 0 || isQuitting()) return undefined
+    const outcome = await policyAuth.login()
+    if (isQuitting() || outcome === 'cancelled') return undefined
+    if (outcome === 'failed') {
+      await updateDialog.show(parent, { type: 'error', title: messages.policyLoginTitle,
+        message: messages.policyLoginFailed, buttons: [messages.updateAcknowledge], cancelId: 0 })
+      return undefined
     }
+    // Drain a pre-login request before asking the server to evaluate the new cookies.
+    await mandatoryPolicy.check('login-return')
+    if (isQuitting()) return undefined
+    return mandatoryPolicy.check('login-return', true)
+  }
+
+  const checkPolicyManually = async (authentication: 'immediate' | 'deferred' = 'immediate') => {
+    if (authenticationOperation !== undefined) return authenticatePolicy()
+    const policy = await mandatoryPolicy?.check('manual', true)
+    if (policy?.error !== 'authentication-required') return policy
+    if (authentication === 'immediate') return authenticatePolicy()
+    queuePolicyAuthentication()
+    return policy
+  }
+
+  const automaticCheck = (): void => {
+    if (!quitting) void mandatoryPolicy?.check('foreground-or-resume').catch((error: unknown) => { console.error(error) })
+    if (!quitting) void updateSchedule.check().catch((error: unknown) => { console.error(error) })
   }
+  powerMonitor.on('resume', automaticCheck)
+  app.on('will-quit', () => {
+    updateSchedule.dispose()
+    powerMonitor.off('resume', automaticCheck)
+    updates.dispose()
+  })
 
   const openPluginWindow = (): void => {
+    if (isMandatory()) { mandatoryUI?.focus(); return }
     if (pluginWindow !== undefined && !pluginWindow.isDestroyed()) {
       pluginWindow.focus()
       return
@@ -443,7 +653,7 @@ async function main(): Promise<void> {
         accelerator: 'CmdOrCtrl+,',
         click: openPluginWindow,
       },
-      { label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
+      { label: messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } },
       { type: 'separator' },
       { role: 'quit' },
     ],
@@ -452,6 +662,7 @@ async function main(): Promise<void> {
   const createMainWindow = (): BrowserWindow => {
     const window = createWindow(appPreload, true)
     mainWindow = window
+    window.on('focus', automaticCheck)
     window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
     window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => {
       if (isMainFrame && code !== -3 && !quitting && !window.isDestroyed()) {
@@ -470,6 +681,8 @@ async function main(): Promise<void> {
     return window
   }
   focusPrimaryWindow = () => {
+    if (quitting) return
+    if (isMandatory()) { mandatoryUI?.focus(); return }
     const window = mainWindow
     if (window === undefined || window.isDestroyed()) {
       try { createMainWindow() } catch (error) { reportFatal(error); return }
@@ -489,13 +702,65 @@ async function main(): Promise<void> {
   })
   app.on('before-quit', (event) => {
     shuttingDown = true
+    updateJournal?.action('quit-requested')
     if (shellInstallerOwnsQuit || quitting) return
     event.preventDefault()
     quitting = true
-    void backend.close().catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
+    mainWindow?.hide()
+    updateSchedule.dispose()
+    updateDialog.dispose()
+    mandatoryUI?.dispose()
+    void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close()])
+      .catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
   })
 
   mainWindow = createMainWindow()
+  const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8'))
+  if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop policy: invalid application manifest')
+  const developmentPolicy = app.isPackaged ? undefined : process.env.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG
+  const policyInput: unknown = app.isPackaged
+    ? ('dshMandatoryUpdatePolicy' in manifest ? manifest.dshMandatoryUpdatePolicy : undefined)
+    : developmentPolicy === undefined ? undefined : JSON.parse(developmentPolicy) as unknown
+  const policyConfig = resolveDesktopPolicyConfig(policyInput, !app.isPackaged)
+  if (policyConfig !== undefined) {
+    if (policyConfig.authentication === 'feishu-test') {
+      policyAuth = new DesktopPolicyTestAuth(policyConfig.origin, locale, () => mandatoryUI?.confirmationWindow ?? mainWindow,
+        (event) => { console.info(`desktop policy authentication: ${event}`); updateJournal?.action(`policy-login-${event}`) })
+    }
+    const bundleId = app.isPackaged
+      ? ('dshDesktopAppId' in manifest ? manifest.dshDesktopAppId : undefined)
+      : process.env.DSH_DESKTOP_APP_ID
+    if (typeof bundleId !== 'string' || bundleId.trim() === '') throw new Error('desktop policy: missing application bundle ID')
+    if (!['win32', 'darwin'].includes(process.platform) || !['x64', 'arm64'].includes(process.arch)) throw new Error('desktop policy: unsupported platform')
+    let wasBlocking = false
+    mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, {
+      platform: process.platform === 'win32' ? 'desktop-win' : 'desktop-mac', arch: process.arch as 'x64' | 'arm64',
+      version: app.getVersion(), bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(),
+      bundleId, locale: locale.id,
+    }, (state) => {
+      if (state.error !== 'authentication-required') policyAuthenticationQueued = false
+      if (state.blocking) {
+        for (const controller of ordinaryDialogs) controller.abort()
+        if (!wasBlocking) updateDialog.cancel()
+        pluginWindow?.close()
+      }
+      mandatoryUI?.sync()
+      if (state.blocking && !wasBlocking) void updateSchedule.check(false, true).catch((error: unknown) => { console.error(error) })
+      wasBlocking = state.blocking
+    }, policyAuth?.request)
+    const policy = mandatoryPolicy
+    mandatoryUI = new DesktopMandatoryUpdateWindow({
+      preload: fileURLToPath(new URL('./preload-mandatory.cjs', import.meta.url)), locale,
+      allowedPageOrigins: policyConfig.allowedPageOrigins, parent: () => mainWindow,
+      policy: () => policy.state, update: () => updates.state,
+      refresh: async () => { await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) },
+      download: downloadUpdate, install: version => updates.install(version),
+    })
+    void mandatoryPolicy.check('launch').then((state) => {
+      if (app.isPackaged && state.error === 'authentication-required' && !isQuitting()) queuePolicyAuthentication()
+    }).catch((error: unknown) => { console.error(error) })
+  }
+  automaticCheck()
   await reconcileBackend().catch(() => undefined)
   // Window lifecycle callbacks run while backend startup is pending.
   // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -505,7 +770,6 @@ async function main(): Promise<void> {
     mainWindow.webContents.openDevTools({ mode: 'detach' })
   }
   publishUpdate(updateState)
-  setTimeout(() => { void checkAndPrompt(false) }, 10_000)
 }
 
 const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() })

+ 4 - 0
apps/desktop/src/mandatory-update-ipc.ts

@@ -0,0 +1,4 @@
+/** Dependency-free IPC names shared with the sandboxed mandatory-update preload. */
+export const MANDATORY_IPC = {
+  status: 'dsh-desktop:mandatory-status', state: 'dsh-desktop:mandatory-state', action: 'dsh-desktop:mandatory-action',
+} as const

+ 224 - 0
apps/desktop/src/mandatory-update-policy.ts

@@ -0,0 +1,224 @@
+/** Mandatory-update policy, independent of local business traffic and updater artifacts. */
+
+import { valid } from 'semver'
+
+/** Installed release identity; no field is supplied by a renderer. */
+export interface DesktopPolicyIdentity {
+  readonly platform: 'desktop-win' | 'desktop-mac'
+  readonly version: string
+  readonly bundledDshVersion: string
+  readonly bundleId: string
+  readonly locale: string
+  readonly arch: 'x64' | 'arm64'
+}
+
+/** Validated deployment choices; test authentication is explicitly enabled, never inferred from a redirect. */
+export interface DesktopPolicyConfig {
+  readonly origin: string
+  readonly allowedPageOrigins: readonly string[]
+  readonly intervalMs: number
+  readonly timeoutMs: number
+  readonly maxBackoffMs: number
+  readonly jitter: number
+  readonly authentication: 'anonymous' | 'feishu-test'
+}
+
+/** A known block survives transport and parsing failures, but not a fresh no-force success. */
+export interface DesktopPolicyState {
+  readonly blocking: boolean
+  readonly checking: boolean
+  readonly title?: string
+  readonly detail?: string
+  readonly page?: string
+  readonly error?: 'unavailable' | 'invalid-policy' | 'authentication-required'
+}
+
+function record(value: unknown): Record<string, unknown> | undefined {
+  return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined
+}
+
+function origin(value: unknown, local: boolean): string {
+  if (typeof value !== 'string') throw new Error('desktop policy: origin must be a URL')
+  const url = new URL(value)
+  if (url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== ''
+    || (url.protocol !== 'https:' && !(local && url.protocol === 'http:' && url.hostname === '127.0.0.1'))) {
+    throw new Error('desktop policy: expected an HTTPS origin without credentials, path, query, or fragment')
+  }
+  return url.origin
+}
+
+/**
+ * Resolve deployment JSON without guessing a production service or download destination.
+ * @param input - Parsed configuration with origin and allowedPageOrigins; absent configuration disables policy queries.
+ * @param allowLoopback - Explicit unpackaged/test permission for an HTTP 127.0.0.1 policy origin only.
+ * @returns Validated polling options, or undefined when unconfigured.
+ */
+export function resolveDesktopPolicyConfig(input: unknown, allowLoopback = false): DesktopPolicyConfig | undefined {
+  if (input === undefined) return undefined
+  const value = record(input)
+  if (value === undefined || !Array.isArray(value.allowedPageOrigins) || value.allowedPageOrigins.length === 0) {
+    throw new Error('desktop policy: configure origin and a nonempty allowedPageOrigins list')
+  }
+  const fields = value
+  function duration(key: string, fallback: number): number {
+    const duration = fields[key] ?? fallback
+    if (typeof duration !== 'number' || !Number.isSafeInteger(duration) || duration < 1_000 || duration > 2_147_483_647) {
+      throw new Error(`desktop policy: ${key} must be an integer from 1000 through 2147483647`)
+    }
+    return duration
+  }
+  const intervalMs = duration('intervalMs', 600_000)
+  const maxBackoffMs = duration('maxBackoffMs', 3_600_000)
+  const jitter = value.jitter ?? 0.2
+  const authentication = value.authentication ?? 'anonymous'
+  if (authentication !== 'anonymous' && authentication !== 'feishu-test') {
+    throw new Error('desktop policy: authentication must be anonymous or feishu-test')
+  }
+  if (typeof jitter !== 'number' || !Number.isFinite(jitter) || jitter < 0 || jitter > 1 || maxBackoffMs < intervalMs) {
+    throw new Error('desktop policy: jitter must be in [0, 1] and maxBackoffMs must cover intervalMs')
+  }
+  return {
+    origin: origin(value.origin, authentication === 'anonymous' && allowLoopback),
+    allowedPageOrigins: value.allowedPageOrigins.map(item => origin(item, false)),
+    intervalMs, timeoutMs: duration('timeoutMs', 15_000), maxBackoffMs, jitter, authentication,
+  }
+}
+
+/**
+ * Validate the fallback page immediately before browser or clipboard use.
+ * @param value - Policy-provided page, never an updater feed or shell command.
+ * @param allowedOrigins - Exact HTTPS origins from deployment configuration.
+ * @returns Normalized allowed URL, or undefined for a missing/disallowed destination.
+ */
+export function desktopPolicyPage(value: unknown, allowedOrigins: readonly string[]): string | undefined {
+  if (typeof value !== 'string' || value.length > 2048) return undefined
+  let url: URL
+  try { url = new URL(value) } catch { return undefined }
+  return url.protocol === 'https:' && url.username === '' && url.password === '' && allowedOrigins.includes(url.origin)
+    ? url.href : undefined
+}
+
+function text(value: unknown, limit: number): string | undefined {
+  return typeof value === 'string' && value.trim() !== '' && value.length <= limit ? value : undefined
+}
+
+function parsePolicy(body: unknown, ok: boolean, config: DesktopPolicyConfig): DesktopPolicyState {
+  const root = record(body)
+  const data = record(root?.data)
+  if (root?.code === 40005) {
+    const content = record(data?.show_content)
+    const title = text(content?.title, 256)
+    const detail = text(content?.detail, 16_384)
+    const page = desktopPolicyPage(data?.desktop_app_link, config.allowedPageOrigins)
+    return { blocking: true, checking: false,
+      ...(title === undefined ? {} : { title }), ...(detail === undefined ? {} : { detail }), ...(page === undefined ? {} : { page }),
+      ...(title === undefined || detail === undefined || page === undefined ? { error: 'invalid-policy' as const } : {}) }
+  }
+  if (ok && root?.code === 0 && data?.biz_code === 0 && data.biz_data === null) return { blocking: false, checking: false }
+  throw new Error('desktop policy: response does not contain a valid mandatory or no-force decision')
+}
+
+/** Owns one immutable installed-client context, its in-flight request, and polling schedule. */
+export class DesktopMandatoryUpdatePolicy {
+  private current: DesktopPolicyState = { blocking: false, checking: false }
+  private pending: Promise<DesktopPolicyState> | undefined
+  private controller: AbortController | undefined
+  private timer: ReturnType<typeof setTimeout> | undefined
+  private disposed = false
+  private failures = 0
+  private nextCheck = -Infinity
+  private readonly headers: Readonly<Record<string, string>>
+
+  /**
+   * @param config - Resolved deployment settings.
+   * @param identity - Installed software identity, copied once for this lifetime.
+   * @param publish - Receives policy changes without controlling downloads or existing tasks.
+   * @param request - Anonymous Fetch or the dedicated test-authentication Session transport.
+   * @param random - Jitter source, replaceable for clock-driven tests.
+   */
+  constructor(
+    private readonly config: DesktopPolicyConfig,
+    identity: DesktopPolicyIdentity,
+    private readonly publish: (state: DesktopPolicyState) => void,
+    private readonly request: typeof fetch = fetch,
+    private readonly random: () => number = Math.random,
+  ) {
+    if (valid(identity.version) === null || valid(identity.bundledDshVersion) === null || identity.bundleId.trim() === ''
+      || (identity.platform === 'desktop-win' && identity.arch !== 'x64')) throw new Error('desktop policy: invalid installed client identity')
+    this.headers = Object.freeze({
+      'x-client-platform': identity.platform, 'x-client-version': identity.version,
+      'x-client-bundle-id': identity.bundleId, 'x-client-locale': identity.locale,
+      'x-client-arch': identity.arch, 'x-client-update-channel': 'nightly',
+      'x-client-bundled-dsh-version': identity.bundledDshVersion,
+    })
+  }
+
+  /** Latest policy; failures never erase a known mandatory decision. */
+  get state(): DesktopPolicyState { return this.current }
+
+  /**
+   * Check immediately when manual, otherwise only when due; matching concurrent callers share one request.
+   * @param scenario - Trigger recorded in the query, independent of backend matching.
+   * @param manual - Bypass interval/backoff without bypassing request coalescing.
+   * @returns Current decision or retained decision with an error; disposed instances reject.
+   */
+  check(scenario: string, manual = false): Promise<DesktopPolicyState> {
+    if (this.disposed) return Promise.reject(new Error('desktop policy: disposed'))
+    if (this.pending !== undefined) return this.pending
+    if (!manual && Date.now() < this.nextCheck) return Promise.resolve(this.current)
+    clearTimeout(this.timer)
+    this.pending = Promise.resolve().then(async () => {
+      if (this.disposed) return this.current
+      const controller = new AbortController()
+      this.controller = controller
+      const timeout = setTimeout(() => { controller.abort() }, this.config.timeoutMs)
+      this.setState({ ...this.current, checking: true })
+      try {
+        const url = new URL('/api/v0/check_client_update', this.config.origin)
+        url.searchParams.set('scenario', scenario)
+        const response = await this.request(url, { headers: this.headers, signal: controller.signal,
+          credentials: this.config.authentication === 'feishu-test' ? 'include' : 'omit', cache: 'no-store', redirect: 'error' })
+        const body: unknown = await response.json()
+        if (this.config.authentication === 'feishu-test' && response.status === 401
+          && record(record(body)?.error)?.code === 'UNAUTHENTICATED') {
+          this.failures++
+          this.setState({ ...this.current, checking: false, error: 'authentication-required' })
+          return this.current
+        }
+        const state = parsePolicy(body, response.ok, this.config)
+        this.failures = 0
+        this.setState(state)
+      } catch {
+        // Transport, JSON, and protocol failures retain any previously established block.
+        this.failures++
+        this.setState({ ...this.current, checking: false, error: 'unavailable' })
+      } finally {
+        clearTimeout(timeout)
+        this.controller = undefined
+      }
+      return this.current
+    }).finally(() => {
+      this.pending = undefined
+      if (this.disposed) return
+      const base = Math.min(this.config.maxBackoffMs, this.config.intervalMs * 2 ** Math.min(this.failures, 20))
+      const delay = Math.min(this.config.maxBackoffMs, Math.round(base * (1 + this.random() * this.config.jitter)))
+      this.nextCheck = Date.now() + delay
+      this.timer = setTimeout(() => { void this.check('periodic') }, delay)
+    })
+    return this.pending
+  }
+
+  /** Abort the owned request and await settlement; late responses cannot publish or schedule work. */
+  async dispose(): Promise<void> {
+    this.disposed = true
+    clearTimeout(this.timer)
+    this.controller?.abort()
+    await this.pending
+  }
+
+  private setState(state: DesktopPolicyState): void {
+    if (this.disposed) return
+    this.current = state
+    this.publish(state)
+  }
+}

+ 251 - 0
apps/desktop/src/mandatory-update-window.ts

@@ -0,0 +1,251 @@
+/** Shell-owned modal policy UI; only explicit actions authorize downloads or browser navigation. */
+
+import { BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
+import type { DesktopLocale } from './locale.ts'
+import type { DesktopUpdateState } from './ipc.ts'
+import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts'
+import { MANDATORY_IPC } from './mandatory-update-ipc.ts'
+import { createUpdateOverlay } from './update-overlay.ts'
+import { DesktopUpdateAttention } from './update-attention.ts'
+
+/** A renderer action never carries a URL or authorizes a different version. */
+export type MandatoryUpdateAction = 'refresh' | 'download' | 'install' | 'later' | 'page' | 'copy'
+
+/** Combined view rendered as text by the shell-owned page. */
+export interface MandatoryUpdateView {
+  readonly locale: DesktopLocale
+  readonly policy: DesktopPolicyState
+  readonly update: DesktopUpdateState
+  readonly error?: string
+  readonly confirmation?: { readonly version: string; readonly active: boolean; readonly revision: number }
+  readonly deferred: boolean
+  readonly restart?: 'stopping-tasks' | 'preparing'
+  readonly navigation?: { readonly page: 'requested' | 'failed'; readonly copy?: 'copied' | 'failed' }
+}
+
+/** Narrow isolated bridge, absent from product and plugin documents. */
+export interface MandatoryUpdateApi {
+  status(): Promise<MandatoryUpdateView>
+  action(action: MandatoryUpdateAction, version?: string, confirmationRevision?: number): Promise<void>
+  subscribe(listener: (state: MandatoryUpdateView) => void): () => void
+}
+
+/** Main-process operations owned by the policy client, updater, and application lifecycle. */
+export interface MandatoryUpdateWindowOptions {
+  readonly preload: string
+  readonly locale: DesktopLocale
+  readonly allowedPageOrigins: readonly string[]
+  readonly parent: () => BrowserWindow | undefined
+  readonly policy: () => DesktopPolicyState
+  readonly update: () => DesktopUpdateState
+  readonly refresh: () => Promise<void>
+  readonly download: (version: string) => Promise<DesktopUpdateState>
+  readonly install: (version: string) => Promise<DesktopUpdateState>
+}
+
+const page = 'dsh-app://shell/mandatory-update.html'
+
+/** A modal child blocks the product window without cancelling work in the Host. */
+export class DesktopMandatoryUpdateWindow {
+  private window: BrowserWindow | undefined
+  private disposed = false
+  private error: string | undefined
+  private action: Promise<void> | undefined
+  private confirmation: { version: string; active: boolean; revision: number; resolve: (approved: boolean) => void } | undefined
+  private confirmationRevision = 0
+  private deferred = false
+  private restart: MandatoryUpdateView['restart']
+  private navigation: MandatoryUpdateView['navigation']
+  private navigationUrl: string | undefined
+  private navigationEpoch = 0
+  private readonly attention: DesktopUpdateAttention
+
+  /** @param options - Main-process actions and immutable deployment/navigation settings. */
+  constructor(private readonly options: MandatoryUpdateWindowOptions) {
+    this.attention = new DesktopUpdateAttention(options.locale)
+    ipcMain.handle(MANDATORY_IPC.status, (event) => { this.assertSender(event); return this.view() })
+    ipcMain.handle(MANDATORY_IPC.action, (event, action: unknown, version: unknown, confirmationRevision: unknown) => {
+      this.assertSender(event)
+      if (!this.options.policy().blocking) throw new Error('desktop policy: no mandatory decision is active')
+      if (typeof action !== 'string' || !['refresh', 'download', 'install', 'later', 'page', 'copy'].includes(action)) throw new Error('desktop policy: invalid action')
+      if (['download', 'install', 'later'].includes(action) && typeof version !== 'string') throw new Error('desktop policy: missing confirmed version')
+      if (action === 'page' || action === 'copy') return this.navigate(action)
+      if (this.confirmation !== undefined && (action === 'install' || action === 'later')) {
+        if (version !== this.confirmation.version || confirmationRevision !== this.confirmation.revision) {
+          throw new Error('desktop policy: stale installation confirmation')
+        }
+        if (action === 'later' && !this.confirmation.active) throw new Error('desktop policy: no task deferral is offered')
+        this.deferred = action === 'later'
+        this.finishConfirmation(action === 'install')
+        this.sync()
+        return Promise.resolve()
+      }
+      if (action === 'later') throw new Error('desktop policy: no installation confirmation')
+      this.action ??= Promise.resolve().then(async () => {
+        this.error = undefined
+        this.restart = undefined
+        this.deferred = false
+        this.clearNavigation()
+        if (action === 'download') this.attention.reset()
+        this.sync()
+        switch (action) {
+          case 'refresh': await this.options.refresh(); break
+          case 'download': await this.options.download(version as string); break
+          case 'install': await this.options.install(version as string); break
+        }
+      }).catch(() => {
+        this.error = this.options.locale.messages.mandatoryActionFailed
+      }).finally(() => { this.action = undefined; this.sync() })
+      return this.action
+    })
+  }
+
+  /** Active modal used as the owner of shell installation-confirmation dialogs. */
+  get confirmationWindow(): BrowserWindow | undefined { return this.window }
+
+  /**
+   * @param version - Updater-owned target, already downloaded and verified.
+   * @param active - Fresh Host task inspection; unknown state must fail before calling.
+   * @returns Explicit approval from this same modal, or false on deferral, policy clearance, or disposal.
+   */
+  confirm(version: string, active: boolean): Promise<boolean> {
+    if (this.disposed || !this.options.policy().blocking) return Promise.resolve(false)
+    this.finishConfirmation(false)
+    this.deferred = false
+    this.restart = undefined
+    return new Promise((resolve) => {
+      this.confirmation = { version, active, revision: ++this.confirmationRevision, resolve }
+      this.sync()
+      const parent = this.options.parent()
+      if (parent !== undefined && this.window !== undefined) {
+        this.attention.ready(version, parent, this.window, () => {
+          if (!this.disposed && this.options.policy().blocking && this.confirmation !== undefined) this.focus()
+        })
+      } else this.finishConfirmation(false)
+    })
+  }
+
+  /** @param active - Whether admitted tasks are actually being stopped after installation approval. */
+  preparingRestart(active: boolean): void {
+    this.restart = active ? 'stopping-tasks' : 'preparing'
+    this.attention.clear()
+    this.sync()
+  }
+
+  /** Publish current status, create the block immediately, or close it only after policy clearance. */
+  sync(): void {
+    if (this.disposed) return
+    if (!this.options.policy().blocking) {
+      this.finishConfirmation(false)
+      this.attention.reset()
+      this.clearNavigation()
+      this.restart = undefined
+      this.deferred = false
+      this.window?.destroy()
+      this.window = undefined
+      this.error = undefined
+      return
+    }
+    if (this.navigationUrl !== this.options.policy().page) this.clearNavigation()
+    if (this.options.update().phase === 'error') this.restart = undefined
+    if (this.window === undefined) {
+      const parent = this.options.parent()
+      if (parent === undefined) return
+      const window = createUpdateOverlay(parent, this.options.preload, this.options.locale.messages.mandatoryTitle)
+      this.window = window
+      window.setMenu(null)
+      window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); window.focus() } })
+      window.on('closed', () => { if (this.window === window) this.window = undefined })
+      window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
+      window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
+      window.webContents.on('render-process-gone', () => {
+        if (!this.disposed) void window.loadURL(page).catch(() => {
+          // A failed recovery keeps the parent blocked and leaves application exit available.
+          if (!window.isDestroyed()) window.setTitle(this.options.locale.messages.mandatoryActionFailed)
+        })
+      })
+      void window.loadURL(page).catch(() => {
+        // The parent stays modal-blocked if its dedicated recovery document cannot load.
+        if (!window.isDestroyed()) window.setTitle(this.options.locale.messages.mandatoryActionFailed)
+      })
+    }
+    this.window.webContents.send(MANDATORY_IPC.state, this.view())
+  }
+
+  /** Focus the block instead of opening ordinary product or plugin interactions. */
+  focus(): void {
+    this.sync()
+    const parent = this.options.parent()
+    if (parent?.isMinimized()) parent.restore()
+    parent?.show()
+    this.window?.show()
+    this.window?.focus()
+  }
+
+  /** Detach IPC and release the modal during application shutdown. */
+  dispose(): void {
+    this.disposed = true
+    this.finishConfirmation(false)
+    this.attention.reset()
+    this.clearNavigation()
+    ipcMain.removeHandler(MANDATORY_IPC.status)
+    ipcMain.removeHandler(MANDATORY_IPC.action)
+    this.window?.destroy()
+    this.window = undefined
+  }
+
+  private view(): MandatoryUpdateView {
+    return { locale: this.options.locale, policy: this.options.policy(), update: this.options.update(),
+      deferred: this.deferred,
+      ...(this.confirmation === undefined ? {}
+        : { confirmation: { version: this.confirmation.version, active: this.confirmation.active, revision: this.confirmation.revision } }),
+      ...(this.restart === undefined ? {} : { restart: this.restart }),
+      ...(this.navigation === undefined ? {} : { navigation: this.navigation }),
+      ...(this.error === undefined ? {} : { error: this.error }) }
+  }
+
+  private finishConfirmation(approved: boolean): void {
+    const confirmation = this.confirmation
+    this.confirmation = undefined
+    this.attention.clear()
+    confirmation?.resolve(approved)
+  }
+
+  private clearNavigation(): void {
+    this.navigation = undefined
+    this.navigationUrl = undefined
+    this.navigationEpoch++
+  }
+
+  private async navigate(action: 'page' | 'copy'): Promise<void> {
+    const url = desktopPolicyPage(this.options.policy().page, this.options.allowedPageOrigins)
+    if (url === undefined) throw new Error('desktop policy: no allowed download page')
+    if (this.navigationUrl !== url) this.clearNavigation()
+    this.navigationUrl = url
+    if (action === 'page') {
+      this.navigation = { page: 'requested' }
+      this.navigationEpoch++
+    }
+    const epoch = this.navigationEpoch
+    this.sync()
+    try {
+      if (action === 'copy') {
+        await clipboard.writeText(url)
+        if (await clipboard.readText() !== url) throw new Error('desktop policy: clipboard did not retain download address')
+      }
+      else await shell.openExternal(url)
+      if (epoch !== this.navigationEpoch || this.disposed) return
+      if (action === 'copy') this.navigation = { page: this.navigation?.page ?? 'requested', copy: 'copied' }
+    } catch {
+      if (epoch !== this.navigationEpoch || this.disposed) return
+      this.navigation = action === 'page' ? { ...this.navigation, page: 'failed' }
+        : { page: this.navigation?.page ?? 'requested', copy: 'failed' }
+    }
+    this.sync()
+  }
+
+  private assertSender(event: IpcMainInvokeEvent): void {
+    if (event.sender !== this.window?.webContents || event.senderFrame !== this.window.webContents.mainFrame
+      || event.senderFrame.url !== page) throw new Error('desktop policy: rejected unowned renderer')
+  }
+}

Неке датотеке нису приказане због велике количине промена