瀏覽代碼

Merge remote-tracking branch 'origin/master' into worktree/code-diff-card

creatixchu 2 周之前
父節點
當前提交
7b93d1bd83
共有 100 個文件被更改,包括 1352 次插入 和 215 次删除
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
  2. 4 0
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
  3. 4 0
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml
  8. 2 0
      .agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md
  9. 2 0
      .agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
  11. 3 3
      .agents/notes/implemented/feature/2026-07-06-sandbox.md
  12. 3 3
      .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
  13. 2 2
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml
  14. 3 3
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md
  15. 3 3
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md
  16. 6 0
      .agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.i18n.yaml
  17. 35 0
      .agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md
  18. 35 0
      .agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md
  19. 6 0
      .agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.i18n.yaml
  20. 23 0
      .agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.md
  21. 23 0
      .agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.zh.md
  22. 2 2
      .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml
  23. 3 3
      .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md
  24. 3 3
      .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md
  25. 22 2
      THIRD_PARTY_NOTICES.md
  26. 27 5
      apps/desktop-host/src/primary-runtime.ts
  27. 2 1
      apps/desktop-host/src/workspace-dependencies.ts
  28. 64 1
      apps/desktop-host/tests/primary-runtime.spec.ts
  29. 2 2
      apps/desktop/README.i18n.yaml
  30. 7 5
      apps/desktop/README.md
  31. 7 5
      apps/desktop/README.zh.md
  32. 27 5
      apps/desktop/scripts/prepare-primary-runtime.ts
  33. 64 3
      apps/desktop/scripts/primary-runtime-lock.json
  34. 90 0
      apps/desktop/scripts/smoke-primary-runtime.py
  35. 30 0
      apps/desktop/src/directory-picker.ts
  36. 19 0
      apps/desktop/src/ipc.ts
  37. 5 13
      apps/desktop/src/main.ts
  38. 2 3
      apps/desktop/src/node-environment.ts
  39. 6 3
      apps/desktop/src/preload-app.ts
  40. 82 0
      apps/desktop/tests/directory-picker.spec.ts
  41. 23 2
      apps/desktop/tests/main-startup.spec.ts
  42. 19 0
      apps/desktop/tests/node-environment.spec.ts
  43. 17 1
      apps/desktop/tests/preload-app.spec.ts
  44. 54 3
      apps/desktop/tests/primary-runtime-preparation.spec.ts
  45. 50 0
      apps/desktop/tests/ptc-runtime.spec.ts
  46. 9 9
      apps/web/tests/expected/plugin-manager/live-enabled.expected.md
  47. 9 9
      apps/web/tests/expected/plugin-manager/manager.expected.md
  48. 38 0
      apps/web/tests/plugin-manager.e2e.ts
  49. 1 1
      lefthook.yml
  50. 1 1
      package.json
  51. 2 2
      packages/client/README.i18n.yaml
  52. 1 1
      packages/client/README.md
  53. 1 1
      packages/client/README.zh.md
  54. 2 2
      packages/client/ui-directory-picker-native/README.i18n.yaml
  55. 8 5
      packages/client/ui-directory-picker-native/README.md
  56. 7 4
      packages/client/ui-directory-picker-native/README.zh.md
  57. 2 1
      packages/client/ui-directory-picker-native/package.json
  58. 4 4
      packages/client/ui-directory-picker-native/src/client/flow.ts
  59. 6 10
      packages/client/ui-directory-picker-native/src/client/index.ts
  60. 58 1
      packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx
  61. 33 0
      packages/client/ui-directory-picker-native/tests/desktop-picker.client.spec.tsx
  62. 2 2
      packages/client/ui-plugin-manager/README.i18n.yaml
  63. 2 0
      packages/client/ui-plugin-manager/README.md
  64. 2 0
      packages/client/ui-plugin-manager/README.zh.md
  65. 6 6
      packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
  66. 12 0
      packages/client/ui-plugin-manager/src/client/locales.ts
  67. 26 1
      packages/client/ui-plugin-manager/src/client/presentation.ts
  68. 61 8
      packages/client/ui-plugin-manager/tests/components.client.spec.tsx
  69. 2 1
      packages/fs/tool-fs/src/sandbox.ts
  70. 11 0
      packages/fs/tool-fs/tests/tools.spec.ts
  71. 2 2
      packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml
  72. 1 1
      packages/ptc-runtime/ptc-runtime-node/README.md
  73. 1 1
      packages/ptc-runtime/ptc-runtime-node/README.zh.md
  74. 2 2
      packages/ptc-runtime/ptc-runtime-node/src/index.ts
  75. 2 0
      packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts
  76. 2 2
      packages/sandbox/sandbox/README.i18n.yaml
  77. 2 2
      packages/sandbox/sandbox/README.md
  78. 2 2
      packages/sandbox/sandbox/README.zh.md
  79. 7 10
      packages/sandbox/sandbox/src/escalation.ts
  80. 15 3
      packages/sandbox/sandbox/tests/escalation.spec.ts
  81. 2 2
      packages/shell/tool-bash/README.i18n.yaml
  82. 1 1
      packages/shell/tool-bash/README.md
  83. 1 1
      packages/shell/tool-bash/README.zh.md
  84. 9 2
      packages/shell/tool-bash/tests/tools.spec.ts
  85. 9 2
      packages/shell/tool-pwsh/tests/tools.spec.ts
  86. 3 0
      pnpm-lock.yaml
  87. 49 0
      scripts/gen-third-party-notices.spec.ts
  88. 61 10
      scripts/gen-third-party-notices.ts
  89. 25 11
      scripts/project-doc-site.spec.ts
  90. 6 4
      scripts/project-doc-site.ts
  91. 1 0
      scripts/run-gates.ts
  92. 1 1
      scripts/verify-package-readme-model-experience.ts
  93. 1 0
      snapshots/acp/acp.snapshot.ts
  94. 14 0
      snapshots/acp/fs-same-mode/input.json
  95. 14 0
      snapshots/acp/fs-same-mode/session.v3.jsonl
  96. 10 0
      snapshots/acp/fs-same-mode/snapshot.yml
  97. 8 0
      snapshots/acp/fs-same-mode/stdout.expected.jsonl
  98. 1 0
      snapshots/acp/fs-same-mode/workspace.expected/escalated.md
  99. 2 0
      snapshots/session/ptc-node-workspace/snapshot.yml
  100. 1 1
      snapshots/session/workspace-dependencies/session.v3.jsonl

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
-2026-09-10-desktop-web-wrapper.md: 13b2a36643198f649d6c4eb56df3e01aa98b50ac
-2026-09-10-desktop-web-wrapper.zh.md: de1f5e671c88ea03d6bd36e88692c81ff6f58404
+2026-09-10-desktop-web-wrapper.md: 445c574f86d6e465763603e7d447d92ffefb4f99
+2026-09-10-desktop-web-wrapper.zh.md: a41ce3c2f18237ddf580777e88fe05507998982f

+ 4 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md

@@ -26,8 +26,12 @@ Shared `initProfile` creates missing profile files and preserves existing conten
 
 This partially supersedes the private composition and portless transport in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That design avoided listening ports and used framed byte pipes to avoid Base64 expansion and cross-version V8 serialization. Shared HTTP gives up the portless guarantee and assigns serving and authentication to the existing Web implementation. Release identity, signing, process ownership, and native shell features remain active decisions.
 
+Native directory selection in the local application uses a narrow preload IPC call to Electron’s window-owned dialog. Main admits only the current application window’s main frame at `dsh-app://app`; shell, remote, and child frames cannot request it. Concurrent requests share the pending dialog and destroyed windows discard selections. Web backend selection and Host browse are shared.
+
 ## Alternatives considered
 
+**Use the Host OS chooser in Electron.** The Host’s macOS AppleScript dialog has no Electron parent window and cannot reliably follow application focus. Electron owns the local dialog while Web keeps its Host chooser; cancellation and errors do not launch a second chooser.
+
 **Maintain a second backend composition and carrier.** This permits a portless application, but every Web route, reload behavior, authentication change, and stream capability needs a Desktop implementation or explicit omission. Reintroduction requires a desktop product requirement that cannot use the Web implementation and justifies that continuing cost.
 
 **Merge CLI and Desktop plugin installations.** Shared boot code does not require shared executable dependencies. Separate installations allow independently qualified releases and plugin versions while their existing data owners govern shared sessions and settings.

+ 4 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md

@@ -26,8 +26,12 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析
 
 本记录部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的私有组合与无端口传输。该设计避免监听端口,并使用分帧字节管道避免 Base64 膨胀与跨版本 V8 序列化。共享 HTTP 放弃无端口保证,将服务与认证交给已有 Web 实现。发布身份、签名、进程归属及原生壳功能仍是有效决策。
 
+本地应用的原生目录选择通过窄 preload IPC 调用 Electron 的窗口所属对话框。Main 仅接受当前应用窗口中位于 `dsh-app://app` 的主框架请求;shell、远程页面和子框架均不能调用。并发请求共用待完成的对话框,窗口销毁后丢弃选择结果。Web 后端选择和 Host 浏览由共享实现负责。
+
 ## Alternatives considered
 
+**在 Electron 中使用 Host 操作系统选择器。** Host 的 macOS AppleScript 对话框没有 Electron 父窗口,无法可靠跟随应用焦点。Electron 负责本地对话框,Web 保留 Host 选择器;取消和错误不会启动第二个选择器。
+
 **维护第二套后端组合与传输。** 这允许应用不监听端口,但每项 Web 路由、重载行为、认证变化和流式能力都需要 Desktop 实现或明确省略。只有无法使用 Web 实现、且足以承担持续维护成本的桌面产品需求,才支持重新引入这种方案。
 
 **合并 CLI 与 Desktop 插件安装。** 共享启动代码不要求共享可执行依赖。独立安装允许分别验收发布与插件版本,共享会话和设置则仍由已有数据归属方负责。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-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/architecture/2026-09-11-desktop-electron-node-runtime.md
-2026-09-11-desktop-electron-node-runtime.md: 6743bbfcc9acffb04d28ef9f1540d516b10dd2fc
-2026-09-11-desktop-electron-node-runtime.zh.md: 73622e66183eb7ed94f654e861cb061e02be7a3d
+2026-09-11-desktop-electron-node-runtime.md: 71516bb930f8eee22c1c6a988b6dab596a53a215
+2026-09-11-desktop-electron-node-runtime.zh.md: 162908392b98ee6a666ca414b602207ad8344937

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md

@@ -18,7 +18,7 @@ This supersedes the separate-Node choice in the [packaging decision](2026-08-25-
 
 Host and pnpm launches pass `--expose-internals`: the bundled Cordis loader uses Node's internal ESM loader, while its native builtin accessor cannot locate the required symbol in Electron 44. The explicit flag makes that loader available without modifying Cordis. The RunAsNode fuse remains enabled.
 
-Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically.
+The Host inherits the caller PATH without Desktop’s private `bin` directory, so PTC and agent shells cannot resolve internal launchers through that directory. `DSH_DESKTOP_NODE_EXECUTABLE` is injected only for package installation. Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically.
 
 Electron's Node patches and native ABI are release compatibility obligations. The packaged native smoke exercises pnpm shell scripts without system Node on PATH, terminal output through the Windows shell, Koffi, Sharp, and HTML conversion. The Host smoke loads an external plugin sharing Cordis and serves its route through the real Web application. Platform signing and installed-application qualification remain required; Windows results do not establish macOS compatibility. Windows token signing runs serially and retains the first failure, preventing queued tasks from repeating a rejected PIN.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md

@@ -18,7 +18,7 @@ Desktop 通过自己的 Electron 可执行文件运行共享 Web Host 和内置
 
 Host 和 pnpm 启动时传入 `--expose-internals`:内置 Cordis 加载器使用 Node 内部 ESM 加载器,而其原生 builtin 访问器无法在 Electron 44 中找到所需符号。显式参数使加载器可用,无需修改 Cordis。RunAsNode fuse 保持启用。
 
-包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。
+Host 继承调用者的 PATH,不加入 Desktop 私有的 `bin` 目录,因此 PTC 和 agent shell 不会通过该目录解析内部启动器。`DSH_DESKTOP_NODE_EXECUTABLE` 仅为包安装注入。包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。
 
 Electron 的 Node 补丁和原生 ABI 属于发布兼容性责任。打包原生 smoke 在 PATH 不含系统 Node 的情况下验证 pnpm shell 脚本,并验证 Windows shell 终端输出、Koffi、Sharp 和 HTML 转换。Host smoke 加载共享 Cordis 的外部插件,通过真实 Web 应用提供其路由。各平台仍需完成签名和已安装应用验收;Windows 结果不能证明 macOS 兼容性。Windows Token 签名串行执行并保留首次失败,阻止排队任务重复提交被拒绝的 PIN。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-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/architecture/2026-09-11-sandboxed-node-ptc-runtime.md
-2026-09-11-sandboxed-node-ptc-runtime.md: 242b3cfedb49b7ab60c47c6ee03005ddb821bb33
-2026-09-11-sandboxed-node-ptc-runtime.zh.md: d1ab47550e49129ee3e1fefbdfa54cda397374a3
+2026-09-11-sandboxed-node-ptc-runtime.md: f851775460c5bd01760d31552bde8c691807ec06
+2026-09-11-sandboxed-node-ptc-runtime.zh.md: 94f3a37eb702a97161c1d08cec531fcffb3460f2

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md

@@ -14,6 +14,8 @@ The [PTC foundation](../feature/2026-06-15-ptc.md) remains responsible for regis
 
 `dsh-ptc-runtime-node` runs each program in one fresh Node process. The host resolves execution choices, confines the launch through the same `ctx.sandbox` provider as Bash, and gives process lifetime to `ctx.subprocess`. The child evaluates erasable TypeScript with direct Node APIs, an empty model environment and host-provided asynchronous bindings. No worker or persistent kernel remains inside this provider.
 
+The host preserves `ELECTRON_RUN_AS_NODE` for child startup; the bootstrap removes it from the native environment before evaluation, and model-visible `process.env` stays empty. Nested Electron launches require their own explicit Node-mode selection. Desktop uses Electron as its Node executable; removing this selector launches Electron's application path instead of the PTC bootstrap. Sandbox permission changes cannot repair that launch mismatch. The macOS Desktop regression uses real Electron to verify binding writes, direct workspace writes, and rejection of writes outside the workspace under restricted policy. It requires an installed Electron binary; ordinary runtime tests cover environment filtering without that dependency.
+
 ### Resolved inputs and policy
 
 `PtcRuntime.resolve(request)` validates supported options and supplies a complete `PtcRunSpec`; `run(spec)` does not introduce defaults. PTC passes the calling Session's cwd and resolved standing policy. Direct runtime callers receive deployment defaults through the same resolver. The filesystem and subprocess providers share one execution world, and bootstrap paths cross through the filesystem's explicit host-file mapping or a configured preinstalled bootstrap.

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md

@@ -14,6 +14,8 @@ Node worker 隔离 JavaScript 状态,但不应用调用 Session 的 OS 沙箱
 
 `dsh-ptc-runtime-node` 在一个全新 Node 进程中运行每个程序。Host 解析执行选择,通过与 Bash 相同的 `ctx.sandbox` 提供方约束启动,并将进程生命周期交给 `ctx.subprocess`。子进程以直接 Node API、空模型环境和 Host 提供的异步绑定求值可擦除 TypeScript。本提供方不保留 worker 或持久内核。
 
+Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前将其从原生环境中删除,模型可见的 `process.env` 仍为空。嵌套启动 Electron 需要自行显式选择 Node 模式。桌面端使用 Electron 作为 Node 可执行文件;删除此选择变量会启动 Electron 应用路径,而不是 PTC bootstrap。更改沙箱权限无法修复这一启动模式不匹配。macOS 桌面端回归测试使用真实 Electron 验证绑定写入、直接工作区写入以及受限策略对工作区外写入的拒绝。该测试需要已安装的 Electron 二进制文件;普通运行时测试无需此依赖即可覆盖环境过滤。
+
 ### 已解析输入与策略
 
 `PtcRuntime.resolve(request)` 验证支持的选项并补全 `PtcRunSpec`;`run(spec)` 不引入默认值。PTC 传入调用 Session 的 cwd 与已解析常设策略。直接运行时调用方通过同一解析器取得部署默认值。文件系统与子进程提供方共享一个执行世界,bootstrap 路径通过文件系统的显式宿主文件映射或配置的预安装 bootstrap 传递。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-06-sandbox.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-07-06-sandbox.md
-2026-07-06-sandbox.md: 9f7139c8088df35ac87ddd66120dd8d6dc8e6e35
-2026-07-06-sandbox.zh.md: b8d8b1feb0db91431d0e71bea42299eafbc6856f
+2026-07-06-sandbox.md: 45a1c9c028f127c67e56f4436088852f59e10140
+2026-07-06-sandbox.zh.md: 6ad51a039583949849c853fab2f7c2a72d6d9db7

+ 3 - 3
.agents/notes/implemented/feature/2026-07-06-sandbox.md

@@ -86,7 +86,7 @@ The model sees the current effective file policy in the owner-derived `sandbox:p
 
 `ctx.sandboxPolicy.resolve()` stamps the complete execution policy — explicit escalation mode > session override > configured default, with `SessionHeader.cwd` > configured fallback root — before the executor runs. `SandboxBashExecutor.resolve()` retains that policy on the spec, or supplies the deployment fallback for a direct agentless caller, so `run()`/`start()` never read mutable session state. Per-process wrap facts are keyed by the returned `ShellProcess`; `onProcessDone()` receives spawn failure out of band from stderr classification and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects.
 
-When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted.
+When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution accepts a repeated effective mode without approval and rejects narrower or unsupported targets ([same-mode requests](2026-09-16-sandbox-same-mode.md)). Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted.
 
 Escalation is a same-turn retry of the denied command with the narrowest sufficient `sandbox_permissions` and a `justification`; the approval prompt is the consent step. It must be grounded in an actual denial, except when the session already observed the same denied access, and a disabled or rejected approval ends that command. The retry, approval decision, and result use existing tool and approval events. `dsh-tool-bash` owns the ask because the executor Service Definition has neither the agent nor call id required for user interaction.
 
@@ -164,7 +164,7 @@ Each phase gets its full design when picked up, validated against the code at th
 What shipped pins — the tiers in Testing hold each:
 
 - A denied command retried with `sandbox_permissions` + `justification` prompts the user through the composed answerer chain; a grant runs THAT call under the wider mode (result facts say so) while every other call keeps its own effective mode; every non-grant outcome produces its distinct error text and executes nothing.
-- The escalation fields exist exactly when the mounted executor confines; a request that is not strictly wider than the call's effective mode fails closed with its own text and prompts no one; a deployment with no ApprovalService fails escalating calls closed and leaves plain calls untouched.
+- The escalation fields exist exactly when the mounted executor confines; a repeated effective mode succeeds without approval; narrower or unsupported targets fail closed without prompting; a deployment with no ApprovalService fails escalating calls closed and leaves plain calls untouched.
 - One sourced policy-context message states the complete current sandbox and approval policies atomically; the whole exchange — context messages, headers, knob events, approval notices, approvals, and results — reconstructs from the session log alone, with no policy bookkeeping events beyond the two knob events.
 - One preset selection records only changed knob values, while a no-op selection records nothing; the next pre-step upserts both current values atomically, and a committed sandbox switch is honored by the next call's stamp.
 - A resumed session's overrides enter its first new policy-context message with no catch-up state; a composition default changed while the process was down likewise appears in that message.
@@ -182,7 +182,7 @@ Costs and accepted limits:
 - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined.
 - **The launcher is a workspace dependency in source and an npm dependency after publication.** The main repository tests reviewed C source, native CI builds, and byte-pinned local tarballs together before publishing the same package family; the real-kernel e2e legs vouch for behavior through those installed bytes.
 - **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants.
-- **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone.
+- **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a same-mode request needs no approval, and narrower or unsupported targets fail without prompting.
 - **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, through the spawn channel when the selected executable cannot start, or through a structured rule when a started runner refuses — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority.
 - **Runtime-context history is append-only.** A policy switch appends a complete superseding snapshot after retained history, preserving the stable system-and-conversation prefix; unchanged state adds no message.
 - **Older policy snapshots remain in history.** Each full snapshot explicitly supersedes earlier runtime-context snapshots, so replay and compaction need only retain the latest materialized message.

+ 3 - 3
.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md

@@ -86,7 +86,7 @@ Landlock launcher 源码和包家族位于 `native/system`,与 harness 消费
 
 `ctx.sandboxPolicy.resolve()` 在执行器运行前盖章完整执行策略——显式升级模式 > 会话覆盖 > 配置默认值,且 `SessionHeader.cwd` > 配置的后备根目录。`SandboxBashExecutor.resolve()` 在 spec 上保留该策略,或为直接的无 agent 调用方提供部署后备值,使 `run()`/`start()` 永不读取可变会话状态。每进程包装事实以返回的 `ShellProcess` 为键;`onProcessDone()` 会通过 stderr 分类之外的通道接收 spawn 失败,并在 `done` 结算前给该句柄盖章,因此重叠进程各自保留自己的模式和 runner 方言。
 
-当约束执行器被挂载时,`bash` 公布配对的 `sandbox_permissions` 和 `justification` 字段。schema 暴露完整的封闭升级词汇,因为有效模式是按会话的;执行拒绝任何不严格宽于该调用有效模式的目标。批准在执行之前解析。`allowed-once` 仅将授权模式盖章到该请求上,而 `rejected`、`cancelled`、`unavailable`、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。
+当约束执行器被挂载时,`bash` 公布配对的 `sandbox_permissions` 和 `justification` 字段。schema 暴露完整的封闭升级词汇,因为有效模式是按会话的;执行允许重复有效模式且无需审批,拒绝更窄或不支持的目标([同模式请求](2026-09-16-sandbox-same-mode.zh.md))。批准在执行之前解析。`allowed-once` 仅将授权模式盖章到该请求上,而 `rejected`、`cancelled`、`unavailable`、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。
 
 升级是对被拒绝命令的同轮次重试,使用最窄的足够 `sandbox_permissions` 和一个 `justification`;批准提示词是同意步骤。它必须基于实际的拒绝,除非会话已观察到相同的被拒绝访问;禁用或被拒绝的批准终结该命令。重试、批准决策和结果使用既有的工具和批准事件。`dsh-tool-bash` 拥有请求动作,因为执行器 Service Definition 既没有 agent 也没有用户交互所需的 call id。
 
@@ -164,7 +164,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边
 已交付并固定的内容——测试中的各层级分别保障:
 
 - 被拒绝的命令以 `sandbox_permissions` + `justification` 重试时,通过组合的应答器链提示用户;授权使该次调用在更宽模式下运行(结果事实如此报告),而其他所有调用保持各自的有效模式;每种非授权结果产生各自不同的错误文本且不执行任何内容。
-- 升级字段恰好在已挂载的执行器约束时存在;不严格宽于调用有效模式的请求以自身文本失败关闭且不提示任何人;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。
+- 升级字段恰好在已挂载的执行器约束时存在;重复有效模式无需审批即可成功;更窄或不支持的目标不提示任何人并失败关闭;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。
 - 一条带来源的策略上下文消息会以原子方式声明完整的当前沙箱策略与批准策略;整个交互——上下文消息、header、旋钮事件、批准通知、批准与结果——仅从会话日志即可重建,除两个旋钮事件外没有策略簿记事件。
 - 一次 preset 选择只记录发生变化的旋钮值,而无操作的选择不记录任何内容;下一次 pre-step 会原子 upsert 两个当前值,已提交的沙箱切换由下一次调用的盖章兑现。
 - 恢复会话的覆盖项会进入其首条新策略上下文消息,无需追赶状态;进程停止期间变更的组合默认值也会出现在该消息中。
@@ -182,7 +182,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边
 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。
 - **launcher 在源码中是 workspace 依赖,发布后是 NPM 依赖。** 主仓库会在发布同一个包家族之前,一起测试经审查的 C 源码、原生 CI 构建和字节固定的本地 tarball;真实内核 e2e 测试环节会验证这些安装字节的实际行为。
 - **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。
-- **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。
+- **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——同模式请求无需审批,更窄或不支持的目标不提示任何人并失败。
 - **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——平台没有链或所有探测失败时在 `confine()` 阶段失败,所选可执行文件无法启动时通过 spawn 通道失败,已启动的 runner 拒绝时则通过结构化规则失败——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。
 - **运行时上下文历史仅追加。** 策略切换会在保留的历史之后追加一份用于取代先前快照的完整快照,从而保留稳定的系统与对话前缀;状态不变时不添加消息。
 - **旧策略快照仍保留在历史中。** 每份完整快照都会明确取代更早的运行时上下文快照,因此回放与压缩(compaction)只需保留最新具体化的消息。

+ 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: 17d489e6c788c786cefcdddf5f1983ac48522bfe
-2026-09-14-desktop-primary-runtime.zh.md: f8be37801452ebf4f4623a1ad0342de770e21ad5
+2026-09-14-desktop-primary-runtime.md: d4dfd5fae400737de5ae852c05e2b6f117dc78f1
+2026-09-14-desktop-primary-runtime.zh.md: e64b6a53d6270f922e477fd844d611f8249581a4

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

@@ -10,11 +10,11 @@ Desktop agents need predictable Python data-processing libraries and an independ
 
 ## Decision
 
-Desktop ships Python, Node.js, pnpm, numpy and pandas as one release-bound payload. The path-query tool installs the payload from application resources into the fixed Harness-home directory and returns absolute paths. It does not change PATH, environment variables or package-manager configuration. pnpm uses its native global-install rules.
+Desktop ships Python, Node.js, pnpm, data-processing libraries and Office authoring libraries as one release-bound payload. The path-query tool installs the payload from application resources into the fixed Harness-home directory and returns absolute paths and the bundled Python distribution versions. The version report excludes packages added by users. It does not change PATH, environment variables or package-manager configuration. pnpm uses its native global-install rules.
 
-The application version and component versions live in `runtime.json`, not the directory name. Installation publishes a completed staged copy and retains the previous directory until replacement succeeds. Matching releases reuse installed files; upgrades replace user-added Python dependencies inside the managed tree. The Desktop single-instance owner and the tool's shared installation promise serialize normal installation requests.
+The application version, component versions, Python distribution versions and locked-input digest live in `runtime.json`, not the directory name. The digest covers the selected target's interpreter and wheel archives, shared wheel inputs, Python distribution versions, pnpm version and assembly format; other targets do not invalidate it. Key order within the selected target, wheel records and distribution map, plus wheel-entry order, participates in this identity; top-level lock key order does not. Extraction or assembly changes that alter payload bytes without changing locked inputs require an explicit format bump. Installation publishes a completed staged copy and retains the previous directory until replacement succeeds. Matching payloads reuse installed files; replacements also replace user-added Python dependencies inside the managed tree. Older manifests without a digest remain readable and differ from the current payload. Distribution names use PEP 503 normalization; duplicate normalized names are rejected, and present numpy/pandas distribution versions must agree with their component versions. The Desktop single-instance owner and the tool's shared installation promise serialize normal installation requests.
 
-Node downloads and hash-verifies the complete locked wheel set and unpacks these library-only archives into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Wheels with `.data` installation directories are rejected; command-line entry-point wrappers are outside this library payload. Native smoke executes the final payload after temporary files are removed, so interpreter links must survive relocation.
+Node downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload.
 
 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.
 

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

@@ -10,11 +10,11 @@ Desktop 代理需要在没有开发环境的机器上获得确定的 Python 数
 
 ## Decision
 
-Desktop 将 Python、Node.js、pnpm、numpy 和 pandas 作为绑定应用版本的产物交付。路径查询工具从应用资源将产物安装到 Harness home 下的固定目录,并返回绝对路径。它不修改 PATH、环境变量或包管理器配置。pnpm 使用原生全局安装规则。
+Desktop 将 Python、Node.js、pnpm、数据处理库和 Office 创作库作为绑定应用版本的产物交付。路径查询工具从应用资源将产物安装到 Harness home 下的固定目录,并返回绝对路径与内置 Python 分发包版本。版本报告不包含用户自行添加的包。它不修改 PATH、环境变量或包管理器配置。pnpm 使用原生全局安装规则。
 
-应用版本和组件版本记录在 `runtime.json` 中,不放在目录名里。安装发布完整的暂存副本,并在替换成功前保留之前的目录。同版本复用已安装文件;升级替换受管目录内用户添加的 Python 依赖。Desktop 单实例所有者和工具共享的安装 Promise 串行处理正常安装请求。
+应用版本、组件版本、Python 分发包版本和锁定输入摘要记录在 `runtime.json` 中,不放在目录名里。摘要涵盖所选目标的解释器与 wheel 压缩包、共享 wheel 输入、Python 分发包版本、pnpm 版本和组装格式;其他目标不会使其失效。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序参与该身份计算;锁文件顶层键的顺序不参与。解压或组装逻辑在锁定输入不变时改变产物字节,必须显式提升格式版本。安装发布完整的暂存副本,并在替换成功前保留之前的目录。相同产物复用已安装文件;替换也会移除受管目录内用户添加的 Python 依赖。不含摘要的旧清单仍可读取,并与当前产物区分。分发包名称按 PEP 503 归一化,归一化后重复的名称会被拒绝;清单包含 numpy/pandas 分发包版本时,必须与相应组件版本一致。Desktop 单实例所有者和工具共享的安装 Promise 串行处理正常安装请求。
 
-Node 下载并校验完整锁定 wheel 集的哈希,将这些仅含库的压缩包解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。含 `.data` 安装目录的 wheel 会被拒绝;命令行入口包装器不属于该库产物。本机 smoke 在临时文件删除后执行最终产物,因此解释器链接必须在迁移后仍有效。
+Node 下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。
 
 macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.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-15-docs-page-markdown-actions.md
+2026-09-15-docs-page-markdown-actions.md: ff4002fd69d670b743b7394544cf8486b141b5ce
+2026-09-15-docs-page-markdown-actions.zh.md: 4e2db5e55f47104d1b2f401099648cc1d6a7dc34

+ 35 - 0
.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md

@@ -0,0 +1,35 @@
+# Agent Note: Page Markdown actions in the documentation site
+
+Status: implemented
+
+English | [中文](2026-09-15-docs-page-markdown-actions.zh.md)
+
+## Problem
+
+Readers need a visible way to obtain one documentation page as Markdown. The published text must retain its current language and projected links. A browser fetch also shares its development URL with Vite's page-module imports, so treating every Markdown request as source text breaks navigation.
+
+## Decision
+
+The [projector](../../../../scripts/project-doc-site.ts) supplies ordinary content pages with a `rawMarkdownPath` from the publication manifest. The [theme](../../../../website/.vitepress/theme/index.ts) combines that path with the site base and exposes copy and view actions above the document. Directory pages use their full `index.md` route, which preserves the same relative-link location in development and static builds. Redirect homes and missing pages have no actions. Raw output excludes projection metadata.
+
+Server-rendered pages expose a raw-Markdown link. Client mounting replaces it with the copy button and menu, so MPA builds and pages without JavaScript retain a usable action. The primary button copies the page directly and keeps keyboard focus during copying through `aria-disabled` and `aria-busy`. Its adjacent toggle opens a menu with icons, action titles, and explanatory text, keeping the common action visible while grouping Markdown options. Menu items support arrow keys, Home, End, and activation; Escape restores toggle focus, while outside pointers and focus leaving dismiss the menu. Menu presses preserve focus until click activation, including in browsers that do not focus pressed controls. Menu state and its outside-pointer listener belong to the page instance.
+
+The [development middleware](../../../../website/raw-markdown.ts) admits explicitly marked `?dsh-raw=1` browser fetches and returns 404 for unpublished raw routes. Script imports always reach Vite. The view link opens the ordinary raw URL in a new tab; copy reads that same projected body on demand. Emitted Markdown files carry a UTF-8 BOM because static hosts can omit the response charset, causing direct browser navigation to misdecode Chinese and other non-ASCII characters. Fetch decoding removes the BOM before copying. Neither operation reconstructs Markdown from the rendered DOM.
+
+The [copy component](../../../../website/.vitepress/theme/page-markdown-actions.ts) calls `clipboard.write` within the click gesture and supplies a promise-backed `text/plain` ClipboardItem. Awaiting the network first would lose user activation in browsers that require it. Each route and language gets its own keyed component instance, so old writes cannot change new-page feedback. Disposal aborts unfinished data reads. A system clipboard write that has already consumed its data cannot be withdrawn. Request and clipboard failures provide localized manual-copy guidance, and success follows the completed write. Denied writes may never consume their data promise, so that promise has its own rejection observer.
+
+## Alternatives considered
+
+**Derive the raw URL from the browser location.** Clean URLs, directory indexes, language prefixes, and deployment bases make this less reliable than the projector's known route.
+
+**Serve every Markdown fetch as source text.** Vite imports the same URLs as JavaScript modules. An explicit request marker keeps source retrieval separate while static hosting still serves the emitted file.
+
+**Fetch first, then call `writeText`.** This can work in Chromium but loses the initiating gesture across the network wait in other browsers. Promise-backed clipboard data preserves that gesture without prefetching pages.
+
+## Consequences
+
+Automatic copying accepts `text/markdown` or `text/plain` responses. Hosts must assign one of these content types to `.md` files; missing types and `application/octet-stream` fail with manual-copy guidance. The allowlist also rejects HTML fallbacks and JavaScript page modules. Automatic copying requires the browser's asynchronous ClipboardItem write API in a secure context. Unsupported or denied writes retain the view link for manual copying. Page copy state is transient and belongs to one route; it creates no Session data or model request.
+
+The [component tests](../../../../website/tests/page-markdown-actions.spec.ts) pin localized accessible output beside their owner and exercise deferred reads, denied writes, and navigation disposal. The [middleware tests](../../../../website/tests/raw-markdown.spec.ts) cover request dispatch. Both run through the unit suite, `docs:check`, and `doc-sync`. The [Mermaid viewer decision](2026-09-14-docs-mermaid-viewer.md) retains its independent rendering and resource-lifetime rules.
+
+**CI coverage gap.** DOM tests simulate the clipboard and do not execute native user-activation rules, actual paste, or responsive layout. Development and static-preview browser verification remains necessary, including both locales and site bases. No real model round participates in this static-document feature.

+ 35 - 0
.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 文档站页面 Markdown 操作
+
+Status: implemented
+
+[English](2026-09-15-docs-page-markdown-actions.md) | 中文
+
+## 问题
+
+读者需要可见的入口来获取单页文档的 Markdown。发布的文本必须保留当前语言和投影后的链接。浏览器请求在开发环境中还与 Vite 页面模块导入共用 URL,因此将所有 Markdown 请求都作为原文处理会破坏导航。
+
+## 决策
+
+[投影器](../../../../scripts/project-doc-site.ts) 根据发布 manifest(元数据清单)为普通内容页提供 `rawMarkdownPath`。[主题](../../../../website/.vitepress/theme/index.ts) 将该路径与站点 base 组合,在正文上方提供复制和查看操作。目录页使用完整的 `index.md` 路由,使相对链接在开发环境和静态构建中保持相同的位置。首页重定向和缺失页面不显示操作。原文输出不包含投影元数据。
+
+服务端渲染的页面提供原文链接。客户端挂载后将其替换为复制按钮和菜单,使 MPA 构建及未运行 JavaScript 的页面保留可用操作。主按钮直接复制页面,并通过 `aria-disabled` 和 `aria-busy` 在复制期间保留键盘焦点。旁边的展开按钮打开带图标、操作标题和说明文字的菜单,使常用操作保持可见,并将 Markdown 选项放在一起。菜单项支持方向键、Home、End 和激活操作;Escape 将焦点恢复到展开按钮,外部指针操作和焦点离开会关闭菜单。菜单按下时保留焦点直至点击执行,也适用于按下控件时不转移焦点的浏览器。菜单状态及其外部指针监听器归属于页面实例。
+
+[开发中间件](../../../../website/raw-markdown.ts) 接受带有显式 `?dsh-raw=1` 标记的浏览器请求,对未发布的原文路由返回 404。脚本导入始终交给 Vite。查看链接在新标签页打开普通原文 URL;复制按需读取相同的投影正文。生成的 Markdown 文件携带 UTF-8 BOM,因为静态托管可能省略响应字符集,导致浏览器直接访问时错误解码中文等非 ASCII 字符。Fetch 解码会在复制前移除 BOM。两种操作都不从渲染后的 DOM 重建 Markdown。
+
+[复制组件](../../../../website/.vitepress/theme/page-markdown-actions.ts) 在点击手势内调用 `clipboard.write`,并提供由 Promise 承载数据的 `text/plain` ClipboardItem。先等待网络会在要求用户激活的浏览器中丢失该激活状态。每个路由和语言组合都有独立的、带 key 的组件实例,因此旧写入无法改变新页面的反馈。释放时取消未完成的数据读取。已经消费数据的系统剪贴板写入无法撤回。请求失败和剪贴板失败提供本地化的手动复制指引,成功提示在写入完成后显示。被拒绝的写入可能完全不消费数据 Promise,因此该 Promise 有独立的拒绝处理器。
+
+## 考虑过的替代方案
+
+**根据浏览器地址推导原文 URL。** 简洁 URL、目录索引、语言前缀和部署 base 使这种方式不如投影器已知的路由可靠。
+
+**将所有 Markdown 请求都作为原文处理。** Vite 将相同的 URL 导入为 JavaScript 模块。显式请求标记区分原文获取,同时静态托管仍可直接返回已生成的文件。
+
+**先获取正文,再调用 `writeText`。** 这种方式可能在 Chromium 中有效,但其他浏览器会在网络等待后丢失最初的手势。由 Promise 承载剪贴板数据可保留该手势,无需预取页面。
+
+## 影响
+
+自动复制接受 `text/markdown` 或 `text/plain` 响应。托管方必须为 `.md` 文件指定其中一种内容类型;缺失类型和 `application/octet-stream` 会触发失败及手动复制指引。白名单还会拒绝 HTML 回退页面和 JavaScript 页面模块。自动复制需要浏览器在安全上下文中提供异步 ClipboardItem 写入 API。写入不受支持或被拒绝时,查看链接仍可用于手动复制。页面复制状态是临时状态,归属于单一路由;它不创建 Session 数据或模型请求。
+
+[组件测试](../../../../website/tests/page-markdown-actions.spec.ts) 在所属目录中固定本地化的可访问输出,并验证延迟读取、写入拒绝和导航时的资源清理。[中间件测试](../../../../website/tests/raw-markdown.spec.ts) 覆盖请求分发。两者都通过单元测试、`docs:check` 和 `doc-sync` 执行。[Mermaid 查看器决策](2026-09-14-docs-mermaid-viewer.zh.md) 保留独立的渲染与资源生命周期规则。
+
+**CI 覆盖缺口。** DOM 测试模拟剪贴板,不执行原生用户激活规则、实际粘贴或响应式布局。因此仍需在开发服务和静态预览中进行浏览器验证,覆盖两种语言和站点 base。此静态文档功能不涉及真实模型轮次。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.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-16-sandbox-same-mode.md
+2026-09-16-sandbox-same-mode.md: f1b7c3ae05103363f47f28fab1ae534a8a8b04c2
+2026-09-16-sandbox-same-mode.zh.md: 0bb04415230ec7a0f0f3fdd3c7f00bd7b76d9d9b

+ 23 - 0
.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.md

@@ -0,0 +1,23 @@
+# Agent Note: Repeated sandbox modes need no approval
+
+Status: implemented
+
+English | [中文](2026-09-16-sandbox-same-mode.zh.md)
+
+## Problem
+
+Models can repeat `sandbox_permissions: danger-full-access` while that mode is already effective. Rejecting the call prevents authorized work without preventing any permission increase.
+
+## Decision
+
+`approveEscalation` returns the effective mode immediately when the requested mode matches it. Argument pairing remains mandatory at the tool. Wider modes still require approval; narrower and unsupported targets still fail. This partially supersedes the non-widening rejection in the [sandbox decision](2026-07-06-sandbox.md); its confinement and per-call approval decisions remain active.
+
+## Alternatives considered
+
+**Reject every non-widening request.** This makes a redundant argument fatal even though it asks for no additional permission.
+
+**Ask for approval again.** Existing permission is sufficient, so another prompt adds no authorization.
+
+## Consequences
+
+Bash and filesystem tools accept repeated effective modes without an approval service or agent. Shared unit tests cover both advertised targets, both tool consumers execute the repeated mode, and the `fs-same-mode` ACP snapshot verifies an unrestricted write with no approval events and checks the resulting file.

+ 23 - 0
.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.zh.md

@@ -0,0 +1,23 @@
+# Agent Note: 重复沙箱模式无需审批
+
+Status: implemented
+
+[English](2026-09-16-sandbox-same-mode.md) | 中文
+
+## 问题
+
+模型可能在 `danger-full-access` 已生效时重复传入 `sandbox_permissions: danger-full-access`。拒绝该调用会阻止已获授权的工作,却没有阻止任何权限增加。
+
+## 决策
+
+当请求模式与生效模式相同时,`approveEscalation` 立即返回生效模式。工具仍要求参数成对出现。更宽模式仍需审批;更窄和不支持的目标仍然失败。这部分取代了[沙箱决策](2026-07-06-sandbox.zh.md)中的非放宽请求拒绝规则;其约束与逐调用审批决策仍然有效。
+
+## 考虑过的替代方案
+
+**拒绝所有非放宽请求。** 即使没有请求额外权限,冗余参数也会导致调用失败。
+
+**再次请求审批。** 现有权限已经足够,再次提示不会增加授权。
+
+## 影响
+
+Bash 和文件系统工具无需审批服务或 agent 即可接受重复生效模式。共享单元测试覆盖两个已公布目标,两个工具消费方均执行重复模式,`fs-same-mode` ACP 快照验证没有审批事件的无限制写入并检查生成文件。

+ 2 - 2
.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.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-07-30-generated-third-party-notices.md
-2026-07-30-generated-third-party-notices.md: 41ac0c75ca55c81f2055c867bd029ee7e4a350f9
-2026-07-30-generated-third-party-notices.zh.md: 4097831828287e7180cf37d713fa4694ae028b31
+2026-07-30-generated-third-party-notices.md: 2b0bd012c23c873b3003916491daad467cf757b3
+2026-07-30-generated-third-party-notices.zh.md: 0c7b4df8edb60f2f70781e179edc7377da930128

+ 3 - 3
.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md

@@ -12,13 +12,13 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o
 
 ## Decision
 
-[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, and `pnpm-workspace.yaml`. The root README pair links the file from its License section.
+[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [Desktop runtime lock](../../../../apps/desktop/scripts/primary-runtime-lock.json). The root README pair links the file from its License section.
 
-**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check.
+**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or Desktop runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check.
 
 One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case.
 
-The file discloses **direct** dependencies by default. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. The one explicit transitive disclosure is the official Claude platform payload set declared by `@anthropic-ai/claude-agent-sdk` through `optionalDependencies`, because those packages carry the distributed Claude Code executable rather than ordinary library implementation detail.
+The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the Desktop runtime lock's `pythonPackages`. Desktop entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The Desktop wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Desktop distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions.
 
 **Tiering follows distribution, not manifest section.** Installed runtime libraries are identified by `dependencies` or `optionalDependencies` outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/`. Browser inputs resolved by the shipping tsdown and Vite configurations also count as runtime, even in `devDependencies`; [browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) owns that classification. Test-support dependencies do not ship merely because their manifest says `dependencies`, and the generator explicitly discloses `tsx` because source launches execute through its ESM hook.
 

+ 3 - 3
.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md

@@ -12,13 +12,13 @@ Status: implemented
 
 ## 决策
 
-[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml` 与 `pnpm-workspace.yaml` 生成。根 README 双语两侧都从「许可证」一节链到该文件。
+[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [Desktop 运行时锁文件](../../../../apps/desktop/scripts/primary-runtime-lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。
 
-**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。
+**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或 Desktop 运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。
 
 有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除** manifest 不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。
 
-文件默认只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。唯一明确披露的传递依赖,是 `@anthropic-ai/claude-agent-sdk` 通过 `optionalDependencies` 声明的官方 Claude 平台载荷集合,因为这些包承载随产品分发的 Claude Code 可执行文件,而非普通的库实现细节。
+文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及 Desktop 运行时锁文件 `pythonPackages` 中的每个分发包。Desktop 条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。Desktop wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。Desktop 分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。
 
 **分层依据是分发内容,而非 manifest 字段名。** 安装的运行时库由 `DEV_ONLY_AREAS` 之外的 `dependencies` 或 `optionalDependencies` 识别;排除区域为根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/`。发布所用的 tsdown 与 Vite 配置解析到的浏览器输入也属于运行时,即使它们位于 `devDependencies`;[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)拥有这项分类。测试支撑依赖不会仅因字段写成 `dependencies` 就被交付,而生成器显式披露 `tsx`,因为源码启动通过其 ESM 钩子执行。
 

+ 22 - 2
THIRD_PARTY_NOTICES.md

@@ -5,9 +5,9 @@
 
 DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
 
-This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude Code platform payload closure. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check.
+This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Desktop bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check.
 
-The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock).
+The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock).
 
 ## Vendored source (`vendor/`)
 
@@ -235,6 +235,26 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm
 | [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only |
 | [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
 
+## Desktop bundled Python distributions
+
+The [Desktop runtime lock](apps/desktop/scripts/primary-runtime-lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels.
+
+| Distribution | Locked version | Project license |
+| --- | --- | --- |
+| [`et-xmlfile`](https://foss.heptapod.net/openpyxl/et_xmlfile) | 2.0.0 | MIT |
+| [`lxml`](https://github.com/lxml/lxml) | 6.1.3 | BSD-3-Clause |
+| [`numpy`](https://github.com/numpy/numpy) | 2.3.5 | BSD-3-Clause |
+| [`openpyxl`](https://foss.heptapod.net/openpyxl/openpyxl) | 3.1.5 | MIT |
+| [`pandas`](https://github.com/pandas-dev/pandas) | 3.0.1 | BSD-3-Clause |
+| [`pillow`](https://github.com/python-pillow/Pillow) | 12.3.0 | MIT-CMU |
+| [`python-dateutil`](https://github.com/dateutil/dateutil) | 2.9.0.post0 | Apache-2.0 OR BSD-3-Clause |
+| [`python-docx`](https://github.com/python-openxml/python-docx) | 1.2.0 | MIT |
+| [`python-pptx`](https://github.com/scanny/python-pptx) | 1.0.2 | MIT |
+| [`six`](https://github.com/benjaminp/six) | 1.17.0 | MIT |
+| [`typing-extensions`](https://github.com/python/typing_extensions) | 4.16.0 | PSF-2.0 |
+| [`tzdata`](https://github.com/python/tzdata) | 2025.2 | Apache-2.0 |
+| [`xlsxwriter`](https://github.com/jmcnamara/XlsxWriter) | 3.2.9 | BSD-2-Clause |
+
 ## First-party native packages
 
 `@deepseek-ai/node-addon-system` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.

+ 27 - 5
apps/desktop-host/src/primary-runtime.ts

@@ -8,6 +8,10 @@ export interface PrimaryRuntimeManifest {
   readonly desktopVersion: string
   readonly platform: string
   readonly arch: string
+  /** Locked payload identity; absent only in installations made before payload hashing. */
+  readonly payloadDigest?: string
+  /** Installed wheel distribution versions; absent in older release manifests. */
+  readonly pythonPackages?: Readonly<Record<string, string>>
   readonly components: {
     readonly python: string
     readonly node: string
@@ -17,17 +21,19 @@ export interface PrimaryRuntimeManifest {
   }
 }
 
-/** Absolute entry points; pnpm is a script executed with the returned Node executable. */
+/** Absolute entry points and bundled versions; pnpm runs through the returned Node executable. */
 export interface WorkspaceDependencies {
   readonly python: string
   readonly node: string
   readonly pnpm: string
   readonly pythonPackages: string
   readonly nodePackages: string
+  /** Locked distribution versions; excludes packages users add to the installed environment. */
+  readonly pythonDistributions: Readonly<Record<string, string>>
 }
 
 /**
- * Read and validate build metadata before selecting interpreter paths.
+ * Read build metadata, rejecting duplicate normalized names and conflicting component/distribution versions.
  * @param root - Installed or bundled primary runtime directory.
  * @returns Validated component versions and target identifiers.
  */
@@ -36,20 +42,35 @@ export async function readPrimaryRuntime(root: string): Promise<PrimaryRuntimeMa
   if (typeof value !== 'object' || value === null) throw new Error('primary runtime: invalid metadata')
   const record = value as Record<string, unknown>
   const components = record.components
+  const packages = record.pythonPackages
   if (typeof record.desktopVersion !== 'string' || record.desktopVersion.length === 0
     || !['win32', 'darwin'].includes(String(record.platform)) || !['x64', 'arm64'].includes(String(record.arch))
     || typeof components !== 'object' || components === null
-    || !['python', 'node', 'pnpm', 'numpy', 'pandas'].every(key => /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/u.test(String((components as Record<string, unknown>)[key])))) {
+    || !['python', 'node', 'pnpm', 'numpy', 'pandas'].every(key => /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/u.test(String((components as Record<string, unknown>)[key])))
+    || (record.payloadDigest !== undefined && (typeof record.payloadDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(record.payloadDigest)))
+    || (packages !== undefined && (typeof packages !== 'object' || packages === null || Array.isArray(packages)
+      || !Object.entries(packages).every(([name, version]) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(name)
+        && typeof version === 'string' && /^\d[\w.!+-]*$/u.test(version))))) {
     throw new Error('primary runtime: invalid metadata')
   }
-  return value as PrimaryRuntimeManifest
+  const manifest = value as PrimaryRuntimeManifest
+  const entries = Object.entries(manifest.pythonPackages ?? {})
+  const distributions = new Map(entries.map(([name, version]) => [name.toLowerCase().replace(/[-_.]+/gu, '-'), version]))
+  if (distributions.size !== entries.length) throw new Error('primary runtime: invalid metadata')
+  for (const name of ['numpy', 'pandas'] as const) {
+    const version = distributions.get(name)
+    if (version !== undefined && version !== manifest.components[name]) {
+      throw new Error(`primary runtime: conflicting ${name} distribution version`)
+    }
+  }
+  return manifest
 }
 
 /**
  * Resolve platform-specific interpreter and library locations without changing the environment.
  * @param root - Absolute installation directory.
  * @param manifest - Validated runtime metadata.
- * @returns Absolute paths for explicit script execution.
+ * @returns Absolute paths for explicit script execution and recorded bundled Python versions.
  */
 export function workspaceDependencyPaths(root: string, manifest: PrimaryRuntimeManifest): WorkspaceDependencies {
   const dependencies = join(root, 'dependencies')
@@ -60,6 +81,7 @@ export function workspaceDependencyPaths(root: string, manifest: PrimaryRuntimeM
     pnpm: join(dependencies, 'pnpm', 'bin', 'pnpm.mjs'),
     pythonPackages: join(dependencies, 'python', ...(windows ? ['Lib'] : ['lib', `python${manifest.components.python.split('.').slice(0, 2).join('.')}`]), 'site-packages'),
     nodePackages: join(dependencies, 'node', 'node_modules'),
+    pythonDistributions: manifest.pythonPackages ?? {},
   }
 }
 

+ 2 - 1
apps/desktop-host/src/workspace-dependencies.ts

@@ -26,7 +26,7 @@ export function apply(ctx: Context, config: Config): void {
   })
   ctx.tools.register(defineTool({
     name: 'load_workspace_dependencies',
-    description: 'Get absolute paths to bundled Python, Node.js, pnpm, and library directories. Python includes numpy and pandas. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings.',
+    description: 'Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings.',
     parameters: {},
     output: {
       schema: {
@@ -37,6 +37,7 @@ export function apply(ctx: Context, config: Config): void {
           pnpm: { type: 'string', required: true },
           pythonPackages: { type: 'string', required: true },
           nodePackages: { type: 'string', required: true },
+          pythonDistributions: { type: 'object', additionalProperties: true, required: true, description: 'Bundled distribution names and versions recorded in runtime.json; excludes user-installed additions.' },
         },
       },
       render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, undefined, 2) }],

+ 64 - 1
apps/desktop-host/tests/primary-runtime.spec.ts

@@ -24,6 +24,7 @@ async function fixture() {
   const manifest: PrimaryRuntimeManifest = {
     desktopVersion: '1.0.0', platform: process.platform === 'win32' ? 'win32' : 'darwin', arch: process.arch,
     components: { python: '3.12.14', node: '24.21.0', pnpm: '11.7.0', numpy: '2.3.5', pandas: '3.0.1' },
+    pythonPackages: { 'python-docx': '1.2.0', 'python-pptx': '1.0.2', openpyxl: '3.1.5' },
   }
   const paths = workspaceDependencyPaths(source, manifest)
   for (const path of [paths.python, paths.node, paths.pnpm]) {
@@ -39,16 +40,18 @@ async function fixture() {
 it.each(['win32', 'darwin'])('returns %s interpreter and package paths', (platform) => {
   const manifest: PrimaryRuntimeManifest = { desktopVersion: '1', platform, arch: 'x64', components: { python: '3.12.14', node: '24.21.0', pnpm: '11.7.0', numpy: '2.3.5', pandas: '3.0.1' } }
   const paths = workspaceDependencyPaths('/runtime', manifest)
+  expect(paths.pythonDistributions).toEqual({})
   expect(paths.python).toBe(join('/runtime', 'dependencies', 'python', ...(platform === 'win32' ? ['python.exe'] : ['bin', 'python3'])))
   expect(paths.pythonPackages).toBe(join('/runtime', 'dependencies', 'python', ...(platform === 'win32' ? ['Lib'] : ['lib', 'python3.12']), 'site-packages'))
 })
 
 it.skipIf(process.platform === 'linux')('installs offline, reuses the same release, and leaves environment and user packages unchanged', async () => {
-  const { source, root } = await fixture()
+  const { source, root, manifest } = await fixture()
   const environment = { ...process.env }
   const installed = await installPrimaryRuntime(source, root)
   await writeFile(join(installed.pythonPackages, 'user-package.py'), 'user content')
   expect(await installPrimaryRuntime(source, root)).toEqual(installed)
+  expect(installed.pythonDistributions).toEqual(manifest.pythonPackages)
   expect(await readFile(join(installed.pythonPackages, 'user-package.py'), 'utf8')).toBe('user content')
   expect(process.env).toEqual(environment)
 })
@@ -62,6 +65,43 @@ it.skipIf(process.platform === 'linux')('replaces release components and recover
   expect((await readPrimaryRuntime(root)).desktopVersion).toBe('2.0.0')
 })
 
+it.skipIf(process.platform === 'linux')('replaces dependencies when the locked payload changes without a Desktop version change', async () => {
+  const { source, root, manifest } = await fixture()
+  const first = { ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.1.2' } }
+  await writeFile(join(source, 'runtime.json'), JSON.stringify(first))
+  const installed = await installPrimaryRuntime(source, root)
+  await writeFile(join(installed.pythonPackages, 'old-package.py'), 'old dependency')
+  const next = { ...first, payloadDigest: 'b'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } }
+  await writeFile(join(source, 'runtime.json'), JSON.stringify(next))
+  await writeFile(join(workspaceDependencyPaths(source, next).pythonPackages, 'new-package.py'), 'new dependency')
+  await installPrimaryRuntime(source, root)
+  expect(await readPrimaryRuntime(root)).toEqual(next)
+  expect(await readFile(join(installed.pythonPackages, 'new-package.py'), 'utf8')).toBe('new dependency')
+  await expect(readFile(join(installed.pythonPackages, 'old-package.py'))).rejects.toMatchObject({ code: 'ENOENT' })
+})
+
+it.skipIf(process.platform === 'linux')('upgrades a release manifest without a payload digest', async () => {
+  const { source, root, manifest } = await fixture()
+  await installPrimaryRuntime(source, root)
+  await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } }))
+  await installPrimaryRuntime(source, root)
+  expect((await readPrimaryRuntime(root)).payloadDigest).toBe('a'.repeat(64))
+})
+
+it.skipIf(process.platform === 'linux')('replaces changed payload bytes when only the digest changes', async () => {
+  const { source, root, manifest } = await fixture()
+  const first = { ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } }
+  const sourceFile = join(workspaceDependencyPaths(source, first).pythonPackages, 'library.py')
+  await writeFile(join(source, 'runtime.json'), JSON.stringify(first))
+  await writeFile(sourceFile, 'first wheel bytes')
+  const installed = await installPrimaryRuntime(source, root)
+  await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...first, payloadDigest: 'b'.repeat(64) }))
+  await writeFile(sourceFile, 'repacked wheel bytes')
+  await installPrimaryRuntime(source, root)
+  expect(await readFile(join(installed.pythonPackages, 'library.py'), 'utf8')).toBe('repacked wheel bytes')
+  expect((await readPrimaryRuntime(root)).pythonPackages).toEqual(first.pythonPackages)
+})
+
 it.skipIf(process.platform === 'linux')('keeps the installed release when the replacement payload is incomplete', async () => {
   const { source, root, manifest } = await fixture()
   await installPrimaryRuntime(source, root)
@@ -90,6 +130,29 @@ it('rejects malformed metadata and incompatible targets', async () => {
   await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata')
 })
 
+it.each([['numpy', 'numpy'], ['pandas', 'pandas'], ['Numpy', 'numpy'], ['PANDAS', 'pandas']] as const)('rejects conflicting %s component and distribution versions', async (distribution, name) => {
+  const { source, manifest } = await fixture()
+  await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, pythonPackages: { [distribution]: '0.0.1' } }))
+  await expect(readPrimaryRuntime(source)).rejects.toThrow(`conflicting ${name} distribution version`)
+  const consistent = { ...manifest, pythonPackages: { [distribution]: manifest.components[name] } }
+  await writeFile(join(source, 'runtime.json'), JSON.stringify(consistent))
+  expect(await readPrimaryRuntime(source)).toEqual(consistent)
+})
+
+it.each([
+  { payloadDigest: 'invalid' },
+  { pythonPackages: ['python-docx'] },
+  { pythonPackages: { 'python-docx': '../escape' } },
+  { pythonPackages: { '../escape': '1.2.0' } },
+  { pythonPackages: { numpy: '2.3.5', Numpy: '2.3.5' } },
+  { pythonPackages: { Pillow: '12.3.0', pillow: '12.3.0' } },
+  { pythonPackages: { typing_extensions: '4.16.0', 'typing.extensions': '4.16.0' } },
+])('rejects invalid locked payload metadata: %j', async (invalid) => {
+  const { source, manifest } = await fixture()
+  await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, ...invalid }))
+  await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata')
+})
+
 it.skipIf(process.platform === 'linux')('loads the real tool through Cordis, exposes installed paths, and unregisters on disposal', async () => {
   const { source, root, manifest, directory } = await fixture()
   const ctx = new Context()

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

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

+ 7 - 5
apps/desktop/README.md

@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
 
 The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
 
+Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog.
+
 ## Key technical decisions
 
 The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation.
@@ -12,15 +14,15 @@ The macOS PNG uses an inset rounded background for legacy ICNS packaging, with r
 
 ### Bundled workspace dependencies
 
-The current Windows Python payload contains unsigned native extensions. Smart App Control blocked `_decimal`, `pyexpat`, `_lzma` and `_uuid` during local validation; XML and LZMA operations fail on that host. Successful numpy/pandas smoke checks do not establish compatibility for every extension.
+The current Windows Python payload contains unsigned native extensions. Smart App Control blocked `_decimal`, `pyexpat`, `_lzma` and `_uuid` during local validation; XML and LZMA operations fail on that host. Successful numpy/pandas smoke checks do not establish compatibility for every extension. Office-library compatibility under Smart App Control is unvalidated: lxml and Pillow also carry unsigned extensions, and blocking lxml prevents python-docx and python-pptx imports.
 
-Desktop carries independent Python, Node.js and pnpm distributions, with numpy and pandas in Python's `site-packages`. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory.
+Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory.
 
-The payload follows the Desktop release. `runtime.json` records the Desktop version, target and component versions; a matching installation is reused, and a different release replaces the directory after a complete staged copy. Python packages added to that directory are retained within the same release and replaced with the application baseline on upgrade. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running.
+The payload follows the Desktop release. `runtime.json` records the Desktop version, target, component and Python distribution versions, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names and conflicting numpy/pandas component and distribution versions reject the manifest. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running.
 
-This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices.
+The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices.
 
-Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](scripts/primary-runtime-lock.json) pins interpreter archives and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. The supported library wheels unpack directly into site-packages; wheels requiring other installation directories are rejected, and package command-line wrappers are not generated. Native-target checks execute the bundled interpreters and numpy/pandas operations after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
+Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](scripts/primary-runtime-lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
 
 | Decision | Why | Direct consequence |
 |---|---|---|

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

@@ -4,6 +4,8 @@
 
 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
 
+Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。
+
 ## 关键技术决策
 
 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。
@@ -12,15 +14,15 @@ macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含
 
 ### 内置工作区依赖
 
-当前 Windows Python 产物包含未签名的原生扩展。本机验证中,Smart App Control 阻止了 `_decimal`、`pyexpat`、`_lzma` 和 `_uuid`;该主机上的 XML 和 LZMA 操作失败。numpy/pandas 冒烟检查通过,不代表所有扩展都兼容。
+当前 Windows Python 产物包含未签名的原生扩展。本机验证中,Smart App Control 阻止了 `_decimal`、`pyexpat`、`_lzma` 和 `_uuid`;该主机上的 XML 和 LZMA 操作失败。numpy/pandas 冒烟检查通过,不代表所有扩展都兼容。Office 库在 Smart App Control 下的兼容性尚未验证:lxml 和 Pillow 也包含未签名扩展,阻止 lxml 会导致 python-docx 和 python-pptx 导入失败。
 
-Desktop 携带独立的 Python、Node.js 和 pnpm 分发包,并在 Python 的 `site-packages` 中预装 numpy 和 pandas。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。
+Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。
 
-该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台和组件版本;匹配的安装会被复用,版本不同时在完整暂存副本完成后替换目录。添加到该目录的 Python 包在同一版本内保留,升级时随应用基线一起替换。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。
+该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、组件与 Python 分发包版本,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复,或 numpy/pandas 的组件版本与分发包版本冲突时,清单会被拒绝。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。
 
-该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。
+Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。
 
-Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](scripts/primary-runtime-lock.json)固定解释器压缩包及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。支持的库 wheel 直接解压到 site-packages;需要其他安装目录的 wheel 会被拒绝,不生成包的命令行包装器。本机目标检查在清理暂存目录后以及 macOS 签名后执行内置解释器及 numpy/pandas 运算。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
+Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](scripts/primary-runtime-lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
 
 | 决策 | 原因 | 直接结果 |
 |---|---|---|

+ 27 - 5
apps/desktop/scripts/prepare-primary-runtime.ts

@@ -41,16 +41,34 @@ async function pythonArchive(target: keyof typeof lock.targets, cache: string):
 }
 
 /**
- * Unpack a locked library wheel whose files all belong in site-packages.
+ * Identify the inputs that assemble one target's payload, excluding unrelated target locks.
+ * @param target - Desktop target whose archives are installed.
+ * @param runtimeLock - Locked interpreter and wheel inputs.
+ * @param pnpmVersion - Package-manager version copied into the payload.
+ * @returns SHA-256 payload identity for installation reuse.
+ */
+export function primaryRuntimePayloadDigest(target: keyof typeof lock.targets, runtimeLock: typeof lock, pnpmVersion: string): string {
+  const { pythonVersion, pythonRelease, nodeVersion, wheels, pythonPackages } = runtimeLock
+  // Identity preserves key order within the selected target, wheel records and distribution map, plus wheel-entry order.
+  // Bump format when extraction or assembly changes payload bytes without changing locked inputs.
+  return createHash('sha256').update(JSON.stringify({
+    format: 2, target, pythonVersion, pythonRelease, nodeVersion,
+    artifact: runtimeLock.targets[target], wheels, pythonPackages, pnpm: pnpmVersion,
+  })).digest('hex')
+}
+
+/**
+ * Unpack a locked library wheel, retaining auxiliary scripts in its distribution data directory.
  * @param archive - Hash-verified wheel archive.
  * @param destination - Absolute site-packages directory.
- * @returns Resolves after extraction; rejects wheels requiring installation into other directories.
+ * @returns Resolves after extraction without command wrappers; rejects other wheel installation schemes.
  */
 export async function unpackPrimaryRuntimeWheel(archive: string, destination: string): Promise<void> {
   await extractZip(archive, {
     dir: destination,
     onEntry: (entry) => {
-      if (entry.fileName.split('/')[0]?.endsWith('.data')) {
+      const [directory, scheme] = entry.fileName.split('/')
+      if (directory?.endsWith('.data') && scheme !== '' && scheme !== 'scripts') {
         throw new Error(`primary runtime: wheel requires unsupported installation paths: ${entry.fileName}`)
       }
     },
@@ -95,9 +113,11 @@ export async function preparePrimaryRuntime(): Promise<void> {
       desktopVersion: desktop.version,
       platform: target === 'win-x64' ? 'win32' : 'darwin',
       arch: target === 'mac-arm64' ? 'arm64' : 'x64',
+      payloadDigest: primaryRuntimePayloadDigest(target, lock, pnpm.version),
+      pythonPackages: lock.pythonPackages,
       components: {
         python: lock.pythonVersion, node: lock.nodeVersion, pnpm: pnpm.version,
-        numpy: lock.numpyVersion, pandas: lock.pandasVersion,
+        numpy: lock.pythonPackages.numpy, pandas: lock.pythonPackages.pandas,
       },
     }
     const entries = workspaceDependencyPaths(output, manifest)
@@ -121,9 +141,11 @@ export async function preparePrimaryRuntime(): Promise<void> {
 export function smokePrimaryRuntime(root: string): void {
   const manifest = JSON.parse(readFileSync(join(root, 'runtime.json'), 'utf8')) as PrimaryRuntimeManifest
   if (manifest.platform !== process.platform || manifest.arch !== process.arch) return
+  if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.')
   const entries = workspaceDependencyPaths(root, manifest)
   const options = { stdio: 'inherit', timeout: 120_000 } as const
-  execFileSync(entries.python, ['-I', '-c', 'import numpy, pandas; assert numpy.arange(4).sum() == 6; assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3'], options)
+  execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages), manifest.components.python], options)
+  execFileSync(entries.python, ['-I', '-B', '-m', 'pip', 'check'], options)
   execFileSync(entries.node, ['-e', `if (process.versions.node !== ${JSON.stringify(manifest.components.node)}) process.exit(1)`], options)
   execFileSync(entries.node, [entries.pnpm, '--version'], options)
 }

+ 64 - 3
apps/desktop/scripts/primary-runtime-lock.json

@@ -2,8 +2,6 @@
   "nodeVersion": "24.21.0",
   "pythonVersion": "3.12.14",
   "pythonRelease": "20260901",
-  "numpyVersion": "2.3.5",
-  "pandasVersion": "3.0.1",
   "targets": {
     "win-x64": {
       "nodeArchive": "win-x64.zip",
@@ -18,6 +16,14 @@
         {
           "url": "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl",
           "sha256": "536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43"
+        },
+        {
+          "url": "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl",
+          "sha256": "a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09"
+        },
+        {
+          "url": "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl",
+          "sha256": "3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c"
         }
       ]
     },
@@ -34,6 +40,14 @@
         {
           "url": "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl",
           "sha256": "0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955"
+        },
+        {
+          "url": "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl",
+          "sha256": "ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7"
+        },
+        {
+          "url": "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl",
+          "sha256": "0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc"
         }
       ]
     },
@@ -50,6 +64,14 @@
         {
           "url": "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl",
           "sha256": "476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d"
+        },
+        {
+          "url": "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl",
+          "sha256": "ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965"
+        },
+        {
+          "url": "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl",
+          "sha256": "623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d"
         }
       ]
     }
@@ -66,6 +88,45 @@
     {
       "url": "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl",
       "sha256": "1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"
+    },
+    {
+      "url": "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl",
+      "sha256": "3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7"
+    },
+    {
+      "url": "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl",
+      "sha256": "160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba"
+    },
+    {
+      "url": "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl",
+      "sha256": "5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2"
+    },
+    {
+      "url": "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl",
+      "sha256": "9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3"
+    },
+    {
+      "url": "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl",
+      "sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"
+    },
+    {
+      "url": "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl",
+      "sha256": "7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa"
     }
-  ]
+  ],
+  "pythonPackages": {
+    "numpy": "2.3.5",
+    "pandas": "3.0.1",
+    "python-dateutil": "2.9.0.post0",
+    "six": "1.17.0",
+    "tzdata": "2025.2",
+    "python-docx": "1.2.0",
+    "python-pptx": "1.0.2",
+    "openpyxl": "3.1.5",
+    "Pillow": "12.3.0",
+    "lxml": "6.1.3",
+    "XlsxWriter": "3.2.9",
+    "typing_extensions": "4.16.0",
+    "et_xmlfile": "2.0.0"
+  }
 }

+ 90 - 0
apps/desktop/scripts/smoke-primary-runtime.py

@@ -0,0 +1,90 @@
+"""Exercise the relocated Office payload with its own isolated interpreter."""
+
+import importlib.metadata
+import json
+from pathlib import Path
+import re
+import sys
+import tempfile
+
+import numpy
+import pandas
+from docx import Document
+from docx.shared import Inches as DocxInches
+from openpyxl import Workbook, load_workbook
+from openpyxl.styles import Font
+from PIL import Image
+from pptx import Presentation
+from pptx.chart.data import CategoryChartData
+from pptx.enum.chart import XL_CHART_TYPE
+from pptx.util import Inches
+
+
+def main():
+    """Check installed versions and read back editable Office documents."""
+    assert sys.version_info[:3] == tuple(map(int, sys.argv[2].split("."))), sys.version
+    versions = json.loads(sys.argv[1])
+    # Only pip belongs to the interpreter baseline; all other distributions must be declared.
+    # Each target's native release-host smoke must confirm this baseline.
+    expected_names = {re.sub(r"[-_.]+", "-", name).lower() for name in versions} | {"pip"}
+    installed_names = {
+        re.sub(r"[-_.]+", "-", distribution.metadata["Name"]).lower()
+        for distribution in importlib.metadata.distributions()
+    }
+    assert installed_names == expected_names, {"unexpected": sorted(installed_names - expected_names), "missing": sorted(expected_names - installed_names)}
+    for name, expected in versions.items():
+        actual = importlib.metadata.version(name)
+        assert actual == expected, (name, actual, expected)
+    assert numpy.arange(4).sum() == 6
+    assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3
+
+    with tempfile.TemporaryDirectory(prefix="dsh-office-smoke-") as directory:
+        root = Path(directory)
+        image = root / "chart.png"
+        Image.new("RGB", (80, 40), "#2878bc").save(image)
+        with Image.open(image) as restored:
+            assert restored.size == (80, 40)
+
+        document = Document()
+        document.add_heading("Office 文档", 0)
+        document.add_paragraph("Editable text")
+        document.add_table(rows=2, cols=2).cell(1, 1).text = "42"
+        document.add_picture(str(image), width=DocxInches(1))
+        document.save(root / "document.docx")
+        reopened_document = Document(root / "document.docx")
+        assert reopened_document.tables[0].cell(1, 1).text == "42"
+        assert reopened_document.paragraphs[0].text == "Office 文档"
+
+        presentation = Presentation()
+        slide = presentation.slides.add_slide(presentation.slide_layouts[6])
+        slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1)).text = "Office 演示"
+        slide.shapes.add_picture(str(image), Inches(1), Inches(2))
+        chart_data = CategoryChartData()
+        chart_data.categories = ["A", "B"]
+        chart_data.add_series("Values", [2, 4])
+        slide.shapes.add_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, Inches(3), Inches(2), Inches(4), Inches(3), chart_data)
+        presentation.save(root / "presentation.pptx")
+        reopened_presentation = Presentation(root / "presentation.pptx")
+        assert len(reopened_presentation.slides) == 1
+        chart = next(shape.chart for shape in reopened_presentation.slides[0].shapes if shape.has_chart)
+        assert list(chart.series[0].values) == [2.0, 4.0]
+
+        workbook = Workbook()
+        sheet = workbook.active
+        sheet.append(["Value", "Formula"])
+        sheet.append([42, "=A2*2"])
+        sheet["A1"].font = Font(bold=True)
+        workbook.save(root / "workbook.xlsx")
+        reopened_workbook = load_workbook(root / "workbook.xlsx")
+        try:
+            assert reopened_workbook.active["A2"].value == 42
+            assert reopened_workbook.active["B2"].value == "=A2*2"
+            assert reopened_workbook.active["A1"].font.bold
+        finally:
+            reopened_workbook.close()
+        assert pandas.read_excel(root / "workbook.xlsx")["Value"].iloc[0] == 42
+    print("Office runtime versions and document round trips passed.")
+
+
+if __name__ == "__main__":
+    main()

+ 30 - 0
apps/desktop/src/directory-picker.ts

@@ -0,0 +1,30 @@
+/** Window-owned workspace directory dialogs for the local Desktop renderer. */
+
+import { dialog, ipcMain, type BrowserWindow } from 'electron'
+import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts'
+
+/**
+ * Install the application-lifetime directory picker IPC handler.
+ * @param getWindow - Current local application window; shell pages and subframes cannot open dialogs.
+ */
+export function installDesktopDirectoryPicker(getWindow: () => BrowserWindow | undefined): void {
+  const pending = new WeakMap<BrowserWindow, Promise<string | null>>()
+  ipcMain.handle(DESKTOP_IPC.directoryPick, async (event) => {
+    const window = getWindow()
+    if (window === undefined || window.isDestroyed() || event.sender !== window.webContents
+      || event.senderFrame !== window.webContents.mainFrame) {
+      throw new Error('dsh desktop: rejected directory picker from an unowned renderer')
+    }
+    assertDesktopSender(event, ['app'])
+    const existing = pending.get(window)
+    if (existing !== undefined) return existing
+    if (window.isMinimized()) window.restore()
+    window.show()
+    window.focus()
+    const result = dialog.showOpenDialog(window, { properties: ['openDirectory', 'createDirectory'] }).then(
+      ({ canceled, filePaths }) => window.isDestroyed() || canceled ? null : filePaths[0] ?? null,
+    ).finally(() => { pending.delete(window) })
+    pending.set(window, result)
+    return result
+  })
+}

+ 19 - 0
apps/desktop/src/ipc.ts

@@ -1,5 +1,6 @@
 /** Typed preload operations exposed only by the Electron shell. */
 
+import type { IpcMainInvokeEvent } from 'electron'
 import type { DesktopPluginRecord } from './project-manager.ts'
 import type { DesktopLocale } from './locale.ts'
 
@@ -8,6 +9,7 @@ export const DESKTOP_IPC = {
   localeGet: 'dsh-desktop:locale-get',
   boot: 'dsh-desktop:boot',
   bootFailed: 'dsh-desktop:boot-failed',
+  directoryPick: 'dsh-desktop:directory-pick',
   pluginsList: 'dsh-desktop:plugins-list',
   pluginsAdd: 'dsh-desktop:plugins-add',
   pluginsRemove: 'dsh-desktop:plugins-remove',
@@ -43,3 +45,20 @@ export interface DshDesktopApi {
     subscribe(listener: (state: DesktopUpdateState) => void): () => void
   }
 }
+
+/** Scheme of Desktop-owned application and shell documents. */
+export const SCHEME = 'dsh-app'
+
+/**
+ * Reject IPC outside the allowed Desktop document origins.
+ * @param event - IPC caller whose frame URL supplies the origin.
+ * @param hostnames - Desktop document hosts allowed for this operation.
+ */
+export function assertDesktopSender(event: IpcMainInvokeEvent, hostnames: readonly string[]): void {
+  const senderFrame = event.senderFrame
+  if (senderFrame === null) throw new Error('dsh desktop: rejected IPC without a sender frame')
+  const url = new URL(senderFrame.url)
+  if (url.protocol !== `${SCHEME}:` || !hostnames.includes(url.hostname)) {
+    throw new Error('dsh desktop: rejected IPC from an unowned renderer')
+  }
+}

+ 5 - 13
apps/desktop/src/main.ts

@@ -19,16 +19,15 @@ import {
 import { resolveDesktopPaths } from './paths.ts'
 import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
 import { DesktopHostProcess } from './host-process.ts'
-import { desktopNodeEnvironment } from './node-environment.ts'
+import { installDesktopDirectoryPicker } from './directory-picker.ts'
 import { DesktopBackendController } from './backend-controller.ts'
-import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
+import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
 import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
 import { claimDesktopSingleInstance } from './single-instance.ts'
 import { DesktopUpdateCoordinator } from './update-coordinator.ts'
 import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
 import { DesktopFatalRecovery } from './fatal-recovery.ts'
 
-const SCHEME = 'dsh-app'
 let focusPrimaryWindow = (): void => {}
 let stopForRecovery = async (): Promise<void> => {}
 let shuttingDown = false
@@ -159,15 +158,6 @@ function createWindow(preload: string, show = false): BrowserWindow {
   return window
 }
 
-function assertDesktopSender(event: IpcMainInvokeEvent, hostnames: readonly string[]): void {
-  const senderFrame = event.senderFrame
-  if (senderFrame === null) throw new Error('dsh desktop: rejected IPC without a sender frame')
-  const url = new URL(senderFrame.url)
-  if (url.protocol !== `${SCHEME}:` || !hostnames.includes(url.hostname)) {
-    throw new Error('dsh desktop: rejected IPC from an unowned renderer')
-  }
-}
-
 async function serveShellAsset(request: Request): Promise<Response> {
   if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 })
   const root = resolve(app.getAppPath(), 'renderer')
@@ -226,7 +216,7 @@ async function main(): Promise<void> {
   const backend = new DesktopBackendController((onFailure) => {
     const hostInspectPort = developmentHostInspectPort(development)
     const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
-      hostInspectPort, desktopNodeEnvironment(resources.node, resources.nodeBin, process.env), onFailure,
+      hostInspectPort, process.env, onFailure,
       development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime')
         : join(process.resourcesPath, 'runtime', 'primary-runtime'),
       development ? 'link' : 'runtime')
@@ -297,6 +287,8 @@ async function main(): Promise<void> {
     return Promise.resolve(new Response(null, { status: 404 }))
   })
 
+  installDesktopDirectoryPicker(() => mainWindow)
+
   ipcMain.handle(DESKTOP_IPC.boot, async (event) => {
     assertDesktopSender(event, ['app'])
     await startup

+ 2 - 3
apps/desktop/src/node-environment.ts

@@ -1,4 +1,4 @@
-/** Electron's Node mode inherited by the Host, pnpm, and their subprocesses. */
+/** Electron Node-mode startup, with private shell launchers scoped to package installation. */
 
 import { delimiter } from 'node:path'
 
@@ -13,7 +13,6 @@ export function desktopNodeEnvironment(executable: string, bin: string | undefin
   return {
     ...environment,
     ELECTRON_RUN_AS_NODE: '1',
-    DSH_DESKTOP_NODE_EXECUTABLE: executable,
-    ...(bin === undefined ? {} : { PATH: `${bin}${delimiter}${environment.PATH ?? ''}` }),
+    ...(bin === undefined ? {} : { DSH_DESKTOP_NODE_EXECUTABLE: executable, PATH: `${bin}${delimiter}${environment.PATH ?? ''}` }),
   }
 }

+ 6 - 3
apps/desktop/src/preload-app.ts

@@ -1,11 +1,14 @@
-/** Context-isolated application boot bridge and desktop carrier marker. */
+/** Context-isolated application boot, local directory picker, and desktop carrier marker. */
 
 import { contextBridge, ipcRenderer } from 'electron'
-import { DESKTOP_IPC } from './ipc.ts'
+import { DESKTOP_IPC, SCHEME } from './ipc.ts'
 import { markDocumentPlatform } from './preload-platform.ts'
 import { syncNativeTheme } from './preload-theme.ts'
 
-if (location.protocol === 'dsh-app:' && location.hostname === 'app') {
+if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
+  contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', {
+    pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise<string | null>,
+  })
   contextBridge.exposeInMainWorld('dshDesktopBoot', {
     ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise<unknown>,
     failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise<void>,

+ 82 - 0
apps/desktop/tests/directory-picker.spec.ts

@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { BrowserWindow, IpcMainInvokeEvent, OpenDialogReturnValue } from 'electron'
+import { DESKTOP_IPC } from '../src/ipc.ts'
+
+const electron = vi.hoisted(() => ({
+  handle: vi.fn<(channel: string, handler: (event: IpcMainInvokeEvent) => Promise<string | null>) => void>(),
+  showOpenDialog: vi.fn<(window: BrowserWindow, options: unknown) => Promise<OpenDialogReturnValue>>(),
+}))
+vi.mock('electron', () => ({ ipcMain: { handle: electron.handle }, dialog: electron }))
+const { installDesktopDirectoryPicker } = await import('../src/directory-picker.ts')
+
+beforeEach(() => { vi.resetAllMocks() })
+
+function fixture() {
+  const frame = { url: 'dsh-app://app/' }
+  const window = {
+    webContents: { mainFrame: frame },
+    isDestroyed: vi.fn(() => false),
+    isMinimized: vi.fn(() => false),
+    restore: vi.fn(),
+    show: vi.fn(),
+    focus: vi.fn(),
+  }
+  let current: BrowserWindow | undefined = window as unknown as BrowserWindow
+  installDesktopDirectoryPicker(() => current)
+  expect(electron.handle.mock.calls[0]?.[0]).toBe(DESKTOP_IPC.directoryPick)
+  const handler = electron.handle.mock.calls[0]![1]
+  const event = { sender: window.webContents, senderFrame: frame } as unknown as IpcMainInvokeEvent
+  return { window, frame, event, handler, detach: () => { current = undefined } }
+}
+
+describe('Desktop directory picker', () => {
+  it('restores and focuses the parent window and shares an unanswered dialog', async () => {
+    const f = fixture()
+    f.window.isMinimized.mockReturnValue(true)
+    let settle!: (value: OpenDialogReturnValue) => void
+    electron.showOpenDialog.mockImplementation(() => new Promise((resolve) => { settle = resolve }))
+    const first = f.handler(f.event)
+    const second = f.handler(f.event)
+    expect(electron.showOpenDialog).toHaveBeenCalledExactlyOnceWith(f.window, { properties: ['openDirectory', 'createDirectory'] })
+    expect(f.window.restore).toHaveBeenCalledOnce()
+    expect(f.window.show).toHaveBeenCalledOnce()
+    expect(f.window.focus).toHaveBeenCalledOnce()
+    settle({ canceled: false, filePaths: ['/workspace'] })
+    await expect(Promise.all([first, second])).resolves.toEqual(['/workspace', '/workspace'])
+    electron.showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] })
+    await expect(f.handler(f.event)).resolves.toBeNull()
+    expect(electron.showOpenDialog).toHaveBeenCalledTimes(2)
+  })
+
+  it('releases a failed dialog so the user can retry', async () => {
+    const f = fixture()
+    electron.showOpenDialog.mockRejectedValueOnce(new Error('chooser failed'))
+    await expect(f.handler(f.event)).rejects.toThrow('chooser failed')
+    electron.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: [] })
+    await expect(f.handler(f.event)).resolves.toBeNull()
+  })
+
+  it('discards the selected path after the parent is destroyed', async () => {
+    const f = fixture()
+    electron.showOpenDialog.mockImplementation(async () => {
+      f.window.isDestroyed.mockReturnValue(true)
+      return { canceled: false, filePaths: ['/workspace'] }
+    })
+    await expect(f.handler(f.event)).resolves.toBeNull()
+  })
+
+  it('rejects other windows, subframes, shell pages, remote pages, and missing windows', async () => {
+    const f = fixture()
+    await expect(f.handler({ ...f.event, sender: {} } as IpcMainInvokeEvent)).rejects.toThrow('unowned renderer')
+    await expect(f.handler({ ...f.event, senderFrame: {} } as IpcMainInvokeEvent)).rejects.toThrow('unowned renderer')
+    for (const url of ['dsh-app://shell/startup.html', 'https://example.com/', 'http://127.0.0.1/']) {
+      f.frame.url = url
+      await expect(f.handler(f.event)).rejects.toThrow('unowned renderer')
+    }
+    f.window.isDestroyed.mockReturnValue(true)
+    await expect(f.handler(f.event)).rejects.toThrow('unowned renderer')
+    f.detach()
+    await expect(f.handler(f.event)).rejects.toThrow('unowned renderer')
+    expect(electron.showOpenDialog).not.toHaveBeenCalled()
+  })
+})

+ 23 - 2
apps/desktop/tests/main-startup.spec.ts

@@ -1,4 +1,5 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { IpcMainInvokeEvent } from 'electron'
 import { join } from 'node:path'
 import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
 import { DESKTOP_IPC } from '../src/ipc.ts'
@@ -29,6 +30,7 @@ const harness = await vi.hoisted(async () => {
     readonly urls: string[] = []
     readonly webContents = Object.assign(new EventEmitter(), {
       id: 42,
+      mainFrame: { url: 'dsh-app://app/' },
       setWindowOpenHandler: vi.fn(),
       openDevTools: vi.fn(),
       getURL: () => this.urls.at(-1) ?? '',
@@ -87,7 +89,11 @@ const harness = await vi.hoisted(async () => {
     popup,
     socketHeaders: vi.fn(),
     menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) },
-    dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise<MessageBoxReturnValue>>() },
+    dialog: {
+      showOpenDialog: vi.fn(),
+      showErrorBox: vi.fn(),
+      showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise<MessageBoxReturnValue>>(),
+    },
     openExternal: vi.fn(),
     applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
     mutateFailure: vi.fn<() => void>(),
@@ -118,7 +124,7 @@ vi.mock('electron', () => ({
   nativeTheme: { themeSource: 'system' },
   ipcMain: {
     on: vi.fn(),
-    handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { harness.handlers.set(channel, handler) },
+    handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { if (harness.handlers.has(channel)) throw new Error(`duplicate IPC handler ${channel}`); harness.handlers.set(channel, handler) },
   },
   Menu: harness.menu,
   session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } },
@@ -220,6 +226,20 @@ describe('desktop main startup', () => {
     expect(callback).toHaveBeenLastCalledWith({})
   })
 
+  it('registers the window-owned directory picker during startup and rejects foreign callers', async () => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    const window = harness.windows[0]!
+    const handler = harness.handlers.get(DESKTOP_IPC.directoryPick) as (event: IpcMainInvokeEvent) => Promise<string | null>
+    expect(handler).toBeTypeOf('function')
+    const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } as unknown as IpcMainInvokeEvent
+    harness.dialog.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: ['/workspace'] })
+    await expect(handler(event)).resolves.toBe('/workspace')
+    expect(harness.dialog.showOpenDialog).toHaveBeenCalledExactlyOnceWith(window, { properties: ['openDirectory', 'createDirectory'] })
+    window.webContents.mainFrame.url = 'https://other.example/'
+    await expect(handler(event)).rejects.toThrow('unowned renderer')
+  })
+
   it('holds boot injections until the Host is ready and rejects foreign boot callers', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -456,6 +476,7 @@ describe('desktop main startup', () => {
       profileResolution: 'runtime',
       profile: 'desktop-test-profile',
     })
+    expect(harness.hosts[0]!.environment).toBe(process.env)
     expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
     expect(harness.windows).toHaveLength(1)
     expect(window.urls).toEqual(['dsh-app://app/'])

+ 19 - 0
apps/desktop/tests/node-environment.spec.ts

@@ -0,0 +1,19 @@
+import { delimiter } from 'node:path'
+import { expect, it } from 'vitest'
+import { desktopNodeEnvironment } from '../src/node-environment.ts'
+
+it('keeps private Desktop launchers out of the Host environment inherited by PTC', () => {
+  const environment = { PATH: '/user/bin', HOME: '/user' }
+  expect(desktopNodeEnvironment('/desktop/electron', undefined, environment)).toEqual({
+    ...environment, ELECTRON_RUN_AS_NODE: '1',
+  })
+  expect(environment).toEqual({ PATH: '/user/bin', HOME: '/user' })
+})
+
+it('provides private launchers only to package installation processes', () => {
+  expect(desktopNodeEnvironment('/desktop/electron', '/desktop/bin', { PATH: '/user/bin' })).toEqual({
+    ELECTRON_RUN_AS_NODE: '1',
+    DSH_DESKTOP_NODE_EXECUTABLE: '/desktop/electron',
+    PATH: `/desktop/bin${delimiter}/user/bin`,
+  })
+})

+ 17 - 1
apps/desktop/tests/preload-app.spec.ts

@@ -11,7 +11,7 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
 
 afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() })
 
-it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes only the carrier marker to %s', async (url) => {
+it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes the carrier marker to %s', async (url) => {
   vi.stubGlobal('location', new URL(url))
   await import('../src/preload-app.ts')
   expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 })
@@ -31,3 +31,19 @@ it('exposes asynchronous boot only to the local application document', async ()
   await import('../src/preload-app.ts')
   expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === 'dshDesktopBoot')).toBe(false)
 })
+
+it('exposes a directory picker only to the local application document', async () => {
+  vi.stubGlobal('location', new URL('dsh-app://app/'))
+  await import('../src/preload-app.ts')
+  const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === '__DSH_DIRECTORY_PICKER__')?.[1] as { pick(): Promise<string | null> }
+  electron.ipcRenderer.invoke.mockResolvedValue('/workspace')
+  await expect(api.pick()).resolves.toBe('/workspace')
+  expect(electron.ipcRenderer.invoke).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.directoryPick)
+  for (const url of ['dsh-app://shell/startup.html', 'https://example.com/']) {
+    vi.resetModules()
+    electron.contextBridge.exposeInMainWorld.mockClear()
+    vi.stubGlobal('location', new URL(url))
+    await import('../src/preload-app.ts')
+    expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === '__DSH_DIRECTORY_PICKER__')).toBe(false)
+  }
+})

+ 54 - 3
apps/desktop/tests/primary-runtime-preparation.spec.ts

@@ -1,13 +1,52 @@
 import { createHash } from 'node:crypto'
 import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+import { basename, join } from 'node:path'
 import { zipSync } from 'fflate'
 import { expect, it } from 'vitest'
-import { downloadPrimaryRuntimeAsset, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts'
+import { downloadPrimaryRuntimeAsset, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts'
+import lock from '../scripts/primary-runtime-lock.json' with { type: 'json' }
 
 const libraryWheel = Buffer.from('UEsDBAoAAAAAAASeLl0sYMPjDAAAAAwAAAAJAAAAc2FtcGxlLnB5c2FtcGxlID0gNDIKUEsBAh4DCgAAAAAABJ4uXSxgw+MMAAAADAAAAAkAAAAAAAAAAQAAAKSBAAAAAHNhbXBsZS5weVBLBQYAAAAAAQABADcAAAAzAAAAAAA=', 'base64')
 const relocatedWheel = Buffer.from('UEsDBAoAAAAAAASeLl3x0Nj9FAAAABQAAAAeAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlcmVxdWlyZXMgcmVsb2NhdGlvbgpQSwECHgMKAAAAAAAEni5d8dDY/RQAAAAUAAAAHgAAAAAAAAABAAAApIEAAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlUEsFBgAAAAABAAEATAAAAFAAAAAAAA==', 'base64')
+const externalLibraryWheel = Buffer.from('UEsDBBQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAc2FtcGxlLTEuMC5kYXRhL3B1cmVsaWIvc2FtcGxlLnB5cmVxdWlyZXMgbGlicmFyeSByZWxvY2F0aW9uClBLAQIUAxQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAAAAAAAAAAACAAQAAAABzYW1wbGUtMS4wLmRhdGEvcHVyZWxpYi9zYW1wbGUucHlQSwUGAAAAAAEAAQBPAAAAWwAAAAAA', 'base64')
+
+it.each(Object.entries(lock.targets))('records every locked wheel distribution and version for %s', (_target, artifact) => {
+  const normalize = (name: string): string => name.toLowerCase().replace(/[-_.]+/gu, '-')
+  const distributions = [...artifact.wheels, ...lock.wheels].map(({ url }) => {
+    const [name, version] = basename(new URL(url).pathname).split('-')
+    return [normalize(name!), version] as const
+  })
+  const declared = Object.entries(lock.pythonPackages).map(([name, version]) => [normalize(name), version] as const)
+  expect(new Set(distributions.map(([name]) => name)).size).toBe(distributions.length)
+  expect(new Set(declared.map(([name]) => name)).size).toBe(declared.length)
+  expect(Object.fromEntries(distributions)).toEqual(Object.fromEntries(declared))
+})
+
+it('keeps a target payload identity independent of other target archives', () => {
+  const changed = structuredClone(lock)
+  changed.targets['win-x64'].wheels[0]!.sha256 = 'a'.repeat(64)
+  expect(primaryRuntimePayloadDigest('mac-arm64', changed, '11.7.0')).toBe(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0'))
+  expect(primaryRuntimePayloadDigest('win-x64', changed, '11.7.0')).not.toBe(primaryRuntimePayloadDigest('win-x64', lock, '11.7.0'))
+})
+
+it('invalidates payload identity for shared wheels, package versions and package-manager changes', () => {
+  const wheel = structuredClone(lock), distribution = structuredClone(lock)
+  wheel.wheels[0]!.sha256 = 'a'.repeat(64)
+  distribution.pythonPackages['python-docx'] = '1.2.1'
+  const original = primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0')
+  expect(primaryRuntimePayloadDigest('mac-arm64', wheel, '11.7.0')).not.toBe(original)
+  expect(primaryRuntimePayloadDigest('mac-arm64', distribution, '11.7.0')).not.toBe(original)
+  expect(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.1')).not.toBe(original)
+})
+
+it('reports missing distribution metadata before trying to execute a stale native payload', async () => {
+  const root = await mkdtemp(join(tmpdir(), 'desktop-stale-runtime-'))
+  try {
+    await writeFile(join(root, 'runtime.json'), JSON.stringify({ platform: process.platform, arch: process.arch }))
+    expect(() => { smokePrimaryRuntime(root) }).toThrow('missing Python distribution versions; prepare the payload')
+  } finally { await rm(root, { recursive: true, force: true }) }
+})
 
 it('extracts a hash-verified cached library without a Python installer or network request', async () => {
   const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-'))
@@ -46,11 +85,23 @@ it('fully extracts a large deflate-compressed wheel entry', async () => {
   }
 })
 
-it('rejects wheels that need installation outside site-packages', async () => {
+it('retains auxiliary wheel scripts without generating command wrappers', async () => {
   const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-'))
   try {
     const archive = join(root, 'relocated.whl')
     await writeFile(archive, relocatedWheel)
+    await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))
+    expect(await readFile(join(root, 'site-packages/sample-1.0.data/scripts/sample'), 'utf8')).toBe('requires relocation\n')
+  } finally {
+    await rm(root, { recursive: true, force: true })
+  }
+})
+
+it('rejects library files requiring an unsupported installation scheme', async () => {
+  const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-'))
+  try {
+    const archive = join(root, 'relocated.whl')
+    await writeFile(archive, externalLibraryWheel)
     await expect(unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))).rejects.toThrow('unsupported installation paths')
   } finally {
     await rm(root, { recursive: true, force: true })

+ 50 - 0
apps/desktop/tests/ptc-runtime.spec.ts

@@ -0,0 +1,50 @@
+/** Desktop owns the Electron devDependency; the PTC helper supplies its real runtime providers. */
+
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { homedir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import { expect, it, onTestFinished, vi } from 'vitest'
+import { mountRuntime } from '../../../packages/ptc-runtime/ptc-runtime-node/tests/setup.ts'
+
+const require = createRequire(import.meta.url)
+const electronInstalled = existsSync(join(dirname(require.resolve('electron')), 'path.txt'))
+
+// Both fixture-owned and shared-provider teardown await native process cleanup.
+vi.setConfig({ hookTimeout: 30_000 })
+
+// Desktop's downloaded Electron binary is not installed by ordinary workspace dependency setup.
+it.skipIf(process.platform !== 'darwin' || !electronInstalled).each(['workspace-write', 'danger-full-access'] as const)('runs PTC writes under Electron with %s', { timeout: 120_000 }, async (mode) => {
+  const electron = require('electron') as string
+  const root = await mkdtemp(join(homedir(), '.dsh-electron-ptc-'))
+  const ctx = new Context()
+  onTestFinished(async () => {
+    try { await ctx.fiber.dispose() } finally {
+      vi.unstubAllEnvs()
+      await rm(root, { recursive: true, force: true })
+    }
+  })
+  const cwd = join(root, 'workspace')
+  await mkdir(cwd)
+  vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
+  vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
+  const runtime = await mountRuntime(ctx, { nodeExecutable: electron }, { mode, workspaceRoot: cwd })
+  const result = await runtime.run(runtime.resolve({
+    // Bound startup failure before the outer test deadline, leaving time for managed cleanup.
+    timeoutMs: 30_000,
+    program: `await tools.write({});
+      const fs = await import('node:fs/promises');
+      await fs.writeFile('direct.txt', 'direct');
+      let outside;
+      try { await fs.writeFile('../outside.txt', 'outside'); outside = true } catch (error) { if (error.code !== 'EPERM' && error.code !== 'EACCES') throw error; outside = false }
+      return { electron: Boolean(process.versions.electron), env: { ...process.env }, outside };`,
+    bindings: [{ global: 'tools', functions: { write: async () => { await writeFile(join(cwd, 'note.txt'), 'hello'); return null } } }],
+  }))
+  expect(result.error).toBeUndefined()
+  expect(result.value).toEqual({ electron: true, env: {}, outside: mode === 'danger-full-access' })
+  expect(await readFile(join(cwd, 'note.txt'), 'utf8')).toBe('hello')
+  expect(await readFile(join(cwd, 'direct.txt'), 'utf8')).toBe('direct')
+  expect(existsSync(join(root, 'outside.txt'))).toBe(mode === 'danger-full-access')
+})

+ 9 - 9
apps/web/tests/expected/plugin-manager/live-enabled.expected.md

@@ -8,17 +8,17 @@
 - text: "3"
 - list:
   - listitem:
-    - button "查看 experimental-agent-team-profile": experimental-agent-team-profile
-    - text: 官方 Experimental profile bundle enabling Agent Teams over dsh-base
-    - switch "启用 experimental-agent-team-profile"
+    - button "查看 智能体团队(实验性)": 智能体团队(实验性)
+    - text: 官方 启用智能体团队协作与团队工具。
+    - switch "启用 智能体团队(实验性)"
   - listitem:
-    - button "查看 experimental-agent-team-web-profile": experimental-agent-team-web-profile
-    - text: 官方 Experimental Web profile layer for Agent Teams Remote and UI plugins
-    - switch "启用 experimental-agent-team-web-profile"
+    - button "查看 智能体团队 Web 界面(实验性)": 智能体团队 Web 界面(实验性)
+    - text: 官方 在浏览器中查看团队成员、任务看板和成员会话。
+    - switch "启用 智能体团队 Web 界面(实验性)"
   - listitem:
-    - button "查看 experimental-auto-review": experimental-auto-review
-    - text: 官方 Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset
-    - switch "启用 experimental-auto-review"
+    - button "查看 自动授权审查(实验性)": 自动授权审查(实验性)
+    - text: 官方 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
+    - switch "启用 自动授权审查(实验性)"
 - heading "已安装" [level=3]
 - text: "1"
 - list:

+ 9 - 9
apps/web/tests/expected/plugin-manager/manager.expected.md

@@ -8,17 +8,17 @@
 - text: "3"
 - list:
   - listitem:
-    - button "查看 experimental-agent-team-profile": experimental-agent-team-profile
-    - text: 官方 Experimental profile bundle enabling Agent Teams over dsh-base
-    - switch "启用 experimental-agent-team-profile"
+    - button "查看 智能体团队(实验性)": 智能体团队(实验性)
+    - text: 官方 启用智能体团队协作与团队工具。
+    - switch "启用 智能体团队(实验性)"
   - listitem:
-    - button "查看 experimental-agent-team-web-profile": experimental-agent-team-web-profile
-    - text: 官方 Experimental Web profile layer for Agent Teams Remote and UI plugins
-    - switch "启用 experimental-agent-team-web-profile"
+    - button "查看 智能体团队 Web 界面(实验性)": 智能体团队 Web 界面(实验性)
+    - text: 官方 在浏览器中查看团队成员、任务看板和成员会话。
+    - switch "启用 智能体团队 Web 界面(实验性)"
   - listitem:
-    - button "查看 experimental-auto-review": experimental-auto-review
-    - text: 官方 Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset
-    - switch "启用 experimental-auto-review"
+    - button "查看 自动授权审查(实验性)": 自动授权审查(实验性)
+    - text: 官方 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
+    - switch "启用 自动授权审查(实验性)"
 - heading "已安装" [level=3]
 - text: "1"
 - list:

+ 38 - 0
apps/web/tests/plugin-manager.e2e.ts

@@ -88,6 +88,44 @@ describe('web e2e: plugin manager', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
+  it('updates built-in names and descriptions when the UI language changes', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale'))
+    const panel = await openPluginsPanel()
+    await panel.getByRole('button', { name: '查看 智能体团队(实验性)' }).click()
+    const packageName = panel.locator('[data-plugin-name]')
+    expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
+    expect(await panel.getByText('启用智能体团队协作与团队工具。').count()).toBe(1)
+    try {
+      await page.getByRole('button', { name: '设置', exact: true }).click()
+      await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '中文' }).click()
+      await page.getByRole('menuitem', { name: 'English' }).click()
+      await page.getByRole('dialog', { name: 'Settings' }).waitFor()
+      await page.keyboard.press('Escape')
+      await panel.getByRole('heading', { name: 'Experimental Agent Teams', exact: true }).waitFor()
+      expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
+      expect(await panel.getByText('Enable agent team collaboration and team tools.').count()).toBe(1)
+      await panel.getByRole('button', { name: 'Back to plugins' }).click()
+      for (const title of ['Experimental Agent Teams', 'Experimental Agent Teams Web UI', 'Experimental Auto Authorization Review']) {
+        await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
+        expect(await panel.getByRole('switch', { name: `Enable ${title}`, exact: true }).count()).toBe(1)
+      }
+      expect(await panel.getByText('View team members, the task board, and teammate sessions in the browser.').count()).toBe(1)
+      expect(await panel.getByText('Add an Auto review permission mode that uses the model to assess authorization before each tool call.').count()).toBe(1)
+    } finally {
+      if (await page.locator('html').getAttribute('lang') === 'en') {
+        if (await page.getByRole('dialog', { name: 'Settings' }).count() === 0) {
+          await page.getByRole('button', { name: 'Settings', exact: true }).click()
+        }
+        await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
+        await page.getByRole('menuitem', { name: '中文' }).click()
+        await page.getByRole('dialog', { name: '设置' }).waitFor()
+      }
+      await closeSettings()
+    }
+    await panel.getByRole('button', { name: '查看 智能体团队(实验性)', exact: true }).waitFor()
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   it('checks a spec before installing it and words what the check refused', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install'))
     const panel = await openPluginsPanel()

+ 1 - 1
lefthook.yml

@@ -28,7 +28,7 @@ pre-commit:
     # lefthook only inspects files present on disk — so that one case still
     # falls through to the freshness assertion in the test lane.
     - name: third-party notices (staged)
-      glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}'
+      glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,apps/desktop/scripts/primary-runtime-lock.json,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}'
       run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md
 
     - name: whitespace (staged)

+ 1 - 1
package.json

@@ -123,7 +123,7 @@
     "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments",
     "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments",
     "docs:preview": "pnpm --filter @deepseek-ai/website run preview",
-    "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts && pnpm run docs:build",
+    "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts && pnpm run docs:build",
     "website:dev": "pnpm run docs:dev",
     "website:build": "pnpm run docs:build",
     "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts",

+ 2 - 2
packages/client/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 packages/client/README.md
-README.md: 1c7a3c055ef399e9dba449f9f0356ffbcd212330
-README.zh.md: de7fb587778f2412ed8ed97b1c865f35dcdd4e66
+README.md: 171653b926b2aa4da906955c079e4abf0dff3fa1
+README.zh.md: 9081ffb98761cc6ecc38a66db6fbbf5e89444f51

+ 1 - 1
packages/client/README.md

@@ -77,7 +77,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the changed-files card, delivery cards, and clickable final-response file references | — |
 | [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — |
-| [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — |
+| [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the local Desktop or Host OS chooser | — |
 | [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — |
 
 -----

+ 1 - 1
packages/client/README.zh.md

@@ -77,7 +77,7 @@ kind: "package-group"
 | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成改动文件卡片、交付文件卡片与可点击的最终响应文件引用 | — |
 | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — |
-| [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — |
+| [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动本地 Desktop 或 Host OS 选择器的原生目录选择界面 | — |
 | [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮 | — |
 
 -----

+ 2 - 2
packages/client/ui-directory-picker-native/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 packages/client/ui-directory-picker-native/README.md
-README.md: 4d35b30e093f06e939d0831a1ea45c050c43bf03
-README.zh.md: 98bdf1b15eea5de9dee2f0a7dab583f0f124b504
+README.md: 73115c441399fca56d0fd208307240226faf653a
+README.zh.md: 5cf92adfb43eb3b0e3b7cc4e441b503e9a0757af

+ 8 - 5
packages/client/ui-directory-picker-native/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Native directory-picker surface: the browser half that drives the host OS chooser for workspace-directory flows; for users and maintainers choosing a picking interaction."
+description: "Native directory-picker surface: the browser half that drives the local Desktop or Host OS chooser for workspace-directory flows; for users and maintainers choosing a picking interaction."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package provides the native directory-picking surface for the Web GUI: when a workspace flow asks for a directory, a renderless browser occupant opens the operating system's own chooser on the machine running the Host and reports the single outcome — a picked path, a cancellation, or a failure. It fills the two directory-flow slots declared by `ui-workspace`, composing the client side of the native picking interaction in one `cordis.yml` row. Choose it when the browser runs on the same machine as the Host; in-process and remote-browser deployments need the [`-browse`](../ui-directory-picker-browse/README.md) surface instead.
+This package provides the native directory-picking surface for the Web GUI: when a workspace flow asks for a directory, a renderless browser occupant opens the operating system's own chooser on the local machine and reports the single outcome — a picked path, a cancellation, or a failure. It fills the two directory-flow slots declared by `ui-workspace`, composing the client side of the native picking interaction in one `cordis.yml` row. Choose it when the browser runs on the same machine as the Host; in-process and remote-browser deployments need the [`-browse`](../ui-directory-picker-browse/README.md) surface instead.
 
 ## Table of Contents
 
@@ -27,6 +27,8 @@ This package provides the native directory-picking surface for the Web GUI: when
 
 Mount this plugin alongside `ui-workspace` and the host backend [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.md); one `cordis.yml` row then composes the whole native picking interaction. When a workspace add or picker flow opens a directory request, the user sees the operating system's folder dialog; the picked path is adopted by the workspace flow, and cancelling closes the dialog.
 
+In the local Electron application, this flow uses the narrow preload directory-picker bridge. Cancellation and failure never retry through the Host chooser. Ordinary Web uses the Host call; the separate browse composition always lists Host directories.
+
 ### When to choose it
 
 Choose this surface when the browser runs on the same machine as the Host, so an OS dialog can open there. Choose the [`-browse`](../ui-directory-picker-browse/README.md) surface when the browser is remote or in-process and no local chooser exists. The two surfaces fill the same slots, so switching is a composition change, not a code change.
@@ -39,7 +41,7 @@ Choose this surface when the browser runs on the same machine as the Host, so an
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-Both slot registrations install as one transactional effect through nested `ctx.slots.inject()` calls, because either declaring entry may activate later or replace its declaration. The occupant arms once per rising `open` edge, so re-renders never launch a second chooser; settlements ride a ref so the answer reaches the owner's latest handlers. An unmount (HMR replacing the occupant) discards the settlement wholesale: the wire carries no per-request abort, so the host-side chooser survives until answered and its answer lands nowhere. The node half is an empty `apply` that keeps the plugin on the host roster.
+Both slot registrations install as one transactional effect through nested `ctx.slots.inject()` calls, because either declaring entry may activate later or replace its declaration. The occupant arms once per rising `open` edge, so re-renders never launch a second chooser; settlements ride a ref so the answer reaches the owner's latest handlers. An unmount (HMR replacing the occupant) discards the settlement wholesale: the wire carries no per-request abort, so the native chooser survives until answered and its answer lands nowhere. The node half is an empty `apply` that keeps the plugin on the host roster.
 
 </details>
 
@@ -73,8 +75,9 @@ None; this package neither assembles nor sends a provider request.
 
 These limits define when the native chooser fits. They are current package constraints, not a general picker comparison or a task backlog.
 
-- **No cancellation of an open chooser** — the wire has no per-request abort, so a chooser already on the host display cannot be closed from the browser; a discarded settlement is ignored.
-- **Local Host carriers only** — an OS dialog opens on the machine running the Host, so in-process and remote-browser deployments need the `-browse` composition instead. Platform failures surface through the owner's retryable folder dialog.
+- **No cancellation of an open chooser** — the wire has no per-request abort, so a chooser already on the local display cannot be closed from the browser; a discarded settlement is ignored.
+- **Local carriers only** — the Electron dialog selects local paths; ordinary Web opens the Host chooser. Remote-browser and in-process deployments use the `-browse` composition. Platform failures surface through the owner's retryable folder dialog.
+- **Linux automatic selection** — without zenity or kdialog, the Host selects browse even in Desktop; the Electron dialog is not used.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 7 - 4
packages/client/ui-directory-picker-native/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "原生目录选择表面:驱动 Host 操作系统选择器的浏览器半部,用于工作区目录流程;供选择拾取交互的用户与维护者阅读。"
+description: "原生目录选择表面:驱动本地 Desktop 或 Host 操作系统选择器的浏览器半部,用于工作区目录流程;供选择拾取交互的用户与维护者阅读。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包提供 Web GUI 的原生目录拾取表面:当工作区流程请求一个目录时,一个无渲染的浏览器填充会在运行 Host 的机器上打开操作系统自带的选择器,并回报唯一结果——拾取的路径、取消或失败。它填充 `ui-workspace` 声明的两个目录流程 slot,用一行 `cordis.yml` 组合出原生拾取交互的客户端一侧。当浏览器与 Host 运行在同一台机器上时选择它;进程内与远程浏览器部署则需要 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。
+本包提供 Web GUI 的原生目录拾取表面:当工作区流程请求一个目录时,一个无渲染的浏览器填充会在本地机器上打开操作系统自带的选择器,并回报唯一结果——拾取的路径、取消或失败。它填充 `ui-workspace` 声明的两个目录流程 slot,用一行 `cordis.yml` 组合出原生拾取交互的客户端一侧。当浏览器与 Host 运行在同一台机器上时选择它;进程内与远程浏览器部署则需要 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。
 
 ## 目录
 
@@ -27,6 +27,8 @@ kind: "package-reference"
 
 与 `ui-workspace` 及 Host 后端 [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.zh.md) 一起挂载本插件;一行 `cordis.yml` 随即组合出完整的原生拾取交互。当工作区添加或选择器流程发起目录请求时,用户看到操作系统的文件夹对话框;拾取的路径被工作区流程采纳,取消则关闭对话框。
 
+在本地 Electron 应用中,此流程使用 preload 提供的窄目录选择接口。取消和失败都不会改用 Host 选择器重试。普通 Web 使用 Host 调用;独立的浏览组合始终列出 Host 目录。
+
 ### 何时选择
 
 当浏览器与 Host 运行在同一台机器上、操作系统对话框可以在那里打开时,选择此表面。当浏览器为远程或进程内、没有本地选择器时,选择 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。两个表面填充相同的 slot,因此切换只是组合改动,而非代码改动。
@@ -73,8 +75,9 @@ kind: "package-reference"
 
 这些限制界定了原生选择器的适用时机。它们是当前包约束,不是通用选择器对比或任务积压。
 
-- **无法取消已打开的选择器**——wire 没有按请求中止的机制,因此已显示在 Host 上的选择器无法从浏览器关闭;被丢弃的结算会被忽略。
-- **仅限本地 Host 承载**——操作系统对话框在运行 Host 的机器上打开,因此进程内与远程浏览器部署需要 `-browse` 组合。平台失败经由持有方的可重试文件夹对话框呈现。
+- **无法取消已打开的选择器**——wire 没有按请求中止的机制,因此已显示在本地的选择器无法从浏览器关闭;被丢弃的结算会被忽略。
+- **仅限本地承载**——Electron 对话框选择本地路径;普通 Web 打开 Host 选择器。远程浏览器与进程内部署使用 `-browse` 组合。平台失败经由持有方的可重试文件夹对话框呈现。
+- **Linux 自动选择**——缺少 zenity 或 kdialog 时,Host 即使在 Desktop 中也选择浏览模式,不使用 Electron 对话框。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 2 - 1
packages/client/ui-directory-picker-native/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-directory-picker-native",
-  "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser",
+  "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the local Desktop or Host OS chooser",
   "version": "0.1.6-alpha.1",
   "publishConfig": {
     "access": "public"
@@ -43,6 +43,7 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@testing-library/react": "^16.1.0",

+ 4 - 4
packages/client/ui-directory-picker-native/src/client/flow.ts

@@ -8,9 +8,9 @@ import type { ReactElement } from 'react'
 // Type-only: the owner contract of the directory-flow holes.
 import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
 
-/** Injected face: the wire call the flow drives (bound in apply's closure). */
+/** Injected face: the native chooser call the flow drives (bound in apply's closure). */
 export interface NativeFlowInjected {
-  /** Ask the local Host to open its native single-directory chooser. */
+  /** Open the local desktop or Host single-directory chooser. */
   pick: () => Promise<string | null>
 }
 
@@ -31,11 +31,11 @@ export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowI
   outcome.current = props
   // Unmount (HMR replacing the occupant) discards settlements wholesale: the
   // dead instance must neither adopt a path nor drive the owner's error
-  // surface. The wire carries no per-request abort, so the host-side chooser
+  // surface. The wire carries no per-request abort, so the native chooser
   // survives until answered — its answer just lands nowhere; the replacement
   // instance re-arms under the owner's still-open request. An injected-face
   // identity change alone (re-registration) keeps the pending settlement:
-  // the chooser on the host display is still the same dialog.
+  // the native chooser is still the same dialog.
   const alive = useRef(true)
   useEffect(() => {
     // StrictMode's development replay runs the cleanup once before the real

+ 6 - 10
packages/client/ui-directory-picker-native/src/client/index.ts

@@ -1,12 +1,4 @@
-/**
- * Browser half of the native directory-picker backend: fills ui-workspace's
- * two directory-flow holes with a renderless occupant that answers each
- * `open` by driving `directoryPicker/pick` (the node half's OS chooser) and
- * reporting the one outcome — picked path, cancellation, or failure — back
- * through the owner conversation. Mounting this package therefore composes
- * both sides of the native interaction with one cordis.yml row; no client
- * code branches on a capability kind.
- */
+/** Native directory flow using the local desktop bridge or the Host's OS chooser. */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 // Type-only: pulls the SlotMap merge declaring the directory-flow holes.
 import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
@@ -26,7 +18,11 @@ export const inject = ['slots', 'uiWorkspace']
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
-  const injected = (): NativeFlowInjected => ({ pick: () => ctx.uiWorkspace.pickDirectory() })
+  const desktop = (globalThis as typeof globalThis & {
+    __DSH_DIRECTORY_PICKER__?: NativeFlowInjected
+  }).__DSH_DIRECTORY_PICKER__
+  const pick = desktop === undefined ? () => ctx.uiWorkspace.pickDirectory() : () => desktop.pick()
+  const injected = (): NativeFlowInjected => ({ pick })
   // Both declaration lifetimes must be live before the pair installs; the
   // generator makes the two registrations one transactional effect. The
   // outer/inner nesting order is arbitrary; neither hole has precedence.

+ 58 - 1
packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx

@@ -9,7 +9,21 @@ import { apply, inject } from '../src/client/index.ts'
 import { NativeDirectoryFlow } from '../src/client/flow.ts'
 import { apply as nodeApply } from '../src/index.ts'
 
-afterEach(cleanup)
+const desktopIpc = await vi.hoisted(async () => {
+  const { createRequire } = await import('node:module')
+  const path = await import('node:path')
+  // Electron belongs to the Desktop app; resolve its mock from that workspace.
+  const electron = createRequire(path.resolve(import.meta.dirname, '../../../../apps/desktop/package.json')).resolve('electron')
+  return { invoke: vi.fn(), electron }
+})
+vi.mock(desktopIpc.electron, () => ({
+  ipcRenderer: { invoke: desktopIpc.invoke },
+  contextBridge: { exposeInMainWorld: (name: string, value: unknown) => { vi.stubGlobal(name, value) } },
+}))
+vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() }))
+vi.mock('../../../../apps/desktop/src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals() })
 
 const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
 
@@ -149,6 +163,49 @@ describe('directory-picker-native client half', () => {
     expect(b.pickDirectory).toHaveBeenCalledOnce()
   })
 
+  it('consumes the actual Desktop preload bridge and sends its directory-pick IPC', async () => {
+    vi.stubGlobal('location', new URL('dsh-app://app/'))
+    // Desktop's preload is typechecked by its own compiler program.
+    const preload = '../../../../apps/desktop/src/preload-app.ts'
+    await import(/* @vite-ignore */ preload)
+    desktopIpc.invoke.mockResolvedValue('/desktop/workspace')
+    const b = await bench()
+    const dispose = b.declare()
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    try {
+      await fiber.await()
+      const entry = b.slots.entries(HOLES[0])[0]!
+      const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
+      await expect(injected.pick()).resolves.toBe('/desktop/workspace')
+      expect(desktopIpc.invoke).toHaveBeenCalledExactlyOnceWith('dsh-desktop:directory-pick')
+      expect(b.pickDirectory).not.toHaveBeenCalled()
+    } finally {
+      await fiber.dispose()
+      dispose()
+    }
+  })
+
+  it('uses the desktop bridge without calling the Host and preserves cancellation and errors', async () => {
+    const pick = vi.fn<() => Promise<string | null>>().mockResolvedValue('/desktop/workspace')
+    vi.stubGlobal('__DSH_DIRECTORY_PICKER__', { pick })
+    const b = await bench()
+    b.declare()
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    try {
+      const entry = b.slots.entries(HOLES[0])[0]!
+      const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
+      await expect(injected.pick()).resolves.toBe('/desktop/workspace')
+      pick.mockResolvedValue(null)
+      await expect(injected.pick()).resolves.toBeNull()
+      pick.mockRejectedValue(new Error('desktop dialog failed'))
+      await expect(injected.pick()).rejects.toThrow('desktop dialog failed')
+      expect(b.pickDirectory).not.toHaveBeenCalled()
+    } finally {
+      await fiber.dispose()
+    }
+  })
+
   it('runs one pick per open edge and reports the path to the latest onPicked', async () => {
     let resolve!: (path: string | null) => void
     const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))

+ 33 - 0
packages/client/ui-directory-picker-native/tests/desktop-picker.client.spec.tsx

@@ -0,0 +1,33 @@
+// @vitest-environment jsdom
+/** Desktop directory flow through the Web bundle roster and production client boot. */
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { afterEach, expect, vi } from 'vitest'
+import { cleanup, render, waitFor } from '@testing-library/react'
+import type { ComponentType } from 'react'
+import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
+import { ClientRoster, createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
+
+const manifest = JSON.parse(readFileSync(resolve(import.meta.dirname, '../package.json'), 'utf8')) as {
+  name: string
+  dsh: { client: { inject: string[] } }
+}
+// Auto mounts the native row dynamically; use that package's actual dependency declaration.
+const test = createClientTest({ roster: ClientRoster.of([...webApp.rows, {
+  name: manifest.name, inject: manifest.dsh.client.inject, immediately: false,
+}]) })
+afterEach(() => { cleanup(); vi.unstubAllGlobals() })
+
+test('the composed native flow cancels through Desktop without invoking the Host chooser', async ({ start, remote }) => {
+  const pick = vi.fn<() => Promise<string | null>>().mockResolvedValue(null)
+  vi.stubGlobal('__DSH_DIRECTORY_PICKER__', { pick })
+  const client = await start()
+  const entry = client.ctx.slots.entries('sidebar.workspaces.directoryFlow')[0]!
+  const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
+  const Component = entry.component as ComponentType<DirectoryFlowOwnerProps & typeof injected>
+  const onCancel = vi.fn()
+  render(<Component {...injected} open busy={false} onCancel={onCancel} onPicked={vi.fn()} onError={vi.fn()} />)
+  await waitFor(() => { expect(onCancel).toHaveBeenCalledOnce() })
+  expect(pick).toHaveBeenCalledOnce()
+  expect(remote.directoryPicker.pick).not.toHaveBeenCalled()
+}, 60_000)

+ 2 - 2
packages/client/ui-plugin-manager/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 packages/client/ui-plugin-manager/README.md
-README.md: 313ebf7df21ea73c0c20b1c84cac6767c2079037
-README.zh.md: 81b15b459d8bea4721ed2f84607b1c737101b3b8
+README.md: 1a3ae0f0b5e6cfca4fdbb272f84ca4b641933ba6
+README.zh.md: 0d4d1aeb98fecb24a6bc3e9d0ac84389b0fe939e

+ 2 - 0
packages/client/ui-plugin-manager/README.md

@@ -27,6 +27,8 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
 
 Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built in** comes first and lists the bundles the installation ships for switching on, each tagged official, off until switched on and without an uninstall; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section.
 
+The built-in Agent Teams, Agent Teams Web UI, and Auto Authorization Review bundles have localized names and descriptions that follow the UI language. Their detail pages retain the full npm package name; other packages display their short package name and original description.
+
 ### Installing a bundle
 
 **Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Not sure what to enter?** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate.

+ 2 - 0
packages/client/ui-plugin-manager/README.zh.md

@@ -27,6 +27,8 @@ kind: "package-reference"
 
 在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置**排在前面,列出安装随附、供开启的组合包,每个带官方标签,开启前保持关闭,且没有卸载;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。
 
+内置的 Agent Teams、Agent Teams Web UI 和 Auto Authorization Review 组合包使用随界面语言切换的本地化名称和描述。详情页保留完整 npm 包名;其他包显示简写包名和原始描述。
+
 ### 安装一个组合包
 
 **添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。

+ 6 - 6
packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx

@@ -21,7 +21,7 @@ import {
   type ConfirmState, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView,
   type PluginManagerFace,
 } from './manager-store.ts'
-import { managementText, noticeText, shortName, type Translate } from './presentation.ts'
+import { managementText, noticeText, packageText, type Translate } from './presentation.ts'
 import css from './PluginManagerPage.module.css'
 
 /** Full component props assembled by the main slot renderer. */
@@ -213,7 +213,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
   readonly onOpen: () => void
   readonly onSetEnabled: (enabled: boolean) => void
 }): ReactNode {
-  const title = shortName(pkg.name)
+  const { title, description } = packageText(pkg, t)
   const status = packageStatus(pkg)
   return (
     <li
@@ -230,7 +230,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
             {pkg.optional ? <Tag className={css.statusTag} tone="info">{t('statusOfficial')}</Tag> : null}
             {status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
           </div>
-          {pkg.description === undefined ? null : <span className={css.cardDesc}>{pkg.description}</span>}
+          {description === undefined ? null : <span className={css.cardDesc}>{description}</span>}
         </div>
         <div className={css.cardEnd}>
           <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
@@ -262,7 +262,7 @@ function PackageDetail({
   readonly onUninstall: () => void
   readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void
 }): ReactNode {
-  const title = shortName(pkg.name)
+  const { title, description } = packageText(pkg, t)
   const status = packageStatus(pkg)
   return (
     <div className={css.detail} data-plugin-detail={pkg.name}>
@@ -299,7 +299,7 @@ function PackageDetail({
           {status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
         </div>
         <p className={css.detailName}><code data-plugin-name>{pkg.name}</code></p>
-        <p className={css.detailDesc}>{pkg.description ?? t('noDescription')}</p>
+        <p className={css.detailDesc}>{description ?? t('noDescription')}</p>
       </div>
       {pkg.error === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {managementText(pkg.error, t)}</p>}
       {pkg.readOnlyReason === undefined ? null : <p className={css.reason} role="status">{managementText({ code: pkg.readOnlyReason }, t)}</p>}
@@ -635,7 +635,7 @@ function ConfirmDialog({ confirm, t, onConfirm, onCancel }: {
   readonly onConfirm: () => void
   readonly onCancel: () => void
 }): ReactNode {
-  const name = shortName(confirm.packageName)
+  const { title: name } = packageText({ name: confirm.packageName }, t)
   return (
     <Modal
       open

+ 12 - 0
packages/client/ui-plugin-manager/src/client/locales.ts

@@ -16,6 +16,12 @@ export const zh = {
   overriddenNotice: '{name} 已保存,但被更高优先级的配置覆盖,当前未生效',
   bundlesTitle: '已安装',
   builtinTitle: '内置',
+  builtinAgentTeamTitle: '智能体团队(实验性)',
+  builtinAgentTeamDescription: '启用智能体团队协作与团队工具。',
+  builtinAgentTeamWebTitle: '智能体团队 Web 界面(实验性)',
+  builtinAgentTeamWebDescription: '在浏览器中查看团队成员、任务看板和成员会话。',
+  builtinAutoReviewTitle: '自动授权审查(实验性)',
+  builtinAutoReviewDescription: '提供自动审查权限模式,由模型在每次工具调用前判断是否授权。',
   statusProblem: '异常',
   statusOfficial: '官方',
   reasonLabel: '原因',
@@ -168,6 +174,12 @@ export const en = {
   overriddenNotice: '{name} was saved, but a higher-priority configuration overrides it, so it is not in effect',
   bundlesTitle: 'Installed',
   builtinTitle: 'Built in',
+  builtinAgentTeamTitle: 'Experimental Agent Teams',
+  builtinAgentTeamDescription: 'Enable agent team collaboration and team tools.',
+  builtinAgentTeamWebTitle: 'Experimental Agent Teams Web UI',
+  builtinAgentTeamWebDescription: 'View team members, the task board, and teammate sessions in the browser.',
+  builtinAutoReviewTitle: 'Experimental Auto Authorization Review',
+  builtinAutoReviewDescription: 'Add an Auto review permission mode that uses the model to assess authorization before each tool call.',
   statusProblem: 'Problem',
   statusOfficial: 'Official',
   reasonLabel: 'Reason',

+ 26 - 1
packages/client/ui-plugin-manager/src/client/presentation.ts

@@ -3,11 +3,23 @@
 import type { ManagementError } from '@deepseek-ai/dsh-api-remotes/client'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type { PluginManagerLocaleKey } from './locales.ts'
-import type { FailedAction, ManagerNotice } from './manager-store.ts'
+import type { FailedAction, ManagerNotice, PackageView } from './manager-store.ts'
 
 /** The translate seat of the manager's dictionary. */
 export type Translate = PropsLocale<'pluginManager'>['t']
 
+const BUILTIN_COPY = new Map<string, { title: PluginManagerLocaleKey; description: PluginManagerLocaleKey }>([
+  ['@deepseek-ai/dsh-experimental-agent-team-profile', {
+    title: 'builtinAgentTeamTitle', description: 'builtinAgentTeamDescription',
+  }],
+  ['@deepseek-ai/dsh-experimental-agent-team-web-profile', {
+    title: 'builtinAgentTeamWebTitle', description: 'builtinAgentTeamWebDescription',
+  }],
+  ['@deepseek-ai/dsh-experimental-auto-review', {
+    title: 'builtinAutoReviewTitle', description: 'builtinAutoReviewDescription',
+  }],
+])
+
 /** The sentence each of the Host's refusal codes reads as. */
 const CODE_KEYS = {
   'management-required': 'reasonManagementRequired',
@@ -54,6 +66,19 @@ export function shortName(name: string): string {
   return unscoped.replace(/^dsh-(?:host-|client-)?/, '')
 }
 
+/**
+ * Localize known built-in packages by exact npm name at render time.
+ * @param pkg - original package identity and optional metadata description.
+ * @param t - the manager's current translate function.
+ * @returns localized copy, or the package's short name and original description.
+ */
+export function packageText(pkg: Pick<PackageView, 'name' | 'description'>, t: Translate): { title: string; description: string | undefined } {
+  const keys = BUILTIN_COPY.get(pkg.name)
+  return keys === undefined
+    ? { title: shortName(pkg.name), description: pkg.description }
+    : { title: t(keys.title), description: t(keys.description) }
+}
+
 /**
  * The sentence one notice shows.
  * @param notice - the last action's outcome.

+ 61 - 8
packages/client/ui-plugin-manager/tests/components.client.spec.tsx

@@ -7,16 +7,18 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
 import type { PluginManagerPageProps } from '../src/client/PluginManagerPage.tsx'
 import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts'
-import { en, type PluginManagerLocaleKey } from '../src/client/locales.ts'
+import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts'
 
 afterEach(cleanup)
 
-const t = ((key: PluginManagerLocaleKey, params?: Record<string, string>): string =>
+const translate = (dict: typeof en): PluginManagerPageProps['t'] => ((key: PluginManagerLocaleKey, params?: Record<string, string>): string =>
   Object.entries(params ?? {}).reduce(
     (text, [name, value]) => text.replaceAll(`{${name}}`, value),
-    en[key],
+    dict[key],
   )) as PluginManagerPageProps['t']
 
+const t = translate(en)
+
 function pkg(overrides: Partial<PackageView> = {}): PackageView {
   return {
     name: 'dsh-better-sidebar',
@@ -74,8 +76,13 @@ function renderTab(state: Partial<PluginManagerState> = {}) {
     ...actions,
     usePluginManager: bindSnapshotSelector(store),
   } as unknown as PluginManagerPageProps
-  render(<PluginManagerPage {...props} />)
-  return { store, actions, set: (next: Partial<PluginManagerState>) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) } }
+  const { rerender } = render(<PluginManagerPage {...props} />)
+  return {
+    store,
+    actions,
+    set: (next: Partial<PluginManagerState>) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) },
+    setLanguage: (dict: typeof en) => { rerender(<PluginManagerPage {...props} t={translate(dict)} />) },
+  }
 }
 
 describe('PluginManagerPage', () => {
@@ -140,14 +147,60 @@ describe('PluginManagerPage', () => {
     const { actions } = renderTab({
       packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false })],
     })
-    fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'experimental-agent-team-profile') }))
+    fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', en.builtinAgentTeamTitle) }))
     const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
     expect(within(detail).getByText(en.statusOfficial)).toBeTruthy()
-    expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', 'experimental-agent-team-profile') })).toBeNull()
-    fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'experimental-agent-team-profile') }))
+    expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', en.builtinAgentTeamTitle) })).toBeNull()
+    fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', en.builtinAgentTeamTitle) }))
     expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true)
   })
 
+  it.each([
+    ['agent-team-profile', 'builtinAgentTeamTitle', 'builtinAgentTeamDescription'],
+    ['agent-team-web-profile', 'builtinAgentTeamWebTitle', 'builtinAgentTeamWebDescription'],
+    ['auto-review', 'builtinAutoReviewTitle', 'builtinAutoReviewDescription'],
+  ] as const)('localizes %s across cards, details, switches, and uninstall confirmation', (suffix, titleKey, descriptionKey) => {
+    const name = `@deepseek-ai/dsh-experimental-${suffix}`
+    const { actions, set, setLanguage } = renderTab({ packages: [pkg({ name, description: 'Original metadata.' })] })
+    const assertCard = (dict: typeof en) => {
+      expect(screen.getByRole('button', { name: dict.openDetail.replace('{name}', dict[titleKey]) }).textContent).toBe(dict[titleKey])
+      expect(screen.getByText(dict[descriptionKey])).toBeTruthy()
+      expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', dict[titleKey]) })).toBeTruthy()
+      expect(screen.queryByText('Original metadata.')).toBeNull()
+    }
+    assertCard(en)
+    setLanguage(zh)
+    assertCard(zh)
+    fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', zh[titleKey]) }))
+    expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false)
+    fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', zh[titleKey]) }))
+    for (const dict of [zh, en]) {
+      setLanguage(dict)
+      expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(dict[titleKey])
+      expect(screen.getByText(dict[descriptionKey])).toBeTruthy()
+      expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
+      expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', dict[titleKey]) })).toBeTruthy()
+      expect(screen.getByRole('button', { name: dict.uninstallLabel.replace('{name}', dict[titleKey]) })).toBeTruthy()
+    }
+    fireEvent.click(screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', en[titleKey]) }))
+    expect(actions.uninstall).toHaveBeenCalledExactlyOnceWith(name)
+    set({ confirm: { action: 'uninstall', packageName: name } })
+    for (const dict of [en, zh]) {
+      setLanguage(dict)
+      expect(screen.getByRole('dialog', { name: dict.confirmUninstallTitle.replace('{name}', dict[titleKey]) })).toBeTruthy()
+    }
+  })
+
+  it('preserves metadata for another scope with the same short name', () => {
+    const name = '@acme/dsh-experimental-agent-team-profile'
+    const { setLanguage } = renderTab({ packages: [pkg({ name, description: 'Third-party description.' })] })
+    setLanguage(zh)
+    fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', 'experimental-agent-team-profile') }))
+    expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('experimental-agent-team-profile')
+    expect(screen.getByText('Third-party description.')).toBeTruthy()
+    expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
+  })
+
   it('opens a guide under the field and drops an example into it', () => {
     const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
     expect(screen.queryByText(en.installGuideIntro)).toBeNull()

+ 2 - 1
packages/fs/tool-fs/src/sandbox.ts

@@ -75,7 +75,8 @@ export class FsSandboxController {
   /**
    * The policy to stamp onto this mutation: an approved escalation grant (a
    * strictly wider retry resolved through `ctx.approval` before anything
-   * executes), else the session's standing mode. The calling session's cwd is
+   * executes), else the session's standing mode. Repeating the standing mode
+   * requires no approval. The calling session's cwd is
    * always carried as the workspace root. Validates the escalation argument
    * pairing first.
    * @param toolName - the mutating tool's name, for the approval audit trail.

+ 11 - 0
packages/fs/tool-fs/tests/tools.spec.ts

@@ -944,6 +944,17 @@ describe('sandbox escalation API (write/edit)', () => {
     }])
   })
 
+  it.each(['workspace-write', 'danger-full-access'] as const)('writes under repeated %s without approval', async (mode) => {
+    const { ctx, fs } = await setupConfining()
+    const result = await call(ctx, 'write', {
+      file_path: 'a.txt', content: 'x', sandbox_permissions: mode, justification: 'use the current permissions',
+    }, escalationAgent([{ type: 'sandbox/mode', data: { mode } }]))
+    expect(result.isError).toBe(false)
+    expect(fs.stamped).toEqual([{
+      mode, workspaceRoot: '/session-project', sessionId: SessionId('sess-fs-esc'),
+    }])
+  })
+
   it('a rejected escalation fails closed with its own text and never mutates', async () => {
     const { ctx, fs } = await setupConfining({ approval: true })
     ctx.on('approval/request', () => Promise.resolve('rejected' as const))

+ 2 - 2
packages/ptc-runtime/ptc-runtime-node/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 packages/ptc-runtime/ptc-runtime-node/README.md
-README.md: f8f7b5535ba8ed1594b898d410df04e24f5dc8ef
-README.zh.md: dd4e33e70f05ae3917f128e33bf26af5d1a915c4
+README.md: 5196d6982c3bf8edda3b8b825e41db83f6994141
+README.zh.md: 16c9f8fc426b1f2d28eaa039ba259f08088664a8

+ 1 - 1
packages/ptc-runtime/ptc-runtime-node/README.md

@@ -85,7 +85,7 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o
 
 ### Launch and control
 
-The host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded.
+The host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The host preserves `ELECTRON_RUN_AS_NODE` only for child startup so the Desktop executable runs the Node bootstrap; the bootstrap removes the selector before evaluating model code. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded.
 
 Length-framed JSON travels separately from stdout/stderr. The host bounds frames and queued writes, validates call identity and declared binding names before dispatch, and refuses invalid traffic. The child flushes its terminal frame and keeps the control channel open until the host closes it. After submitting that frame, it ignores later binding replies and sends no further program control messages. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
 

+ 1 - 1
packages/ptc-runtime/ptc-runtime-node/README.zh.md

@@ -85,7 +85,7 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责
 
 ### 启动与控制
 
-Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。
+Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。Host 仅在子进程启动时保留 `ELECTRON_RUN_AS_NODE`,使桌面端可执行文件运行 Node bootstrap;bootstrap 在求值模型代码前删除该选择变量。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。
 
 带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。子进程刷新终态帧后仍保持控制通道打开,直到 Host 关闭通道。提交终态帧后,子进程忽略后续绑定回复,不再发送程序控制消息。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
 

+ 2 - 2
packages/ptc-runtime/ptc-runtime-node/src/index.ts

@@ -225,9 +225,9 @@ export class NodePtcRuntime extends PtcRuntime {
       // oxlint-disable-next-line typescript/no-unnecessary-condition -- Cancellation can settle during awaited confinement.
       if (settled) return await result.promise
       if (confined !== undefined) sandbox.enforcement = confined.enforcement
-      // Native launchers need executable search and Windows system paths before the child installs its model environment.
+      // Electron needs its Node-mode selector until bootstrap; the child then removes it with other ambient values.
       const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env)
-        .filter(key => !STARTUP_ENVIRONMENT_NAMES.has(key.toUpperCase()))
+        .filter(key => !STARTUP_ENVIRONMENT_NAMES.has(key.toUpperCase()) && key.toUpperCase() !== 'ELECTRON_RUN_AS_NODE')
         .map(key => [key, undefined]))
       if (packaged) {
         env.DSH_PTC_RUNTIME_NODE = '1'

+ 2 - 0
packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts

@@ -484,12 +484,14 @@ describe('Node runtime host failures', () => {
     onTestFinished(() => { vi.unstubAllEnvs() })
     vi.stubEnv('TEMP', 'fixture-temp-first')
     vi.stubEnv('TMP', 'fixture-tmp-second')
+    vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
     vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
     h.onBoot(() => { h.emit({ type: 'done' }) })
     expect((await h.start()).error).toBeUndefined()
     const env = h.spawn.mock.calls[0]?.[0].env ?? {}
     expect(Object.hasOwn(env, 'TEMP')).toBe(false)
     expect(Object.hasOwn(env, 'TMP')).toBe(false)
+    expect(Object.hasOwn(env, 'ELECTRON_RUN_AS_NODE')).toBe(false)
     expect(Object.hasOwn(env, 'DSH_TEST_RUNTIME_SECRET')).toBe(true)
     expect(env.DSH_TEST_RUNTIME_SECRET).toBeUndefined()
   })

+ 2 - 2
packages/sandbox/sandbox/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 packages/sandbox/sandbox/README.md
-README.md: 88e873428115eb7a3880253b668a4de5b42d6e6d
-README.zh.md: b0144d12395cf01667ef7c2045a1d5f722936a4d
+README.md: 56cdd87c274cc8d951d4bc4e1388d7e985aa4a28
+README.zh.md: c09d7e31fca4825e370636a4c5d9648e145f6a54

+ 2 - 2
packages/sandbox/sandbox/README.md

@@ -63,7 +63,7 @@ Enforcement is reported per call: `full` means the backend governs every promise
 
 ### Denied calls and escalation
 
-When a confined call is denied, the operation reports a denial marker naming the mode — `[sandbox: file access denied under <mode> mode]` — and, when the composition advertises escalation, an escalation hint. The model may retry the exact call once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a `justification`; the user sees one approval prompt and can allow once, reject, or cancel. The escalation must be strictly wider than the call's effective mode, and it applies to that one call only.
+When a confined call is denied, the operation reports a denial marker naming the mode — `[sandbox: file access denied under <mode> mode]` — and, when the composition advertises escalation, an escalation hint. The model may retry the exact call once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a `justification`; the user sees one approval prompt and can allow once, reject, or cancel. A wider mode requires approval and applies to that one call only. Repeating the call's effective mode succeeds without approval; narrower targets remain invalid.
 
 ### Fail-closed behavior
 
@@ -97,7 +97,7 @@ This section explains the design decisions behind the contract and points at the
 
 ### Escalation choreography
 
-The ladder is a closed table — `read-only` may escalate to `workspace-write` or `danger-full-access`, `workspace-write` only to `danger-full-access` — checked at execution, never baked into a tool schema, whose enum stays the closed target vocabulary. [`approveEscalation`](src/escalation.ts) validates the `sandbox_permissions`/`justification` pairing, rejects non-widening requests without prompting a human, and maps every approval outcome to its own error before anything executes.
+The ladder is a closed table — `read-only` may escalate to `workspace-write` or `danger-full-access`, `workspace-write` only to `danger-full-access` — checked at execution, never baked into a tool schema, whose enum stays the closed target vocabulary. [`approveEscalation`](src/escalation.ts) returns the current mode without approval when it is repeated, rejects narrower or unsupported targets, and requests approval for wider modes. Callers validate the `sandbox_permissions`/`justification` pairing first.
 
 ### Writable roots
 

+ 2 - 2
packages/sandbox/sandbox/README.zh.md

@@ -63,7 +63,7 @@ kind: "package-reference"
 
 ### 被拒绝的调用与升权
 
-受限调用被拒绝时,操作会报告指明模式的拒绝标记——`[sandbox: file access denied under <mode> mode]`——组合声明升权能力时还会给出升权提示。模型可以用 `sandbox_permissions`(足以放行的最窄更宽模式)加 `justification` 重试一次完全相同的调用;用户会看到一次审批提示,可以选择允许一次、拒绝或取消。升权必须严格宽于调用的生效模式,且只作用于该次调用。
+受限调用被拒绝时,操作会报告指明模式的拒绝标记——`[sandbox: file access denied under <mode> mode]`——组合声明升权能力时还会给出升权提示。模型可以用 `sandbox_permissions`(足以放行的最窄更宽模式)加 `justification` 重试一次完全相同的调用;用户会看到一次审批提示,可以选择允许一次、拒绝或取消。更宽的模式需要审批,且只作用于该次调用。重复指定调用的生效模式无需审批即可成功;更窄的目标仍然无效。
 
 ### 故障关闭行为
 
@@ -97,7 +97,7 @@ kind: "package-reference"
 
 ### 升权编排
 
-阶梯是封闭表——`read-only` 可升权到 `workspace-write` 或 `danger-full-access`,`workspace-write` 只能升权到 `danger-full-access`——在执行时检查,绝不写入工具 schema,schema 的枚举保持封闭的目标词汇。[`approveEscalation`](src/escalation.ts) 校验 `sandbox_permissions`/`justification` 配对、不提示人类就拒绝非加宽请求,并在任何执行前把每个审批结果映射到各自的错误。
+阶梯是封闭表——`read-only` 可升权到 `workspace-write` 或 `danger-full-access`,`workspace-write` 只能升权到 `danger-full-access`——在执行时检查,绝不写入工具 schema,schema 的枚举保持封闭的目标词汇。[`approveEscalation`](src/escalation.ts) 在请求重复当前模式时无需审批就返回该模式,拒绝更窄或不支持的目标,并为更宽模式请求审批。调用方先校验 `sandbox_permissions`/`justification` 配对。
 
 ### 可写根目录
 

+ 7 - 10
packages/sandbox/sandbox/src/escalation.ts

@@ -134,28 +134,25 @@ export interface EscalationRequest {
   requestedMode: string
   /** The model's one-sentence reason, shown verbatim to the user inside the audit reason. */
   justification: string
-  /** The call's effective mode (session override ?? composition default) the request must strictly widen. */
+  /** The call's effective mode (session override ?? composition default); repeating it needs no approval. */
   effectiveMode: SandboxMode
   /** The family's noun for the escalated action in user-facing texts (`command` for bash, `operation` for fs). */
   subject: string
 }
 
 /**
- * Resolve a sandbox-escalation request BEFORE anything executes: check strict
- * widening against the call's effective mode, then resolve the approval
- * channel, then map every outcome — the ordered fail-closed sequence both
- * enforcing families share. Returns the granted mode to stamp onto exactly
- * this call; throws the distinct verbatim text for every other path (a
- * non-widening request, a missing approval service, an agent-less execution,
- * a rejection, a cancellation, an unanswerable ask) — the tool registry turns
- * the throw into the call's isError result, and nothing has run. A
- * non-widening request never prompts a human.
+ * Resolve a sandbox permission request before execution. Repeating the call's
+ * effective mode returns it without approval. A strictly wider mode requires
+ * approval and applies only to this call. Narrower or unsupported targets,
+ * missing approval services or agents for widening, and non-grant outcomes
+ * throw before execution.
  * @param request - the escalation to judge (see {@link EscalationRequest}).
  * @param approval - the approval ingredients the tool holds (see {@link EscalationApproval}).
  * @returns the granted mode, consumed by the one call that asked.
  */
 export async function approveEscalation<A, C>(request: EscalationRequest, approval: EscalationApproval<A, C>): Promise<SandboxMode> {
   const { requestedMode: mode, effectiveMode, justification, subject } = request
+  if (mode === effectiveMode) return effectiveMode
   // Strict widening is an EXECUTION check against the call's effective mode —
   // deliberately not a schema constraint (the enum is the closed target
   // vocabulary; the effective mode is per-call truth).

+ 15 - 3
packages/sandbox/sandbox/tests/escalation.spec.ts

@@ -81,13 +81,25 @@ describe('approveEscalation', () => {
     expect(seen[0]?.reason).toBe('escalate sandbox to workspace-write: the user asked to write in the workspace')
   })
 
-  it('a non-widening request fails closed with its own text and never asks', async () => {
+  it.each(ESCALATION_TARGETS)('repeating %s succeeds without asking for approval', async (mode) => {
+    const seen: unknown[] = []
+    const request = req({ requestedMode: mode, effectiveMode: mode })
+    await expect(approveEscalation(request, ingredients({ approver: approver('rejected', r => seen.push(r)) })))
+      .resolves.toBe(mode)
+    expect(seen).toEqual([])
+    await expect(approveEscalation(request, ingredients({ approver: undefined, agent: undefined })))
+      .resolves.toBe(mode)
+  })
+
+  it('a narrower or unsupported target fails closed without asking', async () => {
     const seen: unknown[] = []
     const spy = ingredients({ approver: approver('allowed-once', r => seen.push(r)) })
-    await expect(approveEscalation(req({ requestedMode: 'read-only' }), spy))
-      .rejects.toThrow(/not strictly wider than this call's current "read-only" mode/)
+    await expect(approveEscalation(req({ requestedMode: 'read-only', effectiveMode: 'workspace-write' }), spy))
+      .rejects.toThrow(/not strictly wider than this call's current "workspace-write" mode/)
     await expect(approveEscalation(req({ requestedMode: 'workspace-write', effectiveMode: 'danger-full-access' as never }), spy))
       .rejects.toThrow(/not strictly wider/)
+    await expect(approveEscalation(req({ requestedMode: 'unknown-mode' }), spy))
+      .rejects.toThrow(/not strictly wider/)
     expect(seen).toEqual([])
   })
 

+ 2 - 2
packages/shell/tool-bash/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 packages/shell/tool-bash/README.md
-README.md: 43c70d7ebc8eb13f28c4e4576dfde8cb2402b775
-README.zh.md: e38e7afa6b7a534fe29d1022fc2797f5a98651b0
+README.md: 3e01a8b800b5156feecc7e36c45f3490f9abdfc0
+README.zh.md: 91eb88c4b08eb515a0fd5f31257d33b6e3033ab9

+ 1 - 1
packages/shell/tool-bash/README.md

@@ -60,7 +60,7 @@ Passing `run_in_background: true` admits a job and returns its id immediately; c
 
 ### Sandboxed execution and escalation
 
-When the mounted executor confines commands (for example `dsh-bash-sandbox`), a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a command failure. The model may then retry the exact same command once in the same turn with `sandbox_permissions` (the narrowest wider mode that suffices) and a one-sentence `justification`; the approval prompt raised by that retry is how the user consents. Escalation is never speculative: a request with no real prior denial, or one that is not strictly wider than the current mode, fails closed without running anything, and a rejected escalation is final for that command.
+When the mounted executor confines commands (for example `dsh-bash-sandbox`), a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a command failure. The model may then retry the exact same command once in the same turn with `sandbox_permissions` (the narrowest wider mode that suffices) and a one-sentence `justification`; the approval prompt raised by that retry is how the user consents. Request wider access only after a real denial; a rejected escalation is final for that command. Repeating the current mode runs without approval, while a narrower target fails before execution.
 
 ### What can go wrong
 

+ 1 - 1
packages/shell/tool-bash/README.zh.md

@@ -60,7 +60,7 @@ kind: "package-reference"
 
 ### 沙箱执行与升权
 
-当已挂载的执行器约束命令(例如 `dsh-bash-sandbox`)时,被阻止的文件操作会报告为 `[sandbox: file access denied under <mode> mode]`——这是策略拒绝,不是命令失败。模型随后可以在同一轮次中用 `sandbox_permissions`(满足需要的最窄更宽模式)与一句 `justification` 重试完全相同的命令一次;该重试引发的审批提示就是用户同意的方式。升权绝不能预先推测:没有真实拒绝依据的请求,或没有严格宽于当前模式的请求,都会直接失败且不执行任何操作;被拒绝的升权对该命令即为最终结果。
+当已挂载的执行器约束命令(例如 `dsh-bash-sandbox`)时,被阻止的文件操作会报告为 `[sandbox: file access denied under <mode> mode]`——这是策略拒绝,不是命令失败。模型随后可以在同一轮次中用 `sandbox_permissions`(满足需要的最窄更宽模式)与一句 `justification` 重试完全相同的命令一次;该重试引发的审批提示就是用户同意的方式。只有发生真实拒绝后才请求更宽权限;被拒绝的升权对该命令即为最终结果。重复当前模式无需审批即可执行,更窄目标则在执行前失败。
 
 ### 可能出什么问题
 

+ 9 - 2
packages/shell/tool-bash/tests/tools.spec.ts

@@ -619,14 +619,14 @@ describe('sandbox escalation through the generic task producer', () => {
     }
   })
 
-  it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => {
+  it('rejects injected escalation without a sandbox and narrower escalation without prompting', async () => {
     const plain = await setup()
     expect(text(await call(plain, 'bash', escalate))).toContain('not available in this composition')
 
     const { ctx } = await setupSandboxed(true)
     const prompted = vi.fn()
     ctx.on('approval/request', () => { prompted(); return Promise.resolve<ApprovalOutcome>('allowed-once') })
-    const result = await call(ctx, 'bash', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write'))
+    const result = await call(ctx, 'bash', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('danger-full-access'))
     expect(text(result)).toContain('not strictly wider')
     expect(prompted).not.toHaveBeenCalled()
 
@@ -639,6 +639,13 @@ describe('sandbox escalation through the generic task producer', () => {
     expect(text(await call(ctx, 'bash', escalate, malformed))).toContain('not strictly wider')
   })
 
+  it.each(['workspace-write', 'danger-full-access'] as const)('runs a repeated %s request without approval', async (mode) => {
+    const { ctx, bash } = await setupSandboxed()
+    const result = await call(ctx, 'bash', { ...escalate, sandbox_permissions: mode }, sandboxAgent(mode))
+    expect(result.isError).toBe(false)
+    expect(bash.modes).toEqual([mode])
+  })
+
   it('fails closed when approval cannot be routed', async () => {
     const withoutService = await setupSandboxed()
     expect(text(await call(withoutService.ctx, 'bash', escalate, sandboxAgent()))).toContain('no approval service')

+ 9 - 2
packages/shell/tool-pwsh/tests/tools.spec.ts

@@ -623,14 +623,14 @@ describe('sandbox escalation through ctx.approval', () => {
     expect(schema.parameters.properties).not.toHaveProperty('sandbox_permissions')
   })
 
-  it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => {
+  it('rejects injected escalation without a sandbox and narrower escalation without prompting', async () => {
     const plain = await setup()
     expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition')
 
     const { ctx } = await setupSandboxed(true)
     const prompted = vi.fn()
     ctx.on('approval/request', () => { prompted(); return Promise.resolve<ApprovalOutcome>('allowed-once') })
-    const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write'))
+    const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('danger-full-access'))
     expect(text(result)).toContain('not strictly wider')
     expect(prompted).not.toHaveBeenCalled()
 
@@ -642,6 +642,13 @@ describe('sandbox escalation through ctx.approval', () => {
     expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider')
   })
 
+  it.each(['workspace-write', 'danger-full-access'] as const)('runs a repeated %s request without approval', async (mode) => {
+    const { ctx, bash } = await setupSandboxed()
+    const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: mode }, sandboxAgent(mode))
+    expect(result.isError).toBe(false)
+    expect(bash.modes).toEqual([mode])
+  })
+
   it('fails closed when approval cannot be routed', async () => {
     const withoutService = await setupSandboxed()
     expect(text(await call(withoutService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval service')

+ 3 - 0
pnpm-lock.yaml

@@ -3089,6 +3089,9 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-client-test-runtime':
+        specifier: workspace:^
+        version: link:../../test-support/client-runtime
       '@deepseek-ai/dsh-client-ui-renderer':
         specifier: workspace:^
         version: link:../ui-renderer

+ 49 - 0
scripts/gen-third-party-notices.spec.ts

@@ -2,11 +2,13 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSyn
 import { join, resolve } from 'node:path'
 import { tmpdir } from 'node:os'
 import { describe, expect, it } from 'vitest'
+import desktopRuntimeLock from '../apps/desktop/scripts/primary-runtime-lock.json' with { type: 'json' }
 import {
   CLAUDE_AGENT_SDK_PACKAGE,
   assertRuntimeLicenses,
   claudeDistributionFromManifest,
   collectPythonDependencies,
+  collectDesktopPythonDependencies,
   isOwnerAuthorizedRuntime,
   isPermissive,
   type Manifest,
@@ -31,6 +33,7 @@ describe('THIRD_PARTY_NOTICES.md', () => {
   }, async () => {
     const generated = await render()
     expect(generated).toContain('It depends on the third-party software listed below.')
+    expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${desktopRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`)
     expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
   })
 })
@@ -281,6 +284,11 @@ describe('parsePyprojectRequirements', () => {
 })
 
 describe('collectPythonDependencies', () => {
+  it('labels shared Python metadata in the Python-project context', () => {
+    const dependencies = collectPythonDependencies(['[project]\ndependencies = ["numpy", "pandas", "six", "tzdata"]\n'])
+    expect(dependencies.map(({ role }) => role)).toEqual(Array(4).fill('Python project dependency'))
+  })
+
   it('excludes normalized local project names without exempting a third-party prefix', () => {
     const pyprojects = [
       '[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n',
@@ -292,6 +300,47 @@ describe('collectPythonDependencies', () => {
   })
 })
 
+describe('collectDesktopPythonDependencies', () => {
+  it('discloses the committed Desktop closure with its exact locked versions', () => {
+    const dependencies = collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages)
+    expect(dependencies).toHaveLength(Object.keys(desktopRuntimeLock.pythonPackages).length)
+    expect(dependencies).toContainEqual({
+      name: 'pillow', version: desktopRuntimeLock.pythonPackages.Pillow,
+      license: 'MIT-CMU', repo: 'https://github.com/python-pillow/Pillow',
+    })
+    expect(dependencies).toContainEqual({
+      name: 'typing-extensions', version: desktopRuntimeLock.pythonPackages.typing_extensions,
+      license: 'PSF-2.0', repo: 'https://github.com/python/typing_extensions',
+    })
+  })
+
+  it('normalizes names while preserving pinned version strings', () => {
+    expect(collectDesktopPythonDependencies({ 'typing_extensions': '4.16.0', 'Pillow': '12.3.0' }))
+      .toEqual([
+        { name: 'pillow', version: '12.3.0', license: 'MIT-CMU', repo: 'https://github.com/python-pillow/Pillow' },
+        { name: 'typing-extensions', version: '4.16.0', license: 'PSF-2.0', repo: 'https://github.com/python/typing_extensions' },
+      ])
+  })
+
+  it('rejects duplicate normalized distribution names with the same locked version', () => {
+    expect(() => collectDesktopPythonDependencies({ typing_extensions: '4.16.0', 'typing.extensions': '4.16.0' }))
+      .toThrow('duplicate normalized names')
+  })
+
+  it('rejects missing distribution metadata and conflicting normalized versions', () => {
+    expect(() => collectDesktopPythonDependencies({ missing: '1.0' })).toThrow('missing from PYTHON_METADATA')
+    expect(() => collectDesktopPythonDependencies({ Pillow: '12.3.0', pillow: '12.4.0' })).toThrow('conflicting locked versions')
+  })
+
+  it('applies the runtime license check to bundled Python distributions', () => {
+    expect(() => { assertRuntimeLicenses(collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages)) }).not.toThrow()
+    const dependencies = collectDesktopPythonDependencies({ 'copyleft-wheel': '1.0' }, {
+      'copyleft-wheel': { license: 'GPL-3.0-only', repo: 'https://example.com/project' },
+    })
+    expect(() => { assertRuntimeLicenses(dependencies) }).toThrow('copyleft-wheel (GPL-3.0-only)')
+  })
+})
+
 describe('isPermissive', () => {
   it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => {
     expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true)

+ 61 - 10
scripts/gen-third-party-notices.ts

@@ -1,8 +1,8 @@
 /**
  * Generate `THIRD_PARTY_NOTICES.md` from the workspace manifests: every
  * external dependency named by a workspace `package.json`, the vendored-package
- * manifest in `vendor/README.md`, the Python `pyproject.toml` files, and the
- * pnpm patch list. License and repository metadata come from the installed
+ * manifest in `vendor/README.md`, the Python `pyproject.toml` files, the Desktop
+ * Python distribution lock, and the pnpm patch list. npm metadata comes from the installed
  * store, so the tree must be installed. `--check` verifies the committed
  * artifact. Tier policy and ownership live in
  * `.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md`.
@@ -13,6 +13,7 @@ import { dirname, resolve } from 'node:path'
 import * as yaml from 'js-yaml'
 import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
 import parseSpdx from 'spdx-expression-parse'
+import desktopRuntimeLock from '../apps/desktop/scripts/primary-runtime-lock.json' with { type: 'json' }
 import { browserBundledExternals } from './browser-bundled-externals.ts'
 
 const root = resolve(import.meta.dirname, '..')
@@ -81,14 +82,27 @@ const OVERRIDES: Record<string, { license?: string; repo?: string }> = {
 }
 
 /**
- * Python dependencies are few and named directly in `pyproject.toml` files
- * without installed metadata to harvest, so license/repo are recorded here and
- * the generator fails when a manifest names a package this map misses.
+ * Python metadata is recorded from the distributions' license and project
+ * fields; generation does not require installing their wheels. Both Python
+ * manifests and the Desktop lock reject names absent from this map.
  */
-const PYTHON_METADATA: Record<string, { license: string; repo: string; role: string }> = {
+const PYTHON_METADATA: Record<string, { license: string; repo: string; role?: string }> = {
   pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness-sdk`' },
   hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' },
+  'et-xmlfile': { license: 'MIT', repo: 'https://foss.heptapod.net/openpyxl/et_xmlfile' },
+  lxml: { license: 'BSD-3-Clause', repo: 'https://github.com/lxml/lxml' },
+  numpy: { license: 'BSD-3-Clause', repo: 'https://github.com/numpy/numpy' },
+  openpyxl: { license: 'MIT', repo: 'https://foss.heptapod.net/openpyxl/openpyxl' },
+  pandas: { license: 'BSD-3-Clause', repo: 'https://github.com/pandas-dev/pandas' },
+  pillow: { license: 'MIT-CMU', repo: 'https://github.com/python-pillow/Pillow' },
+  'python-dateutil': { license: 'Apache-2.0 OR BSD-3-Clause', repo: 'https://github.com/dateutil/dateutil' },
+  'python-docx': { license: 'MIT', repo: 'https://github.com/python-openxml/python-docx' },
+  'python-pptx': { license: 'MIT', repo: 'https://github.com/scanny/python-pptx' },
   pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' },
+  six: { license: 'MIT', repo: 'https://github.com/benjaminp/six' },
+  'typing-extensions': { license: 'PSF-2.0', repo: 'https://github.com/python/typing_extensions' },
+  tzdata: { license: 'Apache-2.0', repo: 'https://github.com/python/tzdata' },
+  xlsxwriter: { license: 'BSD-2-Clause', repo: 'https://github.com/jmcnamara/XlsxWriter' },
 }
 
 type PythonMetadata = typeof PYTHON_METADATA
@@ -581,7 +595,7 @@ export function collectPythonDependencies(
   return [...found].sort((a, b) => a.localeCompare(b)).map((name) => {
     const entry = metadata[name]
     if (entry === undefined) throw new Error(`gen-third-party-notices: python dependency ${name} is missing from PYTHON_METADATA.`)
-    return { name, ...entry }
+    return { name, ...entry, role: entry.role ?? 'Python project dependency' }
   })
 }
 
@@ -592,6 +606,33 @@ function collectPython(): { name: string; license: string; repo: string; role: s
   return collectPythonDependencies(manifests.map(path => readFileSync(resolve(root, path), 'utf8')))
 }
 
+/**
+ * Disclose every Desktop wheel distribution using its locked version, rejecting duplicate normalized names.
+ * @param packages - Distribution names and exact versions from the Desktop runtime lock.
+ * @param metadata - License and source metadata for every locked distribution.
+ * @returns Normalized, sorted distribution identities with versions and licenses.
+ */
+export function collectDesktopPythonDependencies(
+  packages: Readonly<Record<string, string>>,
+  metadata: PythonMetadata = PYTHON_METADATA,
+): { name: string; version: string; license: string; repo: string }[] {
+  const normalized = new Map<string, string>()
+  for (const [distribution, version] of Object.entries(packages)) {
+    const name = normalizePythonDistributionName(distribution)
+    const previous = normalized.get(name)
+    if (previous !== undefined && previous !== version) {
+      throw new Error(`gen-third-party-notices: Desktop python distribution ${name} has conflicting locked versions.`)
+    }
+    if (previous !== undefined) throw new Error(`gen-third-party-notices: Desktop python distribution ${name} has duplicate normalized names.`)
+    normalized.set(name, version)
+  }
+  return [...normalized].sort(([a], [b]) => a.localeCompare(b)).map(([name, version]) => {
+    const entry = metadata[name]
+    if (entry === undefined) throw new Error(`gen-third-party-notices: Desktop python distribution ${name} is missing from PYTHON_METADATA.`)
+    return { name, version, license: entry.license, repo: entry.repo }
+  })
+}
+
 /** pnpm-patched external packages, from `pnpm-workspace.yaml`. */
 function collectPatched(): { spec: string; patch: string }[] {
   const workspace = yaml.load(readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8')) as { patchedDependencies?: Record<string, string> }
@@ -599,7 +640,7 @@ function collectPatched(): { spec: string; patch: string }[] {
 }
 
 /** SPDX identifiers this project may ship without further review. */
-const PERMISSIVE_LICENSES = new Set(['MIT', 'ISC', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', '0BSD', 'Unlicense', 'CC0-1.0', 'BlueOak-1.0.0', 'Python-2.0'])
+const PERMISSIVE_LICENSES = new Set(['MIT', 'MIT-CMU', 'ISC', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', '0BSD', 'Unlicense', 'CC0-1.0', 'BlueOak-1.0.0', 'Python-2.0', 'PSF-2.0'])
 
 /** Evaluate a parsed SPDX expression under the repository's license policy. */
 function isPermissiveSpdx(expression: ReturnType<typeof parseSpdx>): boolean {
@@ -700,6 +741,7 @@ export async function render(): Promise<string> {
   const devDeps = npm.filter(dep => !dep.runtime)
   const vendored = collectVendored()
   const python = collectPython()
+  const desktopPython = collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages)
   const patched = collectPatched()
   const claudeDistribution = runtimeDeps.some(
     dep => dep.name === CLAUDE_AGENT_SDK_PACKAGE,
@@ -708,6 +750,7 @@ export async function render(): Promise<string> {
     : undefined
   const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license))
   assertRuntimeLicenses(runtimeDeps)
+  assertRuntimeLicenses(desktopPython)
   const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`)
 
   return `<!-- Generated by scripts/gen-third-party-notices.ts — do not edit by hand.
@@ -717,9 +760,9 @@ export async function render(): Promise<string> {
 
 DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
 
-This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude Code platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
+This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Desktop bundled Python distributions. It is generated by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
 
-The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded separately in [\`python/sdk/uv.lock\`](python/sdk/uv.lock).
+The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python SDK closure is recorded separately in [\`python/sdk/uv.lock\`](python/sdk/uv.lock).
 
 ## Vendored source (\`vendor/\`)
 
@@ -755,6 +798,14 @@ Direct dependencies of the \`pyproject.toml\` manifests, plus \`uv\` as the deve
 ${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.role} |`).join('\n')}
 | [\`uv\`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
 
+## Desktop bundled Python distributions
+
+The [Desktop runtime lock](apps/desktop/scripts/primary-runtime-lock.json) records each distribution version and the wheel download hashes. The table includes every entry in \`pythonPackages\`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels.
+
+| Distribution | Locked version | Project license |
+| --- | --- | --- |
+${desktopPython.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.version} | ${dep.license} |`).join('\n')}
+
 ## First-party native packages
 
 \`@deepseek-ai/node-addon-system\` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.

+ 25 - 11
scripts/project-doc-site.spec.ts

@@ -566,13 +566,13 @@ describe('sidebar ordering', () => {
 
 describe('addProjectionFrontmatter', () => {
   it('adds frontmatter to an ordinary Markdown page', () => {
-    expect(addProjectionFrontmatter('# Guide\n', { source: 'docs/guide.md' })).toBe(
-      '---\neditSource: "docs/guide.md"\n---\n\n# Guide\n',
+    expect(addProjectionFrontmatter('# Guide\n', { source: 'docs/guide.md', route: 'en/guide.md', sidebar: 'en-guide' })).toBe(
+      '---\neditSource: "docs/guide.md"\nrawMarkdownPath: "en/guide.md"\n---\n\n# Guide\n',
     )
   })
 
   it('extends existing VitePress frontmatter', () => {
-    expect(addProjectionFrontmatter('---\nlayout: home\n---\n', { source: 'docs/index.md' })).toBe(
+    expect(addProjectionFrontmatter('---\nlayout: home\n---\n', { source: 'docs/index.md', route: 'index.md', sidebar: null })).toBe(
       '---\neditSource: "docs/index.md"\nlayout: home\n---\n',
     )
   })
@@ -580,9 +580,11 @@ describe('addProjectionFrontmatter', () => {
   it('adds the page-specific outline depth from the publication manifest', () => {
     expect(addProjectionFrontmatter('# Catalog\n', {
       source: 'docs/catalog.md',
+      route: 'reference/index.md',
+      sidebar: 'zh-reference',
       outline: [2, 4],
     })).toBe(
-      '---\neditSource: "docs/catalog.md"\noutline: [2,4]\n---\n\n# Catalog\n',
+      '---\neditSource: "docs/catalog.md"\nrawMarkdownPath: "reference/index.md"\noutline: [2,4]\n---\n\n# Catalog\n',
     )
   })
 })
@@ -673,9 +675,9 @@ describe('emitRawMarkdownPages', () => {
     // The real path, because image placement proves containment via realpath.
     emitRawMarkdownPages(out, { pages, repoRoot: realpathSync(root), repositoryRef: 'abc123' })
 
-    expect(readFileSync(join(out, 'a.md'), 'utf8')).toBe('[B](./reference-root/b.md) ![logo](./logo.svg)\n')
-    expect(readFileSync(join(out, 'en/a.md'), 'utf8')).toBe('[B](./reference/b.md) ![logo](./logo.svg)\n')
-    expect(readFileSync(join(out, 'reference-root/b.md'), 'utf8')).toBe('# B\n')
+    expect(readFileSync(join(out, 'a.md'), 'utf8')).toBe('\uFEFF[B](./reference-root/b.md) ![logo](./logo.svg)\n')
+    expect(readFileSync(join(out, 'en/a.md'), 'utf8')).toBe('\uFEFF[B](./reference/b.md) ![logo](./logo.svg)\n')
+    expect(readFileSync(join(out, 'reference-root/b.md'), 'utf8')).toBe('\uFEFF# B\n')
     expect(existsSync(join(out, 'logo.svg'))).toBe(true)
     expect(existsSync(join(out, 'en/logo.svg'))).toBe(true)
   })
@@ -691,7 +693,7 @@ describe('emitRawMarkdownPages', () => {
 
     emitRawMarkdownPages(out, { pages, repoRoot: root, repositoryRef: 'abc123' })
 
-    expect(readFileSync(join(out, 'index.md'), 'utf8')).toBe('# Home\n\n[A](./a.md)\n')
+    expect(readFileSync(join(out, 'index.md'), 'utf8')).toBe('\uFEFF# Home\n\n[A](./a.md)\n')
   })
 
   it('emits a parent-level alias for an index route with links recomputed', () => {
@@ -707,8 +709,20 @@ describe('emitRawMarkdownPages', () => {
 
     emitRawMarkdownPages(out, { pages, repoRoot: root, repositoryRef: 'abc123' })
 
-    expect(readFileSync(join(out, 'guide/index.md'), 'utf8')).toBe('# C\n\n[A](../a.md)\n')
-    expect(readFileSync(join(out, 'guide.md'), 'utf8')).toBe('# C\n\n[A](./a.md)\n')
+    expect(readFileSync(join(out, 'guide/index.md'), 'utf8')).toBe('\uFEFF# C\n\n[A](../a.md)\n')
+    expect(readFileSync(join(out, 'guide.md'), 'utf8')).toBe('\uFEFF# C\n\n[A](./a.md)\n')
+  })
+
+  it('identifies UTF-8 to document readers while fetch decoding preserves the Markdown body', async () => {
+    const { root, pages } = fixture()
+    const markdown = '# 中文 → Markdown\n'
+    writeFileSync(join(root, 'docs/a.md'), markdown)
+    const out = mirrorDir()
+    emitRawMarkdownPages(out, { pages, repoRoot: root, repositoryRef: 'abc123' })
+    const bytes = readFileSync(join(out, 'a.md'))
+    expect([...bytes.subarray(0, 3)]).toEqual([0xef, 0xbb, 0xbf])
+    expect(await new Response(bytes).text()).toBe(markdown)
+    expect(fromMarkdown(bytes.toString('utf8')).children[0]?.type).toBe('heading')
   })
 
   it('refuses to overwrite a file the build already carries', () => {
@@ -762,7 +776,7 @@ describe('raw Markdown projection of the published manifest', () => {
 
   it('emits home pages with their bodies instead of the frontmatter stub', () => {
     for (const route of ['index.md', 'en/index.md']) {
-      const home = readFileSync(join(mirror, route), 'utf8')
+      const home = new TextDecoder().decode(readFileSync(join(mirror, route)))
       expect(home.startsWith('---'), route).toBe(false)
       expect(home, route).toContain('# DeepSeek Harness')
     }

+ 6 - 4
scripts/project-doc-site.ts

@@ -202,15 +202,16 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions)
 }
 
 /**
- * Record the canonical edit target in VitePress frontmatter.
+ * Record canonical edit and raw-Markdown targets in VitePress frontmatter.
  *
  * @param markdown Projected Markdown content.
  * @param page Publication manifest entry for the content.
  * @returns Markdown with projection-owned frontmatter fields.
  */
-export function addProjectionFrontmatter(markdown: string, page: Pick<DocsPage, 'source' | 'outline'>): string {
+export function addProjectionFrontmatter(markdown: string, page: Pick<DocsPage, 'source' | 'outline' | 'route' | 'sidebar'>): string {
   const fields = [
     `editSource: ${JSON.stringify(page.source)}`,
+    ...(page.sidebar === null ? [] : [`rawMarkdownPath: ${JSON.stringify(page.route)}`]),
     ...(page.outline === undefined ? [] : [`outline: ${JSON.stringify(page.outline)}`]),
   ].join('\n')
   if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${fields}\n`)
@@ -486,7 +487,8 @@ export function rawMarkdownFiles(pages: DocsPage[] = docsPages): string[] {
  * projected over the alias route so its relative links stay correct.
  * Referenced images are copied beside the pages, keeping the same relative
  * URLs valid in both trees. Existing build files stay in place, and a name
- * collision with one fails the emission.
+ * collision with one fails the emission. Markdown files carry a UTF-8 BOM so
+ * browser navigation decodes them even when static hosting omits a charset.
  *
  * @param outDir Build output directory to emit into.
  * @param context Manifest and repository inputs, defaulting to this repository.
@@ -499,7 +501,7 @@ export function emitRawMarkdownPages(outDir: string, context: ProjectionContext
   projectPagesInto(
     outDir,
     context,
-    (markdown, page) => rawMarkdownPageContent(markdown, page.source),
+    (markdown, page) => `\uFEFF${rawMarkdownPageContent(markdown, page.source)}`,
     [...context.pages, ...aliases],
   )
 }

+ 1 - 0
scripts/run-gates.ts

@@ -779,6 +779,7 @@ function docSyncLeafGates(options: {
     pnpmExec('docs-site-projection', [
       'vitest', 'run', 'scripts/project-doc-site.spec.ts', 'scripts/verify-doc-site-fragments.spec.ts',
       'website/tests/mermaid-viewer.spec.ts',
+      'website/tests/page-markdown-actions.spec.ts', 'website/tests/raw-markdown.spec.ts',
     ], {
       label: 'documentation site checks',
     }),

+ 1 - 1
scripts/verify-package-readme-model-experience.ts

@@ -120,7 +120,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
   'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
   'packages/client/ui-directory-picker-browse': { kind: 'none', reason: 'Browser-side directory-browsing surface; registers nothing model-facing.' },
-  'packages/client/ui-directory-picker-native': { kind: 'none', reason: 'Browser-side surface driving the host OS chooser; registers nothing model-facing.' },
+  'packages/client/ui-directory-picker-native': { kind: 'none', reason: 'Browser-side surface driving the local Desktop or Host OS chooser; registers nothing model-facing.' },
   'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
   'packages/client/ui-sidebar-documentpreview': { kind: 'none', reason: 'Browser-only text viewer; registers no tool, prompt section, or session event, and what the user reads never enters a model request.' },
   'packages/client/ui-sidebar-files': { kind: 'none', reason: 'Browser-only workspace file tree; registers no tool, prompt section, or session event.' },

+ 1 - 0
snapshots/acp/acp.snapshot.ts

@@ -35,6 +35,7 @@ const controllerCases: readonly {
   { name: 'escalation-approved', hasModelTurn: true },
   { name: 'escalation-rejected', hasModelTurn: true },
   { name: 'fs-escalation-approved', hasModelTurn: true },
+  { name: 'fs-same-mode', hasModelTurn: true },
   {
     name: 'image-compaction',
     hasModelTurn: true,

+ 14 - 0
snapshots/acp/fs-same-mode/input.json

@@ -0,0 +1,14 @@
+{
+  "steps": [
+    {
+      "op": "initialize"
+    },
+    {
+      "op": "newSession"
+    },
+    {
+      "op": "prompt",
+      "text": "Use the write tool to create escalated.md containing exactly the single line: escalated. Set sandbox_permissions to danger-full-access and justification to 'use the current permissions'. The session already uses danger-full-access. After the result, reply with exactly DONE."
+    }
+  ]
+}

File diff suppressed because it is too large
+ 14 - 0
snapshots/acp/fs-same-mode/session.v3.jsonl


+ 10 - 0
snapshots/acp/fs-same-mode/snapshot.yml

@@ -0,0 +1,10 @@
+version: 1
+scenario: fs-same-mode
+profile: acp
+composition: acp-default
+recording: authored
+header:
+  class: acp-default
+permission: danger-full-access
+workspace:
+  final: true

+ 8 - 0
snapshots/acp/fs-same-mode/stdout.expected.jsonl

@@ -0,0 +1,8 @@
+{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}}
+{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."}}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"escalated.md","content":"escalated","sandbox_permissions":"danger-full-access","justification":"use the current permissions"}}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/escalated.md</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}}]}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}}
+{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

+ 1 - 0
snapshots/acp/fs-same-mode/workspace.expected/escalated.md

@@ -0,0 +1 @@
+escalated

+ 2 - 0
snapshots/session/ptc-node-workspace/snapshot.yml

@@ -10,6 +10,8 @@ replay:
   override: true
 platform: posix
 permission: workspace-write
+environment:
+  ELECTRON_RUN_AS_NODE: '1'
 workspace:
   final: true
   parent: outside-temp

+ 1 - 1
snapshots/session/workspace-dependencies/session.v3.jsonl

@@ -14,7 +14,7 @@
 {"type":"session/title","data":{"title":"Call load_workspace_dependencies once. I","messageSeqs":[8],"source":{"kind":"fallback"}}}
 {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"dependencies-query","name":"load_workspace_dependencies","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"dependencies-query","name":"load_workspace_dependencies","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"dependencies-query","name":"load_workspace_dependencies","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":1,"callId":"dependencies-query","name":"load_workspace_dependencies","arguments":"{}"}}
-{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"dependencies-query"},"content":[{"type":"tool-result","toolCallId":"dependencies-query","content":[{"type":"text","text":"Error: primary runtime: invalid metadata"}],"isError":true}],"role":"user","id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"dependencies-query"},"content":[{"type":"tool-result","toolCallId":"dependencies-query","content":[{"type":"text","text":"Error: primary runtime: conflicting numpy distribution version"}],"isError":true}],"role":"user","id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"step/start","data":{"turn":1,"step":2}}
 {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The bundled runtime metadata is invalid."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The bundled runtime metadata is invalid."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The bundled runtime metadata is invalid."}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}

Some files were not shown because too many files changed in this diff