瀏覽代碼

Merge master after code block preview rollback

Dudu-0223 5 天之前
父節點
當前提交
953bdc9854
共有 100 個文件被更改,包括 3668 次插入498 次删除
  1. 3 3
      .agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.i18n.yaml
  2. 43 0
      .agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.md
  3. 43 0
      .agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.zh.md
  4. 3 3
      .agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.i18n.yaml
  5. 37 0
      .agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md
  6. 37 0
      .agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.zh.md
  7. 3 3
      .agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml
  8. 35 0
      .agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md
  9. 35 0
      .agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md
  10. 6 0
      .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml
  11. 23 0
      .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md
  12. 23 0
      .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md
  13. 6 0
      .agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.i18n.yaml
  14. 23 0
      .agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.md
  15. 23 0
      .agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.zh.md
  16. 0 47
      .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md
  17. 0 47
      .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md
  18. 0 57
      .agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md
  19. 0 57
      .agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md
  20. 6 0
      .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml
  21. 67 0
      .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md
  22. 67 0
      .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md
  23. 2 2
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml
  24. 4 0
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md
  25. 4 0
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md
  26. 2 2
      .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml
  27. 3 3
      .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md
  28. 3 3
      .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md
  29. 6 0
      .agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.i18n.yaml
  30. 29 0
      .agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md
  31. 29 0
      .agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md
  32. 0 29
      .agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md
  33. 0 29
      .agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.zh.md
  34. 6 0
      .agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.i18n.yaml
  35. 51 0
      .agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md
  36. 51 0
      .agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md
  37. 6 0
      .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.i18n.yaml
  38. 29 0
      .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md
  39. 29 0
      .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.zh.md
  40. 6 0
      .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml
  41. 49 0
      .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md
  42. 49 0
      .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md
  43. 6 0
      .agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml
  44. 148 0
      .agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md
  45. 148 0
      .agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md
  46. 6 0
      .agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml
  47. 113 0
      .agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md
  48. 113 0
      .agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md
  49. 6 0
      .agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.i18n.yaml
  50. 59 0
      .agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md
  51. 59 0
      .agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md
  52. 1 1
      .github/review-ownership/check-approval.mjs
  53. 1 1
      .github/review-ownership/check-approval.test.mjs
  54. 1 0
      .gitignore
  55. 2 5
      THIRD_PARTY_NOTICES.md
  56. 2 1
      apps/cli/tests/desktop-host.e2e.ts
  57. 3 1
      apps/desktop-host/package.json
  58. 27 4
      apps/desktop-host/src/index.ts
  59. 51 0
      apps/desktop-host/src/update-tasks.ts
  60. 3 0
      apps/desktop-host/tsconfig.json
  61. 18 8
      apps/desktop/.env.macos.example
  62. 8 0
      apps/desktop/.env.windows.example
  63. 2 2
      apps/desktop/README.i18n.yaml
  64. 33 8
      apps/desktop/README.md
  65. 34 8
      apps/desktop/README.zh.md
  66. 16 1
      apps/desktop/electron-builder.config.d.mts
  67. 3 151
      apps/desktop/electron-builder.config.mjs
  68. 3 0
      apps/desktop/installer/pages.nsh
  69. 13 0
      apps/desktop/installer/window-frame.cpp
  70. 3 1
      apps/desktop/package.json
  71. 12 0
      apps/desktop/renderer/mandatory-update.css
  72. 37 0
      apps/desktop/renderer/mandatory-update.html
  73. 112 0
      apps/desktop/renderer/mandatory-update.js
  74. 48 0
      apps/desktop/renderer/policy-login-loading.html
  75. 8 0
      apps/desktop/renderer/update-close.svg
  76. 26 0
      apps/desktop/renderer/update-dialog.css
  77. 22 0
      apps/desktop/renderer/update-dialog.html
  78. 42 0
      apps/desktop/renderer/update-dialog.js
  79. 29 0
      apps/desktop/scripts/build-installed-update-worker.mjs
  80. 44 0
      apps/desktop/scripts/cos-operation.ts
  81. 3 5
      apps/desktop/scripts/desktop-auto-update-environment.mjs
  82. 2 1
      apps/desktop/scripts/desktop-build-paths.mjs
  83. 53 0
      apps/desktop/scripts/desktop-cos.ts
  84. 9 3
      apps/desktop/scripts/desktop-package-environment.mjs
  85. 14 0
      apps/desktop/scripts/desktop-policy-environment.d.mts
  86. 36 0
      apps/desktop/scripts/desktop-policy-environment.mjs
  87. 26 12
      apps/desktop/scripts/desktop-upload-plan.ts
  88. 126 0
      apps/desktop/scripts/desktop-upload-run.ts
  89. 2 0
      apps/desktop/scripts/electron-builder-config.d.mts
  90. 204 0
      apps/desktop/scripts/electron-builder-config.mjs
  91. 39 0
      apps/desktop/scripts/installed-update-builder.ts
  92. 112 0
      apps/desktop/scripts/installed-update-cos.ts
  93. 74 0
      apps/desktop/scripts/installed-update-distribution.ts
  94. 7 0
      apps/desktop/scripts/installed-update-identity.d.mts
  95. 27 0
      apps/desktop/scripts/installed-update-identity.mjs
  96. 89 0
      apps/desktop/scripts/installed-update-network.ps1
  97. 125 0
      apps/desktop/scripts/installed-update-package-content.ts
  98. 122 0
      apps/desktop/scripts/installed-update-packaging.ts
  99. 217 0
      apps/desktop/scripts/installed-update-publication.ts
  100. 278 0
      apps/desktop/scripts/installed-update-qualification.ts

+ 3 - 3
.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.i18n.yaml → .agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.i18n.yaml

@@ -1,6 +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-10-codeblock-preview-interaction.md
-2026-09-10-codeblock-preview-interaction.md: 678daa6544f92ab5216251658f9e9471eae4cf10
-2026-09-10-codeblock-preview-interaction.zh.md: afd3a3e2416765b3f71832ddd867c83bf90d8e81
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.md
+2026-09-16-desktop-cos-upload-transport.md: 84504b32fd02d1fbaf8276b4f1baf7d85f7dcbd5
+2026-09-16-desktop-cos-upload-transport.zh.md: 2a2080f95b39ab433b4ccb3bec60e5ba61f52dc9

+ 43 - 0
.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.md

@@ -0,0 +1,43 @@
+# Agent Note: Upload Desktop releases through the Tencent COS SDK
+
+Status: implemented
+
+English | [中文](2026-09-16-desktop-cos-upload-transport.zh.md)
+
+## Problem
+
+Release objects were uploaded with the AWS S3 client pointed at the Tencent COS endpoint. That client's default checksum configuration can send a streamed request trailer under `Content-Encoding: aws-chunked`, a marker S3 removes before storing but the compatibility path can retain as object metadata. A user-visible application download failed with an HTTP/2 `RST_STREAM` immediately after response headers while an adjacent download on the same connection completed, and objects reported to carry the marker failed segmented downloads. Neither observation identifies an exclusive cause, but the upload transport is the part this repository owns, and a CDN serving metadata no origin request produced is a defect worth removing before chasing it further.
+
+## Decision
+
+[desktop-cos.ts](../../../../apps/desktop/scripts/desktop-cos.ts) builds every Desktop COS client from the official `cos-nodejs-sdk-v5` package: HTTPS, no keep-alive, no redirect following, no backup-host switching, no clock-offset correction, and a 900-second inactivity timeout. [upload-target.ts](../../../../apps/desktop/scripts/upload-target.ts) and [installed-update-cos.ts](../../../../apps/desktop/scripts/installed-update-cos.ts) obtain their client from that factory, and `@aws-sdk/client-s3` is no longer a Desktop dependency.
+
+Every uploaded object — binary, blockmap, and YAML feed, including the small string manifests — travels as one `putObject` whose body is a stream carrying an explicit `ContentLength` and a precomputed `Content-MD5`. The stream is what makes the write unrepeatable: the SDK repeats a request only while the body lacks `pipe`, so a streamed PUT is attempted once and neither uploader adds a retry of its own. The SDK also injects an empty `Cache-Control` header when the caller names none; the factory removes that header so the release uploader still leaves cache policy to deployment infrastructure.
+
+The qualification transport keeps the same store interface and namespace check as before. It reads objects through `getObject` with a `Writable` output that hashes received bytes, so an object is never held in memory, and a confirmed `NoSuchKey` is the only absence result; every other status, and a transfer that ends before its declared length, fails the operation.
+
+## Testing
+
+[cos-loopback.ts](../../../../apps/desktop/tests/cos-loopback.ts) redirects a real SDK instance's `before-send` URL to a per-test loopback origin that records the received bytes, so the transport's serialization — not a mock of it — is what the tests observe. [desktop-upload-run.spec.ts](../../../../apps/desktop/tests/desktop-upload-run.spec.ts) and [installed-update-cos.spec.ts](../../../../apps/desktop/tests/installed-update-cos.spec.ts) assert exact body bytes, `Content-Length`, `Content-MD5`, the absence of transfer and content encodings, the signed `x-cos-forbid-overwrite` header, one request per object on an HTTP 500 and on a dropped connection, read 404/403/truncation handling, and that retained records exclude credentials and raw server messages.
+
+[cos-operation.spec.ts](../../../../apps/desktop/tests/cos-operation.spec.ts) verifies total deadlines across retries, active response streams, cancellation of unconfirmed PUTs, and closure without interfering with another operation. Virtual deadline timers are combined with real socket and stream observations.
+
+## Alternatives considered
+
+**Keep the S3 client with `requestChecksumCalculation: WHEN_REQUIRED` on both paths.** The qualification transport already used that setting, and it suppresses the trailer. It still writes through a compatibility layer for a product whose vendor ships a supported client, and the release path would depend on a configuration staying correct rather than on an encoding never being produced. The official SDK removes the layer instead of tuning it.
+
+**Send a buffer body for the small YAML manifests.** A buffer is cheaper for a few hundred bytes and needs no stream handling. It also re-enables the SDK's fixed four-attempt retry, so an uncertain feed write could silently repeat a mutable object. Uniform streamed PUTs keep one write path with one guarantee.
+
+**Use the SDK's `uploadFile` queue or multipart upload.** Those paths parallelize large objects and add progress reporting. They also split one object into parts with per-part retries and upload state, widening the repeat surface for an artifact that must be written exactly once. The release uploader sends one request per object.
+
+**Replace only the release uploader and leave the qualification transport on S3.** The qualification transport writes to the same bucket family, and leaving it behind would keep the compatibility path alive for the objects most likely to be compared against production behavior.
+
+## Consequences
+
+Object writes cannot repeat, which is the property the incident needed, and the transport no longer depends on S3-compatibility behavior. In exchange, each object is one request: a large installer is not parallelized and has no progress reporting, matching the single-request behavior it replaces. Qualification version queries share a 30-second total deadline across all SDK attempts; object reads and PUTs have a 15-minute total deadline. Each operation owns its client and abort signal. [cos-operation.ts](../../../../apps/desktop/scripts/cos-operation.ts) passes that signal to native HTTP requests through the SDK transport and waits for their close events before returning, including on timeout. Continuous response data does not extend the deadline, and an expired signal prevents later retries from opening connections. The release uploader retains its separate inactivity timeout.
+
+The SDK's dependency tree is a fork of the retired `request` package, pulling in older `http-signature`, `tough-cookie`, and `form-data` releases; the lockfile supply-chain check accepts it, and the alternative was hand-writing COS request signing.
+
+Existing objects that already carry the retained encoding marker, and their cached copies, are unaffected by this change. Removing or re-uploading them, and any CDN cache purge, remain separate operator actions; this note records no cloud operation.
+
+The release decision that an uncertain write must stay one inspectable attempt lives in the [packaging and updates decision](2026-08-25-electron-desktop-packaging-and-updates.md); this note changes the transport that implements it.

+ 43 - 0
.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: 通过腾讯 COS SDK 上传 Desktop 发布产物
+
+Status: implemented
+
+[English](2026-09-16-desktop-cos-upload-transport.md) | 中文
+
+## 问题
+
+发布对象此前通过指向腾讯 COS 端点的 AWS S3 客户端上传。该客户端默认的校验和配置可以用 `Content-Encoding: aws-chunked` 发送流式请求尾帧;S3 在存储前会移除该标记,而兼容路径可能把它保留为对象元数据。一次面向用户的应用下载在收到响应头后立即以 HTTP/2 `RST_STREAM` 失败,而同一连接上的相邻下载正常完成;被报告带有该标记的对象分段下载失败。这两项观察都不能证明唯一原因,但上传传输是本仓库自己拥有的部分,而 CDN 提供任何源请求都未产生的元数据本身就是缺陷,值得在继续追查之前先消除。
+
+## 决策
+
+[desktop-cos.ts](../../../../apps/desktop/scripts/desktop-cos.ts)使用官方 `cos-nodejs-sdk-v5` 包构造每个 Desktop COS 客户端:HTTPS、不保持连接、不跟随重定向、不切换备用域名、不做时钟偏移校正,无活动超时为 900 秒。[upload-target.ts](../../../../apps/desktop/scripts/upload-target.ts)与[installed-update-cos.ts](../../../../apps/desktop/scripts/installed-update-cos.ts)都从该工厂获取客户端,`@aws-sdk/client-s3` 不再是 Desktop 依赖。
+
+所有上传对象——安装包、blockmap 和 YAML 清单,包括很小的字符串清单——都以一次 `putObject` 发送,其请求体是流,并携带显式 `ContentLength` 与预先算好的 `Content-MD5`。正是流使写入不可重复:SDK 只在请求体没有 `pipe` 时才重发请求,因此流式 PUT 只会尝试一次,两个上传器自身也不再添加重试。当调用方未指定时,SDK 还会注入空的 `Cache-Control` 头;工厂会移除该头,因此发布上传器仍然把缓存策略留给部署基础设施。
+
+qualification 传输保持原有 store 接口与命名空间检查不变。它通过 `getObject` 配合一个对已接收字节计算哈希的 `Writable` 输出读取对象,因此对象从不整体驻留内存;只有确认的 `NoSuchKey` 表示对象不存在,其他任何状态码,以及早于声明长度结束的传输,都会让操作失败。
+
+## 测试
+
+[cos-loopback.ts](../../../../apps/desktop/tests/cos-loopback.ts)把真实 SDK 实例 `before-send` 中的 URL 重定向到每个测试独立的 loopback 源,并记录实际收到的字节,因此测试观察的是传输的序列化结果,而不是它的 mock。[desktop-upload-run.spec.ts](../../../../apps/desktop/tests/desktop-upload-run.spec.ts)与[installed-update-cos.spec.ts](../../../../apps/desktop/tests/installed-update-cos.spec.ts)断言确切的请求体字节、`Content-Length`、`Content-MD5`、不存在传输编码与内容编码、已签名的 `x-cos-forbid-overwrite` 头、HTTP 500 与连接中断时每个对象只发一次请求、读取 404/403/截断的处理,以及保留记录不包含凭据与原始服务端消息。
+
+[cos-operation.spec.ts](../../../../apps/desktop/tests/cos-operation.spec.ts)验证跨重试的总截止时间、持续返回数据的响应流、未确认 PUT 的取消,以及关闭请求不影响其他操作。测试组合虚拟截止时间计时器与真实 socket 和流观测。
+
+## 考虑过的替代方案
+
+**保留 S3 客户端并在两条路径上都设置 `requestChecksumCalculation: WHEN_REQUIRED`。** qualification 传输原本已使用该设置,它能抑制尾帧。但它仍然通过兼容层写入一个厂商已提供受支持客户端的产品,发布路径也会依赖于某项配置始终正确,而不是依赖编码根本不会产生。官方 SDK 消除的是这一层,而不是调整它。
+
+**对小体积 YAML 清单改用 Buffer 请求体。** 对几百字节而言 Buffer 更省事,也不需要流处理。但它会重新启用 SDK 固定的四次重试,使一次结果不确定的清单写入可能静默重复一个可变对象。统一的流式 PUT 保持单一写入路径和单一保证。
+
+**使用 SDK 的 `uploadFile` 队列或分块上传。** 这些路径可并行处理大对象并提供进度上报。它们也会把单个对象拆成多个分块,带来分块级重试与上传状态,扩大一个必须恰好写入一次的产物的重复面。发布上传器对每个对象只发一次请求。
+
+**只替换发布上传器,qualification 传输继续使用 S3。** qualification 传输写入同一系列 bucket,若把它留下,兼容路径就会继续存在于最可能用于与生产行为对照的对象上。
+
+## 结果
+
+对象写入不可重复,这正是本次故障所需的性质,传输也不再依赖 S3 兼容行为。代价是每个对象只有一次请求:超大安装包不会并行,也没有进度上报,与它替换掉的单请求行为一致。qualification 版本查询的所有 SDK 尝试共享 30 秒总截止时间;对象读取与 PUT 的总截止时间为 15 分钟。每次操作独立拥有客户端与取消信号。[cos-operation.ts](../../../../apps/desktop/scripts/cos-operation.ts)通过 SDK 传输将信号传给底层 HTTP 请求,并在返回前等待请求的关闭事件,超时也不例外。持续返回数据不会延长截止时间,过期信号会阻止后续重试建立连接。发布上传器保留独立的无活动超时。
+
+该 SDK 的依赖树源自已停止维护的 `request` 包,会引入较旧的 `http-signature`、`tough-cookie` 和 `form-data` 版本;lockfile 的供应链检查接受它,替代方案是自行实现 COS 请求签名。
+
+已经带有该保留编码标记的既有对象及其缓存副本不受本次改动影响。删除或重新上传它们,以及任何 CDN 缓存刷新,仍是单独的操作动作;本记录不包含任何云端操作。
+
+「结果不确定的写入必须保持为一次可检查的尝试」这一发布决策由[打包与更新决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责;本记录改变的是实现它的传输。

+ 3 - 3
.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.i18n.yaml → .agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.i18n.yaml

@@ -1,6 +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/simplification/2026-09-14-source-sized-code-block-previews.md
-2026-09-14-source-sized-code-block-previews.md: 66ba419efcf4113886cfddafc4eb9f3ad2583565
-2026-09-14-source-sized-code-block-previews.zh.md: 7e8e96eced72f78f7aff852d993cf32bff22a7e4
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md
+2026-09-16-desktop-policy-login-loading.md: 26fd635c6cc5b92cad8011919382a11497d2e835
+2026-09-16-desktop-policy-login-loading.zh.md: 16de25323afc971b3142d5359c769df50c08800d

+ 37 - 0
.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md

@@ -0,0 +1,37 @@
+# Agent Note: Show a local document while the policy login page loads
+
+Status: implemented
+
+English | [中文](2026-09-16-desktop-policy-login-loading.zh.md)
+
+## Problem
+
+The test-deployment login window ([mandatory-update client](../feature/2026-09-11-desktop-mandatory-update-client.md)) navigated straight to the policy origin. Until that document committed and painted, the window was blank, and the remote page can take seconds to appear. A user pressed "Sign in with Feishu" and saw an empty window with no sign that anything was happening — while the window had to stay closable and must never cover a page that had become interactive.
+
+## Decision
+
+[policy-test-auth.ts](../../../../apps/desktop/src/policy-test-auth.ts) loads [renderer/policy-login-loading.html](../../../../apps/desktop/renderer/policy-login-loading.html) as the window's first document and requests the policy origin only after that document has committed. The placeholder is a packaged, self-contained file whose only text is the label the main process passes through `loadFile`'s query, so the copy stays in the shell locale dictionary (`policyLoginLoading`). The first committed remote document replaces it.
+
+The placeholder never coexists with the remote page, so there is no removal step and no timer: Chromium keeps the placeholder frame until the remote document's first frame is ready, and from then on the third-party page owns the window. A redirect chain keeps the placeholder until the last document commits. The window is created visible and closable as before.
+
+Two places recognize the placeholder's own load by file name and nothing else: the Session's `onBeforeRequest` filter, which cancels document requests outside the policy and Feishu origins, and the `did-fail-load` handler, which fails the login on a main-frame error. The `will-navigate` and `will-redirect` guards still require an allowed HTTPS origin, so the remote page cannot steer the window back to a local file. A placeholder that cannot load is not a login failure: the login request starts anyway and the window is blank exactly as it was before this change.
+
+## Alternatives considered
+
+**Overlay the window with a `WebContentsView` (or `BrowserView`) and remove it when the page is ready.** It would show feedback even earlier and can be removed at any chosen signal, but it needs a second renderer, a position that follows window resizes, and an explicit removal point. Every available removal signal is either late (`did-finish-load` still waits for subresources) or a guess (`dom-ready` can precede the first paint), and a view left in place covers an interactive page.
+
+**Inject an overlay element into the login page with a preload script.** The one approach the decision has to keep out: it puts a shell-owned DOM layer inside a third-party page, and it would give that page a preload bridge the isolated Session deliberately withholds (`webPreferences.preload` is unset).
+
+**Keep the window hidden until the remote page finishes loading.** It removes the blank flash by removing the feedback, and it delays the user's first view of the page until every subresource completes.
+
+**Show a native splash window, or the loading text in the window title.** A second window adds a focus and lifetime problem for a two-second wait, and a title is not visible feedback inside the window.
+
+**Reveal the page with a fixed delay.** A timer cannot distinguish a slow origin from a fast one, and it either covers a ready page or uncovers a blank one.
+
+## Consequences
+
+The login window gives immediate local feedback that cannot depend on the network: the placeholder's CSP sets `default-src 'none'`, so the document has no origin, font, image, or connection to reach. Because the placeholder is the window's document rather than a layer, nothing about it can survive into the third-party page, and a slow subresource on the login page cannot extend it.
+
+The placeholder shares the login Session, so its request passes through the same document filter; the filter's exemption is name-scoped and does not widen the allowed-origin check for navigation. The window title remains the shell-owned login title (the renderer prevents `page-title-updated`), so the placeholder contributes no copy of its own.
+
+[policy-test-auth.spec.ts](../../../../apps/desktop/tests/policy-test-auth.spec.ts) covers the order (placeholder first, remote page only after it settles), the filter's acceptance of the placeholder, a failed placeholder load that must not fail the login, and a window closed while the placeholder is loading that never starts the remote page. [policy-login-loading.spec.ts](../../../../apps/desktop/tests/policy-login-loading.spec.ts) loads the packaged document under jsdom and checks that it renders the label it is given, leaves the label empty without one, and forbids every network source. The placeholder's rendered frames and the first-paint timing of the substituted login page remain unverified against a real login window.

+ 37 - 0
.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.zh.md

@@ -0,0 +1,37 @@
+# Agent Note: 策略登录页加载期间显示本地文档
+
+Status: implemented
+
+[English](2026-09-16-desktop-policy-login-loading.md) | 中文
+
+## 问题
+
+测试部署的登录窗口([强制更新客户端](../feature/2026-09-11-desktop-mandatory-update-client.zh.md))直接导航到策略 origin。在该文档提交并绘制之前,窗口是空白的,而远端页面可能需要数秒才出现。用户点击“通过飞书登录”后只看到一个空窗口,没有任何正在进行的迹象——同时窗口必须保持可关闭,也绝不能遮挡已经可交互的页面。
+
+## 决策
+
+[policy-test-auth.ts](../../../../apps/desktop/src/policy-test-auth.ts)把 [renderer/policy-login-loading.html](../../../../apps/desktop/renderer/policy-login-loading.html) 作为窗口的首个文档载入,并在该文档提交之后才请求策略 origin。占位页是随包发布的、自包含的文件,其中唯一的文本是主进程通过 `loadFile` 的 query 传入的文案,因此文案仍由 shell 本地化字典(`policyLoginLoading`)拥有。首个提交的远端文档会替换它。
+
+占位页从不与远端页面共存,因此没有移除步骤,也没有计时器:Chromium 会一直保留占位帧,直到远端文档的首帧就绪;此后窗口归第三方页面所有。重定向链会把占位页保持到最后一个文档提交。窗口仍与之前一样在创建时即显示且可关闭。
+
+只有两处按文件名识别占位页自身的加载,不识别其他任何东西:Session 的 `onBeforeRequest` 过滤器(它会取消策略与飞书 origin 之外的文档请求)以及 `did-fail-load` 处理器(主框架出错时让登录失败)。`will-navigate` 与 `will-redirect` 守卫仍然要求允许的 HTTPS origin,因此远端页面无法把窗口引回本地文件。占位页无法载入不算登录失败:登录请求照常开始,窗口与本次改动之前一样保持空白。
+
+## 考虑过的替代方案
+
+**用 `WebContentsView`(或 `BrowserView`)覆盖窗口,并在页面就绪时移除。** 它能更早显示反馈,也可以在任选信号处移除,但需要第二个渲染器、一个随窗口尺寸变化的位置,以及一个明确的移除时机。可用的移除信号要么偏晚(`did-finish-load` 仍会等待子资源),要么只是猜测(`dom-ready` 可能早于首帧绘制);而一旦忘记移除,视图就会遮挡可交互的页面。
+
+**通过 preload 脚本向登录页注入覆盖元素。** 这是决策必须排除的做法:它把 shell 拥有的 DOM 层放进第三方页面,并且会给该页面一个隔离 Session 刻意不提供的 preload 桥(`webPreferences.preload` 未设置)。
+
+**在远端页面加载完成前保持窗口隐藏。** 它通过取消反馈来消除空白闪烁,并把用户第一次看到页面的时间推迟到所有子资源完成之后。
+
+**使用原生启动窗口,或把加载文本放进窗口标题。** 为一个两秒的等待增加第二个窗口会带来焦点与生命周期问题,而标题不是窗口内的可见反馈。
+
+**用固定延时揭示页面。** 计时器无法区分慢 origin 与快 origin,结果要么遮挡已就绪的页面,要么在空白页面上取消遮挡。
+
+## 结果
+
+登录窗口给出不依赖网络的即时本地反馈:占位页的 CSP 设置了 `default-src 'none'`,因此该文档没有任何 origin、字体、图片或连接可用。由于占位页是窗口的文档而不是覆盖层,它的任何部分都不会残留到第三方页面中,登录页上缓慢的子资源也无法延长它的存在。
+
+占位页共享登录 Session,因此它的请求会经过同一个文档过滤器;该过滤器的豁免按文件名限定,不会放宽导航所用的允许 origin 检查。窗口标题仍是 shell 拥有的登录标题(渲染器阻止了 `page-title-updated`),因此占位页不贡献自己的文案。
+
+[policy-test-auth.spec.ts](../../../../apps/desktop/tests/policy-test-auth.spec.ts)覆盖顺序(先占位页,只有它稳定后才请求远端页面)、过滤器对占位页的接受、占位页载入失败不得导致登录失败,以及占位页加载期间关闭窗口后绝不启动远端页面。[policy-login-loading.spec.ts](../../../../apps/desktop/tests/policy-login-loading.spec.ts)在 jsdom 下载入随包发布的文档,检查它渲染传入的文案、在没有文案时保持为空,并禁止一切网络来源。占位页的实际渲染帧与替代登录页的首帧时机仍未在真实登录窗口上验证。

+ 3 - 3
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml → .agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml

@@ -1,6 +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-09-markdown-static-previews.md
-2026-09-09-markdown-static-previews.md: cb63b0ccd02abfaf148e61ef452cb714765bfffe
-2026-09-09-markdown-static-previews.zh.md: 7dec68564136d2e9d677c1fd03d8a0b6840cf81b
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md
+2026-09-16-nested-tooltip-suppression.md: 1b06a3a1a4f754aa78e821ee8a1479dab19873ff
+2026-09-16-nested-tooltip-suppression.zh.md: 96063f78b281deb3561045bb73fcffa64a2c1ef8

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md

@@ -0,0 +1,35 @@
+# Agent Note: Suppress an enclosing tooltip while a nested one is shown
+
+Status: implemented
+
+English | [中文](2026-09-16-nested-tooltip-suppression.zh.md)
+
+## Problem
+
+The collapsed sidebar renders its update badge through the `sidebar.toggle.badge` slot inside the expand button, and both the button and the badge attach a `Tooltip`. Hovering the badge showed two bubbles at once — the version notice and "Open sidebar" — because the badge is a DOM descendant of the button: the button's hover state stays true for as long as the pointer rests on the badge, and each tooltip correctly follows its own anchor.
+
+## Decision
+
+[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) provides a suppression setter through an internal `TooltipSuppression` context wrapped around its cloned anchor and its bubble. A nested tooltip consumes the nearest setter and announces whenever its own bubble becomes visible. An enclosing tooltip that receives a suppression claim keeps its hover/focus triggers and its measured position but does not render its bubble, so the enclosing bubble returns in the same commit that the nested one disappears.
+
+The nested tooltip announces synchronously inside `show()` and through a `visible` effect. The synchronous call keeps a nested pair that becomes visible in one React commit from painting both bubbles for a frame; the effect releases the claim on every other path that hides a bubble, including unmount and a `disabled` flip.
+
+No component in `ui-sidebar` or `ui-settings-general` changed: the badge already renders inside the toggle's anchor, so the shared primitive resolves the overlap for any future nested pair as well.
+
+## Alternatives considered
+
+**Disable the toggle tooltip whenever an update exists.** The simplest guard, and wrong: it removes "Open sidebar" from ordinary rail hovers for the whole time an update is available, including hovers that never touch the badge.
+
+**Disable the toggle tooltip while the pointer is over the badge.** Hover-scoped, but `Tooltip` clears its hover and focus triggers when it is disabled, and no new `mouseenter` fires on the button while the pointer moves from the badge onto the button's own area. The enclosing bubble would stay gone until the pointer left and re-entered the button.
+
+**Stop the badge's `mouseenter` from reaching the button.** It only helps a pointer that enters the badge directly; a pointer that enters the button first and then moves onto the badge already has the enclosing bubble visible, because the enclosing hover never ended.
+
+**Render the badge outside the toggle's anchor.** The slot is declared inside the toggle button, and the badge must sit on the button's corner; moving the anchor out of the button would either detach the bubble from the control or change the slot's declared placement.
+
+## Consequences
+
+The rule is a property of tooltip nesting, not of the update badge: any tooltip whose anchor contains another tooltip now yields the bubble to the innermost visible one. A tooltip with no nested tooltip is unaffected — it consumes a null context and never announces.
+
+The enclosing tooltip's bubble is withdrawn only while a descendant is visible; its trigger state survives, so no re-entry is needed to restore it. Suppression does not change what the anchors do: the button still toggles the sidebar and the badge remains a non-interactive marker.
+
+[Tooltip tests](../../../../packages/client/ui-primitives/tests/tooltip.client.spec.tsx) cover withdrawal, restoration when the nested anchor is left for the enclosing one with a real `relatedTarget`, and release when a shown nested tooltip unmounts. The [sidebar shell test](../../../../packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx) covers the product wiring: the rail badge replaces the toggle bubble after the toggle's own hover delay has already elapsed.

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 嵌套提示显示时抑制外层提示
+
+Status: implemented
+
+[English](2026-09-16-nested-tooltip-suppression.md) | 中文
+
+## 问题
+
+侧边栏收起时,更新蓝点通过 `sidebar.toggle.badge` 插槽渲染在展开按钮内部,而按钮和蓝点各自挂了一个 `Tooltip`。hover 蓝点会同时出现两个气泡——版本提示和“打开侧边栏”——因为蓝点是按钮的 DOM 后代:只要指针停在蓝点上,按钮的 hover 状态就保持为真,而两个 tooltip 都在正确地跟随各自的 anchor。
+
+## 决策
+
+[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx)通过内部 `TooltipSuppression` context 提供一个抑制 setter,并把该 context 包在克隆后的 anchor 与气泡外层。嵌套的 tooltip 消费最近的 setter,并在自己的气泡可见时上报。收到抑制声明后,外层 tooltip 保留 hover/focus 触发状态与已测量的位置,但不渲染自己的气泡,因此嵌套气泡消失的同一提交里外层气泡就会回来。
+
+嵌套 tooltip 既在 `show()` 中同步上报,也通过 `visible` effect 上报。同步调用避免在同一 React 提交中同时可见的一对工具提示多绘制一帧的两个气泡;effect 则在其他所有隐藏气泡的路径上释放抑制,包括卸载和 `disabled` 翻转。
+
+`ui-sidebar` 与 `ui-settings-general` 中的组件无需改动:蓝点本就渲染在 toggle 的 anchor 内,因此共享 primitive 也顺带解决了未来任何嵌套组合。
+
+## 考虑过的替代方案
+
+**只要存在更新就禁用 toggle 的 tooltip。** 这是最简单的防护,但也是错的:它会在更新可用的整个期间移除普通 rail hover 的“打开侧边栏”,包括根本不会碰到蓝点的 hover。
+
+**指针位于蓝点上时禁用 toggle 的 tooltip。** 作用域正确,但 `Tooltip` 被禁用时会清空自己的 hover 与 focus 触发状态,而指针从蓝点移到按钮自身区域时按钮不会再触发新的 `mouseenter`。外层气泡会一直消失,直到指针离开按钮并重新进入。
+
+**阻止蓝点的 `mouseenter` 传播到按钮。** 这只能帮助直接从蓝点进入的指针;先进入按钮再移到蓝点上的指针,其外层气泡早已可见,因为外层的 hover 从未结束过。
+
+**把蓝点渲染到 toggle 的 anchor 之外。** 该插槽声明在 toggle 按钮内部,蓝点必须落在按钮角上;把 anchor 移出按钮要么让气泡脱离控件,要么改变插槽声明的放置位置。
+
+## 结果
+
+这条规则是 tooltip 嵌套的性质,而不是更新蓝点的性质:任何 anchor 内含另一个 tooltip 的 tooltip 现在都会把气泡让给最内层可见的那个。没有嵌套 tooltip 的 tooltip 不受影响——它消费到的是 null context,永远不会上报。
+
+外层 tooltip 仅在子级可见期间收回气泡;它的触发状态得以保留,因此不需要重新进入即可恢复。抑制不改变 anchor 的行为:按钮仍然切换侧边栏,蓝点仍然是非交互标记。
+
+[Tooltip 测试](../../../../packages/client/ui-primitives/tests/tooltip.client.spec.tsx)覆盖收回、用真实 `relatedTarget` 从嵌套 anchor 移到外层 anchor 时的恢复,以及已显示的嵌套 tooltip 卸载时的释放。[侧边栏 shell 测试](../../../../packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx)覆盖产品接线:在 toggle 自身的 hover 延迟已经过去之后,rail 蓝点气泡仍替换掉 toggle 气泡。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.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/bug-fix/2026-09-16-movable-mandatory-update-window.md
+2026-09-16-movable-mandatory-update-window.md: 4695074429b76b467e6ec90201632dbb5a007e68
+2026-09-16-movable-mandatory-update-window.zh.md: 3b416c53dc02abce1276bb70a13bb45b4c1afa44

+ 23 - 0
.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md

@@ -0,0 +1,23 @@
+# Agent Note: Movable mandatory-update window
+
+Status: implemented
+
+English | [中文](2026-09-16-movable-mandatory-update-window.zh.md)
+
+## Problem
+
+The Windows mandatory-update page used a frameless modal overlay sized to the product window. The modal disabled its parent, so the parent's native title bar could not be used to move or maximize either window. Closing the overlay was intercepted, leaving no visible exit control.
+
+## Decision
+
+On Windows, mandatory policy uses a separate native framed modal with move, resize, and maximize controls. The parent remains disabled while policy blocks interaction. Closing the modal requests normal application shutdown; it never dismisses policy and resumes the product window. Other platforms retain the existing overlay presentation. The [mandatory-update decision](../feature/2026-09-11-desktop-mandatory-update-client.md) still owns policy and installation authorization.
+
+## Alternatives considered
+
+**Keep the full-content overlay and add a drag region.** A drag region would move the disabled parent indirectly and would not restore native maximize or close controls.
+
+**Let close dismiss the policy page.** That would expose the blocked product window without a fresh no-force policy response.
+
+## Consequences
+
+Windows users can place or maximize the update window and exit the application from its close button. Modal blocking and the second installation approval remain intact. Installer-owned quit disposes the modal before Electron closes windows, so its close guard cannot block installation. The native frame replaces the dimmed full-content overlay on Windows; the ordinary update dialog keeps its existing overlay.

+ 23 - 0
.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md

@@ -0,0 +1,23 @@
+# Agent Note: 可移动的强更窗口
+
+Status: implemented
+
+[English](2026-09-16-movable-mandatory-update-window.md) | 中文
+
+## Problem
+
+Windows 强更页面原先使用与产品窗口等大的无边框模态覆盖层。模态窗口禁用了父窗口,因此无法使用父窗口的原生标题栏移动或最大化窗口。覆盖层的关闭操作又被拦截,界面上没有可用的退出控件。
+
+## Decision
+
+在 Windows 上,强更策略使用独立的原生有边框模态窗口,支持移动、调整大小和最大化。策略阻塞期间父窗口仍不可操作。关闭模态窗口会请求应用正常退出,不会取消策略并恢复产品窗口。其他平台保留现有覆盖层展示。[强更决策](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)仍负责策略与安装授权。
+
+## Alternatives considered
+
+**保留覆盖整个内容区域的窗口并添加拖动区域。** 拖动区域只能间接移动被禁用的父窗口,也无法恢复原生最大化和关闭控件。
+
+**允许关闭时直接取消策略页面。** 这样会在未取得新的无需强更响应时暴露被阻塞的产品窗口。
+
+## Consequences
+
+Windows 用户可以移动或最大化更新窗口,也可通过关闭按钮退出应用。模态阻塞和第二次安装批准保持不变。安装器接管退出时,壳会在 Electron 关闭窗口前释放模态窗口,避免关闭拦截阻止安装。Windows 上的原生边框取代了遮罩整个内容区域的覆盖层;常规更新弹窗仍使用原有覆盖层。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.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/bug-fix/2026-09-16-present-delayed-windows-installer.md
+2026-09-16-present-delayed-windows-installer.md: d4ffcfdc1183bbc414cfdd82595053c959a9975c
+2026-09-16-present-delayed-windows-installer.zh.md: bf97ed8a4f6c7184d1e048c21f7975c90fb4a9cb

+ 23 - 0
.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.md

@@ -0,0 +1,23 @@
+# Agent Note: Present delayed Windows installer
+
+Status: implemented
+
+English | [中文](2026-09-16-present-delayed-windows-installer.zh.md)
+
+## Problem
+
+The native installer hides its window while preparing resources. If the user activates another application during that interval, the welcome page can appear behind that application and seem absent.
+
+## Decision
+
+The first welcome-page display moves the installer above ordinary windows without activating it. When another window owns the foreground, the installer also flashes its taskbar button until foreground interaction. Later page changes do not repeat the move. The window never becomes permanently topmost.
+
+## Alternatives considered
+
+**Force foreground focus.** Windows may reject a background process's foreground request, and taking focus after the user switches applications interrupts their current action.
+
+**Keep the installer topmost.** A persistent topmost window obscures applications the user intentionally opens during installation.
+
+## Consequences
+
+The welcome page becomes visible when preparation completes, while the user retains control of focus and can cover the installer again. A native window-order test and the signed installer smoke test exercise the first display.

+ 23 - 0
.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.zh.md

@@ -0,0 +1,23 @@
+# Agent Note: 延迟显示的 Windows 安装窗口
+
+Status: implemented
+
+[English](2026-09-16-present-delayed-windows-installer.md) | 中文
+
+## Problem
+
+原生安装程序准备资源时会隐藏窗口。如果用户在此期间激活其他应用,欢迎页可能出现在该应用后面,看起来像没有启动。
+
+## Decision
+
+欢迎页首次显示时,安装窗口会移到普通窗口前方,但不抢占焦点。如果其他窗口位于前台,安装程序还会闪烁任务栏按钮,直至用户进行前台交互。后续页面切换不会重复置前。安装窗口不会永久置顶。
+
+## Alternatives considered
+
+**强制获取前台焦点。** Windows 可能拒绝后台进程的前台请求,而且用户切换应用后再抢回焦点会打断当前操作。
+
+**让安装窗口始终置顶。** 持续置顶会遮挡用户在安装期间主动打开的其他应用。
+
+## Consequences
+
+准备完成时欢迎页可见,用户仍能控制焦点,也可以再次用其他窗口覆盖安装程序。原生窗口顺序测试和签名安装程序冒烟测试覆盖首次显示。

+ 0 - 47
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md

@@ -1,47 +0,0 @@
-# Agent Note: Static Markdown fence previews
-
-Status: implemented
-
-English | [中文](2026-09-09-markdown-static-previews.zh.md)
-
-## Problem
-
-Readers need an optional way to inspect Mermaid and DOT diagrams and SVG artwork directly in Assistant replies while retaining code as the primary representation. These sources are untrusted, and a renderer's npm license field may omit compiled components with separate distribution obligations.
-
-## Decision
-
-The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, and `svg` fences through localized `MarkdownLabels.preview` and the parsed fence language. The [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `SourcePreview`, which accepts a renderer, source, and labels without Session, file, or Cordis dependencies. Mermaid and Graphviz each supply a `.ts` renderer to that same component. HTML, consumers without preview labels, and streaming messages retain code.
-
-`CodeBlock.preview` is a standard source-preview descriptor containing the renderer and complete localized output and control labels; callers do not pass React nodes. `MarkdownText` creates one descriptor catalog per preview-label identity, and every settled render reuses its language entries while source text, file mentions, or local-image vocabulary changes. The [CodeBlock interaction decision](2026-09-10-codeblock-preview-interaction.md) supersedes this note's source-first default and release-on-toggle lifetime; it owns the current controls and result retention. The [preview sizing decision](../simplification/2026-09-14-source-sized-code-block-previews.md) owns current geometry. This note retains the independent rendering, security, and distribution decisions.
-
-`SourcePreview` owns pending work, failure, and cancellation of stale result publication. Pending previews show localized status. Source replacement and unmounting cancel publication; cancellation before runtime loading completes skips layout. Failures show a localized error and the original source, and replacing invalid source with valid input recovers the preview.
-
-Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Parsed flowchart nodes are inspected before layout and image nodes are rejected: Mermaid's strict mode still fetches their resources during layout, before SVG image isolation applies. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Intersecting previews and open lightboxes observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT and SVG colors remain intact.
-
-SVG is parsed as XML and displayed with Graphviz output as inert images, so SVG scripts, links, and external resources never become active. A magnifier opens the body-portaled dialog whose viewport fit, pan, and zoom behavior is owned by the [CodeBlock interaction decision](2026-09-10-codeblock-preview-interaction.md).
-
-Graphviz uses the pinned, unmodified `@viz-js/viz` 3.30.0 WebAssembly distribution with the `dot` layout engine. Its npm MIT declaration covers the wrapper; its build attestation identifies Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 (MIT/NCSA). Distribution retains these component terms and the exact Graphviz source download under EPL-2.0 section 3.1. [Full preview notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) also retain Mermaid's MIT text and its DOMPurify dependency's selected Apache-2.0 terms. The UI primitives package ships the notices and the Web build emits the same bytes alongside its assets. License review covers embedded payloads separately from the general permissive npm-metadata policy.
-
-Source highlighting uses the shared lazy Shiki highlighter for both streamed and settled diagram fences. SVG resolves to XML; Mermaid selects the upstream grammar's `#mermaid` body rules because CodeBlock has already removed the Markdown fence. DOT uses the MIT TextMate grammar pinned in the [third-party notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt), with patterns and scopes preserved by mechanical conversion. This keeps source coloring independent of preview rendering and avoids maintaining a separate DOT tokenizer.
-
-## Alternatives considered
-
-**Show visualization by default with overlay icon actions.** The source-first decision favored authored code and avoided diagram work before an explicit request. The [interaction decision](2026-09-10-codeblock-preview-interaction.md) supersedes that default while retaining actions in the banner.
-
-**Render each streamed chunk.** Incomplete diagrams are frequently invalid, and repeated layout work competes with text streaming. Message settlement provides complete source.
-
-**Put rendering inside Chat or add a general preview registry.** A shared primitive with plain props satisfies reuse without another registry or feature-plugin dependency. This follows the [shared-control rule](../architecture/2026-09-05-shared-client-control-primitives.md).
-
-**Pass rendered JSX through `CodeBlock.preview`.** Caller-created elements split preview ownership between the renderer and the primitive. A standard data descriptor keeps rendering, result ownership, and controls in one component API.
-
-**Preview HTML in a sandboxed frame.** HTML preview adds sanitization, iframe policy, a distinct document layout, and another browser dependency. Source display covers the code-review use case without that separate security and distribution surface.
-
-**Insert rendered markup into Chat.** SVG styles and executable behavior would share the application document. Image mode disables SVG behavior while retaining the rendered result.
-
-**Treat Viz.js as MIT-only or use a remote Graphviz service.** The compiled Graphviz license still applies locally; a remote renderer would send conversation content off-device. The bundled renderer retains source availability and legal notices without network rendering.
-
-## Consequences
-
-The feature changes presentation without changing persisted messages, provider requests, tools, or Host APIs. `SourcePreview` associates a result with both its source and renderer and publishes nothing until the matching result settles. Mermaid and Graphviz add lazy browser assets and run layout on the browser thread. Cancellation cannot preempt active layout; Mermaid finishes and releases its measurement DOM even when its result cannot be published. The previews have no editing, export, HTML rendering, or interactive diagram links.
-
-Component tests cover source copying, preview rendering, delayed completion, stale success and failure, unmounting, fallback, cancellation, runtime loading failure, and recovery. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) verifies Chinese Mermaid flowcharts, sequence diagrams, malformed source, configuration overrides, inert SVG images, real image decoding, source toggling, English/Chinese UI snapshots, and served license text. Notices checks pin the reviewed wrapper version and native source downloads so upgrades require renewed review.

+ 0 - 47
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md

@@ -1,47 +0,0 @@
-# Agent Note: 静态 Markdown fence 预览
-
-Status: implemented
-
-[English](2026-09-09-markdown-static-previews.md) | 中文
-
-## Problem
-
-读者需要在保留代码为主要表示的同时,按需直接查看 Assistant 回复中的 Mermaid 与 DOT 图表和 SVG 图像。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。
-
-## Decision
-
-共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 和解析后的 fence 语言启用定稿后的 `mermaid`、`graphviz`/`dot` 和 `svg` fence。[UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `SourcePreview`,它接收渲染器、源码和文案,不依赖 Session、文件或 Cordis。Mermaid 与 Graphviz 各自向同一组件提供 `.ts` renderer。HTML、未提供预览文案的调用方与流式消息保留代码显示。
-
-`CodeBlock.preview` 是标准化的源码预览描述,包含 renderer 以及完整的本地化输出与控件文案;调用方不传入 React 节点。`MarkdownText` 按预览文案对象的 identity 创建一份描述目录,之后正文、文件提及或本地图片词表变化引起的每次定稿渲染都复用其中的语言条目。[CodeBlock 交互决策](2026-09-10-codeblock-preview-interaction.zh.md)取代本文的默认源码和切换时释放结果的选择,负责当前控件与结果保留。[预览尺寸决策](../simplification/2026-09-14-source-sized-code-block-previews.zh.md)负责当前几何行为。本文保留独立的渲染、安全与分发决策。
-
-`SourcePreview` 负责待完成工作、失败和取消过期结果发布。待完成的预览显示本地化状态。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。
-
-Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。布局前检查解析后的 flowchart 节点并拒绝图片节点;Mermaid 的严格模式仍会在图片节点布局时请求外部资源,最终 SVG 图片隔离无法阻止这一请求。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。与视口相交的预览和已打开的 lightbox 观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT 与 SVG 中明确指定的颜色保持原样。
-
-SVG 按 XML 解析后,与 Graphviz 输出一同作为不可执行图片显示,因此 SVG 脚本、链接与外部资源不会激活。放大镜会打开 body portal 对话框;其视口适应、平移和缩放行为由 [CodeBlock 交互决策](2026-09-10-codeblock-preview-interaction.zh.md)负责。
-
-Graphviz 使用固定且未修改的 `@viz-js/viz` 3.30.0 WebAssembly 发布包,布局引擎为 `dot`。其 npm MIT 声明覆盖包装层;构建证明标明 Graphviz 16.0.0(EPL-2.0)、Expat 2.8.4(MIT)与 Emscripten 5.0.7(MIT/NCSA)。分发保留各组件条款,并按 EPL-2.0 第 3.1 节提供准确的 Graphviz 源码下载地址。[完整预览声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)还保留 Mermaid 的 MIT 文本和其 DOMPurify 依赖选用的 Apache-2.0 条款。UI primitives 包携带该声明,Web 构建在资源旁输出相同字节。内嵌产物的许可证检查独立于通用的宽松 npm 元数据策略。
-
-源码高亮在流式与定稿图表 fence 中共用按需加载的 Shiki 高亮器。SVG 解析为 XML;Mermaid 选用上游语法的 `#mermaid` 正文规则,因为 CodeBlock 已去除 Markdown 围栏。DOT 使用[第三方声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)中固定的 MIT TextMate 语法,机械转换保留所有模式与 scope。这让源码着色独立于预览渲染,也避免维护单独的 DOT tokenizer。
-
-## Alternatives considered
-
-**默认显示可视化并使用浮层图标操作。** 默认源码的决策优先展示作者代码,并避免显式请求前执行图表工作。[交互决策](2026-09-10-codeblock-preview-interaction.zh.md)取代这一默认选择,同时继续将操作放在标题栏中。
-
-**渲染每个流式片段。** 未完成的图表通常无效,反复布局会与文本流式输出争用资源。消息定稿可提供完整源码。
-
-**在 Chat 内部渲染,或添加通用预览注册表。** 使用普通属性的共享基础组件即可满足复用,无需另加注册表或功能插件依赖。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。
-
-**通过 `CodeBlock.preview` 传入已渲染 JSX。** 调用方创建的元素会把预览所有权拆分到 renderer 与基础组件。标准数据描述让渲染、结果所有权与控件归入同一个组件 API。
-
-**在 sandbox iframe 中预览 HTML。** HTML 预览需要清理策略、iframe 权限、独立文档布局和额外浏览器依赖。源码展示足以覆盖代码审阅场景,无需引入这组独立的安全与分发范围。
-
-**把渲染标记插入 Chat。** SVG 样式和可执行行为会与应用共享文档。图片模式会禁用 SVG 行为,同时保留渲染结果。
-
-**将 Viz.js 视为仅使用 MIT,或使用远程 Graphviz 服务。** 本地编译的 Graphviz 仍受其许可证约束;远程渲染会把对话内容发送到设备之外。内置渲染器保留源码可获取性与法律声明,无需网络渲染。
-
-## Consequences
-
-该功能改变呈现,不改变持久化消息、provider 请求、工具或 Host API。`SourcePreview` 同时按源码与 renderer 关联结果,在匹配结果完成前不发布预览内容。Mermaid 和 Graphviz 增加按需加载的浏览器资源,并在浏览器线程上执行布局。取消无法抢占进行中的布局;即使结果无法发布,Mermaid 仍会完成并释放测量 DOM。预览不提供编辑、导出、HTML 渲染或交互式图表链接。
-
-组件测试覆盖源码复制、预览渲染、延迟完成、过期成功与失败、卸载、回退、取消、运行时加载失败与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证中文 Mermaid 流程图、时序图、无效源码、配置覆盖、不可执行 SVG 图片、真实图片解码、源码切换、中英文 UI 快照和已提供的许可证文本。声明检查固定已审查的包装层版本与原生源码下载地址,升级时须重新审查。

+ 0 - 57
.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md

@@ -1,57 +0,0 @@
-# Agent Note: CodeBlock preview interaction and retained work
-
-Status: implemented
-
-English | [中文](2026-09-10-codeblock-preview-interaction.zh.md)
-
-## Problem
-
-Readers need to inspect diagrams directly and return to source without paying for the same diagram again. Toolbar state and unrelated grammar notifications can repeat source work even when its text is unchanged.
-
-## Decision
-
-Supported settled blocks initially show Preview. Supported streaming blocks show a 180px image placeholder with a localized loading status and a decorative glyph whose opacity pulses over 1.8 seconds; reduced motion disables the animation. Streaming mounts neither source highlighting nor diagram generation, including after a fence freezes in the incremental parser. Controls return when message streaming ends; the placeholder remains until the image loads. Stopping the stream also exits the waiting state; invalid or unloadable images show an error with Source available. This supersedes the source-first and release-on-toggle choices in the [static preview decision](2026-09-09-markdown-static-previews.md), whose renderer isolation, cancellation, security, and distribution rules remain active.
-
-The language stays on the left. The right side contains magnifier, copy, and Source/Preview controls in that order. Icons follow the Sidebar's 28px circular control and 15px glyph treatment. The segmented control moves only its selected background for 160ms; content switches immediately, and reduced-motion settings suppress that transition. Unsupported blocks have a static selected Source label. Copy always reads source, and its fixed-size icon and localized tooltip show success. Preview provides a magnifier that opens `PreviewLightbox`, disabled with an accessible localized explanation when no usable image is available. Source replaces it with a line-number toggle whose state survives view changes. Preview toolbar opacity transitions over 160ms without a timer or layout change; block hover and keyboard focus reveal it, and touch devices keep it visible. Source toolbar opacity stays at one.
-
-The stable `data-code-block-content` wrapper and `contentRef` let sidebar owners retain their scrollport and restore scroll position. The [preview sizing decision](../simplification/2026-09-14-source-sized-code-block-previews.md) supersedes this note's active-body height and inline-resize choices; it owns current Source/Preview geometry and overflow.
-
-Each mounted `CodeBlock` owns its preview result and pending renderer call. A shared IntersectionObserver defers diagram work and runtime loading until intersection, and cancels unfinished work on exit unless the lightbox is open. Browsers without this API render immediately. Switching views retains completed results; source, renderer, or resolved-theme changes invalidate the work, and unmounting cancels publication. Reentry reuses results with matching source, renderer and palette. Theme refreshes retain the loaded image, its geometry and the open lightbox until a replacement loads. A failed refresh preserves the usable image and displays the localized error. Retention is bounded to one displayed image and one replacement per block.
-
-The owner retains failures too, so toggling cannot repeatedly submit invalid source. Previewable blocks mount source on first selection, then retain that DOM with its highlighting state. `CodeBlock`, its source child, and its copy control are independently memoized. Grammar readiness snapshots report only the source language, so loading another grammar does not schedule source work. This is local retention, with no cache shared across blocks or Sessions.
-
-The lightbox fits the image within 88% of viewport width and 84% of viewport height, independently of inline size. Pointer capture owns dragging; wheel zoom preserves the image point beneath the cursor and stays between 0.25 and 8 times the fitted size. Double-click, Home, source-image changes, and viewport resizing restore the fit. Arrow keys pan and +/− zoom. Gestures write only the image transform and retain its URL; they do not schedule React state or diagram generation. Closing releases capture and listeners and restores focus to the opener.
-
-The source renderer consumes each highlight frame once. `StreamingHighlightSession.updateFrame` returns the same delta frame for repeated code and language, while refs retain completed-line groups across React render retries. The line cache therefore retains the frame identity and returns its existing body for the same frame, code, and language; memoization alone cannot prevent duplicate line insertion. StrictMode coverage checks multiple streaming chunks and settlement, and the Markdown DOM fixtures run under StrictMode without changing their expected output.
-
-## Alternatives considered
-
-**Unmount inactive content.** Releasing images on every Source visit repeats diagram generation and removes the source-view magnifier's result. Retaining one result per mounted block gives the result the same lifetime as its controls.
-
-**Share a global preview cache.** Cross-block reuse adds eviction, renderer identity, and theme-key ownership while retaining conversation content beyond individual mounts. The measured repeated work occurs within one block, so its mounted lifetime is sufficient.
-
-**Mount and highlight source before selection.** The retained image determines block dimensions, so hidden source adds work without improving the preview.
-
-## Consequences
-
-Previewable blocks mount and highlight source on first selection; preview-only readers do not mount source. Ordinary source-only blocks still defer highlighting until they intersect; streaming diagram placeholders do not mount source.
-
-Offscreen previews submit no diagram work until intersection; offscreen theme changes wait for reentry. Returning to Source keeps the image and renderer owner alive, and blocks whose Source view has been opened retain source token DOM. Memory therefore follows mounted blocks, with no claim of reduced heap use. Mermaid's queue and synchronous Graphviz layout still run on the browser thread, and cancellation cannot preempt active layout.
-
-The [viewport tests](../../../../packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx) pin deferred rendering, viewport cancellation and reuse; the 20-block regression calls every renderer against the unoptimized implementation. The [component tests](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx) cover retained results, source identity, pending and failed controls, copy, streaming transitions, invalidation, and stale publication. The [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) exercises equal Source/Preview geometry, internal overflow, toolbar fading, line-number controls, lightbox interaction, and localized UI snapshots. Highlighting retains the existing streamed/settled parity tests. The streaming browser scenario waits for the turn to finish persisting before closing Session handles, including after failed assertions.
-
-
-## Performance evidence
-
-The [manual browser diagnostic](../../../../apps/web/tests/diagram-preview.perf.ts), run with `pnpm run test:web:perf:built apps/web/tests/diagram-preview.perf.ts`, uses 20 Mermaid flowcharts with 10 nodes and 12 edges each, followed by 40 paragraphs. Three fresh Chromium instances use a 1680×1000 viewport, the shipped Web composition and built Client on macOS arm64, Apple M5 Pro and Node 24.20.0. Opening starts at a real Session click and ends when the tail is visible plus two animation-frame opportunities; theme timing starts immediately before system-theme emulation and ends after a visible replacement plus two frames. These endpoints do not measure hardware presentation or model latency.
-
-| Metric | Eager previews, samples → median | Viewport previews, samples → median |
-| --- | --- | --- |
-| Open endpoint (ms) | 129.0, 167.3, 98.5 → 129.0 | 72.1, 106.6, 81.7 → 81.7 |
-| Long tasks in first 3 seconds (ms) | 61, 58, 0 → 58 | 0, 0, 0 → 0 |
-| Additional initial JS transfer (bytes) | 239449 in every sample | 0 in every sample |
-| Initially decoded offscreen diagrams | 20 in every sample | 0 in every sample |
-| Visible theme replacement (ms) | 956.0, 617.6, 697.7 → 697.7 | 93.0, 93.1, 82.1 → 93.0 |
-| Theme placeholder frames / height range | 2 / 180–632px in every sample | 0 / 632–632px in every sample |
-
-Theme preparation scrolls to the loaded target after cold diagrams settle; otherwise an earlier diagram's growth can move the target outside the viewport. Both compared theme phases contain two intersecting previews. First activation and its geometry changes are excluded from timing. Unrelated host activity and filesystem caches are uncontrolled; timings are descriptive, with no CI timing threshold or memory claim. The owning component regression fails on eager rendering, and the browser case verifies deferred Mermaid and Graphviz requests plus a keyless offscreen-state snapshot.

+ 0 - 57
.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md

@@ -1,57 +0,0 @@
-# Agent Note: CodeBlock 预览交互与工作保留
-
-Status: implemented
-
-[English](2026-09-10-codeblock-preview-interaction.md) | 中文
-
-## Problem
-
-读者需要直接查看图表,并返回源码而不必再次生成同一张图。即使文本未变,工具栏状态和无关语法通知也可能重复执行源码工作。
-
-## Decision
-
-支持预览的定稿代码块初始显示预览。受支持的流式代码块显示高 180px 的图片占位、本地化加载状态和以 1.8 秒周期改变透明度的装饰图标;减少动态效果设置禁用动画。流式期间既不挂载源码高亮,也不生成图表,包括 fence 被增量解析器冻结之后。消息流式结束后恢复控件;占位保留至图片加载完成。停止输出也会退出等待状态;无效或无法加载的图片显示错误,并保留源码入口。这取代[静态预览决策](2026-09-09-markdown-static-previews.zh.md)中的默认源码和切换时释放结果的选择;其中的渲染器隔离、取消、安全与分发规则继续适用。
-
-语言名位于左侧。右侧依次放置放大镜、复制和源码/预览控件。图标沿用侧边栏的 28px 圆形控件与 15px 图形样式。分段控件只让选中背景移动 160ms;正文立即切换,减少动态效果设置会禁用该过渡。不支持预览的代码块显示静态选中的源码文案。复制始终读取源码,通过固定尺寸图标与本地化 tooltip 表示成功。预览视图提供放大镜,打开 `PreviewLightbox`;没有可用图片时禁用,并提供本地化的可访问说明。源码视图用行号开关替换放大镜,开关状态跨视图切换保留。预览工具栏的透明度在 160ms 内过渡,不使用计时器或改变布局;鼠标移入代码块和键盘聚焦时显示,触摸设备保持可见。源码工具栏透明度始终为一。
-
-稳定的 `data-code-block-content` 容器与 `contentRef` 让侧栏所有者保留滚动容器并恢复滚动位置。[预览尺寸决策](../simplification/2026-09-14-source-sized-code-block-previews.zh.md)取代本文中由当前正文定高与行内调整尺寸的选择;它负责当前的源码/预览几何与溢出行为。
-
-每个已挂载的 `CodeBlock` 拥有预览结果及待完成的 renderer 调用。共享 IntersectionObserver 把图表生成与运行时加载推迟到与视口相交之后;离开视口会取消未完成的工作,已打开 lightbox 时除外。不支持该 API 的浏览器立即渲染。切换视图保留已完成结果;源码、renderer 或解析后的主题变化会使工作失效,卸载则取消发布。重新进入时复用源码、renderer 与配色相同的结果。主题刷新保留已加载图片、其几何尺寸和已打开的 lightbox,直到替换图片加载完成。刷新失败时保留可用图片并显示本地化错误。每个代码块最多保留一张已显示图片和一张替换图片。
-
-所有者也保留失败结果,因此切换不会反复提交无效源码。支持预览的代码块在首次选中源码时挂载,随后连同高亮状态一起保留该 DOM。`CodeBlock`、源码子组件和复制控件分别 memo 化。语法就绪快照只报告源码语言,因此加载其他语法不会调度源码工作。结果仅在本地保留,不在代码块或 Session 间共享缓存。
-
-大图以屏幕宽度的 88% 和高度的 84% 为上限等比适应,不依赖行内尺寸。指针捕获管理拖拽;滚轮缩放保持鼠标下的图片位置不动,倍率限制在适应尺寸的 0.25 至 8 倍。双击、Home、图片源变化和视口尺寸变化会恢复适应尺寸。方向键平移,+/− 缩放。手势只写入图片 transform 并保留 URL,不调度 React state 或重新生成图表。关闭时释放指针捕获和监听器,并将焦点还给打开按钮。
-
-源码渲染器只消费每个高亮帧一次。对于重复的代码与语言,`StreamingHighlightSession.updateFrame` 返回同一个增量帧,而 ref 会跨 React 渲染重试保留已完成行的分组。因此,行缓存保留帧 identity,并在帧、代码与语言相同时返回已有正文;仅靠 memo 化无法防止重复插入行。StrictMode 测试覆盖多个流式分片及定稿,Markdown DOM fixture 也在 StrictMode 下运行,预期输出保持不变。
-
-## Alternatives considered
-
-**卸载未激活内容。** 每次查看源码都释放图片会重复生成图表,也会移除源码视图放大镜所需的结果。按已挂载代码块保留一个结果,让结果与其控件具有相同生命周期。
-
-**共享全局预览缓存。** 跨代码块复用需要处理淘汰、renderer identity 和主题键归属,还会在单个组件卸载后保留对话内容。测得的重复工作发生在同一代码块内部,因此其挂载生命周期足以满足需求。
-
-**在选中前就挂载并高亮源码。** 保留的图片决定代码块尺寸,隐藏源码只会增加工作,无法改善预览。
-
-## Consequences
-
-支持预览的代码块在首次选中源码时挂载并高亮;只阅读预览时不挂载源码。普通纯源码代码块仍推迟到进入视口后高亮;流式图表占位不挂载源码。
-
-视口外的预览直到与视口相交后才提交图表工作;视口外的主题变化等待重新进入视口后处理。返回源码后,图片与 renderer 所有者保持存活,已经打开过源码视图的代码块保留源码 token DOM。因此内存占用随已挂载代码块增长,不声称减少堆内存。Mermaid 队列和同步 Graphviz 布局仍在浏览器线程执行,取消无法抢占已开始的布局。
-
-[视口测试](../../../../packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx)固定延迟渲染、视口取消和复用行为;20 个代码块的回归用例在未优化实现上会调用全部 renderer。[组件测试](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx)覆盖结果保留、源码 identity、待完成与失败控件、复制、流式转换、失效和过期发布。[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证源码/预览几何一致、内部溢出、工具栏淡出、行号控件、lightbox 交互和本地化 UI 快照。高亮保留已有的流式/定稿一致性测试。流式浏览器场景在关闭 Session 句柄前等待回合完成持久化,包括断言失败的情况。
-
-
-## 性能证据
-
-[手动浏览器诊断](../../../../apps/web/tests/diagram-preview.perf.ts)通过 `pnpm run test:web:perf:built apps/web/tests/diagram-preview.perf.ts` 运行,使用 20 个 Mermaid 流程图,每图 10 个节点和 12 条边,后接 40 段文本。三个全新 Chromium 实例使用 1680×1000 视口、正式 Web 组合和已构建 Client,运行于 macOS arm64、Apple M5 Pro 与 Node 24.20.0。打开阶段从真实 Session 点击开始,到尾部可见再加两次动画帧机会结束;主题阶段从模拟系统主题前开始,到替换图片可见再加两帧结束。这些终点不测量硬件呈现或模型延迟。
-
-| 指标 | 挂载即渲染,样本 → 中位数 | 视口渲染,样本 → 中位数 |
-| --- | --- | --- |
-| 打开终点(ms) | 129.0, 167.3, 98.5 → 129.0 | 72.1, 106.6, 81.7 → 81.7 |
-| 前 3 秒长任务(ms) | 61, 58, 0 → 58 | 0, 0, 0 → 0 |
-| 初始新增 JS 传输(bytes) | 每个样本均为 239449 | 每个样本均为 0 |
-| 初始已解码的视口外图表 | 每个样本均为 20 | 每个样本均为 0 |
-| 可见主题替换(ms) | 956.0, 617.6, 697.7 → 697.7 | 93.0, 93.1, 82.1 → 93.0 |
-| 主题占位帧/高度范围 | 每个样本均为 2 / 180–632px | 每个样本均为 0 / 632–632px |
-
-主题阶段的准备会在冷图表布局稳定后滚到已加载的目标;否则前方图表增高可能把目标移出视口。对比的两个主题阶段都包含两个与视口相交的预览。首次激活及其几何变化不计时。其他主机活动与文件系统缓存不受控制;计时仅作描述,不设 CI 时间阈值,也不声明内存收益。对应组件回归在挂载即渲染实现上失败,浏览器用例验证 Mermaid 与 Graphviz 请求的延迟加载及无需 API key 的屏外状态快照。

+ 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: d66d3257360270a72d5e774282d9f7c61981929f
+2026-09-11-desktop-mandatory-update-client.zh.md: fd6e751a4082578cc4d54746e0fc584104a89226

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

@@ -0,0 +1,67 @@
+# 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. The test login window opens detached DevTools on F12 while retaining its sandbox, navigation allowlist, and memory-only Session. 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 independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. 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. The replacement gets a fresh Web origin and authentication cookie, so the existing application URL reloads after Host readiness and obtains new boot data even when the port is unchanged. 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. Preparation failures carry a typed cause so native and Web presentations select their own locale's guidance without comparing translated messages. 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.
+
+**Keep DevTools disabled in the test login window.** This prevents local inspection of the third-party page but leaves authentication failures difficult to diagnose. F12 is an explicit local action on that window; ordinary login does not open DevTools.
+
+**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.

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

@@ -0,0 +1,67 @@
+# 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 部署环境选定的策略源站。测试环境使用隔离的飞书鉴权;正式环境保持匿名。测试登录窗口按 F12 会打开独立的 DevTools,同时保留沙箱、导航允许列表和仅驻内存的 Session。只有未打包开发模式可以省略策略配置。这避免因遗漏策略配置而静默产出没有强更检查的发布包,或让测试包查询正式环境策略。
+
+协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。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,并要求重新确认安装,不清除强更策略。替代 Host 会获得新的 Web 源站和鉴权 Cookie,因此 Host 就绪后会重新加载原有应用地址,即使端口未变也会重新获取启动数据。其他停止失败会保留后端清理错误并阻止替代进程启动,因为旧进程可能仍持有 profile 状态。
+
+对于已确认的非正常收尾,两类弹窗都将本地化恢复提示与可展开的技术详情分开展示。详情初始折叠,不授予更新权限。诊断展示进程生命周期事实,而非可能包含凭据的任意插件 stderr。强更弹窗在状态未变时保留已展开的详情,诊断改变或清除时则折叠。
+
+任务警告描述运行中的 agent、排队输入和运行中或停止中的后台任务,而非请求流量。用户批准安装后,Host 拒绝新 API 请求,等待已接收请求结束,再检查任务。这既保护已接收的写操作,也不因页面读取弹出警告。超时或被替代的等待不能授权安装;失败后壳解除准入锁。
+
+产品预加载只发布语义化 updater 阶段、版本、进度和失败类别,不携带本地化字符串或原始诊断。准备阶段失败携带类型化原因,原生与 Web 界面分别选用自身 locale 的提示,无需比较翻译后的文案。账户行和侧栏收起状态通过当前 Web locale 解析这些数据,原生弹窗仍使用 Desktop 壳 locale。主动退出应用时,产品窗口会在 Host 收尾前隐藏,并在进程退出前忽略聚焦请求,避免把预期连接中断展示成恢复操作。
+
+## 考虑过的替代方案
+
+**没有网关交接机制时使用系统浏览器。** 其 Cookie 不会与 Electron 请求共享。显式启用的测试部署改用沙箱 BrowserWindow 和独立内存 Session,不启用 Node 集成或预加载。文档导航只允许配置的 HTTPS 策略源站及已审核的飞书源站;拒绝权限、下载、新窗口和 HTTP 鉴权。网关不需要新增 token 交换接口。这以跨重启登录及不受限的身份提供方导航换取隔离。默认匿名协议及 updater 传输不变。
+
+**在测试登录窗口禁用 DevTools。** 这会阻止本地检查第三方页面,却使鉴权失败难以排查。F12 是该窗口上的显式本地操作;正常登录不会自动打开 DevTools。
+
+**将登录重定向完成视为更新决定。** 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/feature/2026-09-14-desktop-primary-runtime.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/feature/2026-09-14-desktop-primary-runtime.md
-2026-09-14-desktop-primary-runtime.md: d4dfd5fae400737de5ae852c05e2b6f117dc78f1
-2026-09-14-desktop-primary-runtime.zh.md: e64b6a53d6270f922e477fd844d611f8249581a4
+2026-09-14-desktop-primary-runtime.md: d3bb7d173450d4c27af4b5b65e16f33cc285605a
+2026-09-14-desktop-primary-runtime.zh.md: 2df6effdccc09a488e322bfe6f144e18b101369c

+ 4 - 0
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md

@@ -18,6 +18,8 @@ Node downloads and hash-verifies the complete locked wheel set and unpacks libra
 
 macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
 
+Windows signed packaging separates materialization from execution with a supervised primary-runtime signing stage. PE inspection excludes foreign-platform Node addons and refuses directory links. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; no failure permits a retry or later stage. Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access.
+
 Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes.
 
 ## Alternatives considered
@@ -28,6 +30,8 @@ Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped depe
 
 **Independent updates and version-named directories.** Runtime releases are coupled to Desktop, and the requested installation location is stable.
 
+**Signing only the interpreter or bypassing native smoke.** Windows code integrity also evaluates DLLs and Python extensions. A signed launcher cannot make an unsigned extension load, and skipping execution would hide unusable installed dependencies. Preserving valid upstream signatures avoids unnecessary hardware operations and retains upstream attribution.
+
 ## Consequences
 
 The application carries additional native files and replaces the complete managed payload on upgrade. Running interpreters can prevent replacement on Windows. Native build smoke, install/reuse/recovery tests and a keyless tool-error session cover distinct installation and model-output paths; macOS signing uses the existing native-runtime signer. Interpreter archives and Python wheels are hash-pinned, and licenses remain with their distributions.

+ 4 - 0
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md

@@ -18,6 +18,8 @@ Node 下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site
 
 macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
 
+Windows 签名打包通过受监督的第一方运行时签名阶段,将文件准备与执行分开。PE 检查排除其他平台的 Node 插件,并拒绝目录链接。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;任何失败都不允许重试或继续后续阶段。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。
+
 Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。
 
 ## Alternatives considered
@@ -28,6 +30,8 @@ Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0
 
 **独立更新和版本目录。** Runtime 发布与 Desktop 绑定,且请求的安装位置固定。
 
+**仅签名解释器或跳过本机 smoke。** Windows 代码完整性也检查 DLL 和 Python 扩展。启动程序有签名不能让未签名扩展被加载,跳过执行会隐藏安装后不可用的依赖。保留有效上游签名既减少硬件操作,也保留上游归属信息。
+
 ## Consequences
 
 应用携带额外的原生文件,并在升级时替换完整受管产物。Windows 上运行中的解释器可能阻止替换。本机构建 smoke、安装与复用及恢复测试、无密钥工具错误会话分别覆盖安装和模型输出路径;macOS 签名复用现有原生 Runtime 签名器。解释器压缩包和 Python wheel 固定哈希,许可证随各分发包保留。

+ 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/process/2026-09-16-desktop-release-version-derivation.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/process/2026-09-16-desktop-release-version-derivation.md
+2026-09-16-desktop-release-version-derivation.md: 0f1d917cb0d382453fbb0dca561a1eae7d530d67
+2026-09-16-desktop-release-version-derivation.zh.md: e9576e30232b5eb247de047cb67f51db5fa43ae3

+ 29 - 0
.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md

@@ -0,0 +1,29 @@
+# Agent Note: Derive Desktop test versions from the complete dsh version
+
+Status: implemented
+
+English | [中文](2026-09-16-desktop-release-version-derivation.zh.md)
+
+## Problem
+
+Replacing a dsh prerelease identifier with the update channel name loses the base release identity and changes SemVer ordering. A date alone cannot distinguish multiple test builds of the same base.
+
+## Decision
+
+The [Desktop release rules](../../../../apps/desktop/README.md#release-versions) preserve the complete dsh base for production and derive dated, indexed test versions from that base. Final release-family package versions remain equal. The base is retained separately before manifests are retagged, so another test release cannot append a second date suffix.
+
+The fixed Nightly feed is a distribution address, independent of version derivation. Existing clients continue to use that address with prerelease updates enabled and downgrades disabled. Test distribution does not publish the unsuffixed base. Operators check existing release records and objects before assigning an index.
+
+The packaging decision retains runtime/version equality; the installed-update materials decision retains private identities and data isolation. Their Nightly naming examples do not define the test version policy. Historical release receipts and frozen archived records remain evidence of what was actually built.
+
+## Alternatives considered
+
+**Replace alpha, beta, or rc with nightly.** This discards the base prerelease and can sort above its later releases, preventing updates to the intended version line.
+
+**Rename the feed with the prerelease identifier.** Installed clients would continue checking their existing feed and miss the replacement publication.
+
+**Enable downgrade to repair an incorrectly numbered release.** A global downgrade allowance changes ordinary update safety. Affected installations use a manual installer; correcting the feed alone cannot migrate them.
+
+## Consequences
+
+A test build has an unambiguous base and creation date without changing production version identity. Signing and upload still validate the final equal package versions. Installed-update material allocation accepts dated prerelease versions; retained legacy Nightly material remains readable for audit.

+ 29 - 0
.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 从完整 dsh 版本派生 Desktop 测试版本
+
+Status: implemented
+
+[English](2026-09-16-desktop-release-version-derivation.md) | 中文
+
+## 问题
+
+用更新通道名替换 dsh 预发布标识会丢失基础发布身份,并改变 SemVer 排序。只有日期无法区分同一基础版本的多次测试构建。
+
+## 决策
+
+[Desktop 发布规则](../../../../apps/desktop/README.zh.md#release-versions)在 production 中保留完整 dsh 基础版本,并从该基础版本派生带日期和序号的测试版本。最终发布家族包版本保持一致。修改清单版本前单独记录基础版本,避免下一次测试发布追加第二个日期后缀。
+
+固定 Nightly feed 是分发地址,与版本派生独立。现有客户端继续使用该地址,允许预发布更新并禁止降级。test 分发不发布无后缀基础版本。操作者分配序号前检查已有发布记录和对象。
+
+打包决策继续规定运行时与包版本一致性;安装版更新物料决策继续规定私有身份和数据隔离。其中的 Nightly 命名示例不定义测试版本规则。历史发布回执和已冻结归档保留为实际构建的证据。
+
+## 考虑过的替代方案
+
+**用 nightly 替换 alpha、beta 或 rc。** 这会丢弃基础预发布标识,并可能排在其后续版本之上,阻止更新到预期版本序列。
+
+**按预发布标识重命名 feed。** 已安装客户端仍查询原有 feed,将无法获取替换后的发布。
+
+**为修复错误版本号而开启降级。** 全局允许降级会改变常规更新的安全性。受影响安装使用手动安装包;仅修正 feed 无法迁移它们。
+
+## 结果
+
+测试构建具有明确的基础版本和创建日期,production 版本身份保持一致。签名与上传仍验证最终各包版本相等。安装版更新物料分配接受带日期的预发布版本;保留的旧 Nightly 物料仍可读取以供审计。

+ 0 - 29
.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md

@@ -1,29 +0,0 @@
-# Agent Note: Diagram-sized code block previews
-
-Status: implemented
-
-English | [中文](2026-09-14-source-sized-code-block-previews.zh.md)
-
-## Problem
-
-Source line count does not predict diagram proportions. Sizing a preview from hidden highlighted source can clip a tall diagram, waste space around a short one, and parse a large source tree before the reader requests it. Independent Source and Preview heights move the surrounding transcript on every toggle.
-
-## Decision
-
-The browser sizes the retained preview image from its intrinsic aspect ratio and available width. CSS limits image height to the smaller of 60vh and 640px; the canvas adds 16px padding and a 120px minimum height. The image remains in document flow while hidden, so both views keep the same geometry through toggles and viewport resizing without JavaScript measurements or resize state.
-
-Source mounts on first selection, overlays the preview area, and scrolls internally. Subsequent toggles retain the source DOM and generated image. Preview remains the default. Supported streaming fences keep the 180px placeholder and mount neither source nor the diagram renderer until settlement.
-
-`CodeBlock` exposes no inline resize controls. Enlarging the image opens the existing lightbox with pan, zoom, and viewport fit.
-
-## Alternatives considered
-
-**Use hidden source as the size owner.** Source length and wrapping do not describe the diagram, and eagerly highlighting it adds work to the default preview path.
-
-**Give each view its own natural height or allow drag resizing.** Independent heights move transcript content; manual resizing adds state and controls where the browser can derive the size from the image.
-
-## Consequences
-
-Preview-only readers do not mount or highlight source. Source-only blocks retain viewport-triggered highlighting. Very tall diagrams scale down in the transcript; the lightbox provides detailed reading. Source DOM remains allocated after the reader first opens it, preserving highlight and scroll state across toggles.
-
-Browser regressions cover wide and narrow viewports, short and tall images, stable toggle geometry, retained DOM, and hidden-source accessibility. Component tests verify that preview settlement performs no source highlighting before Source is selected.

+ 0 - 29
.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.zh.md

@@ -1,29 +0,0 @@
-# Agent Note: 按图表定高的代码块预览
-
-Status: implemented
-
-[English](2026-09-14-source-sized-code-block-previews.md) | 中文
-
-## Problem
-
-源码行数无法预测图表比例。根据隐藏的高亮源码确定预览尺寸,会裁切高图、在短图周围浪费空间,并在读者需要之前解析大量源码节点。源码与预览分别使用独立高度时,每次切换都会移动后续对话内容。
-
-## Decision
-
-浏览器根据保留的预览图片的原始宽高比与可用宽度计算尺寸。CSS 将图片高度限制为 60vh 与 640px 中的较小值;画布增加 16px 内边距与 120px 最小高度。图片隐藏时仍保留在文档流中,因此切换视图与调整视口时,两种视图保持相同几何,无需 JavaScript 测量或尺寸状态。
-
-源码在首次选中时挂载,覆盖预览区域并在内部滚动。后续切换保留源码 DOM 与已生成图片。默认仍为预览。受支持的流式 fence 保留 180px 占位,在定稿前既不挂载源码,也不挂载图表 renderer。
-
-`CodeBlock` 不提供行内尺寸调整控件。放大图片使用现有 lightbox,支持平移、缩放与适应视口。
-
-## Alternatives considered
-
-**用隐藏源码确定尺寸。** 源码长度与换行无法描述图表,提前高亮还会给默认预览路径增加工作。
-
-**让两种视图分别使用自然高度或允许拖拽调整。** 独立高度会移动对话内容;浏览器可以根据图片推导尺寸,手动调整却增加了状态与控件。
-
-## Consequences
-
-只阅读预览时不挂载或高亮源码。仅源码代码块保留视口触发的高亮。特别高的图表在对话中等比缩小,读者可通过 lightbox 查看细节。首次打开后,源码 DOM 持续保留,让高亮与滚动状态跨切换保持不变。
-
-浏览器回归覆盖宽窄视口、长短图片、稳定的切换几何、DOM 保留及隐藏源码的可访问性。组件测试验证预览定稿不会在选中源码前执行源码高亮。

+ 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: 1813c6d9bbdf12697e32f7440e4b654a33bb2384
+2026-09-14-desktop-installed-update-materials.zh.md: b92bb70b32e6549b8f2aed48f6aa9b2b8364fe54

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

@@ -0,0 +1,49 @@
+# 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
+
+Version derivation follows the [release version decision](../process/2026-09-16-desktop-release-version-derivation.md); this record continues to govern material, application identity, and user-data isolation.
+
+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 derived test 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, and its mandatory test policy must be valid. It reads runtime files from ASAR, accepts only electron-builder's dependency-manifest transformation, and checks executable signatures in `app.asar.unpacked`. 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.

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

@@ -0,0 +1,49 @@
+# Agent Note: 将安装版更新物料与发布及用户数据隔离
+
+Status: implemented
+
+[English](2026-09-14-desktop-installed-update-materials.md) | 中文
+
+## 问题
+
+安装版更新演练需要两个递增版本共用应用身份。复用正常发布路径可能向无关客户端暴露测试元数据,而把测试状态放在安装目录中会在替换时丢失证据。复制成功不能证明已有签名安装包。
+
+## 决策
+
+版本派生遵循[发布版本决策](../process/2026-09-16-desktop-release-version-derivation.zh.md);本记录继续规定物料、应用身份和用户数据的隔离。
+
+[物料准备器](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts)接受保留的 test 专用清单,其中包含随机身份和分发命名空间。它将已验证的原始运行时复制为独立的派生测试版本,仅修改发布家族版本和匹配的依赖引用,再生成并验证两个完整性清单。原始运行时再次验证,其描述文件哈希必须保持不变。已有输出目录不被覆盖;复制失败保留失败记录,不能生成完成回执。
+
+生成的启动入口在导入生产主程序前验证已安装包身份。两个版本将相同的应用数据子目录用于测试 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 运行时必须与保留批次一致,测试强更策略也必须有效。检查器从 ASAR 读取运行时文件,只接受 electron-builder 对依赖 manifest 的转换,并在 `app.asar.unpacked` 中检查可执行文件签名。未签名的运行时可执行文件变化不能仅靠重新生成清单就通过。独立且不可覆盖的记录保留每次检查的阶段与失败。最终输入哈希防止安装包/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: fa88a7dd798e050d658969ad586bd86e0ac323df
+2026-09-08-desktop-update-policy-and-installation.zh.md: d9dc74f66a72dceeafd119fac9e491b3c2a29aa1

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

@@ -0,0 +1,59 @@
+# 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 proposal records outstanding release and backend work; the [implemented client decision](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.md) and [Desktop README](../../../../apps/desktop/README.md) own current behavior.
+
+| Document | Owns |
+|---|---|
+| This proposal | Outstanding release, backend, CDN, and product decisions |
+| [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 |
+
+### Release and backend qualification
+
+Release owners must qualify signed Windows x64 and macOS x64/arm64 upgrades end to end: discovery, download, hash and signature checks, task-safe shutdown, installation, restart, new-version Host startup, and profile reconciliation. Publish immutable packages and blockmaps before the mutable Nightly feed. The [packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns version and artifact rules. The [API proposal](2026-09-08-desktop-mandatory-update-api.md) owns policy fields; production integration must verify guest access, force/no-force responses, errors, platform selection, approved origins, and rate limits. Publish a resolving updater release before enabling mandatory policy.
+
+### Open product choices
+
+Persisting a mandatory block across a same-version offline restart requires product confirmation. If newer C appears while A downloads or waits for installation, retain A until product owners approve replacement; never silently turn approval for A into installation of C. Installed Windows and macOS attention remains subject to notification permissions, focus modes, minimized windows, and stale clicks. The [deferred extensions](2026-09-08-desktop-update-extensions.md) own automatic replacement and installation.
+
+<a id="cdn-and-capacity-qualification"></a>
+
+### CDN and capacity qualification
+
+The following release and operations choices are pending; they are not active CDN settings or completed load qualification.
+
+- [ ] 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.
+
+## 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
+
+- Signed installed-version upgrades pass on Windows x64 and macOS x64/arm64, including new-version Host startup and retained data.
+- Deployed policy integration passes the API proposal’s guest, force/no-force, error, and platform matrix without local fixtures.
+- Release owners record CDN freshness, Range and hash behavior, request and bandwidth budgets, alert thresholds, and an incident owner.
+- Product owners decide offline mandatory persistence and target replacement before either behavior is promised.
+
+## Risks
+
+Local updater and UI checks do not prove installed-upgrade compatibility, deployed policy availability, or CDN performance. Enabling mandatory policy before a resolving artifact exists can block users without an in-app upgrade path.

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

@@ -0,0 +1,59 @@
+# Agent Note: Desktop 更新策略与安装
+
+Status: proposed
+
+[English](2026-09-08-desktop-update-policy-and-installation.md) | 中文
+
+## 问题
+
+Desktop 通常运行本地 dsh server,无法依靠远程业务错误可靠触达强制更新策略。用户需要自动发现、主动下载、可见的准备状态,以及考虑运行中任务影响的独立重启确认。
+
+## 提案
+
+本文记录尚未完成的发布与后端工作;[已实施的客户端决策](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)和[桌面 README](../../../../apps/desktop/README.zh.md)负责当前行为。
+
+| 文档 | 负责内容 |
+|---|---|
+| 本提案 | 未决发布、后端、CDN 与产品决策 |
+| [强制更新接口](2026-09-08-desktop-mandatory-update-api.zh.md) | 请求响应字段、服务端策略要求与后端联调清单 |
+| [后续扩展](2026-09-08-desktop-update-extensions.zh.md) | Desktop 独立修订、通道切换、自动安装及待安装更新替换 |
+
+### 发布与后端验收
+
+发布负责人须端到端验收真实签名 Windows x64 与 macOS x64/arm64 升级:发现、下载、哈希与签名检查、任务安全的关闭、安装、重启、新版本 Host 启动及 profile 校准。先发布不可变安装包和 blockmap,再发布可变 Nightly 清单。[打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责版本与产物规则。[接口提案](2026-09-08-desktop-mandatory-update-api.zh.md)负责策略字段;生产联调须验证游客访问、强制/无需强更响应、错误、平台选择、获准源站和速率限制。启用强更策略前先发布能解除要求的 updater 版本。
+
+### 未决产品选择
+
+同版本离线重启后持久保留强更阻断仍需产品确认。若 A 正在下载或等待安装时出现新版 C,在产品负责人批准替换前保留 A;不能把安装 A 的授权静默改为安装 C。已安装 Windows 与 macOS 的提醒仍受通知权限、专注模式、最小化窗口及过期点击影响。[后续扩展](2026-09-08-desktop-update-extensions.zh.md)负责自动替换和安装。
+
+<a id="cdn-and-capacity-qualification"></a>
+
+### CDN 与容量验收
+
+以下发布与运维选择尚待确认,不代表已生效的 CDN 配置或已完成的负载验收。
+
+- [ ] 运维:分别配置 `/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 端点不能要求交互式浏览器验证。上线前记录故障处置流程。
+
+## 考虑过的替代方案
+
+**自动预下载安装包。** 会在用户请求下载前消耗流量。保留自动发现,但一期每次传输与重试都需要用户操作,与后续安装授权分开。
+
+**自动重启或普通退出时安装。** 会绕过明确的任务影响确认。自动安装需要单独的产品授权与平台验证。
+
+**一个远程接口控制全部更新。** 强制策略与普通产物发现职责不同。策略页面与 updater 元数据分离,验证通过后仅复用安装协调器。
+
+## 验收标准
+
+- 真实签名 Windows x64 与 macOS x64/arm64 安装版升级通过,包括新版本 Host 启动及数据保留。
+- 已部署策略服务通过接口提案的游客、强制/无需强更、错误和平台矩阵,不以本地 fixture 替代。
+- 发布负责人记录 CDN 及时更新、Range 与哈希行为、请求和带宽预算、告警阈值及故障负责人。
+- 产品负责人在承诺离线强更持久化或目标替换前作出决定。
+
+## 风险
+
+本地 updater 与界面检查不能证明已安装升级兼容性、线上策略可用性或 CDN 性能。若在能解除要求的产物可用前启用强更策略,用户可能被阻断且无法在应用内升级。

+ 1 - 1
.github/review-ownership/check-approval.mjs

@@ -319,7 +319,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
       for (const reviewId of delegation.reviewIds) {
         const dismissed = await api(`/repos/${pull.repository}/pulls/${pull.number}/reviews/${reviewId}/dismissals`, {
           method: 'PUT',
-          body: { message: `This is by automated Angry Turtle Cyborg, not a human. @${delegation.login} delegated approval to @${delegation.delegatedTo} via /delegate.`, event: 'DISMISS' },
+          body: { message: `@${delegation.login} delegated approval to @${delegation.delegatedTo} via /delegate.`, event: 'DISMISS' },
         })
         if (!isRecord(dismissed) || dismissed.id !== reviewId || dismissed.state !== 'DISMISSED') {
           throw new Error('delegated review dismissal was not confirmed')

+ 1 - 1
.github/review-ownership/check-approval.test.mjs

@@ -915,7 +915,7 @@ test('delegation dismisses only the sender old decisions and stays active across
   })
   assert.deepEqual(calls.map(call => call.method), ['PUT', 'PUT'])
   assert.equal(calls[0].body.event, 'DISMISS')
-  assert.equal(calls[0].body.message, 'This is by automated Angry Turtle Cyborg, not a human. @turtle1999 delegated approval to @writer via /delegate.')
+  assert.equal(calls[0].body.message, '@turtle1999 delegated approval to @writer via /delegate.')
   assert.deepEqual(result.blockers, ['other'])
   assert.equal(result.delegations.length, 1)
   assert.deepEqual(result.delegations[0].reviewIds, [])

+ 1 - 0
.gitignore

@@ -2,6 +2,7 @@ CLAUDE.local.md
 .env
 apps/desktop/.env.windows
 apps/desktop/.env.macos
+apps/desktop/*.p12
 node_modules/
 lib/
 *.tsbuildinfo

+ 2 - 5
THIRD_PARTY_NOTICES.md

@@ -61,7 +61,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
 | [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT |
-| [`@viz-js/viz`](https://github.com/mdaines/viz-js) | MIT |
 | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
 | [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT |
 | [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT |
@@ -90,7 +89,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |
 | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT |
 | [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT |
-| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
 | [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT |
 | [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT |
 | [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT |
@@ -125,8 +123,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`zod`](https://github.com/colinhacks/zod) | MIT |
 | [`zustand`](https://github.com/pmndrs/zustand) | MIT |
 
-The Markdown preview distribution also contains Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 runtime code (MIT/NCSA) inside `@viz-js/viz` 3.30.0. The wrapper's MIT metadata does not relicense these components. [Preview notices](packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) preserve their full license texts and Graphviz source availability, together with Mermaid's MIT and its DOMPurify dependency's selected Apache-2.0 terms. The UI primitives npm package includes this file; the Web build emits it as `preview-third-party-notices.txt`.
-
 pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
 
 - `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch)
@@ -164,7 +160,6 @@ External packages **directly declared** for development, tests, types, or toolin
 
 | Package | License |
 | --- | --- |
-| [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3) | Apache-2.0 |
 | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT |
 | [`@electron/get`](https://github.com/electron/get) | MIT |
 | [`@electron/notarize`](https://github.com/electron/notarize) | MIT |
@@ -200,6 +195,7 @@ External packages **directly declared** for development, tests, types, or toolin
 | [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT |
 | [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
 | [`app-builder-lib`](https://github.com/electron-userland/electron-builder) | MIT |
+| [`cos-nodejs-sdk-v5`](https://github.com/tencentyun/cos-nodejs-sdk-v5) | ISC |
 | [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT |
 | [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT |
 | [`dayjs`](https://github.com/iamkun/dayjs) | MIT |
@@ -216,6 +212,7 @@ External packages **directly declared** for development, tests, types, or toolin
 | [`jsdom`](https://github.com/jsdom/jsdom) | MIT |
 | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT |
 | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
+| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
 | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
 | [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
 | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |

+ 2 - 1
apps/cli/tests/desktop-host.e2e.ts

@@ -37,7 +37,8 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
       process.send({ type: 'booting', packageManager: options.packageManager });
       return new Promise((resolve, reject) => process.once('disconnect', () => {
         if (${String(fail)}) { reject(new Error('fixture boot failure')); return; }
-        resolve({ ctx: { plugin: async () => {}, connection: { authenticatedUrl: value => value }, webServer: { port: 19387 } },
+        resolve({ ctx: { plugin: async () => {}, effect: () => {}, on: () => {},
+          connection: { authenticatedUrl: value => value }, webServer: { port: 19387 } },
           shutdown: { shutdown: async () => writeFileSync(${JSON.stringify(join(root, 'stopped'))}, 'stopped') } });
       }));
     }

+ 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:^",
     "@deepseek-ai/dsh-skill-office": "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 desktopOffice from './office.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
@@ -32,17 +34,38 @@ async function main(): Promise<void> {
       },
     }),
   })
-  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(desktopOffice, {
     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/skill/skill-office" },
     { "path": "../../packages/util/home-paths" },
     { "path": "../../packages/core/tools" },

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

@@ -3,23 +3,33 @@
 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=
 
+# Required local p12 containing the Developer ID Application certificate and private key.
+# CSC_KEY_PASSWORD is the p12 export password, not an Apple account or login password.
+# An explicitly empty password is accepted for an unencrypted p12.
+CSC_LINK=
+CSC_KEY_PASSWORD=
+
 # Choose exactly one notarization strategy; leave the others commented out.
-APPLE_KEYCHAIN_PROFILE=
+APPLE_API_KEY=
+APPLE_API_KEY_ID=
+APPLE_API_ISSUER=
+# APPLE_KEYCHAIN_PROFILE=
 # APPLE_KEYCHAIN=
-# APPLE_API_KEY=
-# APPLE_API_KEY_ID=
-# APPLE_API_ISSUER=
 # APPLE_ID=
 # APPLE_APP_SPECIFIC_PASSWORD=
 # APPLE_TEAM_ID=
 
-# Optional electron-builder certificate import instead of an existing keychain identity.
-# CSC_LINK=
-# CSC_KEY_PASSWORD=
-
 # Optional upload configuration; packaging does not require these credentials.
 # DOWNLOAD_TEST_COS_BUCKET=
 # DOWNLOAD_TEST_COS_SECRET_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: 227fcb57aa2093aaded0cdf6c9375e32bc4328fb
-README.zh.md: 88b5a07e1f7a8e3379cf725f37d2a01528196ba3
+README.md: 14bbdb7ec0fddd47821c12ffe46549dd29abe0de
+README.zh.md: 1e704900743ec6f339efdf849e60767c961d07ee

文件差異過大導致無法顯示
+ 33 - 8
apps/desktop/README.md


文件差異過大導致無法顯示
+ 34 - 8
apps/desktop/README.zh.md


+ 16 - 1
apps/desktop/electron-builder.config.d.mts

@@ -12,8 +12,12 @@ 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 extraResources: readonly [
+    { readonly from: string, readonly to: 'runtime' },
+    { readonly from: string, readonly to: 'icon.png' },
+  ]
   readonly mac: {
     readonly identity: string | undefined
     readonly forceCodeSigning: boolean
@@ -24,6 +28,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
@@ -33,6 +45,7 @@ export interface DesktopElectronBuilderConfig {
     readonly installerLanguages: readonly ['en_US', 'zh_CN']
   }
   readonly beforeBuild: () => Promise<boolean>
+  readonly beforePack: (context: { readonly appOutDir: string }) => Promise<void>
   readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise<void> | undefined
   readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null
 }
@@ -42,12 +55,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 - 151
apps/desktop/electron-builder.config.mjs

@@ -1,153 +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/main.js',
-      'lib/preload-app.cjs',
-      '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()

+ 3 - 0
apps/desktop/installer/pages.nsh

@@ -167,6 +167,9 @@ Function InstallerCreate
     System::Call 'user32::SetPropW(p $HWNDPARENT, w "HarnessInstaller.Ready", p 1)'
     ShowWindow $InstallerDialog 5
     ShowWindow $HWNDPARENT 5
+    ${If} $InstallerPhase == "welcome"
+        System::Call '$PLUGINSDIR\window-frame.dll::InstallerPresentWelcome(p $HWNDPARENT) i.r0 ?c'
+    ${EndIf}
     nsDialogs::Show
     ${NSD_KillTimer} InstallerValidateEditedPath
     ${NSD_FreeImage} $InstallerImage

+ 13 - 0
apps/desktop/installer/window-frame.cpp

@@ -242,3 +242,16 @@ extern "C" __declspec(dllexport) HRESULT __cdecl InstallerApplyFrame(HWND window
                  SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
     return result;
 }
+
+// Present the first interactive page after resource preparation without keeping the installer topmost.
+extern "C" __declspec(dllexport) BOOL __cdecl InstallerPresentWelcome(HWND window) {
+    if (!IsWindowVisible(window)) return FALSE;
+    if (!SetWindowPos(window, HWND_TOP, 0, 0, 0, 0,
+                      SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE)) return FALSE;
+    if (GetForegroundWindow() != window) {
+        FLASHWINFO flash = {sizeof(flash), window, FLASHW_TRAY | FLASHW_TIMERNOFG, 0, 0};
+        FlashWindowEx(&flash);
+    }
+    SetPropW(window, L"HarnessInstaller.Presented", reinterpret_cast<HANDLE>(1));
+    return TRUE;
+}

+ 3 - 1
apps/desktop/package.json

@@ -11,8 +11,10 @@
     "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",
+    "sign:primary-runtime": "tsx scripts/sign-primary-runtime.ts",
     "prepare:packages": "tsx scripts/prepare-package-set.ts",
     "prepare:dsh": "tsx scripts/prepare-dsh.ts",
     "prepare:package": "tsx scripts/package-target.ts --prepare-only",
@@ -36,7 +38,6 @@
     "semver": "^7.8.5"
   },
   "devDependencies": {
-    "@aws-sdk/client-s3": "3.1067.0",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@electron/get": "^5.1.0",
@@ -45,6 +46,7 @@
     "@types/node": "^22.20.0",
     "@types/semver": "^7.8.0",
     "app-builder-lib": "26.15.3",
+    "cos-nodejs-sdk-v5": "3.0.0",
     "electron": "^44.0.0",
     "electron-builder": "^26.15.3",
     "extract-zip": "^2.0.1",

+ 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>

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

@@ -0,0 +1,112 @@
+/** 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 preparationMessages = {
+    'stop-failed': messages.updateStopFailed,
+    'tasks-changed': messages.updateTasksChanged,
+    'tasks-unavailable': messages.updateTasksUnavailable,
+  }
+  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'
+        ? preparationMessages[update.preparationFailure] ?? 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) })

+ 48 - 0
apps/desktop/renderer/policy-login-loading.html

@@ -0,0 +1,48 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <!--
+      The document is the login window's placeholder until the first remote
+      document commits. It loads from disk before any network request, so it may
+      reach nothing: no origin, no font, no image, and no preload bridge.
+    -->
+    <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
+    <title></title>
+    <style>
+      html, body { height: 100%; margin: 0; }
+      body {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        gap: 10px;
+        background: Canvas;
+        color: CanvasText;
+        font: 14px/22px system-ui, -apple-system, "Segoe UI", sans-serif;
+      }
+      .spinner {
+        width: 16px;
+        height: 16px;
+        border: 2px solid color-mix(in srgb, CanvasText 25%, transparent);
+        border-top-color: CanvasText;
+        border-radius: 50%;
+        animation: spin 1s linear infinite;
+      }
+      @keyframes spin { to { transform: rotate(360deg); } }
+      @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
+    </style>
+  </head>
+  <body>
+    <div class="spinner" aria-hidden="true"></div>
+    <p id="label" role="status"></p>
+    <script>
+      // The main process owns the copy; this reads the one label it passed.
+      // Wrapped so the document declares no global of its own.
+      (() => {
+        const label = new URLSearchParams(location.search).get('label')
+        if (label !== null) document.getElementById('label').textContent = label
+      })()
+    </script>
+  </body>
+</html>

+ 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
+}

+ 44 - 0
apps/desktop/scripts/cos-operation.ts

@@ -0,0 +1,44 @@
+/** Total deadlines and request cleanup for one qualification COS operation. */
+import * as http from 'node:http'
+import * as https from 'node:https'
+import type COS from 'cos-nodejs-sdk-v5'
+
+/**
+ * Run one SDK operation with a shared deadline across its HTTP attempts.
+ * @param cos Client dedicated to this operation, with no concurrent users.
+ * @param timeoutMs Total network-operation budget in milliseconds.
+ * @param operation SDK call whose result is returned after its requests close.
+ * @returns SDK result; expiration rejects with TimeoutError after aborting the transport.
+ */
+export async function cosOperation<T>(cos: COS, timeoutMs: number, operation: () => Promise<T>): Promise<T> {
+  const controller = new AbortController()
+  const timeout = new Error('installed update: COS operation exceeded its deadline')
+  timeout.name = 'TimeoutError'
+  const closed: Promise<void>[] = []
+  const wrap = (transport: typeof http | typeof https) => ({
+    ...transport,
+    request(options: http.RequestOptions) {
+      const request = transport.request({ ...options, signal: controller.signal })
+      closed.push(new Promise<void>((resolve) => { request.once('close', resolve) }))
+      return request
+    },
+  })
+  const modules = { 'http:': wrap(http), 'https:': wrap(https) }
+  // cos-request forwards httpModules to its native request layer, including every SDK retry.
+  const configure = (options: { httpModules?: typeof modules }): void => { options.httpModules = modules }
+  cos.on('before-send', configure)
+  const timer = setTimeout(() => { controller.abort(timeout) }, timeoutMs)
+  try {
+    const result = await operation()
+    if (controller.signal.aborted) throw timeout
+    return result
+  } catch (error) {
+    if (controller.signal.aborted) throw timeout
+    throw error
+  } finally {
+    clearTimeout(timer)
+    controller.abort()
+    await Promise.all(closed)
+    cos.off('before-send', configure)
+  }
+}

+ 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)) {

+ 53 - 0
apps/desktop/scripts/desktop-cos.ts

@@ -0,0 +1,53 @@
+/** Shared Tencent COS client construction for Desktop update objects. */
+
+import COS from 'cos-nodejs-sdk-v5'
+
+/** Region of every Desktop update bucket, present or future deployment. */
+export const DESKTOP_COS_REGION = 'ap-beijing'
+
+/** Request-level inactivity deadline for COS transfers, in milliseconds. */
+const TRANSFER_TIMEOUT_MS = 900_000
+
+/** Credentials for one COS client; values are never written to retained records. */
+export interface DesktopCosCredentials {
+  readonly secretId: string
+  readonly secretKey: string
+}
+
+/** Request options the SDK exposes to `before-send` listeners. */
+interface CosRequestOptions {
+  headers: Record<string, unknown>
+}
+
+/**
+ * Create a COS client whose writes cannot be repeated by the SDK.
+ *
+ * The SDK retries a failed request only while the request body is not a stream, so every caller
+ * supplies a stream with an explicit `ContentLength` and a precomputed `Content-MD5`. Host
+ * switching, redirect following, and clock-offset correction stay off so an ambiguous write is
+ * never sent to another endpoint. The SDK also injects an empty `Cache-Control` header when the
+ * caller names none; that header is removed here so uploading leaves cache policy to deployment
+ * infrastructure.
+ * @param credentials SecretId and SecretKey for the selected deployment.
+ * @returns A COS client that sends HTTPS requests to the region named by each call.
+ */
+export function createDesktopCos(credentials: DesktopCosCredentials): COS {
+  const cos = new COS({
+    SecretId: credentials.secretId,
+    SecretKey: credentials.secretKey,
+    Protocol: 'https:',
+    KeepAlive: false,
+    FollowRedirect: false,
+    AutoSwitchHost: false,
+    CorrectClockSkew: false,
+    ChunkRetryTimes: 0,
+    Timeout: TRANSFER_TIMEOUT_MS,
+    UploadCheckContentMd5: false,
+  })
+  cos.on('before-send', (options: CosRequestOptions) => {
+    for (const name of Object.keys(options.headers)) {
+      if (name.toLowerCase() === 'cache-control' && options.headers[name] === '') delete options.headers[name]
+    }
+  })
+  return cos
+}

+ 9 - 3
apps/desktop/scripts/desktop-package-environment.mjs

@@ -7,13 +7,14 @@ 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 FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN']
+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', 'CSC_LINK']
 
 /**
  * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values.
@@ -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') {
@@ -96,5 +98,9 @@ export function validateDesktopPackageEnvironment(environment, target, options =
     const credentials = resolveMacOSNotarizationEnvironment(environment)
     if ('appleApiKey' in credentials) requireReadableFile(environment, 'APPLE_API_KEY')
     if ('keychain' in credentials) requireReadableFile(environment, 'APPLE_KEYCHAIN')
+    requireReadableFile(environment, 'CSC_LINK')
+    if (environment.CSC_KEY_PASSWORD === undefined) {
+      throw new Error('desktop package: CSC_KEY_PASSWORD must be set to the p12 export password (use an explicit empty value for an unencrypted p12)')
+    }
   }
 }

+ 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,

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

@@ -0,0 +1,126 @@
+/** 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 { Readable } from 'node:stream'
+import type COS from 'cos-nodejs-sdk-v5'
+import type { DesktopUploadArtifact, DesktopUploadPlan } from './desktop-upload-plan.ts'
+import { DESKTOP_COS_REGION } from './desktop-cos.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'])
+
+/** Object keys fingerprinted into every upload record to bind evidence to this uploader. */
+const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts']
+
+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) return {}
+  const response = value as { statusCode?: unknown; RequestId?: unknown }
+  return {
+    ...(typeof response.statusCode === 'number' ? { httpStatus: response.statusCode } : {}),
+    ...(typeof response.RequestId === 'string' && /^[\w+/=.-]{1,256}$/u.test(response.RequestId)
+      ? { requestId: response.RequestId } : {}),
+  }
+}
+
+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) }
+}
+
+function streamedBody(artifact: DesktopUploadArtifact): Readable {
+  return artifact.contents === undefined
+    ? createReadStream(artifact.path)
+    : Readable.from([Buffer.from(artifact.contents)])
+}
+
+/**
+ * Upload an already validated release, flushing intent and response evidence around every PUT.
+ *
+ * Each object is sent as one streamed PUT with an explicit length and Content-MD5, which is also
+ * what keeps the COS SDK's internal retry path unreachable: it repeats a request only when the
+ * body is not a stream. This function never retries either, so every confirmed PUT is the only
+ * write for its key.
+ * @param plan Validated release metadata; credential values must not be included.
+ * @param cos Caller-owned client from the Desktop COS factory in `desktop-cos.ts`.
+ * @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, cos: COS, 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 })
+    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 UPLOADER_SOURCES) {
+      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 = streamedBody(artifact)
+      try {
+        const response = await cos.putObject({ Bucket: plan.bucket, Region: DESKTOP_COS_REGION, Key: key,
+          Body: body, ContentLength: artifact.size, ContentType: artifact.contentType,
+          Headers: { 'Content-MD5': artifact.md5 } })
+        confirmedPuts++
+        stage = 'record-response'
+        recordPackagingEvent(directory, { type: 'put-confirmed', key, attempts: 1, ...receipt(response) })
+      } finally {
+        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'

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

@@ -0,0 +1,204 @@
+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 { preserveWindowsRuntimeSignature } from './windows-runtime-signature.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 buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
+  let primaryRuntimeDestination
+  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,
+        preserveSignature: async path => primaryRuntimeDestination === undefined ? false : preserveWindowsRuntimeSignature(path, {
+          sourceRoot: join(buildPaths.runtime, 'primary-runtime'),
+          destinationRoot: primaryRuntimeDestination,
+          runDirectory: env.DSH_DESKTOP_PACKAGING_RUN_DIR,
+        }),
+      })
+    : undefined
+  if (windowsSigner !== undefined) {
+    installWindowsNsisBootstrapSigner({ sign: windowsSigner })
+  }
+  const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch)
+  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/main.js',
+      'lib/preload-app.cjs',
+      'lib/preload-mandatory.cjs',
+      'lib/preload-update-dialog.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' },
+      { from: fileURLToPath(new URL('../resources/icon-windows.png', import.meta.url)), to: 'icon.png' },
+    ],
+    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 context => {
+      if (windowsSigner !== undefined) primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime')
+      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' }],
+  }
+}

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

@@ -0,0 +1,112 @@
+/** Fixed test-COS transport; callers authorize writes separately from local planning. */
+import { createReadStream } from 'node:fs'
+import { createHash } from 'node:crypto'
+import { Readable, Writable } from 'node:stream'
+import { cosOperation } from './cos-operation.ts'
+import { createDesktopCos, DESKTOP_COS_REGION } from './desktop-cos.ts'
+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'
+
+/** COS reports a missing key through this error code; no other status means absence. */
+function isMissingObject(error: unknown): boolean {
+  return typeof error === 'object' && error !== null && 'code' in error && error.code === 'NoSuchKey'
+}
+
+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.
+ * Version queries have a 30-second total deadline; object reads and PUTs have 15 minutes.
+ * Expiration aborts HTTP requests and waits for closure before releasing the publication operation.
+ * @returns Store whose writes are streamed and therefore cannot be repeated by the SDK.
+ */
+export function createInstalledUpdateCos(): InstalledUpdatePublicationStore {
+  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 credentials = {
+    secretId: environment.DOWNLOAD_TEST_COS_SECRET_ID,
+    secretKey: environment.DOWNLOAD_TEST_COS_SECRET_KEY,
+  }
+  const client = () => createDesktopCos(credentials)
+  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 cos = client()
+      const response = await cosOperation(cos, 30_000, () => cos.getBucketVersioning({ Bucket: BUCKET, Region: DESKTOP_COS_REGION }))
+      const status: 'Enabled' | 'Suspended' | undefined = response.VersioningConfiguration.Status
+      return response.statusCode === 200 && status === undefined
+    },
+    async read(key) {
+      keyAllowed(key)
+      const hash = createHash('sha512')
+      let size = 0
+      // Output keeps the object out of memory and makes the SDK wait for the write to finish.
+      const output = new Writable({
+        write(bytes: Buffer, _encoding, done) { hash.update(bytes); size += bytes.length; done() },
+      })
+      try {
+        const cos = client()
+        await cosOperation(cos, 900_000, () => cos.getObject({ Bucket: BUCKET, Region: DESKTOP_COS_REGION, Key: key, Output: output }))
+      } catch (error) {
+        if (isMissingObject(error)) return null
+        throw error
+      } finally { output.destroy() }
+      return { sha512: hash.digest('base64'), size }
+    },
+    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 headers: Record<string, string> = { 'Content-MD5': md5.digest('base64') }
+      if (object.forbidOverwrite) headers['x-cos-forbid-overwrite'] = 'true'
+      const body = 'path' in object.source
+        ? createReadStream(object.source.path)
+        : Readable.from([Buffer.from(object.source.contents)])
+      try {
+        const cos = client()
+        const response = await cosOperation(cos, 900_000, () => cos.putObject({
+          Bucket: BUCKET, Region: DESKTOP_COS_REGION, Key: key, Body: body,
+          ContentLength: object.size, ContentType: key.endsWith('.yml') ? 'application/yaml' : 'application/octet-stream',
+          CacheControl: 'no-store', Headers: headers }))
+        return response.RequestId === undefined ? {} : { requestId: response.RequestId }
+      } finally { body.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 }
+}

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

@@ -0,0 +1,125 @@
+/** Verify identity and update configuration from an extracted installer payload, not a neighboring unpacked build. */
+import { createHash } from 'node:crypto'
+import { mkdtemp, readFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { dirname, 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 { inventoryDesktopRuntime, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts'
+import { resolveDesktopPolicyConfig } from '../src/mandatory-update-policy.ts'
+
+const require = createRequire(import.meta.url)
+const builderRequire = createRequire(require.resolve('app-builder-lib/package.json'))
+const { extractAll } = builderRequire('@electron/asar') as { extractAll: (archive: string, destination: string) => void }
+const { createTransformer } = builderRequire('app-builder-lib/out/fileTransformer.js') as {
+  createTransformer: (source: string, configuration: object, metadata: null) =>
+  (file: string) => string | null | Promise<string | null>
+}
+
+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'))
+  const policy = resolveDesktopPolicyConfig(metadata.dshMandatoryUpdatePolicy)
+  if (metadata.name !== `dsh-update-test-${run.id}` || metadata.version !== version
+    || metadata.dshDesktopAppId !== run.appId || metadata.main !== 'qualification-bootstrap.mjs'
+    || metadata.type !== 'module' || policy?.authentication !== 'feishu-test') {
+    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 && !path.startsWith('dsh/'))) {
+      throw new Error('installed update: application archive contains external entries')
+    }
+    if (node.files !== undefined) {
+      for (const [name, child] of Object.entries(node.files)) {
+        if (name === '.' || name === '..' || /[\\/:*?"<>|\x00-\x1f]/u.test(name)) {
+          throw new Error('installed update: application archive contains an unsafe path')
+        }
+        inspect(child, path === '' ? name : `${path}/${name}`)
+      }
+    } else if (!path.startsWith('dsh/') && !expectedPaths.has(path)) {
+      throw new Error('installed update: application archive contains additional files')
+    }
+  }
+  inspect(archive.header)
+  if (archive.header.files?.dsh?.files === undefined) throw new Error('installed update: application archive lacks the dsh runtime')
+  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 extracted = await mkdtemp(join(dirname(payload), 'asar-'))
+  extractAll(join(payload, 'resources/app.asar'), extracted)
+  const prepared = await verifyDesktopRuntime(join(run.root, version, 'dsh'), version, { platform: 'win32', arch: 'x64' })
+  const runtime = readDesktopRuntime(join(extracted, 'dsh'))
+  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')
+  }
+  const actualFiles = inventoryDesktopRuntime(join(extracted, 'dsh'))
+  if (JSON.stringify(actualFiles.map(file => file.path)) !== JSON.stringify(prepared.files.map(file => file.path))) {
+    throw new Error('installed update: packaged runtime file list differs from prepared inputs')
+  }
+  const transform = createTransformer('', {}, null)
+  for (const [index, file] of prepared.files.entries()) {
+    if (file.path.endsWith('.exe')) continue
+    if (runtime.files[index]!.sha256 !== file.sha256 || runtime.files[index]!.bytes !== file.bytes) {
+      throw new Error('installed update: non-executable runtime descriptor differs from prepared inputs')
+    }
+    const transformed = file.path.startsWith('node_modules/') && file.path.endsWith('/package.json')
+      ? await transform(runtimePath(join(run.root, version, 'dsh'), file.path)) : null
+    const bytes = transformed === null ? file.bytes : Buffer.byteLength(transformed)
+    const hash = transformed === null ? file.sha256 : createHash('sha256').update(transformed).digest('hex')
+    if (actualFiles[index]!.sha256 !== hash || actualFiles[index]!.bytes !== 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) => {
+      if (archive.getFile(join('dsh', file.path), false).unpacked !== true) {
+        throw new Error('installed update: executable runtime file must be outside ASAR')
+      }
+      return join(payload, 'resources/app.asar.unpacked/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 && 'statusCode' in error) {
+      const statusCode = error.statusCode
+      if (typeof statusCode === 'number' && statusCode >= 100 && statusCode <= 599) {
+        result.httpStatus = statusCode
+      }
+    }
+    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) }
+  }
+}

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

@@ -0,0 +1,278 @@
+/** 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 test 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 {
+  const pattern = /^\d+\.\d+\.\d+-(?:nightly\.[0-9.]+|[0-9A-Za-z.-]+\.\d{8}\.[1-9]\d*)$/u
+  if (versions.some(version => valid(version) !== version || !pattern.test(version))
+    || !gt(versions[1], versions[0])) {
+    throw new Error('installed update: two increasing dated test 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}`)
+  }
+}

部分文件因文件數量過多而無法顯示