Merge remote-tracking branch 'origin/master' into fix/multiline-display-math

This commit is contained in:
Chinesezjc committed 2026-09-20 18:23:52 +08:00
commit dbef698ef7
332 files changed
+9367 -9009

No files matched your search

@@ -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/bug-fix/2026-09-16-movable-mandatory-update-window.md
2026-09-16-movable-mandatory-update-window.md: 4695074429b76b467e6ec90201632dbb5a007e68
2026-09-16-movable-mandatory-update-window.zh.md: 3b416c53dc02abce1276bb70a13bb45b4c1afa44
2026-09-16-movable-mandatory-update-window.md: da996e5b37f990ff773e3ec88de0baed3c4b54f3
2026-09-16-movable-mandatory-update-window.zh.md: 15fd4eb2520a39d8259321a747b33c833b56cbb1
@@ -1,6 +1,7 @@
# Agent Note: Movable mandatory-update window
Status: implemented
Archived: 2026-09-18
English | [中文](2026-09-16-movable-mandatory-update-window.zh.md)
@@ -1,6 +1,7 @@
# Agent Note: 可移动的强更窗口
Status: implemented
Archived: 2026-09-18
[English](2026-09-16-movable-mandatory-update-window.md) | 中文
+3
View File
@@ -751,6 +751,9 @@
"bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml": "sha256:a4404fe8e35ca5b6371f9cdb58fea6724cf3e7737bb2db8a3a321d534454b17c",
"bug-fix/2026-09-09-content-sized-diagram-previews.md": "sha256:5239e98e5c057d6f7e10c3f10a0f67273778d99b0c4f10b31a1921c3c6474b80",
"bug-fix/2026-09-09-content-sized-diagram-previews.zh.md": "sha256:c1ee3790a9d5c3561e1d93d5e649afa904c7d19b9d77248c85ef16daccd953e2",
"bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml": "sha256:e526ce14ae9557e24364e3686778b130090445ef2152d976b7cc93e76eac6401",
"bug-fix/2026-09-16-movable-mandatory-update-window.md": "sha256:67546cea0b3de2b3e32957a508dbde097d91dd919fe067c8dbecb2c65b50c366",
"bug-fix/2026-09-16-movable-mandatory-update-window.zh.md": "sha256:20a0aade6ee71c89e84d70e23c89bb51e8a38c674d103dd43022feabb8d27be3",
"bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml": "sha256:4442529ced1c8e48d30f7b50c65ff03eb92dce9815beb438cedbf29f234bce47",
"bug-fix/2026-09-11-session-controller-fork-turn-cut.md": "sha256:a996f24a896c7fc5d81e015db3eae27d8a46c853287572e815623ce35eae1b12",
"bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md": "sha256:6486f0eacf5067ef9850dc32f1c27b2109a7d4e9e3c498366f5c38742bb53ac5",
@@ -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/architecture/2026-09-17-shared-office-runtime.md
2026-09-17-shared-office-runtime.md: 25b6ddccbb5b17b93bec2c0b024dfcb63e94abc3
2026-09-17-shared-office-runtime.zh.md: 99dffe8d0233e60480689ff983b6261cbb83bb7b
@@ -0,0 +1,37 @@
# Agent Note: Shared Office runtime for Desktop and SDK
Status: implemented
English | [中文](2026-09-17-shared-office-runtime.zh.md)
## Problem
SDK deployments need the same Office authoring libraries as Desktop while keeping interpreter payloads in read-only container image layers. A Desktop-owned query and builder require downstream carriers to duplicate dependency locks and path conventions.
## Decision
[`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.md) owns manifest validation, interpreter paths, installation and the model query. It stays in the skill group beside its Office workflow consumer: the payload supplies those workflows, while the package exposes no workspace entity service. Desktop imports this package and retains copy-on-first-use installation. The packaged SDK profile enables the query and Office skills from its carrier default; `DSH_PRIMARY_RUNTIME` overrides that path, including an empty-string opt-out. It reads the payload in place. The common base and `sdk-minimal` do not enable Office implicitly.
The [shared build entry](../../../../scripts/primary-runtime/prepare.ts) owns the download lock, extraction and native smoke checks for Desktop targets and GNU/Linux x64/ARM64. Desktop supplies output paths and its release version, requires Node.js and pnpm, and applies its signing-specific subprocess environment. Other carriers choose their output directory and may build a Python-only payload. The existing `desktopVersion` manifest field retains its name and records the carrier release; `payloadDigest` distinguishes locked inputs and component selection.
The [Desktop primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own Desktop installation and platform signing. This decision replaces only its Desktop-only builder and query placement; both records remain active.
`runtime.json` stores Python, Node.js and pnpm versions at the top level and all Python distribution versions in `pythonPackages`. Build metadata does not single out numpy or pandas. The shared parser accepts legacy `components` files without rewriting them, retains their consistency checks, and returns only the canonical flat fields. Mixed formats are rejected. The build digest includes the assembly format so changed manifest bytes invalidate payload reuse.
The Python runtime wheel carries target-native CPython and Office libraries plus external skills in a sibling `<platform>-<arch>/` resource directory. Short platform directory names leave room for Python extension and dependency DLL paths after installation on Windows. Bundling supplies defaults; the skill registry still lets project, custom and user skills override same-name bundled entries, and profile patches can disable or replace Office skills without replacing Python. This retains configurable workflows while eliminating a separate environment installation step. Real filesystem resources let Python load native extensions and skills invoke their shared checker without startup extraction. The wheel grows by the compressed payload size; public-index size limits remain enforced by the release workflow.
## Alternatives considered
**Keep implementation in Desktop.** This leaves SDK and container builders depending on application packaging and signing code despite needing only interpreters, Office resources and a query.
**Enable Office in the common base.** A base deployment has no guaranteed bundled interpreter or resources. Explicit SDK carrier configuration makes that requirement observable without changing unrelated profiles.
**Always copy to the Harness home.** Copying duplicates image-layer content, requires a writable destination and cannot serve the intended immutable carrier deployment.
**One component field per Python library.** This duplicates the distribution map and requires schema changes for each library. A complete map records the locked package set independently of the interpreter and package-manager fields.
## Consequences
Successful tool preparation is shared by concurrent calls for one plugin lifetime. Failed preparation is retryable; disposal removes registration and waits for outstanding filesystem work. Copy mode verifies a staged replacement and restores the preceding installation after a failed publication. In-place mode performs no payload writes. Paths are absolute, and runtime metadata and declared entries are checked before results are returned.
SDK configuration changes require restart. Missing Office resources produce a startup warning and leave the optional skill provider inactive; runtime metadata and interpreter availability are validated on the first tool call. musl builds remain outside the lock. Cross-target assembly verifies archive hashes but requires execution on the target host; the Linux artifact CI lane owns native Linux checks. Unit coverage pins manifest, installation and lifecycle behavior; SDK profile tests observe optional tools, skill discovery and results, and the recorded workspace-dependencies scenario pins model-visible validation errors.
@@ -0,0 +1,37 @@
# Agent Note: Desktop 与 SDK 共享 Office 运行时
Status: implemented
[English](2026-09-17-shared-office-runtime.md) | 中文
## 问题
SDK 部署需要与 Desktop 相同的 Office 创作库,同时将解释器 payload 保存在只读容器镜像层中。由 Desktop 持有查询工具和构建器,会要求下游载体重复维护依赖锁与路径约定。
## 决策
[`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.zh.md) 持有清单校验、解释器路径、安装逻辑和模型查询。它保留在 skill 组中,与 Office 工作流消费者相邻:payload 服务于这些工作流,本包不提供 workspace 实体服务。Desktop 引用本包并保留首次使用时复制安装。打包的 SDK profile 通过载体默认路径启用查询和 Office skills;`DSH_PRIMARY_RUNTIME` 覆盖该路径,空字符串表示禁用。它原位读取 payload。公共 base 与 `sdk-minimal` 不会隐式启用 Office。
[共享构建入口](../../../../scripts/primary-runtime/prepare.ts) 持有 Desktop 目标与 GNU/Linux x64/ARM64 的下载锁、解压和本机冒烟检查。Desktop 提供输出路径和发布版本,要求包含 Node.js 与 pnpm,并应用签名专用子进程环境。其他载体选择自己的输出目录,也可构建 Python-only payload。现有 `desktopVersion` 清单字段保留名字并记录载体发布版本;`payloadDigest` 区分锁定输入与组件选择。
[Desktop 第一方运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md)继续持有 Desktop 安装与平台签名规则。本决策仅替代其中由 Desktop 独占构建器和查询实现的部分;两份记录均保持有效。
`runtime.json` 在顶层保存 Python、Node.js 和 pnpm 版本,并在 `pythonPackages` 中保存全部 Python 分发包版本。构建元数据不单独列出 numpy 或 pandas。共享解析器接受旧 `components` 文件而不改写它们,保留旧格式的一致性校验,并仅返回统一的扁平字段。混合格式会被拒绝。构建摘要包含组装格式,因此清单字节变化会使旧产物身份失效。
Python runtime wheel 将目标平台的 CPython、Office 库和外部 skills 放在可执行文件同级的 `<platform>-<arch>/` 资源目录中。较短的平台目录名为 Windows 安装后的 Python 扩展与依赖 DLL 路径保留长度空间。打包提供默认资源;skill 注册表仍允许项目、自定义目录和用户 skills 覆盖同名随包条目,profile patch 可在保留 Python 的同时禁用或替换 Office skills。这样既保留工作流配置能力,又省去单独安装环境的步骤。真实文件系统资源允许 Python 加载原生扩展,并让 skills 调用共用检查脚本,无需启动时解包。wheel 体积增加一个压缩 payload;发布流程仍执行公开索引的大小限制检查。
## 考虑过的替代方案
**将实现保留在 Desktop。** SDK 和容器构建器只需要解释器、Office 资源和查询,却仍会依赖应用打包及签名代码。
**在公共 base 中启用 Office。** base 部署不保证提供内置解释器或资源。显式 SDK 载体配置可以让该要求可见,并保持其他 profile 的行为。
**总是复制到 Harness home。** 复制会重复镜像层内容、要求目标可写,也无法满足预期的不可变载体部署。
**为每个 Python 库增加组件字段。** 这会重复分发包版本表,并使新增库要求修改 schema。完整版本表将锁定包集合与解释器、包管理器字段分开记录。
## 影响
同一插件生命周期内的并发调用共享成功的工具准备结果。准备失败后可重试;卸载移除工具注册并等待未完成的文件系统工作。复制模式验证暂存替换内容,并在发布失败后恢复原安装。原位模式不写入 payload。路径为绝对路径,返回结果前会检查运行时元数据和所声明的条目。
SDK 配置变更需要重启。缺少 Office 资源会产生启动警告,并使可选的 skill 提供方保持未激活;运行时元数据和解释器是否存在在首次工具调用时校验。musl 构建仍不在锁文件范围内。跨目标组装校验归档哈希,但需要在目标主机执行;Linux 产物 CI 负责本机 Linux 检查。单元覆盖固定清单、安装与生命周期行为;SDK profile 测试观察可选工具、skill 发现与结果,workspace-dependencies 录制场景固定模型可见的校验错误。
@@ -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/bug-fix/2026-09-15-messages-v1-base-url.md
2026-09-15-messages-v1-base-url.md: 97f7294c28988e6f115963033cdac6794d70d6f3
2026-09-15-messages-v1-base-url.zh.md: ccb8c5930dbbfa6274770cc03087b4d8077fe27b
2026-09-15-messages-v1-base-url.md: d9014756eacd1752495bfecb52860634ece08ae1
2026-09-15-messages-v1-base-url.zh.md: 8f5803e4bdc68195164e187d1aa8cd537f71c5d6
@@ -12,7 +12,7 @@ The Messages transport appends the Anthropic-standard `/v1` namespace to a confi
The shared Messages API owner trims trailing slashes and treats only a final path segment exactly equal to `v1` as the existing API version. It preserves that root and appends `/messages` or `/files`; every other base receives `/v1/messages` or `/v1/files`. The same resolved root scopes cached file uploads. The official `https://api.deepseek.com/anthropic` base therefore resolves to `/anthropic/v1`, and an explicit `/anthropic/v1` base remains unchanged.
Chat Completions retains its independent URL behavior. The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them.
The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them.
## Alternatives considered
@@ -12,7 +12,7 @@ Messages 传输会在配置的基址后追加 Anthropic 标准 `/v1` 命名空
共享 Messages API 所有者移除末尾斜线,仅把严格等于 `v1` 的最后路径段视为已有 API 版本。它保留该根地址并追加 `/messages` 或 `/files`;其他基址均追加 `/v1/messages` 或 `/v1/files`。缓存文件上传也使用同一个解析后的根地址划分作用域。因此,官方 `https://api.deepseek.com/anthropic` 基址解析为 `/anthropic/v1`,显式 `/anthropic/v1` 基址保持不变。
Chat Completions 保留独立的 URL 行为。Messages 规则不会推断对 `v1beta`、`v2`、`v4` 或其他版本式后缀的支持;包含这些路径段的部署会在其下获得标准 `/v1` 命名空间。
Messages 规则不推断 `v1beta`、`v2`、`v4` 或其他类似版本后缀的支持;包含这些路径段的部署会在其下接收标准 `/v1` 命名空间。
## 考虑过的替代方案
@@ -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/bug-fix/2026-09-16-messages-historical-tool-input.md
2026-09-16-messages-historical-tool-input.md: 201b0f21ad041395a4c4101d6919b2878e22c9ab
2026-09-16-messages-historical-tool-input.zh.md: bcc637982420124b401ef65c668b068394b6d915
2026-09-16-messages-historical-tool-input.md: 85e4047bb5274eb7c7adb458d564e1268d4ea81e
2026-09-16-messages-historical-tool-input.zh.md: 5a8c31dbee32094f124102ccac9ffb7b8b81b177
@@ -10,7 +10,7 @@ Chat Completions retains tool arguments as strings, including malformed JSON fro
## Decision
The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again.
The [Messages serializer](../../../../packages/llm/llm-deepseek/src/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again.
This supersedes the historical argument rejection in the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md). New Messages responses still require valid object arguments before successful completion; output-limit truncation retains its existing pruning behavior. No Session event, persistence type, or protocol configuration changes.
@@ -10,7 +10,7 @@ Chat Completions 将工具参数保留为字符串,其中可能包含失败调
## 决策
[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。
[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。
这取代了 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的历史参数拒绝规则。新生成的 Messages 响应在成功完成前仍要求工具参数是有效对象;达到输出上限时仍按现有规则裁剪。不改变 Session 事件、持久化类型或协议配置。
@@ -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/bug-fix/2026-09-18-messages-input-history-compatibility.md
2026-09-18-messages-input-history-compatibility.md: 1775276deec6180f1fd186b11cb7a757a3328a54
2026-09-18-messages-input-history-compatibility.zh.md: 50367e33c29df0679a44aca1fe680d2bedc8c263
2026-09-18-messages-input-history-compatibility.md: dd974fb1ae883716002a1381f4c374dee792b3b3
2026-09-18-messages-input-history-compatibility.zh.md: 6e850328db63d5ae5cb508aa3529e9f2507182b0
@@ -10,7 +10,7 @@ Saved subagent settlement notices can contain child reasoning and tool calls in
## Decision
The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks.
The [Messages serializer](../../../../packages/llm/llm-deepseek/src/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks.
This partially supersedes the settlement decision's rejection of serializer tolerance and the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md)'s input rejection. Notice construction still projects nonempty child text for every parent provider. Canonical child output and ordinary assistant reasoning and tool calls remain available to their existing consumers. The omission is based on input role and block type, not the notice source or creation date, so it also applies to newly supplied user and tool-result content.
@@ -10,7 +10,7 @@ Status: implemented
## 决策
[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。
[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。
这部分取代了结算决策对序列化器容错的拒绝,以及 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的输入拒绝规则。通知构建仍为每种父级提供方投影子级的非空文本。子级的规范输出以及普通助手推理和工具调用仍可供现有消费者使用。省略依据是输入角色和块类型,而不是通知来源或创建日期,因此也适用于新提供的用户和工具结果内容。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md
2026-09-20-windows-embedded-mandatory-update.md: 99577a4d85adc3fe13ea0ae262d5aa1af6128237
2026-09-20-windows-embedded-mandatory-update.zh.md: abe4a9577d8e11d8de2089dc53ef07433e10bbdb
@@ -0,0 +1,25 @@
# Agent Note: Windows embedded mandatory update
Status: implemented
English | [中文](2026-09-20-windows-embedded-mandatory-update.zh.md)
## Problem
A second native Windows window does not resize atomically with its parent, producing a detached overlay during maximization (#4566). Native modality also disables the parent controls that users need to exit.
## Decision
Windows uses one native window and a shell-origin frame below the caption. This partially supersedes the presentation decision in the [mandatory-update client](../feature/2026-09-11-desktop-mandatory-update-client.md); that note retains policy and installation ownership. macOS retains its native overlay.
The isolated application preload transfers a private MessageChannel endpoint directly to the shell frame. Only messages on the paired private port authorize update IPC; synthetic window events cannot invoke actions. The main process validates the current application main frame and rebinds state delivery when the main window changes. Destroyed windows receive neither state nor focus operations.
## Alternatives considered
**Synchronize a second native window.** Parent movement, maximization and restoration expose independently scheduled geometry updates; removing the second window removes that synchronization problem.
**Treat source and origin on window events as authentication.** Product scripts can construct those event fields. A private port keeps action authority outside the shared product document.
## Consequences
Caption menus and Web controls remain reachable. The DOM overlay blocks ordinary background input but cannot prevent product scripts from hiding or imitating presentation; it is not a tamper-proof security surface. Main-process policy and installation authorization remain separate from that display. Regression coverage requires forged-event rejection, real channel delivery, caption keyboard operation, and destroyed/replacement-window lifecycle cases.
@@ -0,0 +1,25 @@
# Agent Note: Windows 内嵌强更弹窗
Status: implemented
[English](2026-09-20-windows-embedded-mandatory-update.md) | 中文
## 问题
Windows 的第二个原生窗口无法与父窗口原子地缩放,最大化时会出现脱离的蒙层(#4566)。原生模态还会禁用用户退出应用所需的父窗口控件。
## 决策
Windows 使用一个原生窗口和顶栏下方的壳源站 frame。此决策部分取代[强更客户端](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)的展示决策;该说明继续负责策略与安装归属。macOS 保留原生蒙层。
隔离的应用预加载将私有 MessageChannel 端点直接传给壳 frame。只有配对私有端口上的消息才能授权更新 IPC;合成的 window 事件不能调用操作。主进程校验当前应用主 frame,并在主窗口变化时重新绑定状态投递。已销毁窗口不再接收状态或焦点操作。
## 考虑过的替代方案
**同步第二个原生窗口。** 父窗口移动、最大化与还原会暴露独立调度的几何更新;去掉第二个窗口即可消除这一同步问题。
**把 window 事件的 source 和 origin 当作身份认证。** 产品脚本可以构造这些事件字段。私有端口将操作权限保留在共享产品文档之外。
## 后果
顶栏菜单和 Web 控件仍可触达。DOM 蒙层阻止普通背景输入,但不能阻止产品脚本隐藏或仿造展示;它不是防篡改的安全界面。主进程策略和安装授权与展示相互独立。回归覆盖要求包括拒绝伪造事件、真实通道投递、顶栏键盘操作,以及窗口销毁和替换的生命周期用例。
@@ -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-08-20-unified-image-request-pipeline.md
2026-08-20-unified-image-request-pipeline.md: 7431a474b55a9dae82b1c9cd36b770b31fe39602
2026-08-20-unified-image-request-pipeline.zh.md: 247a57b8017f513c552204d951ef6931f7f9d045
2026-08-20-unified-image-request-pipeline.md: b79eeb7d908d664362851c28bda64dab163aac89
2026-08-20-unified-image-request-pipeline.zh.md: 444234715256c48167b0b45dbd8843a9c80079f7
@@ -34,7 +34,7 @@ Every retained request image is preceded by its display name or complete attachm
### DeepSeek Files lifecycle
The direct `deepseek-official` adapter normally uploads every retained request version through its selected protocol’s Files API and sends file-id references. [Messages](2026-09-07-deepseek-messages-adapter.md) and Chat Completions share the lifecycle while retaining their own endpoint and wire formats. A [bounded inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by Files endpoint and API-key scope plus `variantId`. Uploads request seven days by default. Chat records the returned `expires_at`; Messages metadata omits expiry, so its local reuse deadline uses the original upload time plus the configured lifetime without promising remote deletion. A mapping with no more than one hour of reuse remaining is replaced without a preceding retrieve call. The index never stores the API key.
The direct `deepseek-official` adapter uploads every retained request version through the [Messages Files API](2026-09-07-deepseek-messages-adapter.md) and sends file-id references. A [bounded inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. Uploaded ids are indexed by Files endpoint and API-key scope plus `variantId`. Uploads request seven days by default. Messages metadata omits expiry, so local reuse uses the original upload time plus the configured lifetime without promising remote deletion. A mapping with no more than one hour of reuse remaining is replaced without a preceding retrieve call. The index never stores the API key.
An upload is indexed only after the response returns a valid native file object with a matching byte count and the client establishes the protocol’s reuse deadline. A missing or inconsistent response leaves no local mapping, so a later request uploads again. Concurrent upload resolution for one scoped `variantId` shares one provider operation; one waiter cannot cancel another, and the upload stops when every waiter has cancelled. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If the model endpoint reports expired, deleted, missing, or invalid ids and names one or more ids used by the request, only those mappings are removed. A stale-file response without a specific id removes every mapping used by that model request. The affected request bytes are uploaded again and the model request is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third model request. One upload quota error first lists the configured number of oldest harness-owned `dsh-` files, then deletes that collected set and retries once; deleting after pagination keeps provider cursors valid. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. Every Files request carries the shared Harness `User-Agent`. The client enforces a 128MiB upload limit, a 32MiB request-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range.
@@ -34,7 +34,7 @@ Status: implemented
### DeepSeek Files 生命周期
直接 `deepseek-official` 适配器通常通过所选协议的 Files API 上传每张保留的请求版本,并发送 file-id 引用。[Messages](2026-09-07-deepseek-messages-adapter.zh.md) 与 Chat Completions 共享生命周期,各自保留端点和协议格式。文件解析失败时,[有界内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)会发送相同的确定性请求版本。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按 Files 端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期。Chat 记录返回的 `expires_at`;Messages 元数据不含过期时间,因此本地复用期限使用原始上传时间加配置的生存期,但不保证远端文件删除。本地映射剩余复用时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。
直连 `deepseek-official` 适配器通过 [Messages Files API](2026-09-07-deepseek-messages-adapter.zh.md) 上传每个保留的请求版本,并发送 file-id 引用。文件解析失败时,[有界内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)发送相同的确定性请求版本。上传 id 按 Files 端点、API-key 作用域及 `variantId` 建立索引。上传默认请求七天有效期。Messages 元数据不含过期时间,因此本地复用使用原始上传时间加配置的有效期,不承诺远端删除。剩余复用时间不超过一小时的映射会直接替换,不先调用 retrieve。索引从不存储 API 密钥。
只有上传响应返回有效的原生文件对象、字节数匹配,且客户端确定了该协议的复用期限时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。同一作用域和 `variantId` 的并发解析共享一次提供方上传;单个等待方无法取消其他等待方,全部等待方取消时才会停止上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果模型端点报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次模型请求使用的全部映射。受影响的请求字节会重新上传,模型请求只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次模型请求。一次上传配额错误会先列出配置数量的最旧 `dsh-` 文件,再删除收集到的文件并重试一次;分页完成后才删除,避免游标失效。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。每个 Files 请求都携带 Harness 的共享 `User-Agent`。客户端执行 Files 单次上传 128MiB、请求单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。
@@ -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-07-deepseek-messages-adapter.md
2026-09-07-deepseek-messages-adapter.md: b79c2526461843a9534d6a997029281a584e0bed
2026-09-07-deepseek-messages-adapter.zh.md: 49a967828e22803296ae3d4c8eef943b9ec8e3c5
2026-09-07-deepseek-messages-adapter.md: 29f712e57c8048ddb0c05b5f56fd4793c52bec5f
2026-09-07-deepseek-messages-adapter.zh.md: 3cf60fc2101fb835423b74a6532e5edcc5bee911
@@ -6,29 +6,29 @@ English | [中文](2026-09-07-deepseek-messages-adapter.zh.md)
## Problem
Deployments expose DeepSeek through Anthropic Messages gateways as well as chat-completions. Messages represents thinking, signatures, tool calls, tool results, and cumulative usage differently. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns.
Messages represents thinking, signatures, tool calls, tool results, and cumulative usage as native protocol fields. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns.
## Decision
The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves multiple protocols under one `deepseek-official` route and `llm-deepseek` settings namespace. `common/` shares configuration, the model catalog, capability resolution, and Files lifecycle; `protocols/chat-completions/` and `protocols/messages/` own serialization, stream conversion, and transport. Cordis YAML selects the implementation through `protocol`, defaulting to `messages`; shipped first-party compositions inherit that default. The existing `PreparedAdapterCall` freezes protocol, endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration.
The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves Messages under one `deepseek-official` route and `llm-deepseek` settings namespace. The [single-protocol decision](../simplification/2026-09-19-deepseek-messages-only.md) owns the transport scope. `PreparedAdapterCall` freezes the endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration.
The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. Both protocols forward explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration.
The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. The adapter forwards explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration.
Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; historical tool arguments use the [empty-input fallback](../bug-fix/2026-09-16-messages-historical-tool-input.md) when Messages cannot represent them. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning.
Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md), while Chat Completions appends `/files`. Messages Files requests carry the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned Messages roots share uploads. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement.
The adapter prefers Files references for deterministic request images, with upload caching, refresh, quota recovery, and attachment offload. The Files client follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md) and sends the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned roots share uploads. Metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement.
JSON syntax failure after a successful HTTP response does not establish a transport failure. Files decoding uses the existing `INVALID_RESPONSE` code with operation context instead of relabeling the failure as `TRANSPORT` or adding a retry; the [provider README](../../../../packages/llm/llm-deepseek/README.md) owns the error fields.
System updates use the existing [route capability](2026-09-02-in-history-system-prompt-replacement.md) when explicitly declared for an endpoint/model. Messages retains the initial top-level system and emits later snapshots as native system turns after the corresponding user/tool-result turn, preserving previously sent prefixes. This placement differs from the loop's system-before-user admission; serialization changes neither the durable log nor conversation-turn order. Undeclared routes consolidate the latest snapshot at the top level, including direct compaction calls. Capability inference from protocol or model names is insufficient because support and update semantics depend on the deployed endpoint.
Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. Messages follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default and appends `/chat/completions` without adding a version segment.
Web displays DeepSeek with an endpoint and credential reference. Without an endpoint override, resolution uses `https://api.deepseek.com/anthropic`. Overrides must support Messages. The adapter follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog advertises the capabilities declared by its entries.
Both transports use the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accept captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings.
The adapter uses the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accepts captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings.
## Alternatives considered
**Separate plugins per protocol.** This duplicates credentials, catalogs, settings cards, and provider identities, and forces users to reselect models when the wire protocol changes. Protocol folders inside one plugin retain implementation isolation; Responses can add an implementation without changing the user configuration structure.
**Separate Messages provider identity.** This duplicates credentials, catalogs, and settings cards, and forces users to reselect models when adopting Messages. The `deepseek-official` identity keeps those user choices stable.
**Delegate the new route to pi-ai or the Anthropic SDK.** Both provide maintained protocol implementations, but the requested direct adapter needs DeepSeek-specific configuration, attachment policy, credential resolution, and retry ownership. A small stream translator with a maintained SSE parser keeps these responsibilities explicit; the library-backed adapter remains available independently.
@@ -38,6 +38,6 @@ Both transports use the existing [request-extension registry](../architecture/20
## Consequences
The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol.
The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages serves the shared base, Web, and standalone first-party compositions. Explicit endpoint overrides must support Messages.
Verification covers wire fixtures, real Loader composition, per-file unit coverage, [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml) with [unknown replay versions](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy.
@@ -6,29 +6,29 @@ Status: implemented
## 问题
部署可以通过 Anthropic Messages 网关或 chat-completions 调用 DeepSeek。Messages 对思考、签名、工具调用、工具结果和累计用量的表示不同。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。
Messages 以原生协议字段表示思考、签名、工具调用、工具结果和累计用量。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。
## 决策
[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)通过一个 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间支持多个协议。`common/` 共享配置、模型目录、能力解析和 Files 生命周期;`protocols/chat-completions/` 与 `protocols/messages/` 分别负责协议序列化、流转换和传输。`protocol` 配置在 Cordis YAML 中选择实现,默认 `messages`;随产品交付的官方组合继承该默认值。已有 `PreparedAdapterCall` 冻结协议、端点、凭据引用与模型能力,重试保持同一代配置,后续调用读取新配置。
[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)在唯一的 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间下提供 Messages。[单协议决策](../simplification/2026-09-19-deepseek-messages-only.zh.md)拥有传输范围。`PreparedAdapterCall` 固定端点、凭据引用与模型能力;重试保留该代配置,后续调用读取新配置。
适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。两种协议都转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。
适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。适配器转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。
助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;Messages 无法表示历史工具参数时使用[空输入兜底](../bug-fix/2026-09-16-messages-historical-tool-input.zh.md)。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。
两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 遵循[严格匹配 `/v1` 的根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md),Chat Completions 则追加 `/files`。Messages Files 请求携带必需的 beta 标头。缓存 id 按解析后的 Files 根地址和凭据限定作用域,因此等价的 `/v1` 与无版本 Messages 根地址可以复用上传。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。
适配器优先使用确定性请求图片的 Files 引用,并管理上传缓存、刷新、配额恢复与附件省略。Files 客户端遵循[精确 `/v1` 根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md)并发送必需的 beta 标头。缓存 id 按解析后的 Files 根地址与凭据限定作用域,因此等价的 `/v1` 与无版本根地址共用上传。元数据不含过期时间,因此本地复用以原始上传时间为界,不断言远端删除。Files 解析失败会按独立内联图片预算重建完整请求;调用方取消会停止它。图片策略在请求和 token 计量中保留 128 MiB 保留图片预算、20 MiB 内联 base64 预算及最旧前缀省略。
成功 HTTP 响应之后的 JSON 语法错误不足以证明传输故障。Files 解码复用已有的 `INVALID_RESPONSE` code 并补充操作上下文,不将其改标为 `TRANSPORT` 或添加重试;错误字段由 [provider README](../../../../packages/llm/llm-deepseek/README.zh.md) 维护。
系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](2026-09-02-in-history-system-prompt-replacement.zh.md)。Messages 保留初始顶层 system,在对应的用户或工具结果轮次之后,将后续快照发送为原生 system 轮次,保留此前发送的前缀。这个位置不同于循环先 system、后 user 的接纳顺序;序列化既不改写持久化日志,也不改变对话轮次的顺序。未声明能力的路由将最新快照归并到顶层,直接压缩调用也如此。仅凭协议或模型名称推断能力并不充分,因为支持情况和更新语义取决于实际部署的端点。
Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。Messages 遵循严格匹配 `/v1` 的根地址规则,不根据其他版本式后缀推断兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值,追加 `/chat/completions` 而不增加版本段。
Web 显示 DeepSeek,并提供端点与凭据引用。没有端点覆盖时,解析使用 `https://api.deepseek.com/anthropic`。覆盖地址必须支持 Messages。适配器遵循精确 `/v1` 根地址规则,不从其他类似版本的后缀推断兼容性。单一模型目录公布各条目声明的能力。
两种传输都在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。
适配器在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。
## 考虑过的替代方案
**每个协议独立插件。** 这会重复凭据配置、模型目录、设置卡片和 provider ID,并迫使用户在底层协议变化时重选模型。单插件中的协议目录保留实现隔离;Responses 可以增加自己的实现而不改变用户配置结构。
**独立的 Messages 提供方身份。** 这会重复凭据、目录与设置卡,并迫使用户在采用 Messages 时重新选择模型。`deepseek-official` 身份使这些用户选择保持稳定。
**把新路由委托给 pi-ai 或 Anthropic SDK。** 两者均提供持续维护的协议实现,但所需的直接适配器需要 DeepSeek 专属配置、附件策略、凭证解析和重试所有权。小型流转换器配合持续维护的 SSE 解析器使这些职责保持明确;库实现适配器仍可独立使用。
@@ -38,6 +38,6 @@ Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseU
## 结果
该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。
该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 服务共享 base、Web 和独立的官方组合。显式端点覆盖必须支持 Messages。
验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。
@@ -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-11-desktop-mandatory-update-client.md
2026-09-11-desktop-mandatory-update-client.md: d66d3257360270a72d5e774282d9f7c61981929f
2026-09-11-desktop-mandatory-update-client.zh.md: fd6e751a4082578cc4d54746e0fc584104a89226
2026-09-11-desktop-mandatory-update-client.md: ff55f30836a37e49229adac6414cb3a98bca4d6c
2026-09-11-desktop-mandatory-update-client.zh.md: 6c17a9fbd0ce2741b635b742be682d4c5fd9f8c6
@@ -14,11 +14,11 @@ The shell polls the guest protocol in the [API proposal](../../proposed/feature/
A coordinator owns one immutable installed-client identity, one in-flight request, a deadline, interval jitter, and bounded failure backoff. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided.
User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory window. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows.
User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory dialog. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows.
A shell-owned modal uses a separate sandboxed preload with dependency-free IPC names. The main process verifies its exact document URL and WebContents identity. Server title/detail are text, never HTML. Close, Esc, ordinary updater prompts, plugin-window entry, plugin mutations, and recovery actions cannot bypass the policy. The Host keeps existing work running; the existing updater's task-aware confirmation alone authorizes installation teardown. Closing the application remains available.
On macOS a shell-owned modal uses a separate sandboxed preload, and the main process verifies its exact document URL and WebContents identity. Windows embeds the shell-origin dialog in the main document below the caption so maximization and resizing have one native owner; a second native window caused visibly unsynchronized resizing (#4566). The isolated application preload owns a private MessageChannel to the frame and forwards actions to main-process IPC, which verifies the current app origin and main-frame identity. Product scripts share the DOM and can hide or imitate presentation, but cannot obtain the privileged port, clear main-process policy, or approve task teardown through window message events. The caption and its Web controls remain reachable by design. Server title/detail are text, never HTML. Esc and ordinary updater prompts cannot dismiss the block; main-process guards refuse plugin-window entry and plugin mutations while it is active. The Host keeps existing work running; task-aware installation confirmation remains separate from policy. Closing the application remains available. Windows presentation and its trust limits are owned by the [embedded-update decision](../bug-fix/2026-09-20-windows-embedded-mandatory-update.md).
The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned document and main frame; closing, aborting, or losing an ordinary renderer cancels its approval.
The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned IPC sender; closing, aborting, or losing an ordinary renderer cancels its approval.
The recovery layout keeps localized errors and folded technical details inside the block. Page actions appear only for failure or absent-artifact recovery, not beside the normal action. Exact HTTPS origins are checked before browser or clipboard use. A browser request immediately exposes copy recovery; its completion cannot prove external page loading. Only copy failure reveals the complete validated address for manual copying. Navigation and copy results are independent of updater errors and ignore completion after a changed policy destination. Neither browser opening, readiness, nor installer handoff clears policy or changes the updater target.
@@ -14,11 +14,11 @@ Status: implemented
协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。
用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更窗口。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。
用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更弹窗。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。
壳拥有的模态窗口使用独立沙箱预加载和无依赖的 IPC 名称。主进程校验其精确文档 URL 和 WebContents 身份。服务端标题/详情按文本展示,绝不作为 HTML。关闭、Esc、常规更新弹窗、插件窗口入口、插件修改和恢复操作都不能绕过策略。Host 保持现有工作运行;只有现有 updater 的任务影响确认才能授权安装收尾。仍可退出应用。
macOS 的壳模态窗口使用独立沙箱预加载,主进程校验其精确文档 URL 和 WebContents 身份。Windows 将壳源站弹窗嵌入主文档顶栏下方,让最大化与缩放只有一个原生窗口负责;第二个原生窗口曾造成可见的缩放不同步(#4566)。隔离的应用预加载拥有通向 frame 的私有 MessageChannel,将操作转发至校验当前应用源站和主 frame 身份的主进程 IPC。产品脚本共享 DOM,可以隐藏或仿造展示,但不能获得特权端口、清除主进程策略,或通过 window 消息事件批准任务收尾。顶栏及其中的 Web 控件按设计保持可触达。服务端标题/详情按文本展示,绝不作为 HTML。Esc 和常规更新弹窗不能解除阻塞;阻塞期间主进程拒绝插件窗口入口和插件修改。Host 保持现有工作运行,任务影响安装确认独立于策略。仍可退出应用。 Windows 展示及其信任限制由[内嵌强更决策](../bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md)负责。
模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属文档与主 frame;关闭、中止或失去常规渲染进程会取消其批准。
模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属 IPC 发送者;关闭、中止或失去常规渲染进程会取消其批准。
恢复界面在阻塞窗口内保留本地化错误和折叠技术详情。页面操作仅在失败或没有可用产物时出现,不与正常操作并列。浏览器或剪贴板使用前校验精确 HTTPS 源站。请求打开浏览器后立即提供复制恢复入口;请求完成不能证明外部网页加载。仅在复制失败时展示完整、经过校验的地址供手动复制。导航和复制结果独立于 updater 错误,策略目标地址改变后忽略迟到结果。打开浏览器、下载就绪和安装器交接均不清除策略或改变 updater 目标。
@@ -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: 51bbaac89c5c2ec9a3a016030e7b320d2b5566b8
2026-09-14-desktop-primary-runtime.zh.md: 4f27d61b5e713f66e2f478894dbd547cd7ecfff4
2026-09-14-desktop-primary-runtime.md: a1491ca0c47e40c6d5b885c9f6a42ccf8180b78c
2026-09-14-desktop-primary-runtime.zh.md: 6447086a6948ed262699b62061eb44af8d6071d7
@@ -12,9 +12,9 @@ Desktop agents need predictable Python data-processing libraries and an independ
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, 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.
The application version, top-level interpreter/package-manager versions, Python distribution map 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. The reader normalizes legacy `components` files and retains their numpy/pandas consistency checks; current manifests carry Python libraries only in the distribution map. 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 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.
The shared Node build entry 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.
@@ -22,6 +22,8 @@ Windows signed packaging separates materialization from execution with supervise
Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes.
The [shared-runtime decision](../architecture/2026-09-17-shared-office-runtime.md) owns builder, lock and query-package placement for Desktop and SDK carriers, including GNU/Linux x64 and Python-only payloads. Desktop retains the installation and signing behavior described here and requires the full Python, Node.js and pnpm payload.
## Alternatives considered
**System interpreters only.** They do not provide predictable availability or preinstalled numpy and pandas.
@@ -12,9 +12,9 @@ Desktop 代理需要在没有开发环境的机器上获得确定的 Python 数
Desktop 将 Python、Node.js、pnpm、数据处理库和 Office 创作库作为绑定应用版本的产物交付。路径查询工具从应用资源将产物安装到 Harness home 下的固定目录,并返回绝对路径与内置 Python 分发包版本。版本报告不包含用户自行添加的包。它不修改 PATH、环境变量或包管理器配置。pnpm 使用原生全局安装规则。
应用版本、组件版本、Python 分发包版本和锁定输入摘要记录在 `runtime.json` 中,不放在目录名里。摘要涵盖所选目标的解释器与 wheel 压缩包、共享 wheel 输入、Python 分发包版本、pnpm 版本和组装格式;其他目标不会使其失效。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序参与该身份计算;锁文件顶层键的顺序不参与。解压或组装逻辑在锁定输入不变时改变产物字节,必须显式提升格式版本。安装发布完整的暂存副本,并在替换成功前保留之前的目录。相同产物复用已安装文件;替换也会移除受管目录内用户添加的 Python 依赖。不含摘要的旧清单仍可读取,并与当前产物区分。分发包名称按 PEP 503 归一化,归一化后重复的名称会被拒绝;清单包含 numpy/pandas 分发包版本时,必须与相应组件版本一致。Desktop 单实例所有者和工具共享的安装 Promise 串行处理正常安装请求。
应用版本、顶层解释器和包管理器版本、Python 分发包版本表和锁定输入摘要记录在 `runtime.json` 中,不放在目录名里。摘要涵盖所选目标的解释器与 wheel 压缩包、共享 wheel 输入、Python 分发包版本、pnpm 版本和组装格式;其他目标不会使其失效。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序参与该身份计算;锁文件顶层键的顺序不参与。解压或组装逻辑在锁定输入不变时改变产物字节,必须显式提升格式版本。安装发布完整的暂存副本,并在替换成功前保留之前的目录。相同产物复用已安装文件;替换也会移除受管目录内用户添加的 Python 依赖。不含摘要的旧清单仍可读取,并与当前产物区分。分发包名称按 PEP 503 归一化,归一化后重复的名称会被拒绝。读取器归一化旧 `components` 文件并保留其 numpy/pandas 一致性校验;当前清单仅在分发包版本表中记录 Python 库。Desktop 单实例所有者和工具共享的安装 Promise 串行处理正常安装请求。
Node 下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。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 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
@@ -22,6 +22,8 @@ Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应
Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。
[共享运行时决策](../architecture/2026-09-17-shared-office-runtime.zh.md)持有 Desktop 与 SDK 载体的构建器、锁文件和查询包归属,覆盖 GNU/Linux x64 与 Python-only payload。Desktop 保留本文的安装与签名行为,并要求完整的 Python、Node.js 和 pnpm payload。
## Alternatives considered
**只使用系统解释器。** 无法保证可用性或预装 numpy 和 pandas。
@@ -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: 2b0bd012c23c873b3003916491daad467cf757b3
2026-07-30-generated-third-party-notices.zh.md: 0c7b4df8edb60f2f70781e179edc7377da930128
2026-07-30-generated-third-party-notices.md: 71f1fd0a5b650d53c5ef21438e17e0a6e162bdf5
2026-07-30-generated-third-party-notices.zh.md: e3adb43e4b407945a8cca88105de689407b5908b
@@ -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, `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.
[`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 [shared runtime lock](../../../../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 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.
**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 shared 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; 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.
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 shared runtime lock's `pythonPackages`. Bundled entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The shared wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Bundled 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.
@@ -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` 与 [Desktop 运行时锁文件](../../../../apps/desktop/scripts/primary-runtime-lock.json)生成。根 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` 与 [共享运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。
**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 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` 仍然可用。
**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 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` 仍然可用。
有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除** manifest 不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。
文件默认只披露**直接**依赖;完整的 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`。
文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及共享运行时锁文件 `pythonPackages` 中的每个分发包。内置条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。共享 wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。内置分发包同样通过运行时许可证检查,其中包括 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 钩子执行。
@@ -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/simplification/2026-09-19-deepseek-messages-only.md
2026-09-19-deepseek-messages-only.md: c10ecae950bb12f4556021de21c9716ad2906227
2026-09-19-deepseek-messages-only.zh.md: 3cfadac6ebc0b4bb1587a8b72e9b169d2ade2ee9
@@ -0,0 +1,25 @@
# Agent Note: One Messages transport for the official DeepSeek route
Status: implemented
English | [中文](2026-09-19-deepseek-messages-only.zh.md)
## Problem
The official DeepSeek route needs Messages thinking replay, image uploads, and in-history updates. Selecting a second transport duplicates serializers, stream handling, Files wire formats, configuration branches, and fixtures without adding a required capability to this route.
## Decision
`dsh-llm-deepseek` uses Messages exclusively. One adapter resolves a request-local configuration snapshot and performs the request. Configuration exposes one endpoint and no protocol selector. The Files client uses Messages authentication, metadata, and pagination directly; quota cleanup scans every page before selecting the oldest owned files.
The [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md) continues to own native thinking replay, system-message placement, image recovery, and request-extension acceptance. Replay discriminators and upload-index namespaces identify durable data and retain their existing values. The separate pi-ai adapter retains its provider-specific protocols.
## Alternatives considered
**Keep Chat Completions behind an optional selector.** This retains the duplicated implementation and test matrix even though the official route requires only Messages.
**Keep a generic protocol dispatcher with one implementation.** The dispatcher adds a second dependency representation and forwards every adapter operation without selecting anything.
## Consequences
The official route requires a Messages-compatible endpoint. Its mock-backed recovery, Loader, CLI, and Web tests exercise Messages; removing a wire implementation does not remove those behavior checks. Committed Session generations remain readable through provider-neutral content and the retained Messages replay metadata.
@@ -0,0 +1,25 @@
# Agent Note: One Messages transport for the official DeepSeek route
Status: implemented
[English](2026-09-19-deepseek-messages-only.md) | 中文
## 问题
官方 DeepSeek 路由需要 Messages 思考回放、图片上传与历史内更新。选择第二种传输会重复序列化器、流处理、Files 协议格式、配置分支与夹具,却没有为这条路由增加必需能力。
## 决策
`dsh-llm-deepseek` 仅使用 Messages。一个适配器解析请求局部的配置快照并执行请求。配置暴露一个端点,不提供协议选择器。Files 客户端直接使用 Messages 认证、元数据与分页;配额清理扫描全部页面后再选择最旧的自有文件。
[Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)继续拥有原生思考回放、系统消息位置、图片恢复与请求扩展接受规则。回放判别值和上传索引命名空间标识持久数据,并保留现有值。独立的 pi-ai 适配器保留各提供方协议。
## 考虑过的替代方案
**在可选选择器后保留 Chat Completions。** 即使官方路由只需要 Messages,这仍会保留重复实现与测试矩阵。
**保留只有一个实现的通用协议分派器。** 分派器增加第二种依赖表示,并转发每个适配器操作,却没有进行任何选择。
## 后果
官方路由要求兼容 Messages 的端点。其基于 mock 的恢复、Loader、CLI 与 Web 测试使用 Messages;移除一种协议实现不会移除这些行为检查。已提交的 Session 代际继续通过提供方无关内容与保留的 Messages 回放元数据读取。
@@ -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/testing/2026-06-19-real-api-e2e-ci.md
2026-06-19-real-api-e2e-ci.md: b218d77f0817416b01459fbb70f5cacffe048ee8
2026-06-19-real-api-e2e-ci.zh.md: 52c988dad00ddb3ee7f0cd2d23da9771f0900808
2026-06-19-real-api-e2e-ci.md: bdb20b3d5413077a4ddf1c1d9293b3b808cd6c8c
2026-06-19-real-api-e2e-ci.zh.md: b95f7df62e0a57d4c58fc5c12a83905dd848c55e
@@ -49,7 +49,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS
- **Step-scoped secret.** `DEEPSEEK_API_KEY` is set in the `env:` of only the preflight and e2e steps, never job-level — so checkout/setup-node/install never see it. A compromised install-time lifecycle script in a dependency cannot read a secret that isn't in its environment.
- **`permissions: contents: read`.** The job only reads the repo to run tests; it needs no write scopes (no PR comments, no status writes), so the `GITHUB_TOKEN` is dropped to least privilege.
- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint.
- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com/anthropic` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint.
- **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value or its length.
### Scope, runtime shape
@@ -49,7 +49,7 @@ repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试
- **步骤级 secret。** `DEEPSEEK_API_KEY` 仅在 preflight 和 e2e 步骤的 `env:` 中设置,从不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。
- **`permissions: contents: read`。** job 仅读取仓库以运行测试;不需要写权限(无 PR 评论、无 status 写入),因此 `GITHUB_TOKEN` 降至最小权限。
- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。
- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com/anthropic`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。
- **不回显 secret。** preflight 仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。
### 范围与运行时形态
+3
View File
@@ -357,6 +357,9 @@ jobs:
if: vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]'
run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium
- name: Build and execute the Linux Office runtime
run: CI=true pnpm run prepare:primary-runtime --target linux-x64 --output "$RUNNER_TEMP/office-runtime" --cache "$RUNNER_TEMP/office-runtime-downloads"
- name: Run compatibility, snapshot, and artifact gates
run: pnpm run check:ci:consumers
+1 -2
View File
@@ -32,8 +32,7 @@ mise.toml
dist-exe/
/dist/
apps/desktop/.desktop-build/
python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*
python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/
python/sdk-runtime/src/deepseek_harness_runtime/runtime/
python/**/__pycache__/
python/**/.pytest_cache/
packages/**/__pycache__/
+3 -3
View File
@@ -5,7 +5,7 @@
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, 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.
This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the 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 SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock).
@@ -245,9 +245,9 @@ 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
## 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.
The [shared runtime lock](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 |
| --- | --- | --- |
+2
View File
@@ -69,6 +69,8 @@
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-filesystem": "workspace:^",
"@deepseek-ai/dsh-skill-office": "workspace:^",
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-terminal-bash": "workspace:^",
"@deepseek-ai/dsh-time-context": "workspace:^",
+3 -6
View File
@@ -520,7 +520,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
successText: 'ACP BUILT PROFILE OK',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-'))
writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n')
const child = execa(process.execPath, [dshBin, '--profile', 'acp'], {
cwd: home,
reject: false,
@@ -606,7 +605,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
successText: 'published headless profile reached the mock',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n')
try {
const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
DSH_HOME: home,
@@ -618,7 +616,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(result.stdout).toBe('published headless profile reached the mock')
expect(result.stderr).toBe('dsh: reasoning:\nInspecting the published entry.')
expect(server.requests.length).toBeGreaterThan(0)
expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
expect(server.requests.every(request => request.path === '/v1/messages')).toBe(true)
expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
} finally {
await server.close()
@@ -743,7 +741,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
successText: 'launching endpoint reached the mock',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n')
const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
createEnvironmentProbeProfile(home, project)
@@ -766,8 +763,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(result.stdout).not.toContain(apiKey)
expect(result.stderr).not.toContain(apiKey)
expect(server.requests).toHaveLength(1)
expect(server.requests[0]?.path).toBe('/chat/completions')
expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
expect(server.requests[0]?.path).toBe('/v1/messages')
expect(server.requests[0]?.headers['x-api-key']).toBe(apiKey)
expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
} finally {
await server.close()
+1 -2
View File
@@ -13,7 +13,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
const modules = join(root, 'node_modules', '@deepseek-ai')
const hostDirectory = fileURLToPath(new URL('../../desktop-host/', import.meta.url))
const manifest = JSON.parse(readFileSync(join(hostDirectory, 'package.json'), 'utf8')) as { dependencies: Record<string, string> }
const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths', '@deepseek-ai/dsh-tools'])
const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths'])
for (const name of Object.keys(manifest.dependencies)) {
const destination = join(root, 'node_modules', name)
mkdirSync(dirname(destination), { recursive: true })
@@ -22,7 +22,6 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
}
for (const [name, source] of [
['dsh-home-paths', `export const resolveDshHome = () => ${JSON.stringify(root)}`],
['dsh-tools', 'export const defineTool = value => value'],
] as const) {
writeFileSync(join(modules, name, 'package.json'), '{"type":"module","exports":"./index.js"}')
writeFileSync(join(modules, name, 'index.js'), source)
@@ -4,7 +4,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: !!js process.env.DSH_SNAPSHOT_PROTOCOL
apiKeyEnv: DSH_SNAPSHOT_API_KEY
baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
thinking: disabled
@@ -18,9 +18,9 @@ const IMAGE_OFFLOAD_CONFIG = fileURLToPath(new URL('./fixtures/image-offload.cor
const SNAPSHOTS_DIR = fileURLToPath(new URL('../../../../../../snapshots/acp/', import.meta.url))
const READ_IMAGE_WORKSPACE = fileURLToPath(new URL('../../../../../../snapshots/session/read-image/workspace/', import.meta.url))
it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and inline fallback in assembled requests', async (protocol) => {
const filesPath = protocol === 'messages' ? '/v1/files' : '/files'
const modelPath = protocol === 'messages' ? '/v1/messages' : '/chat/completions'
it('pins Messages Files offload and inline fallback in assembled requests', async () => {
const filesPath = '/v1/files'
const modelPath = '/v1/messages'
const requests: Record<string, unknown>[] = []
const fileRequests: Array<{ method: string; path: string; bytes: number }> = []
let rejectFiles = false
@@ -49,21 +49,13 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
return
}
const createdAt = Math.floor(Date.now() / 1_000)
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(protocol === 'messages' ? {
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({
id: 'file-api-snapshot-1',
type: 'file',
size_bytes: file.size,
created_at: new Date(createdAt * 1_000).toISOString(),
filename: 'dsh-snapshot.png',
mime_type: file.type,
} : {
id: 'file-api-snapshot-1',
object: 'file',
bytes: file.size,
created_at: createdAt,
filename: 'dsh-snapshot.png',
purpose: 'user_data',
expires_at: createdAt + Number(form.get('expires_after[seconds]')),
}))
return
}
@@ -73,35 +65,18 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
}
requests.push(JSON.parse(body.toString('utf8')) as Record<string, unknown>)
response.writeHead(200, { 'content-type': 'text/event-stream' })
if (protocol === 'messages') {
const toolCall = requests.length === 1
const events = [
{ type: 'message_start', message: { id: 'offload-response', model: 'deepseek-v4-flash-vision-exp', usage: { input_tokens: 3, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: toolCall
? { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } }
: { type: 'text', text: 'DONE' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: toolCall ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 1 } },
{ type: 'message_stop' },
]
response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''))
return
}
const events = requests.length === 1
? [
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}',
'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'data: [DONE]',
'',
]
: [
'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}',
'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'data: [DONE]',
'',
]
response.end(events.join('\n\n'))
const toolCall = requests.length === 1
const events = [
{ type: 'message_start', message: { id: 'offload-response', model: 'deepseek-v4-flash-vision-exp', usage: { input_tokens: 3, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: toolCall
? { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } }
: { type: 'text', text: 'DONE' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: toolCall ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 1 } },
{ type: 'message_stop' },
]
response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''))
return
})().catch((error: unknown) => {
response.writeHead(500, { 'content-type': 'text/plain' }).end(String(error))
})
@@ -137,7 +112,6 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'),
workspaceDir: READ_IMAGE_WORKSPACE,
env: {
DSH_SNAPSHOT_PROTOCOL: protocol,
DSH_SNAPSHOT_API_KEY: 'snapshot-key',
DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`,
},
@@ -162,14 +136,9 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
+ ' Copy to a writable path ending in .png before editing.'
}
const normalizedAccess = accessText(result.cwd)
const imagePrefix = protocol === 'messages' ? '' : '\n'
const fileImage = protocol === 'messages'
? { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } }
: { type: 'file', file_id: 'file-api-snapshot-1' }
const fileImage = { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } }
const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]`
const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}`
const normalizedToolImageHandle = `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}`
.replaceAll(result.cwd, '{{cwd}}')
const runtimeContext = 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n'
+ 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n'
+ 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
@@ -179,80 +148,31 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
{ type: 'text', text: 'Compare the older image ' },
{ type: 'text', text: offloadedImage },
{ type: 'text', text: ' with the newer image ' },
{ type: 'text', text: `${imagePrefix}${imageHandle}` },
{ type: 'text', text: imageHandle },
fileImage,
{ type: 'text', text: ', then use read_image on red.png and reply with DONE.' },
...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [],
{ type: 'text', text: runtimeContext },
])
if (protocol === 'messages') {
const followup = requests[1]?.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
expect(followup).toHaveLength(3)
expect(followup[0]?.content.filter(block => block.type === 'image')).toEqual([])
expect(followup[0]?.content.filter(block => block.text === offloadedImage)).toHaveLength(2)
expect(followup[1]).toEqual({ role: 'assistant', content: [
{ type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } },
] })
expect(followup[2]?.role).toBe('user')
expect(followup[2]?.content).toHaveLength(1)
const toolResult = followup[2]!.content[0]!
expect(toolResult.type).toBe('tool_result')
expect(toolResult.tool_use_id).toBe('native-read-image')
const content = toolResult.content as Array<Record<string, unknown>>
expect(content[0]?.type).toBe('text')
expect(content[0]?.text).toContain('image/png image, 1x1 px, 69 bytes')
expect(content.slice(1)).toEqual([
{ type: 'text', text: `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` },
fileImage,
])
} else {
const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{
role?: unknown
content?: unknown
}>
const toolMessage = followup.find(message => message.role === 'tool')
if (toolMessage === undefined || typeof toolMessage.content !== 'string') {
throw new Error('native read_image request has no tool content')
}
const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => (
cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`]
)))]
let toolContent = toolMessage.content
for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}')
toolMessage.content = toolContent
expect(followup).toEqual([
{
role: 'user',
content: `Compare the older image ${offloadedImage} with the newer image ${offloadedImage}, then use read_image on red.png and reply with DONE.`,
},
{
role: 'user',
content: runtimeContext,
},
{
role: 'assistant',
content: '',
tool_calls: [{
id: 'native-read-image',
type: 'function',
function: { name: 'read_image', arguments: '{"file_path":"red.png"}' },
}],
},
{
role: 'tool',
tool_call_id: 'native-read-image',
content: '<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n'
+ `</content>\n${normalizedToolImageHandle}`,
},
{
role: 'user',
content: [
{ type: 'text', text: 'Attached image(s) from tool result:' },
{ type: 'file', file_id: 'file-api-snapshot-1' },
],
},
])
}
const followup = requests[1]?.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
expect(followup).toHaveLength(3)
expect(followup[0]?.content.filter(block => block.type === 'image')).toEqual([])
expect(followup[0]?.content.filter(block => block.text === offloadedImage)).toHaveLength(2)
expect(followup[1]).toEqual({ role: 'assistant', content: [
{ type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } },
] })
expect(followup[2]?.role).toBe('user')
expect(followup[2]?.content).toHaveLength(1)
const toolResult = followup[2]!.content[0]!
expect(toolResult.type).toBe('tool_result')
expect(toolResult.tool_use_id).toBe('native-read-image')
const content = toolResult.content as Array<Record<string, unknown>>
expect(content[0]?.type).toBe('text')
expect(content[0]?.text).toContain('image/png image, 1x1 px, 69 bytes')
expect(content.slice(1)).toEqual([
{ type: 'text', text: `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` },
fileImage,
])
rejectFiles = true
const fallback = await runScenario(input, {
@@ -262,7 +182,6 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'),
workspaceDir: READ_IMAGE_WORKSPACE,
env: {
DSH_SNAPSHOT_PROTOCOL: protocol,
DSH_SNAPSHOT_API_KEY: 'snapshot-fallback-key',
DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`,
},
@@ -280,12 +199,10 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
{ type: 'text', text: 'Compare the older image ' },
{ type: 'text', text: `[image omitted to fit request image limits; ${attachmentId}.${fallbackAccess}]` },
{ type: 'text', text: ' with the newer image ' },
{ type: 'text', text: `${imagePrefix}Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` },
protocol === 'messages'
? { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } }
: { type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } },
{ type: 'text', text: `Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } },
{ type: 'text', text: ', then use read_image on red.png and reply with DONE.' },
...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [],
{ type: 'text', text: runtimeContext },
])
} finally {
await new Promise<void>(resolve => server.close(() => { resolve() }))
@@ -83,7 +83,9 @@ async function expectHeadlessStream(normalized: string, expectedPath: string): P
}
/** Serve one deterministic DeepSeek-compatible response while retaining its request body. */
async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean; protocol?: 'messages' } = {}): Promise<DeepSeekDefaultsServer> {
async function deepseekDefaultsServer(
options: { waitForTitleRequest?: boolean; piAiCompatibility?: true } = {},
): Promise<DeepSeekDefaultsServer> {
const requests: JsonObject[] = []
const paths: string[] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
@@ -103,7 +105,7 @@ async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean;
timer = setTimeout(write, 60)
return
}
if (options.protocol === 'messages') {
if (options.piAiCompatibility !== true) {
response.end([
{ type: 'message_start', message: { id: 'defaults-response', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
@@ -582,7 +584,7 @@ describe('headless stream-json snapshots', () => {
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('keeps provider comments alive and sends DeepSeek defaults through the one-shot app', async () => {
const server = await deepseekDefaultsServer({ protocol: 'messages' })
const server = await deepseekDefaultsServer()
try {
const result = await runLoaderSmoke({
label: 'DeepSeek adapter defaults headless stream-json snapshot',
@@ -638,7 +640,7 @@ describe('headless stream-json snapshots', () => {
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('keeps the compatibility stream open until the title request arrives', async () => {
const server = await deepseekDefaultsServer({ waitForTitleRequest: true })
const server = await deepseekDefaultsServer({ waitForTitleRequest: true, piAiCompatibility: true })
try {
const response = await fetch(server.url, {
method: 'POST',
@@ -675,7 +677,7 @@ describe('headless stream-json snapshots', () => {
})
it('sends pi-ai DeepSeek compatibility through the one-shot app', async () => {
const server = await deepseekDefaultsServer({ waitForTitleRequest: true })
const server = await deepseekDefaultsServer({ waitForTitleRequest: true, piAiCompatibility: true })
try {
const result = await runLoaderSmoke({
label: 'pi-ai DeepSeek compatibility headless stream-json snapshot',
@@ -1,13 +1,14 @@
import { createServer } from 'node:http'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { zstdDecompress } from 'node:zlib'
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, onTestFinished } from 'vitest'
import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies'
const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url))
const launch = resolveExampleLaunch({
@@ -408,3 +409,123 @@ describe('Python SDK dsh profile keyless smoke', () => {
}
}, 30_000)
})
/** Materialize a native-layout payload whose paths the query can validate without executing binaries. */
async function officeFixture(root: string, pythonOnly: boolean) {
const source = join(root, 'resources', 'primary-runtime')
const manifest: PrimaryRuntimeManifest = {
desktopVersion: '1.0.0', platform: process.platform, arch: process.arch,
python: '3.12.14',
...(pythonOnly ? {} : { node: '24.21.0', pnpm: '11.7.0' }),
pythonPackages: { 'python-docx': '1.2.0', 'python-pptx': '1.0.2', openpyxl: '3.1.5' },
}
const paths = workspaceDependencyPaths(source, manifest)
for (const file of [paths.python, paths.node, paths.pnpm]) {
if (file === undefined) continue
await mkdir(dirname(file), { recursive: true })
await writeFile(file, 'fixture interpreter')
}
await mkdir(paths.pythonPackages, { recursive: true })
if (paths.nodePackages !== undefined) await mkdir(paths.nodePackages, { recursive: true })
await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest))
await cp(join(repoRoot, 'packages/skill/skill-office/assets'), join(root, 'resources', 'office-skills'), { recursive: true })
return { source, paths }
}
it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', 'wrong-type'] as const)('composes Office resources through the SDK profile (%s)', async (mode) => {
const root = await mkdtemp(join(tmpdir(), 'sdk-office-'))
onTestFinished(() => rm(root, { recursive: true, force: true }))
const enabled = mode !== 'unset' && mode !== 'empty'
const { source, paths } = await officeFixture(root, mode === 'python-only')
if (mode === 'missing-assets') await rm(join(root, 'resources', 'office-skills'), { recursive: true })
if (mode === 'wrong-type') {
await rm(paths.python)
await mkdir(paths.python)
}
const requests: Record<string, unknown>[] = []
const server = createServer((request, response) => {
let body = ''
request.setEncoding('utf8').on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
requests.push(JSON.parse(body) as Record<string, unknown>)
const query = requests.length === 1 && enabled
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(messagesResponse(query
? { type: 'tool_use', id: 'workspace-dependencies', name: 'load_workspace_dependencies', input: {} }
: { type: 'text', text: 'done' }, query ? 'tool_use' : 'end_turn'))
})
})
onTestFinished(() => new Promise<void>((resolve) => { server.close(() => { resolve() }) }))
await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve) })
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('model fixture did not bind')
const home = join(root, 'home')
const officeLaunch = resolveExampleLaunch({
srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib',
configArgs: ['--profile', 'sdk'],
env: { DSH_HOME: home, DSH_PRIMARY_RUNTIME: mode === 'unset' || mode === 'bundled' ? undefined : mode === 'empty' ? '' : source + '/',
DSH_BUNDLED_PRIMARY_RUNTIME: mode === 'unset' ? undefined : mode === 'bundled' || mode === 'empty' ? source : join(root, 'unused-default'),
DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: 'local-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` },
})
const child = execa(officeLaunch.command, officeLaunch.args, { cwd: repoRoot, env: officeLaunch.env, timeout: 60_000, reject: false })
onTestFinished(async () => { child.kill('SIGKILL'); await child })
let buffer = '', stderr = ''
const lines: string[] = []
child.stdout.on('data', (chunk: Buffer) => {
buffer += chunk.toString()
const parts = buffer.split('\n')
buffer = parts.pop() ?? ''
lines.push(...parts)
})
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
const send = (id: number, method: string, params?: object) => {
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`)
return waitForLine(lines, value => value.id === id, () => stderr)
}
expect(await send(1, 'initialize', { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' })).toHaveProperty('result')
await send(2, 'session/prompt', { sessionId: 'office', contentBlocks: [{ type: 'text', text: 'Query workspace dependencies.' }] })
await waitForLine(lines, (value) => {
const params = value.params as { sessionId?: string; event?: { type?: string } } | undefined
return value.method === 'session.event' && params?.sessionId === 'office' && params.event?.type === 'turn/end'
}, () => stderr)
const names = (requests[0]!.tools as { name: string }[]).map(value => value.name)
expect(names.includes('load_workspace_dependencies')).toBe(enabled)
for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) {
expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets')
}
if (mode === 'wrong-type') {
expect(requests).toHaveLength(2)
const messages = requests[1]!.messages as { content: { type: string; tool_use_id?: string; content?: unknown }[] }[]
const result = messages.flatMap(message => message.content).find(block => block.tool_use_id === 'workspace-dependencies')
expect(JSON.parse(JSON.stringify(result).replaceAll(JSON.stringify(paths.python).slice(1, -1), '<python>'))).toMatchInlineSnapshot(`
{
"content": [
{
"text": "Error: primary runtime: expected file at <python>",
"type": "text",
},
],
"is_error": true,
"tool_use_id": "workspace-dependencies",
"type": "tool_result",
}
`)
} else if (enabled) {
expect(requests).toHaveLength(2)
const content: unknown = expect.arrayContaining([
expect.objectContaining({ type: 'tool_result', tool_use_id: 'workspace-dependencies',
content: [{ type: 'text', text: JSON.stringify(paths, undefined, 2) }] }),
])
expect(requests[1]!.messages).toEqual(expect.arrayContaining([
expect.objectContaining({ role: 'user', content }),
]))
}
if (mode === 'missing-assets') expect(stderr).toContain('check_office.py')
await send(3, 'shutdown')
const exit = await child
expect(exit.timedOut).toBe(false)
expect(exit.signal).toBeUndefined()
expect(exit.exitCode, stderr).toBe(0)
await expect(readFile(join(home, 'dsh-runtimes', 'dsh-primary-runtime', 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' })
})
+1 -1
View File
@@ -125,7 +125,7 @@ describe('runProfile with an application-owned profile', () => {
name: 'desktop', dir: home, patchPath: profilePatch,
patches: [{ id: 'target', config: { profile: true, priority: 'profile' } }],
layers: [{
packageName: 'test-bundle', packageDir: home, patchPath: join(home, 'bundle.yml'),
packageName: 'test-bundle', packageDir: home, patchPaths: [join(home, 'bundle.yml')],
patches: [{ insert: [
{ id: 'target', name: 'target', config: { bundle: true, priority: 'bundle' } },
{ id: 'session-telemetry-otel', name: 'telemetry' },
+1 -1
View File
@@ -18,7 +18,7 @@
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^",
"@deepseek-ai/dsh-skill-office": "workspace:^"
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
import { dirname, join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import * as officeSkills from '@deepseek-ai/dsh-skill-office'
import * as workspaceDependencies from './workspace-dependencies.ts'
import * as workspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies'
/** Loader identity for the application-owned Office composition. */
export const name = 'desktop-office'
-134
View File
@@ -1,134 +0,0 @@
/** Offline installation and absolute paths for Desktop's bundled script dependencies. */
import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
/** Versions recorded by the Desktop build, independent of user-installed packages. */
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
readonly pnpm: string
readonly numpy: string
readonly pandas: string
}
}
/** 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 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.
*/
export async function readPrimaryRuntime(root: string): Promise<PrimaryRuntimeManifest> {
const value: unknown = JSON.parse(await readFile(join(root, 'runtime.json'), 'utf8'))
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])))
|| (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')
}
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 and recorded bundled Python versions.
*/
export function workspaceDependencyPaths(root: string, manifest: PrimaryRuntimeManifest): WorkspaceDependencies {
const dependencies = join(root, 'dependencies')
const windows = manifest.platform === 'win32'
return {
python: join(dependencies, 'python', ...(windows ? ['python.exe'] : ['bin', 'python3'])),
node: join(dependencies, 'node', 'bin', windows ? 'node.exe' : 'node'),
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 ?? {},
}
}
async function exists(path: string): Promise<boolean> {
try {
if ((await lstat(path)).isSymbolicLink()) throw new Error(`primary runtime: installation path is a filesystem link: ${path}`)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
throw error
}
}
/**
* Install the application-owned payload locally, retaining a complete previous tree on copy failure.
* @param source - Payload carried by the current Desktop installation.
* @param root - Fixed primary runtime directory under the Harness home.
* @returns Paths into the installed payload; no PATH or package-manager configuration is changed.
*/
export async function installPrimaryRuntime(source: string, root: string): Promise<WorkspaceDependencies> {
const manifest = await readPrimaryRuntime(source)
if (manifest.platform !== process.platform || manifest.arch !== process.arch) throw new Error('primary runtime: incompatible platform or architecture')
await mkdir(dirname(root), { recursive: true })
const previous = `${root}.previous`
await exists(previous)
await exists(root)
if (!await exists(root) && await exists(previous)) await rename(previous, root)
if (await exists(join(root, 'runtime.json')) && JSON.stringify(await readPrimaryRuntime(root)) === JSON.stringify(manifest)) {
const paths = workspaceDependencyPaths(root, manifest)
for (const path of [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages]) await stat(path)
return paths
}
const staging = await mkdtemp(join(dirname(root), '.primary-runtime-'))
try {
await cp(source, staging, { recursive: true, dereference: true })
const paths = workspaceDependencyPaths(staging, manifest)
for (const path of [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages]) await stat(path)
await rm(previous, { recursive: true, force: true })
const replacing = await exists(root)
if (replacing) await rename(root, previous)
try { await rename(staging, root) } catch (error) {
if (replacing) await rename(previous, root)
throw error
}
await rm(previous, { recursive: true, force: true })
} finally {
await rm(staging, { recursive: true, force: true })
}
return workspaceDependencyPaths(root, manifest)
}
@@ -1,54 +0,0 @@
/** Desktop tool exposing bundled interpreters without modifying command resolution. */
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { installPrimaryRuntime, type WorkspaceDependencies } from './primary-runtime.ts'
export const name = 'desktop-workspace-dependencies'
export const inject = ['tools']
/** Application-selected payload and installation directories. */
export interface Config {
readonly source: string
readonly root: string
}
/**
* Register the read-only path query, preparing bundled files on its first invocation.
* @param ctx - Desktop tool registry owner.
* @param config - Application payload and fixed installation paths.
*/
export function apply(ctx: Context, config: Config): void {
let installation: Promise<WorkspaceDependencies> | undefined
ctx.effect(() => async () => {
// Tool execution reports installation failures; disposal only waits for filesystem work to settle.
await installation?.catch(() => undefined)
})
ctx.tools.register(defineTool({
name: 'load_workspace_dependencies',
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: {
type: 'object', additionalProperties: false,
properties: {
python: { type: 'string', required: true },
node: { type: 'string', required: true },
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) }],
},
execute: () => {
installation ??= installPrimaryRuntime(config.source, config.root).catch((error: unknown) => {
installation = undefined
throw error
})
return installation
},
presentCall: () => ({ card: 'generic', title: 'Load workspace dependencies', kind: 'read' }),
}))
}
@@ -1,193 +0,0 @@
import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { installPrimaryRuntime, readPrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../src/primary-runtime.ts'
import * as workspaceDependencies from '../src/workspace-dependencies.ts'
const roots: string[] = []
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) })
async function fixture() {
const directory = await mkdtemp(join(tmpdir(), 'dsh-primary-runtime-'))
roots.push(directory)
const source = join(directory, 'resources')
const root = join(directory, 'home', 'dsh-runtimes', 'dsh-primary-runtime')
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]) {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, 'interpreter')
}
await mkdir(paths.pythonPackages, { recursive: true })
await mkdir(paths.nodePackages, { recursive: true })
await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest))
return { source, root, manifest, directory }
}
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, 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)
})
it.skipIf(process.platform === 'linux')('replaces release components and recovers an interrupted directory swap', async () => {
const { source, root, manifest } = await fixture()
await installPrimaryRuntime(source, root)
await rename(root, `${root}.previous`)
await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, desktopVersion: '2.0.0' }))
await installPrimaryRuntime(source, root)
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)
await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, desktopVersion: '2.0.0' }))
await rm(workspaceDependencyPaths(source, manifest).python)
await expect(installPrimaryRuntime(source, root)).rejects.toThrow()
expect((await readPrimaryRuntime(root)).desktopVersion).toBe('1.0.0')
})
it.skipIf(process.platform === 'linux')('refuses linked installation directories without modifying their targets', async () => {
const { source, root, directory } = await fixture()
const outside = join(directory, 'outside')
await mkdir(outside)
await writeFile(join(outside, 'keep'), 'untouched')
await mkdir(dirname(root), { recursive: true })
await symlink(outside, root, process.platform === 'win32' ? 'junction' : 'dir')
await expect(installPrimaryRuntime(source, root)).rejects.toThrow('filesystem link')
expect(await readFile(join(outside, 'keep'), 'utf8')).toBe('untouched')
})
it('rejects malformed metadata and incompatible targets', async () => {
const { source, root, manifest } = await fixture()
await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, arch: process.arch === 'x64' ? 'arm64' : 'x64' }))
await expect(installPrimaryRuntime(source, root)).rejects.toThrow('incompatible')
await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, components: { ...manifest.components, python: '../escape' } }))
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()
try {
ctx.baseUrl = pathToFileURL(directory).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['dependencies', workspaceDependencies],
])
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected plugin ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof ctx.loader.internal>
const config = join(directory, 'cordis.yml')
await writeFile(config, `- name: agents\n- name: systemPrompt\n- name: tools\n- name: dependencies\n config:\n source: ${JSON.stringify(source)}\n root: ${JSON.stringify(root)}\n`)
await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } })
await ctx.loader.await()
for (const entry of ctx.loader.entries()) await entry.fiber?.await()
const environment = { ...process.env }
const request = { signal: new AbortController().signal, name: 'load_workspace_dependencies', arguments: {} }
const results = await Promise.all(['first', 'second'].map(id => ctx.tools.execute({ ...request, callId: ToolCallId(id) })))
for (const result of results) {
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(workspaceDependencyPaths(root, manifest), undefined, 2) }])
}
expect(process.env).toEqual(environment)
const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'dependencies')
expect(entry).toBeDefined()
await entry?.fiber?.dispose()
expect(ctx.tools.schemas().some(tool => tool.name === 'load_workspace_dependencies')).toBe(false)
} finally {
await ctx.fiber.dispose()
}
})
+1 -1
View File
@@ -13,7 +13,7 @@
{ "path": "../../packages/jobs/jobs" },
{ "path": "../../packages/skill/skill-office" },
{ "path": "../../packages/util/home-paths" },
{ "path": "../../packages/core/tools" },
{ "path": "../../packages/skill/tool-workspace-dependencies" },
{
"path": "../cli"
},
+2 -2
View File
@@ -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: 0fc43998495f43b5b8586a21e88e13e7764be73e
README.zh.md: 8eadd2fc96199cc2a3f58bb3d746851e3e8b5816
README.md: 94d09c628d6bb40933f95cd3874d412ab18521df
README.zh.md: bfb156bfaa0400799498eabb6034f32c2d50e7f3
+3 -3
View File
@@ -24,11 +24,11 @@ Desktop carries independent Python, Node.js and pnpm distributions. Python inclu
Desktop registers `office-docx`, `office-pptx`, and `office-xlsx` by default. The skills use the bundled Python libraries for creation and focused edits, then reopen the files and run a shared structural checker before delivery. PowerPoint creation and editing use python-pptx. Skill resources are copied to `runtime/office-skills` outside ASAR so Python can read the checker. An available `render_document` tool can add visual inspection; its absence does not prevent authoring or delivery. See the [Office skill package](../../packages/skill/skill-office/README.md) for checks and limitations.
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.
The payload follows the Desktop release. `runtime.json` records the Desktop version, target, top-level interpreter/package-manager versions and the Python distribution map, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names reject the manifest. Legacy `components` manifests remain readable through normalization, with their existing library-version consistency checks. 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.
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, 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.
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; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. 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 |
|---|---|---|
@@ -322,7 +322,7 @@ With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENT
Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified.
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked.
+4 -4
View File
@@ -24,11 +24,11 @@ Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 nump
Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技能使用内置 Python 库创建文件和进行定点编辑,随后重新打开文件,并在交付前运行共享结构检查器。PowerPoint 的创建和编辑使用 python-pptx。技能资源复制到 ASAR 外的 `runtime/office-skills`,让 Python 可以读取检查器。可用的 `render_document` 工具可以补充视觉检查;缺少该工具不妨碍创作或交付。检查范围与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。
该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、组件与 Python 分发包版本,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复,或 numpy/pandas 的组件版本与分发包版本冲突时,清单会被拒绝。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。
该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、顶层解释器和包管理器版本及 Python 分发包版本表,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复时,清单会被拒绝。旧 `components` 清单通过归一化继续可读,并保留其原有库版本一致性校验。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。
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)固定解释器压缩包、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`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 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`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
| 决策 | 原因 | 直接结果 |
|---|---|---|
@@ -54,7 +54,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。
macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
@@ -322,7 +322,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
-2
View File
@@ -53,11 +53,9 @@
"electron": "^44.0.0",
"electron-builder": "^26.15.3",
"extract-zip": "^2.0.1",
"fflate": "^0.8.2",
"js-yaml": "^4.2.0",
"koffi": "^3.1.0",
"pnpm": "11.7.0",
"tar": "^7.5.0",
"typescript": "^6.0.3"
}
}
@@ -0,0 +1,42 @@
/** The embedded shell page delegates actions only to its owning application preload. */
if (window.parent !== window && window.dshMandatoryUpdate === undefined) {
let state
let port
let sequence = 0
const listeners = new Set()
const requests = new Map()
const initial = Promise.withResolvers()
const receive = event => {
const message = event.data
if (message?.type === 'dsh-mandatory-state' && message.state) {
state = message.state
initial.resolve(state)
for (const listener of listeners) listener(state)
}
if (message?.type === 'dsh-mandatory-result') {
const request = requests.get(message.id)
if (!request) return
requests.delete(message.id)
if (message.ok) request.resolve()
else request.reject(new Error('Update action failed'))
}
}
window.addEventListener('message', event => {
if (!event.isTrusted || event.source !== window.parent || event.origin !== 'dsh-app://app'
|| event.data?.type !== 'dsh-mandatory-connect' || event.ports.length !== 1 || port) return
port = event.ports[0]
port.onmessage = receive
})
window.addEventListener('pagehide', () => { port?.close() }, { once: true })
window.dshMandatoryUpdate = {
status: () => state ? Promise.resolve(state) : initial.promise,
subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) },
action: (action, version, revision) => {
const id = ++sequence
const request = Promise.withResolvers()
requests.set(id, request)
port.postMessage({ type: 'dsh-mandatory-action', id, action, version, revision })
return request.promise
},
}
}
@@ -32,6 +32,7 @@
<label id="manual-copy" hidden><span id="address-label"></span><textarea id="address" readonly rows="3" spellcheck="false"></textarea></label>
</div>
</main>
<script src="mandatory-update-frame.js"></script>
<script src="mandatory-update.js"></script>
</body>
</html>
+14 -150
View File
@@ -1,171 +1,35 @@
/** Prepare pinned, relocatable script interpreters without installing into the build host. */
/** Desktop resource locations and signing-aware verification for the shared runtime builder. */
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { cp } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import extractZip from 'extract-zip'
import { x as extractTar } from 'tar'
import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../desktop-host/src/primary-runtime.ts'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { preparePrimaryRuntime as preparePayload, smokePrimaryRuntime as smokePayload } from '../../../scripts/primary-runtime/prepare.ts'
import { parsePrimaryRuntime, workspaceDependencyPaths } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts'
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
import { scrubWindowsSigningEnvironment } from './windows-sign.mjs'
import lock from './primary-runtime-lock.json' with { type: 'json' }
/**
* Download or reuse an archive only when its bytes match the release lock.
* @param url - Locked archive URL.
* @param sha256 - Expected SHA-256 digest.
* @param cache - Download cache directory.
* @returns Verified local archive path.
*/
export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string): Promise<string> {
const destination = join(cache, sha256)
let bytes: Buffer
try { bytes = readFileSync(destination) } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const response = await fetch(url)
if (!response.ok) throw new Error(`primary runtime download: ${String(response.status)} ${url}`)
bytes = Buffer.from(await response.arrayBuffer())
}
if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`primary runtime download: checksum mismatch for ${url}`)
writeFileSync(destination, bytes)
return destination
}
async function pythonArchive(target: keyof typeof lock.targets, cache: string): Promise<string> {
const artifact = lock.targets[target]
const filename = `cpython-${lock.pythonVersion}+${lock.pythonRelease}-${artifact.pythonTarget}-install_only_stripped.tar.gz`
return downloadPrimaryRuntimeAsset(`https://github.com/astral-sh/python-build-standalone/releases/download/${lock.pythonRelease}/${encodeURIComponent(filename)}`, artifact.pythonSha256, cache)
}
/**
* 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 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) => {
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}`)
}
},
})
}
/**
* Copy the skill package's complete asset tree to ordinary filesystem resources.
* @param source - The package's assets directory.
* @param destination - Desktop runtime resource directory outside ASAR.
* @returns Resolves after replacing the external assets with the complete package tree.
*/
export async function prepareOfficeSkillAssets(source: string, destination: string): Promise<void> {
rmSync(destination, { recursive: true, force: true })
await cp(source, destination, { recursive: true, dereference: true })
}
/**
* Materialize the selected Desktop target's primary runtime in its build resources.
* Prepare Desktop resources for its selected packaging target.
* @param options - Signed Windows packaging defers execution until its supervised signing stage.
* @returns Resolves after materialization and, unless deferred, native-target execution checks.
* @returns Resolves after preparation and, unless deferred, native-target execution checks.
*/
export async function preparePrimaryRuntime(options: { deferSmoke?: boolean } = {}): Promise<void> {
const target = resolveDesktopBuildTarget()
const paths = resolveDesktopTargetBuildPaths()
const artifact = lock.targets[target]
mkdirSync(paths.runtime, { recursive: true })
mkdirSync(paths.downloads, { recursive: true })
const staging = mkdtempSync(join(tmpdir(), 'dsh-primary-'))
try {
const output = join(staging, 'payload')
const dependencies = join(output, 'dependencies')
mkdirSync(dependencies, { recursive: true })
const nodeFilename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}`
const nodeArchive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${nodeFilename}`, artifact.nodeSha256, paths.downloads)
const unpackedNode = join(staging, 'node')
mkdirSync(unpackedNode)
if (target === 'win-x64') await extractZip(nodeArchive, { dir: unpackedNode })
else await extractTar({ file: nodeArchive, cwd: unpackedNode })
const nodeSource = join(unpackedNode, nodeFilename.replace(/\.(?:zip|tar\.gz)$/u, ''))
mkdirSync(join(dependencies, 'node', 'bin'), { recursive: true })
mkdirSync(join(dependencies, 'node', 'node_modules'))
writeFileSync(join(dependencies, 'node', 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n')
cpSync(join(nodeSource, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])),
join(dependencies, 'node', 'bin', target === 'win-x64' ? 'node.exe' : 'node'))
cpSync(join(nodeSource, 'LICENSE'), join(dependencies, 'node', 'LICENSE'))
await extractTar({ file: await pythonArchive(target, paths.downloads), cwd: dependencies })
const require = createRequire(import.meta.url)
const pnpmManifest = require.resolve('pnpm')
const pnpm = JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string }
await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true })
const desktop = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')) as { version: string }
const manifest: PrimaryRuntimeManifest = {
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.pythonPackages.numpy, pandas: lock.pythonPackages.pandas,
},
}
const entries = workspaceDependencyPaths(output, manifest)
for (const wheel of [...artifact.wheels, ...lock.wheels]) {
await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages)
}
writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`)
const destination = join(paths.runtime, 'primary-runtime')
rmSync(destination, { recursive: true, force: true })
await cp(output, destination, { recursive: true, dereference: true })
} finally {
rmSync(staging, { recursive: true, force: true })
}
const hostRequire = createRequire(resolve(import.meta.dirname, '..', '..', 'desktop-host', 'package.json'))
await prepareOfficeSkillAssets(join(dirname(hostRequire.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'),
join(paths.runtime, 'office-skills'))
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }
await preparePayload({ target: resolveDesktopBuildTarget(), output: paths.runtime, cache: paths.downloads, version })
if (!options.deferSmoke) smokePrimaryRuntime(join(paths.runtime, 'primary-runtime'))
}
/**
* Execute the native payload's interpreters, package manager and Python libraries.
* Verify Desktop's complete payload after preparation or platform signing.
* @param root - Final payload directory, including any platform signatures.
*/
export function smokePrimaryRuntime(root: string): void {
const manifest = JSON.parse(readFileSync(join(root, 'runtime.json'), 'utf8')) as PrimaryRuntimeManifest
const manifest = parsePrimaryRuntime(JSON.parse(readFileSync(join(root, 'runtime.json'), 'utf8')))
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.')
if (Object.keys(manifest.pythonPackages).length === 0) 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, env: scrubWindowsSigningEnvironment(process.env) } as const
execFileSync(entries.python, ['-I', '-c', 'import decimal, xml.parsers.expat, lzma, uuid, 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, join(dirname(root), 'office-skills', 'scripts', 'check_office.py')], 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)
if (entries.node === undefined || entries.pnpm === undefined) throw new Error('primary runtime: the Desktop payload must declare node and pnpm components.')
smokePayload(root, scrubWindowsSigningEnvironment(process.env))
}
if (import.meta.main) await preparePrimaryRuntime()
@@ -43,6 +43,11 @@ try {
for (const owner of [application, project]) {
await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(owner, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
}
const target = `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`
const targetRoot = join(application, '.desktop-build/targets', target)
await mkdir(targetRoot, { recursive: true })
await symlink(join(repo, 'apps/desktop/.desktop-build/targets', target, 'runtime'), join(targetRoot, 'runtime'),
process.platform === 'win32' ? 'junction' : 'dir')
await writeFile(join(profile, 'cordis.patch.yml'), JSON.stringify([
{ id: 'webserver', config: { host: '127.0.0.1', port: 0 } },
{ id: 'llm-deepseek', disabled: true }, { id: 'session-title-llm', disabled: true },
+1 -1
View File
@@ -410,7 +410,7 @@ async function main(): Promise<void> {
protocol.handle(SCHEME, (request) => {
const url = new URL(request.url)
// Serves shell URLs declared in update-dialog.ts and mandatory-update-window.ts plus their renderer assets.
// Removing this route leaves those modal windows empty while their parent remains blocked.
// Removing this route leaves those shell windows and embedded documents empty while their parent remains blocked.
if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer'))
if (url.hostname === 'app') {
if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/')
+51 -8
View File
@@ -2,10 +2,10 @@
import { app, BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
import type { DesktopLocale } from './locale.ts'
import type { DesktopUpdateState } from './ipc.ts'
import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts'
import { MANDATORY_IPC } from './mandatory-update-ipc.ts'
import { createMandatoryUpdateWindow } from './update-overlay.ts'
import { createUpdateOverlay } from './update-overlay.ts'
import { DesktopUpdateAttention } from './update-attention.ts'
/** A renderer action never carries a URL or authorizes a different version. */
@@ -46,8 +46,12 @@ export interface MandatoryUpdateWindowOptions {
// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route.
const page = 'dsh-app://shell/mandatory-update.html'
/** A modal child blocks the product window without cancelling work in the Host. */
/** Shell-owned update presentation; Windows embeds it in the main document without a child window. */
export class DesktopMandatoryUpdateWindow {
private readonly embedded = process.platform === 'win32'
private embeddedParent: BrowserWindow | undefined
private embeddedBlocking = false
private readonly publishEmbedded = (): void => { this.embeddedBlocking = false; this.sync() }
private window: BrowserWindow | undefined
private closing: ReturnType<typeof setTimeout> | undefined
private disposed = false
@@ -65,6 +69,8 @@ export class DesktopMandatoryUpdateWindow {
/** @param options - Main-process actions and immutable deployment/navigation settings. */
constructor(private readonly options: MandatoryUpdateWindowOptions) {
this.attention = new DesktopUpdateAttention(options.locale)
this.embeddedParent = this.embedded ? options.parent() : undefined
this.embeddedParent?.webContents.on('did-finish-load', this.publishEmbedded)
ipcMain.handle(MANDATORY_IPC.status, (event) => { this.assertSender(event); return this.view() })
ipcMain.handle(MANDATORY_IPC.action, (event, action: unknown, version: unknown, confirmationRevision: unknown) => {
this.assertSender(event)
@@ -103,7 +109,7 @@ export class DesktopMandatoryUpdateWindow {
}
/** Active modal used as the owner of shell installation-confirmation dialogs. */
get confirmationWindow(): BrowserWindow | undefined { return this.window }
get confirmationWindow(): BrowserWindow | undefined { return this.embedded ? this.options.parent() : this.window }
/**
* @param version - Updater-owned target, already downloaded and verified.
@@ -119,8 +125,9 @@ export class DesktopMandatoryUpdateWindow {
this.confirmation = { version, active, revision: ++this.confirmationRevision, resolve }
this.sync()
const parent = this.options.parent()
if (parent !== undefined && this.window !== undefined) {
this.attention.ready(version, parent, this.window, () => {
const owner = this.confirmationWindow
if (parent !== undefined && owner !== undefined) {
this.attention.ready(version, parent, owner, () => {
if (!this.disposed && this.options.policy().blocking && this.confirmation !== undefined) this.focus()
})
} else this.finishConfirmation(false)
@@ -137,6 +144,18 @@ export class DesktopMandatoryUpdateWindow {
/** Publish current status, create the block immediately, or close it only after policy clearance. */
sync(): void {
if (this.disposed) return
if (this.embedded) {
const parent = this.options.parent()
if (parent !== this.embeddedParent) {
if (this.embeddedParent !== undefined && !this.embeddedParent.isDestroyed()) {
this.embeddedParent.webContents.off('did-finish-load', this.publishEmbedded)
}
this.embeddedBlocking = false
this.embeddedParent = parent
if (parent !== undefined && !parent.isDestroyed()) parent.webContents.on('did-finish-load', this.publishEmbedded)
}
if (parent === undefined || parent.isDestroyed()) return
}
if (!this.options.policy().blocking) {
this.finishConfirmation(false)
this.attention.reset()
@@ -153,16 +172,25 @@ export class DesktopMandatoryUpdateWindow {
}, 150)
}
this.error = undefined
if (this.embedded && this.embeddedBlocking) {
this.embeddedBlocking = false
this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view())
}
return
}
clearTimeout(this.closing)
this.closing = undefined
if (this.navigationUrl !== this.options.policy().page) this.clearNavigation()
if (this.options.update().phase === 'error') this.restart = undefined
if (this.embedded) {
this.embeddedBlocking = true
this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view())
return
}
if (this.window === undefined) {
const parent = this.options.parent()
if (parent === undefined) return
const window = createMandatoryUpdateWindow(parent, this.options.preload, this.options.locale.messages.mandatoryTitle)
const window = createUpdateOverlay(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false)
this.window = window
window.setMenu(null)
window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } })
@@ -187,13 +215,15 @@ export class DesktopMandatoryUpdateWindow {
focus(): void {
this.sync()
const parent = this.options.parent()
if (parent?.isDestroyed()) return
if (parent?.isMinimized()) parent.restore()
parent?.show()
if (this.embedded) parent?.focus()
this.window?.show()
this.window?.focus()
}
/** Detach IPC and release the modal during application shutdown. */
/** Detach IPC and release the modal during shutdown, including after the main window closes. */
dispose(): void {
this.disposed = true
clearTimeout(this.closing)
@@ -203,6 +233,10 @@ export class DesktopMandatoryUpdateWindow {
this.clearNavigation()
ipcMain.removeHandler(MANDATORY_IPC.status)
ipcMain.removeHandler(MANDATORY_IPC.action)
if (this.embeddedParent !== undefined && !this.embeddedParent.isDestroyed()) {
this.embeddedParent.webContents.off('did-finish-load', this.publishEmbedded)
this.embeddedParent.webContents.send(MANDATORY_IPC.state, { ...this.view(), policy: { blocking: false, checking: false } })
}
this.window?.destroy()
this.window = undefined
}
@@ -258,6 +292,15 @@ export class DesktopMandatoryUpdateWindow {
}
private assertSender(event: IpcMainInvokeEvent): void {
if (this.embedded) {
const parent = this.options.parent()
if (parent === undefined || parent.isDestroyed() || event.sender !== parent.webContents
|| event.senderFrame !== parent.webContents.mainFrame) {
throw new Error('desktop policy: rejected unowned renderer')
}
assertDesktopSender(event, ['app'])
return
}
if (event.sender !== this.window?.webContents || event.senderFrame !== this.window.webContents.mainFrame
|| event.senderFrame.url !== page) throw new Error('desktop policy: rejected unowned renderer')
}
+2
View File
@@ -5,6 +5,7 @@ import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePrese
import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.ts'
import { syncNativeTheme } from './preload-theme.ts'
import { syncWindowsAppearance } from './preload-windows.ts'
import { installMandatoryUpdateOverlay } from './preload-mandatory-overlay.ts'
const product: DshDesktopProductApi = {
protocolVersion: 1,
@@ -21,6 +22,7 @@ const product: DshDesktopProductApi = {
if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
syncWindowsAppearance()
if (process.platform === 'win32') installMandatoryUpdateOverlay()
contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', {
pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise<string | null>,
})
@@ -0,0 +1,85 @@
/** Mounts the shell-owned Windows update document inside the main window's content area. */
import { ipcRenderer } from 'electron'
import { MANDATORY_IPC } from './mandatory-update-ipc.ts'
import type { MandatoryUpdateView } from './mandatory-update-window.ts'
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
/** Keep update actions on a private channel to the shell frame; the shared product DOM is not a tamper-proof display. */
export function installMandatoryUpdateOverlay(): void {
let disposed = false
let state: MandatoryUpdateView | undefined
let host: HTMLDivElement | undefined
let frame: HTMLIFrameElement | undefined
let port: MessagePort | undefined
let closing: ReturnType<typeof setTimeout> | undefined
const publish = (): void => { port?.postMessage({ type: 'dsh-mandatory-state', state }) }
const remove = (): void => { port?.close(); port = undefined; host?.remove(); host = undefined; frame = undefined }
const render = (): void => {
if (state === undefined || document.readyState === 'loading') return
if (!state.policy.blocking) {
if (frame === undefined || closing !== undefined) return
publish()
closing = setTimeout(remove, 150)
return
}
clearTimeout(closing)
closing = undefined
if (frame === undefined) {
host = document.createElement('div')
host.style.cssText = `position:fixed;top:${WINDOWS_TITLEBAR_HEIGHT}px;left:0;right:0;bottom:0;z-index:2147483647;backdrop-filter:blur(2px)`
const shadow = host.attachShadow({ mode: 'closed' })
frame = document.createElement('iframe')
frame.title = state.locale.messages.mandatoryTitle
frame.style.cssText = 'display:block;width:100%;height:100%;border:0;background:transparent'
frame.src = 'dsh-app://shell/mandatory-update.html'
shadow.append(frame)
document.documentElement.append(host)
frame.addEventListener('load', () => {
port?.close()
const channel = new MessageChannel()
port = channel.port1
port.onmessage = message
frame?.contentWindow?.postMessage({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2])
publish()
frame?.focus()
})
}
publish()
}
const receive = (_event: Electron.IpcRendererEvent, next: MandatoryUpdateView): void => { state = next; render() }
const message = (event: MessageEvent<unknown>): void => {
const value = event.data
if (typeof value !== 'object' || value === null || !('type' in value)) return
if (value.type !== 'dsh-mandatory-action' || !('id' in value) || !Number.isSafeInteger(value.id)
|| !('action' in value) || !('version' in value) || !('revision' in value)) return
const target = port
if (target === undefined) return
void ipcRenderer.invoke(MANDATORY_IPC.action, value.action, value.version, value.revision).then(
() => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: true }) },
() => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: false }) },
)
}
const blockBackgroundKey = (event: KeyboardEvent): void => {
if (frame === undefined || state?.policy.blocking !== true
|| event.composedPath().some(target => target instanceof HTMLElement && target.hasAttribute('data-windows-menu'))) return
event.preventDefault()
event.stopImmediatePropagation()
frame.focus()
}
ipcRenderer.on(MANDATORY_IPC.state, receive)
void ipcRenderer.invoke(MANDATORY_IPC.status).then((initial: MandatoryUpdateView) => {
if (!disposed && state === undefined) { state = initial; render() }
}, () => {
// Development may omit policy configuration; a later main-frame load still resynchronizes configured policy.
})
window.addEventListener('keydown', blockBackgroundKey, true)
window.addEventListener('DOMContentLoaded', render, { once: true })
window.addEventListener('pagehide', () => {
disposed = true
clearTimeout(closing)
remove()
ipcRenderer.off(MANDATORY_IPC.state, receive)
window.removeEventListener('keydown', blockBackgroundKey, true)
window.removeEventListener('DOMContentLoaded', render)
}, { once: true })
}
-16
View File
@@ -47,19 +47,3 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
return window
}
/** A native Windows modal retains its own title bar while the product window remains blocked. */
export function createMandatoryUpdateWindow(parent: BrowserWindow, preload: string, title: string,
platform: NodeJS.Platform = process.platform): BrowserWindow {
if (platform !== 'win32') return createUpdateOverlay(parent, preload, title, false)
const window = new BrowserWindow({
parent, modal: true, show: false, title,
width: 640, height: 560, minWidth: 480, minHeight: 360,
movable: true, resizable: true, maximizable: true,
backgroundColor: '#f5f5f5',
webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true },
})
window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() })
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
return window
}
+2 -2
View File
@@ -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/tests/README.md
README.md: 6171b058adad8e34a3275c013bd875e00f586afc
README.zh.md: fa18cd947e1315248b5449eaf9bc426bba3d18fc
README.md: a2684a2bced4326bdc316ff56d80003061d0136d
README.zh.md: fba5b65aa41aa14b9971f80676c8f0fef4d7be9e
+2 -2
View File
@@ -33,7 +33,7 @@ The local command builds Desktop and executes Electron 44 with the actual HTTP u
| Ordinary updater | Same/older-version rejection, user-authorized full download, SHA-512 rejection, interrupted transfer, stalled-feed/download deadlines, explicit retry, coalesced requests, same-address feed replacement, readiness, and separate install handoff pass |
| Mandatory policy | Flattened `40005`, exact release headers, guest requests, no-force validation, failure retention, interval/backoff, timeout, and disposal regressions pass |
| Ordinary scheduling | Fake-clock regressions with the real coordinator verify bounded jitter/backoff, manual joins, success reset, no automatic download retry, wall-clock independence, and disposal. Main-entry tests verify focus/resume throttling, immediate explicit checks, and quit cleanup |
| Actual mandatory window | Text-only server content, native Windows move/resize/maximize and restore, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy |
| Actual mandatory window | Text-only server content, an embedded Windows shell frame with no additional native window and an enabled main window, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy |
| Actual ordinary dialog | Isolated preload, 380px card, 24px corners, black primary button, parent blur, Escape cancellation retaining readiness, and task-warning approval with recorded installation handoff pass |
| Unlocked Windows interaction | OS-level clicks and screenshots of the actual mandatory renderer confirm Escape blocking, user-started download, readiness, red policy-error feedback, and modal clearance with the parent enabled again. A fixture-provided native task-warning dialog returns to readiness on deferral; task activity is simulated, not a full Host workload |
| Main entry | A known block refuses plugin mutations and recovery without stopping the Host; fresh success closes the block; packaged policy ignores environment overrides; installer failure after a clean stop restores the Host before another confirmation and retains mandatory blocking |
@@ -47,7 +47,7 @@ Chromium headless shell revision 1228 is installed in the ignored `.desktop-buil
The [built Host scenario](fixtures/host-update-qualification.mjs) uses the actual profile Loader, standard agent preset, task services, and Node background processes. It verifies queued turns/steps, a running model request, pending questions/approvals, global and agent jobs in running/stopping states, admission locking without cancellation, restored admission, and rejected inspection after Host disposal. Only model responses and human answers are substituted. Two independent invocations pass concurrently with private profiles and session data; all owned agents, jobs, and Hosts finish before a success report is written.
The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events. Ten scenarios pass, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results.
The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events and Chromium debugger input for the embedded Windows frame. The private application reuses the prepared target runtime resources. Ten scenarios pass, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results.
The [Windows signature runner](../scripts/test-windows-update-signature.mjs) uses the installed electron-builder metadata generator and `NsisUpdater` verifier with the public release certificate and real executable inputs. Matching publisher attributes pass; the same valid signature with a different expected publisher and an unsigned executable are rejected. A missing-publisher negative control confirms that verification is skipped. Unit regressions cover DN escaping, incomplete identities, and explicit or host-default Windows targets; removing the publisher configuration fails both metadata cases. This check does not download, install, or sign an artifact.
+2 -2
View File
@@ -33,7 +33,7 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录
| 常规 updater | 同版本/旧版本拒绝、用户授权完整下载、SHA-512 拒绝、传输中断、清单/下载停滞截止时间、显式重试、请求合并、同地址清单替换、就绪状态和独立安装交接通过 |
| 强更策略 | 扁平化 `40005`、完整发布头、游客请求、无需强更验证、失败保留、间隔/退避、超时和 dispose(资源释放)回归通过 |
| 常规调度 | 真实协调器配合模拟时钟的回归验证有上限的抖动/退避、手动复用、成功重置、不自动重试下载、不受系统时钟调整影响,以及 dispose。主入口测试验证聚焦/恢复节流、显式检查立即执行和退出清理 |
| 真实强更窗口 | 服务端纯文本内容、Windows 原生窗口移动/缩放/最大化及还原、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 |
| 真实强更窗口 | 服务端纯文本内容、Windows 内嵌 shell frame、不创建额外原生窗口且主窗口保持启用、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 |
| 真实常规弹窗 | 隔离预加载、380px 卡片、24px 圆角、黑色主按钮、父窗口模糊、Esc 取消后保留就绪,以及任务警告批准后的安装交接记录均通过 |
| 解锁后的 Windows 交互 | 对真实强更页面执行系统级点击并截图,确认 Esc 阻塞、用户发起下载、就绪、红色策略错误提示,以及弹窗解除后父窗口恢复可用。fixture 提供的原生任务警告弹窗在选择稍后更新后回到就绪态;任务活动为模拟,不是完整 Host 工作负载 |
| 主入口 | 已知阻塞拒绝插件修改和恢复,但不停止 Host;新成功响应关闭阻塞;打包策略忽略环境覆盖;正常停止后的安装器失败在下次确认前恢复 Host,并保留强更阻塞 |
@@ -47,7 +47,7 @@ Chromium headless shell revision 1228 已安装在忽略目录 `.desktop-build/p
[构建后 Host 场景](fixtures/host-update-qualification.mjs) 使用真实 profile Loader、standard agent 预设、任务服务和 Node 后台进程。它验证排队的轮次/步骤、运行中的模型请求、等待答复的提问/审批、运行/停止中的全局和 agent job、不会取消任务的准入锁定、准入恢复,以及 Host 释放后的检查拒绝。仅模型响应和人工答复使用替身。两个独立调用使用私有 profile 和会话数据并发通过;全部自有 agent、job 和 Host 完成后才写入成功报告。
[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件操作页面按钮。十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。
[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件及嵌入式 Windows frame 的 Chromium 调试器输入操作页面按钮。私有应用复用已准备的目标运行时资源。十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。
[Windows 验签运行器](../scripts/test-windows-update-signature.mjs) 使用已安装的 electron-builder 元数据生成器和 `NsisUpdater` 验签器,输入为公开发布证书和真实可执行文件。发布者属性匹配时通过;同一有效签名在预期发布者不同时被拒绝,未签名可执行文件也被拒绝。缺少发布者的负对照确认验签被跳过。单元回归覆盖 DN 转义、不完整身份,以及显式或宿主默认 Windows 目标;移除发布者配置会使两条元数据用例失败。此检查不下载、安装或签名产物。
+36 -31
View File
@@ -1,5 +1,8 @@
/** Actual Electron HTTP/download qualification; installer execution is forbidden. */
import { mandatoryFrame } from './mandatory-frame.mjs'
import { setTimeout as delay } from 'node:timers/promises'
import assert from 'node:assert/strict'
import { once } from 'node:events'
import fs from 'node:fs'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import { join, sep } from 'node:path'
@@ -225,13 +228,14 @@ async function main() {
})
await scenario('mandatory-real-window-policy-download-retry-and-clear', async () => {
protocol.handle('dsh-app', async request => {
if (new URL(request.url).hostname === 'app') return new Response('<!doctype html><title>Updater qualification</title>', { headers: { 'content-type': 'text/html' } })
const path = new URL(request.url).pathname.slice(1)
assert.ok(['mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path))
assert.ok(['mandatory-update-frame.js', 'mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path))
const mime = path.endsWith('.js') ? 'text/javascript' : path.endsWith('.css') ? 'text/css' : 'text/html'
return new Response(await readFile(new URL(`../../renderer/${path}`, import.meta.url)), { headers: { 'content-type': mime } })
})
const parent = new BrowserWindow({ show: true, width: 900, height: 650 })
await parent.loadURL('data:text/html;charset=utf-8,<title>Local updater qualification</title>')
const parent = new BrowserWindow({ show: true, width: 900, height: 650, webPreferences: { preload: fileURLToPath(new URL('../../lib/preload-app.cjs', import.meta.url)), sandbox: true, contextIsolation: true } })
await parent.loadURL('dsh-app://app/')
const f = await fixture('hold-download')
const config = resolveDesktopPolicyConfig({ origin: server.url, allowedPageOrigins: ['https://downloads.example.com'],
intervalMs: 600_000, timeoutMs: 1000 }, true)
@@ -262,51 +266,46 @@ async function main() {
server.policy('force')
await policy.check('launch')
const window = modal.confirmationWindow
assert.ok(window?.isModal())
assert.ok(window)
const contents = process.platform === 'win32' ? await mandatoryFrame(parent) : window.webContents
assert.equal(window.isModal(), false)
assert.equal(parent.isEnabled(), true)
window.webContents.on('console-message', event => { console.log('mandatory renderer:', event.message) })
window.webContents.on('preload-error', (_event, _path, error) => { console.error('mandatory preload:', error) })
async function until(expression) {
try { return await window.webContents.executeJavaScript(`new Promise((resolve, reject) => {
try { return await contents.executeJavaScript(`new Promise((resolve, reject) => {
const observer = new MutationObserver(check);
const deadline = setTimeout(() => { observer.disconnect(); reject(new Error('Modal DOM condition timed out')); }, 10000);
function check() { if (${expression}) { observer.disconnect(); clearTimeout(deadline); resolve(true); } }
observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true }); check();
})`) } catch (error) {
console.error('Modal condition:', expression, await window.webContents.executeJavaScript('({ url: location.href, text: document.body?.innerText, bridge: typeof window.dshMandatoryUpdate })'))
console.error('Modal condition:', expression, await contents.executeJavaScript('({ url: location.href, text: document.body?.innerText, bridge: typeof window.dshMandatoryUpdate })'))
throw error
}
}
await until("document.getElementById('title')?.textContent === '需要更新'")
assert.equal(await window.webContents.executeJavaScript("document.querySelector('#title b') === null"), true)
assert.equal(await contents.executeJavaScript("document.querySelector('#title b') === null"), true)
if (process.platform === 'win32') {
assert.equal(window.isMovable(), true)
assert.equal(window.isResizable(), true)
assert.equal(window.isMaximizable(), true)
const bounds = window.getBounds()
window.setPosition(bounds.x + 20, bounds.y + 20)
assert.notDeepEqual(window.getBounds(), bounds)
window.maximize()
assert.equal(window.isMaximized(), true)
window.unmaximize()
assert.equal(window.isMaximized(), false)
assert.equal(window, parent)
assert.equal(parent.getChildWindows().length, 0)
}
assert.equal(await window.webContents.executeJavaScript("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))"), false)
assert.equal(await contents.executeJavaScript("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))"), false)
assert.equal(window.isDestroyed(), false)
assert.equal(f.installations.length, 0)
await window.webContents.executeJavaScript("document.getElementById('page').click()")
await contents.executeJavaScript("document.getElementById('page').click()")
await until("document.getElementById('browser-message').textContent.includes('无法打开浏览器')")
assert.deepEqual(opened, ['https://downloads.example.com/desktop'])
await window.webContents.executeJavaScript("document.getElementById('copy').click()")
await contents.executeJavaScript("document.getElementById('copy').click()")
await copyComplete.promise
await until("document.getElementById('copy').textContent === '已复制链接'")
assert.deepEqual(copied, opened)
await f.coordinator.check()
modal.sync()
await until("!document.getElementById('update').hidden")
await window.webContents.executeJavaScript("document.getElementById('update').click()")
await contents.executeJavaScript("document.getElementById('update').click()")
await server.arrived()
assert.equal(f.coordinator.state.phase, 'downloading')
assert.equal(await window.webContents.executeJavaScript("document.getElementById('page').hidden"), true)
assert.equal(await contents.executeJavaScript("document.getElementById('page').hidden"), true)
server.release()
await until("document.getElementById('update').textContent === '停止任务并更新' && !document.getElementById('update').disabled")
assert.equal(f.installations.length, 0)
@@ -314,14 +313,14 @@ async function main() {
server.policy('stall')
await policy.check('manual', true)
assert.equal(window.isDestroyed(), false)
assert.equal(await window.webContents.executeJavaScript("document.getElementById('error').hidden"), true)
const snapshot = await window.webContents.executeJavaScript(`({
assert.equal(await contents.executeJavaScript("document.getElementById('error').hidden"), true)
const snapshot = await contents.executeJavaScript(`({
title: document.getElementById('title').textContent, detail: document.getElementById('detail').textContent,
status: document.getElementById('status').textContent, version: document.getElementById('version').textContent,
buttons: [...document.querySelectorAll('button')].filter(button => button.getClientRects().length > 0).map(button => button.textContent),
})`)
assert.deepEqual(snapshot, JSON.parse(await readFile(new URL('../expected/mandatory-update-zh.json', import.meta.url), 'utf8')))
await window.webContents.executeJavaScript("document.getElementById('later').click()")
await contents.executeJavaScript("document.getElementById('later').click()")
await until("document.getElementById('update').textContent === '继续安装更新' && !document.getElementById('update').disabled")
assert.equal(f.installations.length, 0)
let capture
@@ -333,19 +332,19 @@ async function main() {
}
const restartRequested = Promise.withResolvers()
f.restart(async () => { restartRequested.resolve(); return false })
await window.webContents.executeJavaScript("document.getElementById('update').click()")
await contents.executeJavaScript("document.getElementById('update').click()")
await restartRequested.promise
await until("!document.getElementById('update').disabled")
assert.equal(f.installations.length, 0)
assert.equal(policy.state.blocking, true)
f.restart(async () => { throw new DesktopUpdatePreparationError('stop-failed', resolveDesktopLocale('zh-CN').messages.updateStopFailed,
'exit 0; shutdown acknowledged false') })
await window.webContents.executeJavaScript("document.getElementById('update').click()")
await contents.executeJavaScript("document.getElementById('update').click()")
await until("!document.getElementById('technical-details').hidden && !document.getElementById('update').disabled")
assert.equal(await window.webContents.executeJavaScript("document.getElementById('technical-details').open"), false)
assert.equal(await contents.executeJavaScript("document.getElementById('technical-details').open"), false)
for (const expanded of [false, true]) {
if (expanded) {
assert.equal(await window.webContents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true)
assert.equal(await contents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true)
}
const name = expanded ? 'mandatory-error-expanded.png' : 'mandatory-error-collapsed.png'
await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG())
@@ -354,14 +353,20 @@ async function main() {
assert.equal(policy.state.blocking, true)
assert.equal(f.installations.length, 0)
server.policy('clear')
const closed = process.platform === 'win32' ? undefined : once(window, 'closed')
await policy.check('manual', true)
assert.equal(window.isDestroyed(), true)
if (closed) { await closed; assert.equal(window.isDestroyed(), true) }
else {
const deadline = Date.now() + 10000
while (!contents.detached && Date.now() < deadline) await delay(20)
assert.equal(contents.detached, true)
}
assert.equal(parent.isDestroyed(), false)
} finally {
server.release()
parent.destroy()
modal.dispose()
await policy.dispose()
parent.destroy()
shell.openExternal = originalOpen
clipboard.writeText = originalCopy
clipboard.readText = originalRead
+35
View File
@@ -0,0 +1,35 @@
import { WINDOWS_TITLEBAR_HEIGHT } from '../../lib/types/windows-layout.js'
import { setTimeout as delay } from 'node:timers/promises'
export async function mandatoryFrame(parent) {
const deadline = Date.now() + 10000
while (Date.now() < deadline) {
const frame = parent.webContents.mainFrame.frames.find(frame => frame.url === 'dsh-app://shell/mandatory-update.html')
if (frame && await frame.executeJavaScript("document.readyState === 'complete'")) return frame
await delay(20)
}
throw new Error('Mandatory update frame did not load')
}
export async function mandatoryFrameDriver(parent) {
const frame = await mandatoryFrame(parent)
if (!parent.webContents.debugger.isAttached()) parent.webContents.debugger.attach('1.3')
const contents = new Proxy(parent.webContents, { get(target, key) {
if (key === 'executeJavaScript') return frame.executeJavaScript.bind(frame)
if (key === 'sendInputEvent') return event => {
if (typeof event.y !== 'number') return target.sendInputEvent(event)
return target.debugger.sendCommand('Input.dispatchMouseEvent', {
type: event.type === 'mouseDown' ? 'mousePressed' : event.type === 'mouseUp' ? 'mouseReleased' : 'mouseMoved',
x: event.x, y: event.y + WINDOWS_TITLEBAR_HEIGHT, button: event.button ?? 'none', clickCount: event.clickCount ?? 0,
})
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
} })
return new Proxy(parent, { get(target, key) {
if (key === 'webContents') return contents
if (key === 'isDestroyed') return () => frame.detached
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
} })
}
+46 -22
View File
@@ -1,4 +1,6 @@
import { WINDOWS_TITLEBAR_HEIGHT } from '../../lib/types/windows-layout.js'
/** Real Electron main entry, preload, shared Web Host, and local updater; no installer executes. */
import { mandatoryFrameDriver } from './mandatory-frame.mjs'
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import { appendFile, readFile, writeFile } from 'node:fs/promises'
@@ -95,6 +97,9 @@ async function documentReady(window, expression) {
})`))
}
async function windowAt(url) {
if (process.platform === 'win32' && url === 'dsh-app://shell/mandatory-update.html') {
return mandatoryFrameDriver(await windowAt('dsh-app://app/'))
}
const existing = BrowserWindow.getAllWindows().find(window => window.webContents.getURL() === url)
if (existing) return existing
return new Promise((resolve, reject) => {
@@ -146,13 +151,15 @@ async function press(window, expression) {
if (!target.contains(document.elementFromPoint(point.x, point.y))) throw new Error('Click target is obscured');
return point;
})()`))
window.webContents.sendInputEvent({ type: 'mouseMove', ...point })
window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point })
window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point })
await window.webContents.sendInputEvent({ type: 'mouseMove', ...point })
await window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point })
await window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point })
}
async function clickText(window, label) {
const title = await window.webContents.executeJavaScript("document.getElementById('title').textContent")
await press(window, `[...document.querySelectorAll('button')].find(button => button.textContent.trim() === ${JSON.stringify(label)})`)
await waitFor(() => window.isDestroyed(), `dialog response: ${label}`)
await waitFor(async () => window.isDestroyed()
|| await window.webContents.executeJavaScript("document.getElementById('title').textContent") !== title, `dialog response: ${label}`)
}
async function dialogWith(message) {
let found
@@ -173,9 +180,9 @@ async function qualify() {
try {
await import(entry)
console.log('workspace qualification: compiled main module loaded')
const applicationUrl = await fixture.ready.promise
await fixture.ready.promise
console.log('workspace qualification: Host process ready')
mainWindow = await windowAt(new URL('/', applicationUrl).href)
mainWindow = await windowAt('dsh-app://app/')
await documentReady(mainWindow, `document.querySelector('[class*="frame"]') && window.dshDesktop?.updates`)
assert.equal(mainWindow.isVisible(), true, 'Workspace qualification requires a visible application window')
console.log('workspace qualification: workspace document ready')
@@ -187,7 +194,15 @@ async function qualify() {
console.log('workspace qualification: startup API requests settled')
const messages = resolveDesktopLocale(app.getLocale()).messages
await screenshot(mainWindow, 'workspace.png')
const menu = Menu.getApplicationMenu().items[0].submenu.items
let menu
if (process.platform === 'win32') {
const popup = Menu.prototype.popup
try {
Menu.prototype.popup = function (options) { menu = this.items; options.callback?.() }
await mainWindow.webContents.executeJavaScript("document.querySelector('[data-windows-menu]').shadowRoot.querySelector('button').click()")
await waitFor(() => menu !== undefined, 'Windows application menu')
} finally { Menu.prototype.popup = popup }
} else menu = Menu.getApplicationMenu().items[0].submenu.items
const checkMenu = menu.find(item => item.label === messages.checkUpdatesMenu)
assert.ok(checkMenu)
if (interactive) {
@@ -203,9 +218,8 @@ async function qualify() {
const checking = await dialogWith(messages.updateChecking)
await screenshot(checking, 'checking.png')
server.release()
await waitFor(() => checking.isDestroyed(), 'checking dialog to close')
console.log('workspace qualification: checking dialog closed')
const current = await dialogWith(messages.updateCurrent.replace('{version}', app.getVersion()))
assert.equal(current, checking)
await clickText(current, messages.updateAcknowledge)
assert.equal(server.requests.filter(path => path === '/payload.exe').length, 0)
cases.push('native-menu-checking-and-current-without-download')
@@ -272,16 +286,21 @@ async function qualify() {
await documentReady(mandatory, `document.getElementById('title')?.textContent === '需要更新'`)
console.log('workspace qualification: mandatory title rendered')
assert.equal(await mandatory.webContents.executeJavaScript(`document.getElementById('title').children.length`), 0)
assert.equal(mandatory.isMovable(), true)
assert.equal(mandatory.isResizable(), true)
assert.equal(mandatory.isMaximizable(), true)
const originalBounds = mandatory.getBounds()
mandatory.setPosition(originalBounds.x + 20, originalBounds.y + 20)
assert.notDeepEqual(mandatory.getBounds(), originalBounds)
mandatory.maximize()
await waitFor(() => mandatory.isMaximized(), 'mandatory maximize')
mandatory.unmaximize()
await waitFor(() => !mandatory.isMaximized(), 'mandatory restore')
assert.equal(mandatory.isModal(), false)
await waitFor(() => mainWindow.isEnabled(), 'ordinary modal releases the main window')
assert.equal(mainWindow.isEnabled(), true)
if (process.platform === 'win32') assert.equal(mainWindow.getChildWindows().length, 0)
const originalBounds = mainWindow.getBounds()
mainWindow.setPosition(originalBounds.x + 20, originalBounds.y + 20)
mainWindow.maximize()
await waitFor(() => mainWindow.isMaximized(), 'blocked parent maximize')
mainWindow.unmaximize()
await waitFor(() => !mainWindow.isMaximized(), 'blocked parent restore')
if (process.platform === 'win32') {
const viewport = await mandatory.webContents.executeJavaScript('({ width: innerWidth, height: innerHeight })')
const bounds = mainWindow.getContentBounds()
assert.deepEqual(viewport, { width: bounds.width, height: bounds.height - WINDOWS_TITLEBAR_HEIGHT })
}
mandatory.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
mandatory.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
assert.equal((await control('status')).queued, 1)
@@ -329,7 +348,8 @@ async function qualify() {
server.policy('force')
checkMenu.click()
const forcedRecovery = await windowAt('dsh-app://shell/mandatory-update.html')
let forcedRecovery = await windowAt('dsh-app://shell/mandatory-update.html')
await waitFor(() => mainWindow.isEnabled(), 'ordinary modal releases the main window for recovery')
await press(forcedRecovery, `document.getElementById('update')`)
await documentReady(forcedRecovery, `document.getElementById('update')?.textContent === ${JSON.stringify(messages.installAndRestart)}`)
assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
@@ -340,7 +360,11 @@ async function qualify() {
await documentReady(forcedRecovery, `document.getElementById('error')?.textContent === ${JSON.stringify(messages.updateStopFailed)}`)
await screenshot(forcedRecovery, 'mandatory-stop-failure.png')
await waitFor(() => fixture.host !== forcedHost && fixture.readyHosts.has(fixture.host), 'mandatory replacement Host readiness')
assert.equal(mainWindow.isEnabled(), false)
if (process.platform === 'win32') {
await waitFor(() => forcedRecovery.isDestroyed(), 'recovery reload replaces the embedded document')
forcedRecovery = await mandatoryFrameDriver(mainWindow)
}
assert.equal(mainWindow.isEnabled(), true)
assert.equal(fixture.installations.length, 0)
await control('queue')
await press(forcedRecovery, `document.getElementById('update')`)
@@ -349,7 +373,7 @@ async function qualify() {
await press(forcedRecovery, `document.getElementById('later')`)
await waitFor(() => fixture.coordinator.state.phase === 'ready', 'mandatory deferred retry readiness')
assert.equal(forcedRecovery.isDestroyed(), false)
assert.equal(mainWindow.isEnabled(), false)
assert.equal(mainWindow.isEnabled(), true)
assert.equal(fixture.installations.length, 0)
server.policy('clear')
checkMenu.click()
+22 -12
View File
@@ -62,7 +62,9 @@ const harness = await vi.hoisted(async () => {
getZoomFactor: () => 1,
focus: vi.fn(),
sendInputEvent: vi.fn(),
send: vi.fn(),
send: vi.fn((channel: string, state: { policy?: { blocking: boolean } }) => {
if (channel === 'dsh-desktop:mandatory-state' && state.policy?.blocking) policyBlocked.resolve()
}),
})
readonly show = vi.fn()
readonly hide = vi.fn()
@@ -71,7 +73,7 @@ const harness = await vi.hoisted(async () => {
readonly setSize = vi.fn()
readonly setTitleBarOverlay = vi.fn()
constructor(readonly options: { show: boolean; modal?: boolean }) {
super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this); if (options.modal) policyBlocked.resolve()
super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this)
}
isDestroyed() { return this.destroyed }
fullscreen = false
@@ -468,7 +470,7 @@ describe('desktop main startup', () => {
expect(testAuth.login).not.toHaveBeenCalled()
})
it('retains the same blocking window and running Host after expired test login is cancelled', async () => {
it('retains the embedded block and running Host after expired test login is cancelled', async () => {
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
const request = vi.fn<typeof fetch>().mockImplementation(async () =>
@@ -479,7 +481,7 @@ describe('desktop main startup', () => {
const host = await readyForUpdate()
await harness.policyBlocked.promise
await vi.advanceTimersByTimeAsync(1000)
const modal = harness.windows.find(window => window.options.modal)!
const modal = harness.windows[0]!
const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
harness.dialog.showMessageBox.mockResolvedValue({ response: 0 })
testAuth.login.mockResolvedValueOnce('cancelled')
@@ -867,14 +869,15 @@ describe('desktop main startup', () => {
vi.stubGlobal('fetch', request)
const host = await readyForUpdate()
await harness.policyBlocked.promise
const modal = harness.windows.find(window => window.options.modal)!
const modal = harness.windows[0]!
expect(modal).toBeDefined()
expect(harness.windows).toHaveLength(1)
const status = harness.handlers.get(MANDATORY_IPC.status)!
const action = harness.handlers.get(MANDATORY_IPC.action)!
const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
expect(status(owned)).toMatchObject({ policy: { blocking: true } })
const unowned = [
{ ...owned, sender: harness.windows[0]!.webContents },
{ ...owned, sender: {} },
{ ...owned, senderFrame: { url: 'dsh-app://app/index.html' } },
{ ...owned, senderFrame: { url: 'https://untrusted.example.com/' } },
]
@@ -899,7 +902,8 @@ describe('desktop main startup', () => {
await vi.advanceTimersByTimeAsync(20_000)
expect(modal.isDestroyed()).toBe(false)
await vi.advanceTimersByTimeAsync(150)
expect(modal.isDestroyed()).toBe(true)
expect(modal.isDestroyed()).toBe(false)
expect(modal.webContents.send.mock.calls.at(-1)).toMatchObject([MANDATORY_IPC.state, { policy: { blocking: false } }])
expect(host.stop).not.toHaveBeenCalled()
expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' })
})
@@ -1115,7 +1119,7 @@ describe('desktop main startup', () => {
})
async function answerMandatory(action: 'install' | 'later') {
const modal = harness.windows.find(window => window.options.modal && !window.isDestroyed())!
const modal = harness.windows[0]!
const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
await vi.waitFor(() => {
expect(harness.handlers.get(MANDATORY_IPC.status)!(event)).toHaveProperty('confirmation')
@@ -1124,14 +1128,14 @@ describe('desktop main startup', () => {
await harness.handlers.get(MANDATORY_IPC.action)!(event, action, view.confirmation.version, view.confirmation.revision)
}
it('releases the mandatory modal when the confirmed installer quits Electron', async () => {
it('closes the main window when the confirmed installer quits Electron', async () => {
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
} })))
const host = await readyForUpdate()
await harness.policyBlocked.promise
const modal = harness.windows.find(window => window.options.modal)!
const modal = harness.windows[0]!
const preparing = harness.prepareUpdate()
await answerMandatory('install')
await host.stopping.promise
@@ -1175,7 +1179,10 @@ describe('desktop main startup', () => {
await expect(retry).resolves.toBe(false)
expect(replacement.updateTasks.mock.calls).toEqual([['inspect']])
expect(replacement.stop).not.toHaveBeenCalled()
if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false)
if (mandatory) {
expect(harness.windows).toHaveLength(1)
expect(harness.windows[0]!.isDestroyed()).toBe(false)
}
})
it.each([false, true])('restores a confirmed non-graceful exit without approving installation, mandatory: %s', async (mandatory) => {
@@ -1209,7 +1216,10 @@ describe('desktop main startup', () => {
await expect(retry).resolves.toBe(false)
expect(replacement.updateTasks.mock.calls).toEqual([['inspect']])
expect(replacement.stop).not.toHaveBeenCalled()
if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false)
if (mandatory) {
expect(harness.windows).toHaveLength(1)
expect(harness.windows[0]!.isDestroyed()).toBe(false)
}
})
it('does not replace a Host whose failed stop has not confirmed process exit', async () => {
@@ -13,7 +13,7 @@ vi.mock('electron', () => ({ ipcMain: {
handle: (channel: string, handler: (...args: unknown[]) => unknown) => native.handlers.set(channel, handler),
removeHandler: (channel: string) => native.handlers.delete(channel),
}, app: { quit: native.quit }, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } }))
vi.mock('../src/update-overlay.ts', () => ({ createMandatoryUpdateWindow: () => window }))
vi.mock('../src/update-overlay.ts', () => ({ createUpdateOverlay: vi.fn(() => window) }))
let window: ReturnType<typeof fakeWindow>
let ui: DesktopMandatoryUpdateWindow | undefined
@@ -21,12 +21,14 @@ function fakeWindow() {
const contents = Object.assign(new EventEmitter(), { mainFrame: { url: 'dsh-app://shell/mandatory-update.html' },
send: vi.fn(), setWindowOpenHandler: vi.fn() })
return Object.assign(new EventEmitter(), { webContents: contents, setMenu: vi.fn(), setTitle: vi.fn(),
loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: () => false,
loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: (): boolean => false,
isFocused: () => true, isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() })
}
afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() })
function setup() {
afterEach(() => { ui?.dispose(); vi.restoreAllMocks(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() })
function setup(platform: NodeJS.Platform = 'darwin') {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
window = fakeWindow()
if (platform === 'win32') window.webContents.mainFrame.url = 'dsh-app://app/'
let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' }
let update: DesktopUpdateState = { phase: 'ready', version: '1.0.1-nightly.1' }
const install = vi.fn(async () => update)
@@ -42,6 +44,16 @@ function setup() {
update(next: DesktopUpdateState) { update = next; ui!.sync() } }
}
it('releases IPC after the Windows main window has already been destroyed', () => {
setup('win32')
vi.spyOn(window, 'isDestroyed').mockReturnValue(true)
Object.defineProperty(window, 'webContents', { get() { throw new Error('Object has been destroyed') } })
expect(() => { ui!.sync(); ui!.focus() }).not.toThrow()
expect(() => { ui!.dispose() }).not.toThrow()
expect(native.handlers.size).toBe(0)
expect(() => { ui!.dispose() }).not.toThrow()
})
it('waits for a version-bound second click in the same modal and rejects obsolete or hidden responses', async () => {
const f = setup()
const pending = ui!.confirm('1.0.1-nightly.1', false)
@@ -152,3 +164,38 @@ it('fades on clearance, reuses a reblocked window, and cancels teardown on dispo
ui!.dispose()
expect(vi.getTimerCount()).toBe(0)
})
it('publishes Windows overlays into the main document without creating or destroying a window', () => {
const f = setup('win32')
expect(window.loadURL).not.toHaveBeenCalled()
expect(ui!.confirmationWindow).toBe(window)
expect(window.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state, f.view())
window.webContents.emit('did-finish-load')
expect(window.webContents.send).toHaveBeenCalledTimes(2)
const event = { sender: window.webContents, senderFrame: { ...window.webContents.mainFrame } }
expect(() => native.handlers.get(MANDATORY_IPC.status)!(event)).toThrow(/unowned/)
f.policy({ blocking: false, checking: false })
expect(window.destroy).not.toHaveBeenCalled()
ui!.dispose()
expect(window.destroy).not.toHaveBeenCalled()
expect(window.webContents.listenerCount('did-finish-load')).toBe(0)
})
it('rebinds policy updates and reloads to a replacement Windows main window', () => {
setup('win32')
const previous = window
window = fakeWindow()
window.webContents.mainFrame.url = 'dsh-app://app/?recovery=1#home'
ui!.sync()
expect(previous.webContents.listenerCount('did-finish-load')).toBe(0)
expect(window.webContents.send.mock.calls.at(-1)).toMatchObject([MANDATORY_IPC.state, { policy: { blocking: true } }])
window.webContents.send.mockClear()
window.webContents.emit('did-finish-load')
expect(window.webContents.send).toHaveBeenCalledOnce()
expect(() => native.handlers.get(MANDATORY_IPC.status)!({
sender: previous.webContents, senderFrame: previous.webContents.mainFrame,
})).toThrow(/unowned/)
expect(() => native.handlers.get(MANDATORY_IPC.status)!({
sender: window.webContents, senderFrame: window.webContents.mainFrame,
})).not.toThrow()
})
+14 -1
View File
@@ -1,4 +1,5 @@
import { afterEach, expect, it, vi } from 'vitest'
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
import { syncWindowsAppearance } from '../src/preload-windows.ts'
import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts'
@@ -10,8 +11,9 @@ vi.mock('electron', () => electron)
vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() }))
vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
vi.mock('../src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() }))
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() })
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() })
it('limits product documents to update status and a native confirmation action', async () => {
vi.stubGlobal('location', new URL('dsh-app://app/index.html'))
@@ -76,3 +78,14 @@ it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://examp
expect(syncWindowsAppearance).toHaveBeenCalledTimes(url === 'dsh-app://app/' ? 1 : 0)
},
)
it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only in the Windows app document (%s)', async (platform) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
for (const url of ['dsh-app://app/', 'dsh-app://shell/mandatory-update.html', 'https://example.com/']) {
vi.resetModules()
vi.mocked(installMandatoryUpdateOverlay).mockClear()
vi.stubGlobal('location', new URL(url))
await import('../src/preload-app.ts')
expect(installMandatoryUpdateOverlay).toHaveBeenCalledTimes(platform === 'win32' && url === 'dsh-app://app/' ? 1 : 0)
}
})
@@ -0,0 +1,104 @@
import { MessageChannel } from 'node:worker_threads'
import { JSDOM } from 'jsdom'
import { afterEach, expect, it, vi } from 'vitest'
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts'
import { resolveDesktopLocale } from '../src/locale.ts'
import type { MandatoryUpdateView } from '../src/mandatory-update-window.ts'
const ipc = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), invoke: vi.fn(async () => {}) }))
vi.mock('electron', () => ({ ipcRenderer: ipc }))
let dom: JSDOM
let channel: MessageChannel
afterEach(() => {
dom.window.dispatchEvent(new dom.window.Event('pagehide'))
dom.window.close()
channel?.port1.close()
channel?.port2.close()
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.clearAllMocks()
vi.useRealTimers()
})
function setup() {
dom = new JSDOM('<html><body><button>Product action</button><div data-windows-menu></div></body></html>', { url: 'dsh-app://app/' })
vi.stubGlobal('window', dom.window)
vi.stubGlobal('document', dom.window.document)
vi.stubGlobal('HTMLElement', dom.window.HTMLElement)
vi.stubGlobal('MessageChannel', function createChannel() { channel = new MessageChannel(); return channel })
vi.spyOn(dom.window.document, 'readyState', 'get').mockReturnValue('complete')
const shadow = vi.spyOn(dom.window.HTMLElement.prototype, 'attachShadow')
installMandatoryUpdateOverlay()
const publish = ipc.on.mock.calls.find(([name]) => name === MANDATORY_IPC.state)![1] as
(event: unknown, state: MandatoryUpdateView) => void
const view: MandatoryUpdateView = { locale: resolveDesktopLocale('zh-CN'), policy: { blocking: true, checking: false },
update: { phase: 'available', version: '2.0.0' }, deferred: false }
publish({}, view)
ipc.invoke.mockClear()
const root = shadow.mock.results[0]!.value as ShadowRoot
const frame = root.querySelector('iframe')!
// JSDOM has no browsing context for a shadow-root iframe; transport uses real paired Node ports.
const post = vi.spyOn(dom.window, 'postMessage').mockImplementation(() => {})
Object.defineProperty(frame, 'contentWindow', { value: dom.window })
const focus = vi.spyOn(frame, 'focus').mockImplementation(() => {})
frame.dispatchEvent(new dom.window.Event('load'))
expect(post).toHaveBeenCalledWith({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2])
return { publish, view, root, frame, focus, host: root.host as HTMLElement }
}
it('uses an in-page frame below the caption and reuses it on updates', () => {
const f = setup()
expect(f.host.style.top).toBe('40px')
expect(f.host.style.bottom).toBe('0px')
expect(f.frame.src).toBe('dsh-app://shell/mandatory-update.html')
expect(dom.window.document.body.style.filter).toBe('')
f.publish({}, { ...f.view, update: { phase: 'downloading', version: '2.0.0', percent: 30 } })
expect(f.root.querySelector('iframe')).toBe(f.frame)
})
it('rejects forged window events and accepts actions only through the transferred private port', async () => {
const f = setup()
const data = { type: 'dsh-mandatory-action', id: 1, action: 'download', version: '2.0.0', revision: undefined }
dom.window.dispatchEvent(new dom.window.MessageEvent('message', { source: f.frame.contentWindow, origin: 'dsh-app://shell', data }))
expect(ipc.invoke).not.toHaveBeenCalled()
const result = new Promise<unknown>(resolve => channel.port2.on('message', (message: unknown) => {
if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'dsh-mandatory-result') resolve(message)
}))
channel.port2.postMessage(data)
await expect(result).resolves.toEqual({ type: 'dsh-mandatory-result', id: 1, ok: true })
expect(ipc.invoke).toHaveBeenCalledExactlyOnceWith(MANDATORY_IPC.action, 'download', '2.0.0', undefined)
})
it('keeps caption menu keyboard events available while blocking background shortcuts', () => {
const f = setup()
const host = dom.window.document.querySelector('[data-windows-menu]')!
const shadow = host.attachShadow({ mode: 'open' })
const button = dom.window.document.createElement('button')
shadow.append(button)
const received = vi.fn()
button.addEventListener('keydown', received)
for (const key of ['Enter', 'ArrowDown', 'ArrowRight']) {
const event = new dom.window.KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true })
button.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
}
expect(received).toHaveBeenCalledTimes(3)
const background = new dom.window.KeyboardEvent('keydown', { key: 'n', bubbles: true, cancelable: true })
dom.window.document.body.dispatchEvent(background)
expect(background.defaultPrevented).toBe(true)
expect(f.focus).toHaveBeenCalled()
})
it('clears once without postponing removal on progress ticks, and closes the channel on navigation', () => {
vi.useFakeTimers()
const f = setup()
const cleared = { ...f.view, policy: { blocking: false, checking: false } }
f.publish({}, cleared)
vi.advanceTimersByTime(100)
f.publish({}, cleared)
vi.advanceTimersByTime(50)
expect(f.host.isConnected).toBe(false)
dom.window.dispatchEvent(new dom.window.Event('pagehide'))
expect(ipc.off).toHaveBeenCalledWith(MANDATORY_IPC.state, expect.any(Function))
})
@@ -1,131 +1,18 @@
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
/** Desktop carriers keep a complete interpreter payload even when SDK carriers choose Python-only. */
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { zipSync } from 'fflate'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { downloadPrimaryRuntimeAsset, prepareOfficeSkillAssets, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts'
import lock from '../scripts/primary-runtime-lock.json' with { type: 'json' }
import { smokePrimaryRuntime } from '../scripts/prepare-primary-runtime.ts'
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-'))
it('rejects a Python-only native Desktop payload before executing interpreters', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-python-only-'))
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-'))
try {
const hash = createHash('sha256').update(libraryWheel).digest('hex')
const archive = join(root, hash)
await writeFile(archive, libraryWheel)
expect(await downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).toBe(archive)
await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))
expect(await readFile(join(root, 'site-packages/sample.py'), 'utf8')).toBe('sample = 42\n')
await writeFile(archive, 'corrupt archive')
await expect(downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).rejects.toThrow('checksum mismatch')
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('fully extracts a large deflate-compressed wheel entry', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-large-wheel-'))
try {
const expected = Buffer.alloc(1024 * 1024)
// Seeded xorshift bytes keep the compressed entry larger than the stream buffers.
let state = 26
for (let index = 0; index < expected.length; index++) {
state ^= state << 13
state ^= state >>> 17
state ^= state << 5
expected[index] = state & 255
}
const archive = join(root, 'large.whl')
await writeFile(archive, zipSync({ 'large.bin': expected }, { level: 6 }))
await unpackPrimaryRuntimeWheel(archive, root)
expect(await readFile(join(root, 'large.bin'))).toEqual(expected)
} finally {
await rm(root, { recursive: true, force: true })
}
})
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 })
}
})
it('copies complete Office resources outside the application archive and removes obsolete assets', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-office-assets-'))
try {
const source = join(root, 'package', 'assets')
const destination = join(root, 'Contents', 'Resources', 'runtime', 'office-skills')
await mkdir(join(source, 'scripts'), { recursive: true })
await writeFile(join(source, 'scripts', 'check_office.py'), 'print("checker")\n')
for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) {
await mkdir(join(source, name))
await writeFile(join(source, name, 'SKILL.md'), `# ${name}\n`)
}
await prepareOfficeSkillAssets(source, destination)
await writeFile(join(destination, 'obsolete.py'), 'old helper')
await prepareOfficeSkillAssets(source, destination)
for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) {
expect(await readFile(join(destination, name, 'SKILL.md'), 'utf8')).toBe(`# ${name}\n`)
}
expect(await readFile(join(destination, 'scripts', 'check_office.py'), 'utf8')).toBe('print("checker")\n')
await expect(readFile(join(destination, 'obsolete.py'))).rejects.toMatchObject({ code: 'ENOENT' })
await writeFile(join(root, 'runtime.json'), JSON.stringify({
desktopVersion: '1.0.0', platform: process.platform, arch: process.arch,
python: '3.12.14', pythonPackages: { numpy: '2.3.5', pandas: '3.0.1' },
}))
expect(() => { smokePrimaryRuntime(root) }).toThrow('Desktop payload must declare node and pnpm')
} finally { await rm(root, { recursive: true, force: true }) }
})
+2 -22
View File
@@ -1,31 +1,11 @@
import { EventEmitter } from 'node:events'
import { expect, it, vi } from 'vitest'
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'
import { createMandatoryUpdateWindow } from '../src/update-overlay.ts'
import { createUpdateOverlay } from '../src/update-overlay.ts'
const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() }))
vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } }))
it('gives the Windows mandatory modal native move, resize, and maximize controls', () => {
const window = Object.assign(new EventEmitter(), {
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), isDestroyed: () => false,
})
native.create.mockReturnValue(window)
const parent = {} as BrowserWindow
expect(createMandatoryUpdateWindow(parent, 'owned', 'Update required', 'win32')).toBe(window)
expect(native.create).toHaveBeenCalledWith(expect.objectContaining({
parent, modal: true, show: false, title: 'Update required',
movable: true, resizable: true, maximizable: true,
minWidth: 480, minHeight: 360,
}))
const options = native.create.mock.calls[0]![0]
expect(options.webPreferences).toMatchObject({ preload: 'owned', sandbox: true, nodeIntegration: false })
expect(options).not.toHaveProperty('frame', false)
window.emit('ready-to-show')
expect(window.show).toHaveBeenCalledOnce()
expect(window.webContents.setWindowOpenHandler).toHaveBeenCalledOnce()
})
it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const parent = Object.assign(new EventEmitter(), {
@@ -39,7 +19,7 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu
})
native.create.mockReturnValue(window)
try {
createMandatoryUpdateWindow(parent as unknown as BrowserWindow, 'owned', 'Update required', 'darwin')
createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false)
expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false }))
const event = { preventDefault: vi.fn() }
parent.webContents.emit('before-input-event', event)
+53 -15
View File
@@ -78,30 +78,68 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 })
// The intro copy also names 标准模式. Wait for the roster's own action so
// the snapshot cannot land between the section shell and its cards.
await dialog.getByRole('button', { name: '查看: 标准模式', exact: true }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '查看配置: 标准模式', exact: true }).waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
const toggle = dialog.getByRole('switch', { name: '允许切换agent模式' })
const toggle = dialog.getByRole('switch', { name: '新任务可选择模式' })
expect(await toggle.getAttribute('aria-checked')).toBe('true')
// The intro states the copy path directly, and the shipped rows offer
// The authoring entry is visible, and the shipped rows offer
// view/copy but never delete or a location — their
// install is overwritten by upgrades and is not the user's to manage.
expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建')
expect(snapshot).toContain('让 Agent 帮我创建预设模式')
expect(snapshot).not.toContain('新建预设')
expect(snapshot).toContain('查看: 标准模式')
expect(snapshot).toContain('查看配置: 标准模式')
expect(snapshot).not.toContain('删除: 标准模式')
expect(snapshot).not.toContain('打开目录')
// The rest of this scenario exercises the existing default and Creator
// actions with the beta picker enabled by default.
}, 60_000)
it('reads technical details and examples without changing the new-task default', async () => {
const settings = settingsDialog()
const trigger = settings.getByRole('button', { name: '模式说明: PTC 模式', exact: true })
await trigger.click()
const guide = page.getByRole('dialog', { name: 'PTC 模式', exact: true })
await guide.getByRole('heading', { name: '怎样调用工具', exact: true }).waitFor()
const beforeSwitch = await guide.boundingBox()
expect(await guide.textContent()).toContain('run_code')
await guide.getByRole('tab', { name: '如何使用', exact: true }).click()
await guide.getByRole('heading', { name: '批量检查配置文件', exact: true }).waitFor()
expect(await guide.boundingBox()).toEqual(beforeSwitch)
await guide.getByRole('tab', { name: '如何使用', exact: true }).press('ArrowLeft')
expect(await guide.getByRole('tab', { name: '模式说明', exact: true }).getAttribute('aria-selected')).toBe('true')
await guide.getByRole('tab', { name: '模式说明', exact: true }).press('Escape')
await guide.waitFor({ state: 'detached' })
expect(await trigger.evaluate(element => document.activeElement === element)).toBe(true)
expect(await settings.isVisible()).toBe(true)
expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true')
await settings.getByRole('button', { name: '如何使用: 创造模式', exact: true }).click()
const creator = page.getByRole('dialog', { name: '创造模式', exact: true })
await creator.getByRole('heading', { name: '添加一个界面', exact: true }).waitFor()
expect(await creator.getByRole('heading', { name: '添加一个工具', exact: true }).isVisible()).toBe(true)
const closePosition = await creator.getByRole('button', { name: '关闭', exact: true }).boundingBox()
const examples = creator.getByRole('tabpanel', { name: '如何使用', exact: true })
const scrolledTo = await examples.evaluate((element) => {
element.scrollTop = element.scrollHeight
return element.scrollTop
})
expect(scrolledTo).toBeGreaterThan(0)
expect(await creator.getByRole('button', { name: '关闭', exact: true }).boundingBox()).toEqual(closePosition)
await creator.getByRole('tab', { name: '模式说明', exact: true }).click()
await creator.getByRole('tab', { name: '如何使用', exact: true }).click()
expect(await examples.evaluate(element => element.scrollTop)).toBe(scrolledTo)
await creator.getByRole('button', { name: '关闭', exact: true }).click()
await creator.waitFor({ state: 'detached' })
}, 60_000)
it('views a shipped composition read-only instead of editing it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '查看: 标准模式' }).click()
const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' })
await dialog.getByRole('button', { name: '查看配置: 标准模式' }).click()
const viewer = page.getByRole('dialog', { name: '查看配置 · 标准模式' })
await viewer.waitFor({ timeout: 10_000 })
// The real shipped composition, not a golden: the viewer shows whatever
@@ -118,7 +156,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
it('copies 极简模式 whole under a new id and lands in its files', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
await dialog.getByRole('button', { name: '复制预设: 极简模式' }).click()
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
await copyDialog.waitFor({ timeout: 10_000 })
@@ -131,7 +169,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
expect(dialogSnapshot).not.toContain('描述')
await copyDialog.getByPlaceholder('my-agent').fill('my-agent')
await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式')
await copyDialog.getByPlaceholder('在模式列表中显示的名称,不填则使用标识符').fill('我的模式')
await copyDialog.getByRole('button', { name: '创建' }).click()
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
@@ -175,7 +213,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
// The custom group outlives its only member: the heading stays with the
// creator entry so the place to author a preset never disappears.
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1)
expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1)
expect(await dialog.getByRole('button', { name: '让 Agent 帮我创建预设模式' }).count()).toBe(1)
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
}, 60_000)
@@ -205,10 +243,10 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
expect(snapshot).toContain('not valid YAML')
expect(snapshot).toContain('agent.cordis.yml is missing')
expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true)
expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
expect(await dialog.getByRole('button', { name: '复制预设: 幽灵预设' }).isDisabled()).toBe(true)
// A broken card offers no "set default" affordance at all — the aria name
// IS the broken marking, so the picking name must not exist.
expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0)
expect(await dialog.getByRole('button', { name: '设为新任务默认: broken-yaml' }).count()).toBe(0)
// The ghost's way out is the card's own delete — and the id it blocked
// is claimable again immediately afterwards.
@@ -220,13 +258,13 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0)
expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
await dialog.getByRole('button', { name: '复制预设: 极简模式' }).click()
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
await copyDialog.waitFor({ timeout: 10_000 })
await copyDialog.getByPlaceholder('my-agent').fill('ghost')
await copyDialog.getByRole('button', { name: '创建' }).click()
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '设为新任务默认: ghost' }).waitFor({ timeout: 10_000 })
// Leave the roster as the earlier tests shaped it.
await dialog.getByRole('button', { name: '删除: ghost' }).click()
@@ -248,7 +286,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
const dialog = settingsDialog()
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).click()
await dialog.getByRole('button', { name: '让 Agent 帮我创建预设模式' }).click()
// Leaving settings is part of the gesture: the flow lands on the
// new-session screen with the self-referential preset staged, and the
+5 -5
View File
@@ -265,7 +265,7 @@ describe('web e2e: agent-preset selection', () => {
await page.getByRole('button', { name: 'Settings', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Settings' })
await dialog.getByRole('button', { name: 'Agent presets' }).click()
const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' })
const toggle = dialog.getByRole('switch', { name: 'Choose a mode for new tasks' })
await dialog.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 })
expect(await toggle.getAttribute('aria-checked')).toBe('true')
await dialog.getByRole('button', { name: 'Close' }).last().click()
@@ -365,13 +365,13 @@ describe('web e2e: agent-preset selection', () => {
await page.getByRole('button', { name: 'Settings', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Settings' })
await dialog.getByRole('button', { name: 'Agent presets' }).click()
await dialog.getByRole('button', { name: 'Set as default: Minimal mode' }).click()
await dialog.getByRole('button', { name: 'Set as new task default: Minimal mode' }).click()
await dialog.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' })
const toggle = dialog.getByRole('switch', { name: 'Choose a mode for new tasks' })
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
await dialog.getByRole('button', { name: 'Default: Standard mode' }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Application default: Standard mode' }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Close' }).last().click()
await expect.poll(() => page.getByRole('button', { name: / mode$/ }).count()).toBe(0)
@@ -382,7 +382,7 @@ describe('web e2e: agent-preset selection', () => {
await page.getByRole('button', { name: 'Settings', exact: true }).click()
const reopened = page.getByRole('dialog', { name: 'Settings' })
await reopened.getByRole('button', { name: 'Agent presets' }).click()
const reopenedToggle = reopened.getByRole('switch', { name: 'Allow switching Agent modes' })
const reopenedToggle = reopened.getByRole('switch', { name: 'Choose a mode for new tasks' })
await reopenedToggle.click()
await expect.poll(() => reopenedToggle.getAttribute('aria-checked')).toBe('true')
await reopened.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
+1 -1
View File
@@ -24,7 +24,6 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', ()
beforeAll(async () => {
replayFixture = await selectedSessionFixture(FIXTURE, false)
scaffold = await launchWebScaffold({
deepSeekMessages: true,
replayFixture,
paceMs: 5,
replayProviders: [{
@@ -36,6 +35,7 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', ()
}],
}],
})
await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
@@ -1,4 +1,4 @@
/** Opt-in Web Messages configuration, credential reuse, and recovery from a saved Chat Completions selection. */
/** Web DeepSeek configuration, credential reuse, and saved model selection. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -12,14 +12,14 @@ import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './s
const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url))
describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-in', () => {
describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages settings', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, deepSeekMessages: true })
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
@@ -68,7 +68,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-
await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({
name: 'Messages Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history',
})
expect(scaffold.ctx.settings.get('llm-deepseek')).toMatchObject({ protocol: 'messages' })
expect(scaffold.ctx.settings.get('llm-deepseek')).not.toHaveProperty('protocol')
expect(settings).not.toContain('sk-e2e-')
const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-messages')
@@ -84,7 +84,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps a saved Chat Completions selection available after the YAML protocol switch', async () => {
it('keeps a saved DeepSeek selection available after editing provider settings', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default'))
await page.keyboard.press('Escape')
await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
@@ -25,7 +25,7 @@ it('persists developer tools in the Host settings document and restores the acce
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
await page.getByRole('button', { name: 'Agent presets', exact: true }).click()
const pickerToggle = page.getByRole('switch', { name: 'Allow switching Agent modes' })
const pickerToggle = page.getByRole('switch', { name: 'Choose a mode for new tasks' })
await expect.poll(() => page.getByRole('heading', { name: 'Agent presets', exact: true }).count()).toBe(1)
expect(await pickerToggle.count()).toBe(0)
await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true })
@@ -1,13 +1,13 @@
- dialog "复制预设 · 复制自 极简模式":
- heading "复制预设 · 复制自 极简模式" [level=2]
- button "关闭"
- paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。
- paragraph: 复制后会在本机保存一份预设,你可以打开目录修改配置。标识符用作目录名,创建后不能修改。
- text: 标识符
- textbox "标识符":
- /placeholder: my-agent
- text: 名称
- textbox "名称":
- /placeholder: 选择器中显示的名字,缺省用标识符
- /placeholder: 在模式列表中显示的名称,不填则使用标识符
- alert: 请填写标识符。
- button "取消"
- button "创建" [disabled]
@@ -8,46 +8,59 @@
- button "打开配置文件"
- button "关闭"
- heading "Agent 预设" [level=2]
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
- text: 允许切换agent模式 beta
- paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。
- switch "允许切换agent模式" [checked]
- paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。
- text: 新任务可选择模式 Beta
- paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。
- switch "新任务可选择模式" [checked]
- heading "内置" [level=3]
- list:
- listitem:
- 'button "新任务默认: 标准模式" [disabled] [pressed]':
- text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
- text: 标准模式 新任务默认
- code: standard
- 'button "查看: 标准模式"': 查看
- 'button "复制: 标准模式"': 复制
- text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。
- 'button "模式说明: 标准模式"': 模式说明
- 'button "如何使用: 标准模式"': 如何使用
- 'button "查看配置: 标准模式"': 查看配置
- 'button "复制预设: 标准模式"': 复制预设
- listitem:
- 'button "设为默认: PTC 模式"':
- text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。
- 'button "设为新任务默认: PTC 模式"':
- text: PTC 模式 内置
- code: ptc
- 'button "查看: PTC 模式"': 查看
- 'button "复制: PTC 模式"': 复制
- text: 包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。
- 'button "模式说明: PTC 模式"': 模式说明
- 'button "如何使用: PTC 模式"': 如何使用
- 'button "查看配置: PTC 模式"': 查看配置
- 'button "复制预设: PTC 模式"': 复制预设
- listitem:
- 'button "设为默认: 极简模式"':
- text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。
- 'button "设为新任务默认: 极简模式"':
- text: 极简模式 内置
- code: minimal
- 'button "查看: 极简模式"': 查看
- 'button "复制: 极简模式"': 复制
- text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。
- 'button "模式说明: 极简模式"': 模式说明
- 'button "如何使用: 极简模式"': 如何使用
- 'button "查看配置: 极简模式"': 查看配置
- 'button "复制预设: 极简模式"': 复制预设
- listitem:
- 'button "设为默认: 创造模式"':
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。
- 'button "设为新任务默认: 创造模式"':
- text: 创造模式 内置
- code: cordis
- 'button "查看: 创造模式"': 查看
- 'button "复制: 创造模式"': 复制
- text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。
- 'button "模式说明: 创造模式"': 模式说明
- 'button "如何使用: 创造模式"': 如何使用
- 'button "查看配置: 创造模式"': 查看配置
- 'button "复制预设: 创造模式"': 复制预设
- heading "自定义" [level=3]
- list:
- listitem:
- 'button "设为默认: 我的模式"':
- text: 我的模式 自定义 仅提供持久 shell 的单工具编码 Agent。
- 'button "设为新任务默认: 我的模式"':
- text: 我的模式 自定义
- code: my-agent
- text: 仅提供持久 shell 的单工具编码 Agent。
- 'button "查看路径: 我的模式"': 查看路径
- 'button "复制: 我的模式"': 复制
- 'button "复制预设: 我的模式"': 复制预设
- 'button "删除: 我的模式"': 删除
- paragraph:
- text: 预设文件:
- code: {{presetRoot}}/my-agent
- button "用「创造模式」创作自定义预设"
- button "让 Agent 帮我创建预设模式"
@@ -8,52 +8,66 @@
- button "打开配置文件"
- button "关闭"
- heading "Agent 预设" [level=2]
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
- text: 允许切换agent模式 beta
- paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。
- switch "允许切换agent模式" [checked]
- paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。
- text: 新任务可选择模式 Beta
- paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。
- switch "新任务可选择模式" [checked]
- heading "内置" [level=3]
- list:
- listitem:
- 'button "新任务默认: 标准模式" [disabled] [pressed]':
- text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
- text: 标准模式 新任务默认
- code: standard
- 'button "查看: 标准模式"': 查看
- 'button "复制: 标准模式"': 复制
- text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。
- 'button "模式说明: 标准模式"': 模式说明
- 'button "如何使用: 标准模式"': 如何使用
- 'button "查看配置: 标准模式"': 查看配置
- 'button "复制预设: 标准模式"': 复制预设
- listitem:
- 'button "设为默认: PTC 模式"':
- text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。
- 'button "设为新任务默认: PTC 模式"':
- text: PTC 模式 内置
- code: ptc
- 'button "查看: PTC 模式"': 查看
- 'button "复制: PTC 模式"': 复制
- text: 包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。
- 'button "模式说明: PTC 模式"': 模式说明
- 'button "如何使用: PTC 模式"': 如何使用
- 'button "查看配置: PTC 模式"': 查看配置
- 'button "复制预设: PTC 模式"': 复制预设
- listitem:
- 'button "设为默认: 极简模式"':
- text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。
- 'button "设为新任务默认: 极简模式"':
- text: 极简模式 内置
- code: minimal
- 'button "查看: 极简模式"': 查看
- 'button "复制: 极简模式"': 复制
- text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。
- 'button "模式说明: 极简模式"': 模式说明
- 'button "如何使用: 极简模式"': 如何使用
- 'button "查看配置: 极简模式"': 查看配置
- 'button "复制预设: 极简模式"': 复制预设
- listitem:
- 'button "设为默认: 创造模式"':
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。
- 'button "设为新任务默认: 创造模式"':
- text: 创造模式 内置
- code: cordis
- 'button "查看: 创造模式"': 查看
- 'button "复制: 创造模式"': 复制
- text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。
- 'button "模式说明: 创造模式"': 模式说明
- 'button "如何使用: 创造模式"': 如何使用
- 'button "查看配置: 创造模式"': 查看配置
- 'button "复制预设: 创造模式"': 复制预设
- heading "自定义" [level=3]
- list:
- listitem:
- 'button "加载失败: broken-yaml" [disabled]':
- text: broken-yaml 加载失败 自定义 暂无描述。
- alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
- text: broken-yaml 加载失败 自定义
- code: broken-yaml
- text: 暂无描述。
- alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
- 'button "查看路径: broken-yaml"': 查看路径
- 'button "复制: broken-yaml" [disabled]': 预设加载失败,不能复制
- 'button "复制预设: broken-yaml" [disabled]': 预设加载失败,不能复制
- 'button "删除: broken-yaml"': 删除
- listitem:
- 'button "加载失败: 幽灵预设" [disabled]':
- text: 幽灵预设 加载失败 自定义 composition 已被手动删除。
- alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
- text: 幽灵预设 加载失败 自定义
- code: ghost
- text: composition 已被手动删除。
- alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
- 'button "查看路径: 幽灵预设"': 查看路径
- 'button "复制: 幽灵预设" [disabled]': 预设加载失败,不能复制
- 'button "复制预设: 幽灵预设" [disabled]': 预设加载失败,不能复制
- 'button "删除: 幽灵预设"': 删除
- button "用「创造模式」创作自定义预设"
- button "让 Agent 帮我创建预设模式"
@@ -8,35 +8,47 @@
- button "打开配置文件"
- button "关闭"
- heading "Agent 预设" [level=2]
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
- text: 允许切换agent模式 beta
- paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。
- switch "允许切换agent模式" [checked]
- paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。
- text: 新任务可选择模式 Beta
- paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。
- switch "新任务可选择模式" [checked]
- heading "内置" [level=3]
- list:
- listitem:
- 'button "新任务默认: 标准模式" [disabled] [pressed]':
- text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
- text: 标准模式 新任务默认
- code: standard
- 'button "查看: 标准模式"': 查看
- 'button "复制: 标准模式"': 复制
- text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。
- 'button "模式说明: 标准模式"': 模式说明
- 'button "如何使用: 标准模式"': 如何使用
- 'button "查看配置: 标准模式"': 查看配置
- 'button "复制预设: 标准模式"': 复制预设
- listitem:
- 'button "设为默认: PTC 模式"':
- text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。
- 'button "设为新任务默认: PTC 模式"':
- text: PTC 模式 内置
- code: ptc
- 'button "查看: PTC 模式"': 查看
- 'button "复制: PTC 模式"': 复制
- text: 包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。
- 'button "模式说明: PTC 模式"': 模式说明
- 'button "如何使用: PTC 模式"': 如何使用
- 'button "查看配置: PTC 模式"': 查看配置
- 'button "复制预设: PTC 模式"': 复制预设
- listitem:
- 'button "设为默认: 极简模式"':
- text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。
- 'button "设为新任务默认: 极简模式"':
- text: 极简模式 内置
- code: minimal
- 'button "查看: 极简模式"': 查看
- 'button "复制: 极简模式"': 复制
- text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。
- 'button "模式说明: 极简模式"': 模式说明
- 'button "如何使用: 极简模式"': 如何使用
- 'button "查看配置: 极简模式"': 查看配置
- 'button "复制预设: 极简模式"': 复制预设
- listitem:
- 'button "设为默认: 创造模式"':
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。
- 'button "设为新任务默认: 创造模式"':
- text: 创造模式 内置
- code: cordis
- 'button "查看: 创造模式"': 查看
- 'button "复制: 创造模式"': 复制
- text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。
- 'button "模式说明: 创造模式"': 模式说明
- 'button "如何使用: 创造模式"': 如何使用
- 'button "查看配置: 创造模式"': 查看配置
- 'button "复制预设: 创造模式"': 复制预设
- heading "自定义" [level=3]
- button "用「创造模式」创作自定义预设"
- button "让 Agent 帮我创建预设模式"
@@ -1,6 +1,6 @@
- menu:
- menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows."
- menuitem "PTC mode Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program."
- menuitem "Minimal mode Single-tool coding agent with a persistent shell."
- menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, persistent plugin management, and preset-authoring guidance."
- menuitem "Standard mode Work with code, files, and information. Suitable for most tasks, with search, editing, terminal commands, and other tools available as needed."
- menuitem "PTC mode Includes all Standard mode capabilities, with the agent connecting tools through code for batch processing, filtering, and summaries. Just describe your task."
- menuitem "Minimal mode The agent works using only a terminal tool. Useful for testing and comparing its basic performance."
- menuitem "Creator mode Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode."
- menuitem "Refusing mode Resolves, then refuses to start."
@@ -21,7 +21,7 @@
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com/anthropic
- text: 请填写与当前连接配置兼容的 API 地址。
- text: 请填写兼容 Anthropic Messages 协议的 API 地址。
- region "模型目录":
- text: 模型目录 正在使用适配器默认模型
- textbox "模型 ID 1":
@@ -21,7 +21,7 @@
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com/anthropic
- text: 请填写与当前连接配置兼容的 API 地址。
- text: 请填写兼容 Anthropic Messages 协议的 API 地址。
- region "模型目录":
- text: 模型目录 正在使用适配器默认模型
- textbox "模型 ID 1":
@@ -21,7 +21,7 @@
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com/anthropic
- text: 请填写与当前连接配置兼容的 API 地址。
- text: 请填写兼容 Anthropic Messages 协议的 API 地址。
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
+10 -1
View File
@@ -227,14 +227,19 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
document.body.append(probe)
const actual = getComputedStyle(element)
const reference = getComputedStyle(probe)
// The sibling access-mode trigger: the chip shares its corner
// curvature (the theme's superellipse, not a circular capsule).
const accessMode = document.querySelector('button[aria-label^="Access mode"]')
const result = {
color: actual.color,
backgroundColor: actual.backgroundColor,
height: actual.height,
borderRadius: actual.borderRadius,
cornerShape: actual.getPropertyValue('corner-shape'),
fontSize: actual.fontSize,
referenceColor: reference.color,
referenceBackgroundColor: reference.backgroundColor,
siblingCornerShape: accessMode === null ? null : getComputedStyle(accessMode).getPropertyValue('corner-shape'),
}
probe.remove()
return result
@@ -242,7 +247,11 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
expect(planStyle.color).toBe(planStyle.referenceColor)
expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor)
expect(planStyle.height).toBe('28px')
expect(planStyle.borderRadius).toBe('999px')
// Half the 28px height, the compact Button geometry, under the theme's
// corner curvature; a 999px pill would need the circular opt-out.
expect(planStyle.borderRadius).toBe('14px')
expect(planStyle.siblingCornerShape).not.toBeNull()
expect(planStyle.cornerShape).toBe(planStyle.siblingCornerShape)
expect(planStyle.fontSize).toBe('13px')
await planButton.click()
await expect.poll(() => planButton.count()).toBe(0)
+46 -1
View File
@@ -10,7 +10,7 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import type { Browser, ConsoleMessage, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
@@ -211,6 +211,51 @@ describe('web e2e: plan review takeover round trip', () => {
}
}, 60_000)
it.skipIf(MODE === 'record')('opens a review that arrives while a global panel is active', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-panel-return'))
const agent = scaffold.ctx.agents.get(reviewedSession)
if (agent === undefined) throw new Error('The reviewed Session has no active agent')
// The Plugins panel replaces the Conversation and its right Sidebar. The
// review arrives while neither is mounted; returning mounts both in one
// commit, and the review's automatic open runs before the Sidebar's own
// effects. A crash there retires the opener for the rest of the page.
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await expect.poll(() => page.locator('[data-composer-input]').count()).toBe(0)
const crashes: string[] = []
const onConsole = (message: ConsoleMessage): void => {
if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) crashes.push(message.text())
}
page.on('console', onConsole)
const controller = new AbortController()
const asked = scaffold.ctx.userQuestions.ask({
agent, signal: controller.signal,
questions: [{ id: 'off-screen', question: 'Approve this plan?',
detail: '# Off-screen review\n\nSubmitted while the Plugins panel was open.',
options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' },
}],
})
const outcome = asked.then(value => value, (error: unknown) => ({ error }))
try {
const row = page.locator('[role="treeitem"]').filter({ has: page.locator('[data-state="warning"]') }).first()
await row.waitFor({ timeout: 10_000 })
await row.click()
const card = page.locator('[data-plan-review-key]')
await card.waitFor({ timeout: 10_000 })
const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]')
await preview.waitFor({ state: 'visible', timeout: 10_000 })
expect(await preview.getByText('Submitted while the Plugins panel was open.').isVisible()).toBe(true)
expect(await card.getByRole('button', { name: 'Open plan in sidebar' }).count()).toBe(1)
expect(await page.locator('[data-slot-error]').count()).toBe(0)
expect(crashes).toEqual([])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} finally {
page.off('console', onConsole)
controller.abort()
await outcome
}
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md', 'preview.expected.md',
+9 -20
View File
@@ -261,10 +261,9 @@ class RouteOnlyAdapter extends LlmAdapter {
}
}
function replayProviders(contextWindow: number | undefined, messages: boolean): typeof REPLAY_PROVIDERS {
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
return REPLAY_PROVIDERS.map(provider => ({
...provider,
id: messages ? 'deepseek-messages' : provider.id,
models: provider.models.map(model => ({
...model,
...contextWindow === undefined ? {} : { contextWindow },
@@ -391,8 +390,6 @@ export interface LaunchOptions {
* keyless first-run configuration lane; the default disables the adapter.
*/
deepSeekMissingCredential?: boolean
/** Record or replay a Messages scenario; older scenarios explicitly retain their recorded Chat Completions route. */
deepSeekMessages?: boolean
/** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */
welcomeNoticePending?: boolean
/**
@@ -494,7 +491,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
}
const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
const messages = options.deepSeekMessages === true
const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
let credentialEnvironmentRestored = false
const restoreCredentialEnvironment = (): void => {
@@ -571,13 +567,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// Without HMR the profile applies configuration changes at its next start.
...options.profile?.hmr === false ? [{ id: 'hmr', disabled: true }] : [],
{ id: 'session-log-deepseek', config: { enabled: false } },
// The historical Messages fixture retains its recorded route during replay;
// live configuration uses the shared DeepSeek route. Explicit overlays win.
...messages
? [{ id: 'agent-default-model', config: { provider: mode === 'record' || maskDeepSeekCredential ? 'deepseek-official' : 'deepseek-messages', model: maskDeepSeekCredential ? 'deepseek-flash' : 'deepseek-v4-flash' } }]
: mode === 'record' || options.deepSeekMissingCredential === true
? []
: [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
...mode === 'record' || options.deepSeekMissingCredential === true
? []
: [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
...extraOverlayPatches,
// The roster's shipped presets are the plugin's own, bundled inside
// `dsh-agent-presets` and prepended by it. Pin only the machine-local
@@ -693,10 +685,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
baseURL: options.deepSeekSearch.baseURL,
},
}],
...maskDeepSeekCredential && !messages ? [] : [
{ id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential,
config: messages ? {} : { protocol: 'chat-completions' } },
],
{ id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential },
]
const patches: PatchOptions[] = [...basePatches, ...surfacePatches, ...overlayPatches]
@@ -737,7 +726,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
return {
packageName: manifest.name,
packageDir,
patchPath: join(packageDir, 'cordis.patch.yml'),
patchPaths: [join(packageDir, 'cordis.patch.yml')],
patches: [],
}
}))
@@ -870,7 +859,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
if (mode !== 'record' && replayFixture !== undefined) {
replayHandle = installLlmReplay(ctx, {
file: replayFixture,
providers: (options.replayProviders ?? replayProviders(options.replayContextWindow, messages)).map(provider => ({
providers: (options.replayProviders ?? replayProviders(options.replayContextWindow)).map(provider => ({
...provider,
...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }),
})),
@@ -885,8 +874,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// a fixture would, with streaming that still fails loud: the scenario
// issues no model calls, and one that slipped in must not pass quietly.
ctx.effect(() => ctx.llm.registerAdapter(
replayProviders(options.replayContextWindow, messages).map(provider => provider.id),
new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)),
replayProviders(options.replayContextWindow).map(provider => provider.id),
new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
), 'web e2e scaffold: route-only adapter')
}
if (publicProxy === undefined || publicHost === undefined || publicPrefix === undefined) {
+44
View File
@@ -187,6 +187,50 @@ describe('web e2e: settings modal and General preferences', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('scrolls overflowing section navigation independently of the title and settings content', async () => {
const overflowPage = await browser.newPage({ viewport: { width: 1280, height: 768 }, locale: ZH_BROWSER_LOCALE })
onTestFinished(() => overflowPage.close())
onTestFailed(() => saveFailureShot(overflowPage, 'web-e2e-settings-navigation'))
const console = watchConsole(overflowPage)
await overflowPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await overflowPage.getByRole('button', { name: '设置', exact: true }).click()
const dialog = overflowPage.getByRole('dialog', { name: '设置', exact: true })
const navigation = dialog.getByRole('navigation')
const list = navigation.locator('[class*="_navList"]')
const title = navigation.getByText('设置', { exact: true })
const options = dialog.locator('[class*="_options"]')
const last = navigation.getByRole('button').last()
await last.waitFor()
expect(await list.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(false)
// A short viewport overflows the shipped sections without synthetic registrations.
await overflowPage.setViewportSize({ width: 1280, height: 240 })
expect(await list.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true)
const titleBefore = await title.boundingBox()
const contentBefore = await options.innerText()
await navigation.getByRole('button').first().hover()
await overflowPage.mouse.wheel(0, 1000)
await expect.poll(() => last.evaluate((el) => {
const rect = el.getBoundingClientRect()
return el.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2))
})).toBe(true)
const navScroll = await list.evaluate(el => el.scrollTop)
expect(navScroll).toBeGreaterThan(0)
expect(await title.boundingBox()).toEqual(titleBefore)
expect(await options.evaluate(el => el.scrollTop)).toBe(0)
await options.hover()
await overflowPage.mouse.wheel(0, 1000)
await expect.poll(() => options.evaluate(el => el.scrollTop)).toBeGreaterThan(0)
expect(await list.evaluate(el => el.scrollTop)).toBe(navScroll)
expect(await title.boundingBox()).toEqual(titleBefore)
await last.click()
await expect.poll(() => last.getAttribute('aria-current')).toBe('true')
await expect.poll(() => options.innerText()).not.toBe(contentBefore)
expect(console.pageErrors).toEqual([])
expect(console.warnings).toEqual([])
})
it('stores Permission as the default for future sessions without changing an existing session', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
+2 -2
View File
@@ -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 docs/config-catalog.md
config-catalog.md: 92b24a0948883b2974c5cb8ab650bd3db3b50326
config-catalog.zh.md: 4ba0378810f3db4797c0d41fa5230f7069438b3f
config-catalog.md: 460d7a69837cdc43fe089b164b4d889b7e696650
config-catalog.zh.md: 46b6a74511c89e806416770318a8244a94a45964
Loaded 100 of 332 files, more files were not shown because too many files have changed in this diff. Show more