Merge master into refactor/css-anchored-panels

This commit is contained in:
Stevring
2026-11-03 10:50:14 +08:00
492 changed files with 11446 additions and 4158 deletions
@@ -21,8 +21,8 @@
en: 0a4240d88cca240a
zh: 8f5f1527caf571cd
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/the-loading-flow-end-to-end:
en: 15fb56befd328339
zh: 0ffb10ada7e52805
en: dd05aa1ee608c414
zh: 2863ced0086e3122
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/live-graph-reconciliation:
en: 37e2fa1fe5cb2b95
zh: 80e513db87709cb4
@@ -33,8 +33,8 @@
en: 94ca0d09e6fd1e3a
zh: d5949a2bdec795ba
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/consequences:
en: 16e1f2467c559315
zh: a714ec6ff2722cf1
en: 624efdd136029771
zh: 7d60207147e631fe
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/alternatives-considered:
en: b8f036f50cff7cd8
zh: 6d623c4a1f4314b1
@@ -67,8 +67,8 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a
1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser.
2. It creates every graph row uniformly. Importing the modules row returns the memoized bootstrap exports, whose `apply()` reads that tree's `Loader.internal` and provides the same instance as `ctx.modules`; rows that require that service remain PENDING until then, so the modules row needs no special creation position. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry.
3. Graph order governs synchronous factory availability; Cordis activation remains independent and proceeds through service waiting.
4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor.
5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass.
4. `settled` = every entry created + `loader.await()` quiescent + the boot audit. The audit lists each import-failed, FAILED, or PENDING fiber with its activation error or missing services. It exists because cordis inject waits have no timeout. An inactive entry on the browser required list rejects startup; other inactive entries produce one console warning ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)).
5. The framework-free loading page projects real fiber states via `internal/status`. After the audit accepts the tree, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass.
### Live graph reconciliation
@@ -104,7 +104,7 @@ The current package inventory and build forms live in the [client shell layering
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps two replaceable carrier hooks: `loadBundle` for bundle bytes and `resolveAsset` for package-asset references.
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file.
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the boot audit; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file.
Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding, browser authentication, RPC envelopes, and exact Fetch routes belong to the Connection node half, while Remote dispatch belongs to API Gateway and the dev bundle watch plus SSE channel belongs to the hmr node half.
@@ -67,8 +67,8 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的
1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。
2. 它统一创建每个 graph row。Import modules row 会返回记忆化的 bootstrap exports,其 `apply()` 读取该树的 `Loader.internal`,把同一个实例提供为 `ctx.modules`;需要该 service 的 row 会保持 PENDING 直至此时,因此 modules row 无需特殊创建位置。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。
3. Graph 顺序治理同步 factory 可用性;Cordis 激活与之独立,仍经服务等待推进。
4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。
5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。
4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 启动审计。审计列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其激活错误或缺失的服务。它存在的理由:cordis 的 inject 等待没有超时。浏览器必需名单中的 entry 未激活会拒绝启动;其他未激活 entry 输出一条控制台警告([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。
5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。审计接受插件树后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。
### 动态图对账
@@ -104,7 +104,7 @@ Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统只保留两个可替换的载体钩子:负责 bundle 字节的 `loadBundle` 和负责包资源引用的 `resolveAsset`。
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在启动审计时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。
名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 Client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定、浏览器认证、RPC envelope 与精确 Fetch 路由属于 Connection node 半,Remote 分发属于 API Gateway,开发期 bundle 监视与 SSE 通道属于 HMR node 半。
@@ -9,8 +9,8 @@
en: 4548e7fbdbee5b5a
zh: 0078762ea92fa180
/agent-note-consumer-owned-startup-strictness/decision:
en: 1f11e04162e19ad1
zh: d102e300eba9a2ea
en: 62e93742af5bed66
zh: 3944d685b6503a2c
/agent-note-consumer-owned-startup-strictness/alternatives-considered:
en: 1b67e062213184cd
zh: 91cc8a015de2bfe4
@@ -18,5 +18,5 @@
en: 283dec1b336c540d
zh: 2ed358079072cfff
/agent-note-consumer-owned-startup-strictness/testing:
en: c38221069ce98fd3
zh: 9a2f91f539eba4d4
en: cee14c22297e5471
zh: a68748cc36f4a574
@@ -20,6 +20,8 @@ The audit runs only during initial application boot. Later config HMR remains be
This policy governs [Web host boot](../../../../packages/boot/app-boot/README.md), including its [client plugin roster](2026-07-23-client-plugin-loading-model.md). [Per-session presets](2026-09-18-declarative-agent-presets.md) own a separate strict subtree audit.
The browser boot audit applies the same policy to the client plugin tree with its own global list in [`boot-client.ts`](../../../../packages/client/web/src/boot-client.ts): the module system, Session control, the renderer, the application frame, the Session view, and its sidebar. Browser entries are named by package, so the list holds package names; a name absent from the boot graph has no effect, and neither list is derived from the other. The list is the minimum fatal set: an entry that a listed entry waits on blocks startup too. Feature plugins, including credential masking, stay optional. A failed entry reports its activation error. Optional failures appear in one console warning, and in Settings → Plugins → Plugin list only after a later graph synchronization reconciles them. Renaming a listed package requires updating the list and its tests.
## Alternatives considered
- **Add transactional and best-effort modes to vendored Loader.** Rejected because strictness belongs to the application or resource owner, while a Loader group contains unrelated plugins. A mode would also expand the vendor patch and leave callers to select a policy at every group.
@@ -36,6 +38,8 @@ The compact terminal report keeps the failing plugins visible; a separate file r
App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. Unit expectations pin diagnostic grouping, preservation of original error objects and import logs, exporter cleanup, complete diagnostic values, private file creation, concurrent report names, and failed-write fallback. The built Web-profile acceptance asserts a single port-conflict stack without Node wrapper output, verifies the saved diagnostic file and its stderr fallback, serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate.
Browser audit unit tests (`boot-client.client.spec.ts`, `boot.client.spec.ts`) cover required and optional import, creation, activation, and pending failures, carrier presentation, and required names absent from the boot graph. Test carriers are stricter than the product: `TestClient` requires every roster entry to activate unless a case passes `allowInactiveEntries`, and the browser e2e lanes treat a `web boot: warning` as a failure with no opt-out.
The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../../../../packages/boot/app-boot/tests/user-patches.spec.ts): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together.
The matrix enables Chokidar's `awaitWriteFinish` to acknowledge stable file contents before each reload; otherwise its short change-event suppression window can discard the next test edit. Native events remain required, and assertions wait for observed activation or failure rather than a fixed settling sleep. This is explicit test configuration, not evidence for the default watcher timing.
@@ -20,6 +20,8 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles
该策略适用于 [Web host 启动](../../../../packages/boot/app-boot/README.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-09-18-declarative-agent-presets.zh.md)持有独立的严格子树审计。
浏览器启动审计以同一策略审计客户端插件树,使用 [`boot-client.ts`](../../../../packages/client/web/src/boot-client.ts) 中独立的全局名单:模块系统、Session 控制、渲染器、应用框架、Session 视图及其侧栏。浏览器 entry 以包名命名,因此名单写包名;启动图中不存在的名字不产生影响,两份名单互不推导。名单是最小致命集合:已列出 entry 所等待的 entry 同样会阻止启动。功能插件(包括凭据遮挡)保持可选。失败 entry 报告其激活错误。可选失败出现在一条控制台警告中;只有后续图同步调和之后,才会出现在「设置 → 插件 → 插件列表」。重命名名单中的包需要同步更新名单及其测试。
## 考虑过的替代方案
- **给 vendored Loader 增加 transactional 与 best-effort mode。** 拒绝,因为严格语义属于应用或资源 owner,而一个 Loader group 包含互不相关的 plugin。Mode 还会扩大 vendor patch,并要求 caller 为每个 group 选择 policy。
@@ -36,6 +38,8 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles
App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。单元预期输出固定诊断分组、原始错误对象与导入日志的保留、exporter 清理、完整诊断值、私有文件创建、并发报告命名以及写入失败回退行为。构建后的 Web-profile acceptance 断言端口冲突堆栈只输出一次且不包含 Node 包装输出,验证已保存的诊断文件及其 stderr 回退,并会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。
浏览器审计单元测试(`boot-client.client.spec.ts`、`boot.client.spec.ts`)覆盖必需与可选 entry 的导入、创建、激活与 pending 失败、carrier 呈现,以及启动图中不存在的必需名称。测试载体比产品更严格:除非用例传入 `allowInactiveEntries`,`TestClient` 要求每个 roster entry 都激活;浏览器 e2e 把 `web boot: warning` 视为失败,且不提供放宽。
[Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。
矩阵启用 Chokidar 的 `awaitWriteFinish`,在每次重载前确认文件内容已稳定;否则它的短暂 change 事件抑制窗口可能丢弃下一次测试编辑。测试仍然依赖原生事件,并等待观察到激活或失败,而不是固定时长的休眠。这是显式测试配置,不能证明默认监听器的时序行为。
@@ -9,8 +9,8 @@
en: 386864a282e5ad58
zh: a4ea75ec70eb7778
/agent-note-shared-native-cua-process-with-agent-scoped-state/decision:
en: 11bab6870c3d802f
zh: a22114f2217a3a41
en: 60ec2276ed0bff33
zh: 5fb48cc787194254
/agent-note-shared-native-cua-process-with-agent-scoped-state/alternatives-considered:
en: 883f71387f599160
zh: 4b5f1450be799397
@@ -12,7 +12,7 @@ On macOS 26.6.2, replayd derives capture-client identity from the executable pat
One user shares a native service per executable content digest across Agents and Hosts. A version lock elects the service; an additional canonical-executable-path lock prevents different builds published at one path from running together. Locks remain held until process exit. The managed startup command hands service lifetime to its client connections, and the last connection's completed cleanup ends the service. The diagnostic stdin/stdout entry proxies to this same service.
The service runs as a signed application launched through LaunchServices. Directly spawning the executable inherits its launcher’s TCC responsibility, so it cannot give the helper independent system permissions. A launch lock serializes discovery and startup. Complete signed applications use immutable content-addressed directories: replacing an executable used by a live process would violate both path-lock ownership and code-signature integrity. Ad-hoc builds can require fresh grants when their code hash changes; a stable bundle identifier alone does not preserve those grants. Installed packages retain their distributed signatures.
The service runs as a signed application launched through LaunchServices. Directly spawning the executable inherits its launcher’s TCC responsibility, so it cannot give the helper independent system permissions. A launch lock serializes discovery and startup. Complete signed applications use immutable content-addressed directories: replacing an executable used by a live process would violate both path-lock ownership and code-signature integrity. Ad-hoc builds can require fresh grants when their code hash changes; a stable bundle identifier alone does not preserve those grants. Source checkouts can keep grants with the [development signing identity](2026-10-29-cua-dev-signing-identity.md). Installed packages retain their distributed signatures.
Each Agent connection owns its application handles, AX snapshots, limits and cancellation state. An app handle grants no exclusive use of the application. A FIFO orders complete desktop operations, including clipboard transactions, while response writes are independent. Agent disposal retires only its connection. The [object API decision](2026-09-17-experimental-cua-object-api.md) retains evaluator independence and coordinate semantics; the [consent decision](2026-09-26-cua-application-consent.md) retains application authorization.
@@ -12,7 +12,7 @@ Status: implemented
同一用户的多个 Agent 和 Host 按可执行文件内容摘要共享原生服务。版本锁选出服务实例,规范化可执行路径上的另一把锁阻止发布在同一路径的不同构建同时运行。进程退出才释放锁。受管启动命令将服务生命期交给客户端连接,最后一条连接清理完成后服务退出。诊断用 stdin/stdout 入口也代理到同一个服务。
服务通过 LaunchServices 以已签名应用运行。直接启动可执行文件会继承启动器的 TCC 责任,无法让 helper 独立取得系统权限。启动锁串行处理发现与启动。完整签名应用保存在不可变的内容寻址目录中,替换运行中进程的可执行文件会破坏路径锁的归属与代码签名完整性。临时签名构建的代码哈希变化后可能需要重新授权,固定 Bundle ID 不能保证保留授权。安装版保留分发时的签名。
服务通过 LaunchServices 以已签名应用运行。直接启动可执行文件会继承启动器的 TCC 责任,无法让 helper 独立取得系统权限。启动锁串行处理发现与启动。完整签名应用保存在不可变的内容寻址目录中,替换运行中进程的可执行文件会破坏路径锁的归属与代码签名完整性。临时签名构建的代码哈希变化后可能需要重新授权,固定 Bundle ID 不能保证保留授权。源码检出可以用[开发签名身份](2026-10-29-cua-dev-signing-identity.zh.md)保留授权。安装版保留分发时的签名。
每条 Agent 连接独立持有应用句柄、AX 快照、资源限制和取消状态。持有 app 句柄不代表独占应用。FIFO 队列串行执行包含剪贴板事务的完整桌面操作,响应写入独立进行。Agent 销毁只释放自己的连接。[对象接口决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责执行器独立性及坐标语义,[授权决策](2026-09-26-cua-application-consent.zh.md)继续负责应用许可。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-29-cua-dev-signing-identity.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.md
/agent-note-development-signing-identity-for-source-built-cua-helpers:
en: 472c4efd47184a32
zh: 49f64628531970e9
/agent-note-development-signing-identity-for-source-built-cua-helpers/problem:
en: a24857a312482724
zh: dbac75d363670e86
/agent-note-development-signing-identity-for-source-built-cua-helpers/decision:
en: d2265f9ec5fb7a79
zh: 05da2479b024a4bb
/agent-note-development-signing-identity-for-source-built-cua-helpers/alternatives-considered:
en: e30db5bc9c7aab43
zh: fa84883b5527334e
/agent-note-development-signing-identity-for-source-built-cua-helpers/consequences:
en: fb0e8fe44890bdf9
zh: d9e9ace1a4c10060
@@ -0,0 +1,35 @@
# Agent Note: Development signing identity for source-built CUA helpers
Status: implemented
English | [中文](2026-10-29-cua-dev-signing-identity.zh.md)
## Problem
Source checkouts compile `DSH Computer Use.app` with an ad-hoc signature. macOS stores Accessibility and Screen Recording grants for a bundle identifier with the designated requirement of the granted code, and an ad-hoc requirement is the code hash. Every changed build and every other worktree therefore needs new grants, and these builds shared the release helper's bundle identifier and replaced its grant entry.
## Decision
The Host builds source checkouts as a development variant with bundle identifier `com.deepseek.harness.computer-use.dev` and name `DSH Computer Use Dev`, in `DSH Computer Use Dev.app`. The macOS privacy list and the helper’s reported launch application use the bundle filename, so that filename also includes Dev. The release variant and its packaging are unchanged. Grants stay with the helper application launched through LaunchServices, as the [shared helper decision](2026-10-09-cua-shared-native-helper.md) requires.
`sourceSigning` belongs to the macOS provider and accepts only default `ad-hoc` or `{ identity, keychain }` selecting an existing certificate and absolute keychain path. Builds do not create, unlock or remove identities. Developer credentials stay separate from the user-facing DSH home, which stores only the certificate selection; the shared `cua` package owns no macOS signing configuration.
The [developer signing skill](../../../skills/dsh-cua-macos-signing/SKILL.md) guides Agents through creating, reusing and unlocking an isolated identity under `~/.cua-macos-keys/`, then selecting its fingerprint and keychain path in user configuration. The tool stores a random password in a `0600` file, permits codesign access, and adds the dedicated keychain to the user search list. A file lock serializes setup and removal for that directory; a missing keychain with an existing record is preserved and rejected. A directory marker limits identity management to explicitly claimed, initially empty directories. Unexpected files or symlinked identity entries stop setup and removal before changes, so a developer-selected path cannot erase unrelated contents.
Certificate signatures embed a signing time even without a timestamp server. Development publication records which generation each unsigned bundle and identity produced, then selects that generation again instead of re-signing, so identical sources keep one executable digest and one shared service. The cache uses certificate fingerprints; explicit certificate names are not reused because renewal can change the certificate behind a name. Signing failures stop publication. Developer setup diagnostics omit passwords and complete command arguments. The build CLI defaults to `--identity -` and accepts an existing certificate with `--identity` and optional `--keychain` for release packaging.
## Alternatives considered
**Grant the launching terminal.** Launching the service directly makes macOS attribute permissions to the terminal or editor that started DSH. Every program that application runs, including commands run by the model, then has the permissions.
**Automatically create a fixed identity during source builds.** This makes ordinary startup create developer private keys and ties their lifetime to user data. An explicit developer workflow preserves the option to reduce repeated grants while keeping identity creation, removal and selection under developer control.
**Self-signed identity in the login keychain.** The login keychain is already unlocked and on the search list, but allowing `codesign` to use a new key there without a dialog requires the user's login password.
**Add the keychain to the search list only while signing.** The release packaging script does this for one invocation. Concurrent builds in several worktrees would read and restore the user list over each other, and a stopped build would leave the entry behind anyway.
**A stable launcher that spawns each worktree's helper.** Grants would follow the launcher, but any local process could ask it to run an arbitrary executable with those grants, which is the same exposure as the stored key without a signature check.
## Consequences
Development builds signed by the same certificate share a signing requirement; actual permission retention after grants, rebuilds and worktree changes still requires verification. Other processes of the same user can read the stored password and sign with that identity; the release helper has a different bundle identifier. Deleting DSH home removes its selection, not the developer identity; deleting the identity requires clearing every configuration that references it. The real-signing e2e checks reuse, unlocking, requirements across output directories and search-list restoration after removal.
@@ -0,0 +1,35 @@
# Agent Note: 源码构建的 CUA helper 使用开发签名身份
Status: implemented
[English](2026-10-29-cua-dev-signing-identity.md) | 中文
## 问题
源码检出用临时签名编译 `DSH Computer Use.app`。macOS 按 Bundle ID 和被授权代码的指定要求保存辅助功能与屏幕录制授权,临时签名的指定要求就是代码哈希。因此每次构建内容变化、每换一个 worktree,都需要重新授权;这些构建还与正式 helper 共用 Bundle ID,会替换正式版的授权条目。
## 决策
Host 以开发版构建源码检出,Bundle ID 为 `com.deepseek.harness.computer-use.dev`,名称为 `DSH Computer Use Dev`,应用包文件名为 `DSH Computer Use Dev.app`。macOS 隐私列表和 helper 返回的启动应用名称使用应用包文件名,因此文件名也需包含 Dev。正式版及其打包流程不变。按[共享 helper 决策](2026-10-09-cua-shared-native-helper.zh.md)的要求,授权仍归经 LaunchServices 启动的 helper 应用。
`sourceSigning` 由 macOS provider 定义,只接受默认的 `ad-hoc` 和指定已有证书及钥匙串绝对路径的 `{ identity, keychain }`。构建不创建、解锁或移除身份。开发者凭据与面向用户的 DSH home 分开,后者只保存选择证书的配置;共享 `cua` 包不持有 macOS 签名配置。
[开发者签名 skill](../../../skills/dsh-cua-macos-signing/SKILL.md)指导 Agent 在 `~/.cua-macos-keys/` 创建、复用和解锁独立身份,将证书指纹和钥匙串路径写入用户配置。工具把随机密码存入 `0600` 文件,允许 codesign 访问私钥,并把专用钥匙串加入用户搜索列表。文件锁让同一目录的初始化和移除串行执行;已有记录但钥匙串缺失时保留记录并报错。目录标记将身份管理限制在明确占用的初始空目录中。发现无关文件或身份文件软链接时,初始化和移除都会在修改前停止,避免开发者指定的路径导致无关内容被删除。
即使不使用时间戳服务器,证书签名也包含签名时间。开发构建记录每份未签名内容与身份对应的已签名目录,再次构建相同内容时复用该目录,保持可执行文件摘要和共享服务一致。缓存使用证书指纹;按证书名称选择时每次签名,避免续期后复用旧证书。签名失败会中止发布。开发者工具的诊断不包含密码或完整命令参数;构建 CLI 默认 `--identity -`,支持指定证书,正式打包也可省略 `--keychain`。
## 考虑过的替代方案
**把权限交给启动终端。** 直接启动服务时,macOS 把权限归属到启动 DSH 的终端或编辑器。该应用运行的所有程序,包括模型执行的命令,都会带上这些权限。
**源码构建自动创建固定身份。** 这会让普通启动生成开发者私钥,并把身份生命周期绑定到用户数据目录。显式开发者操作保留了减少重新授权的选择,也让证书的创建、移除和使用范围由开发者决定。
**把自签名身份放进登录钥匙串。** 登录钥匙串已解锁且在搜索列表中,但要让 `codesign` 不弹窗地使用其中的新私钥,需要用户的登录密码。
**只在签名期间把钥匙串加入搜索列表。** 正式打包脚本对单次调用这样处理。多个 worktree 并发构建时会互相覆盖读取和恢复的列表,构建被中断时条目也会残留。
**用固定的启动器运行各 worktree 的 helper。** 授权会跟随启动器,但任何本机进程都能让它带着这些授权运行任意可执行文件,暴露面与保存的私钥相同,还少了签名检查。
## 后果
同一证书签出的开发构建共用签名要求;授权后重建和跨 worktree 的实际权限保持仍需验证。同一用户的进程可读取保存的密码并使用该身份签名,正式 helper 使用不同的 Bundle ID。删除 DSH home 只移除配置,不删除开发者身份;删除该身份后应清理所有引用它的配置。真实签名 e2e 验证复用、解锁、不同输出目录的签名要求和移除后的搜索列表恢复。
@@ -12,8 +12,8 @@
en: 47b44ed9313c2ccf
zh: 7ad254f8ef88351d
/agent-note-subagent-end-turn-guidance-stays-in-the-delegation-paragraph/alternatives-considered:
en: d52fa1af379dea5b
zh: 4e404994f7a607dc
en: 3153c1e7563a3357
zh: 9841db2b0156075b
/agent-note-subagent-end-turn-guidance-stays-in-the-delegation-paragraph/verification:
en: be076216381ea64d
zh: 2da8d9819c20e2fe
@@ -29,7 +29,7 @@ The sentence stays one literal line. Rewording `the next result starts a new tur
**Move the guidance into the tool description.** The paragraph is assembled from the set of visible delegation tools and lists their names; the tool description describes one tool. Moving the sentence changes ownership without changing the behavior it prevents.
**Restore every prompt phrase reverted together with this sentence.** The read guidance and the `run_code` argument order are separate decisions with their own evidence; this note records only the delegation paragraph.
**Restore every prompt phrase reverted together with this sentence.** The read guidance ([2026-10-26 note](2026-10-26-read-guidance-line-ranges.md)) and the `run_code` argument order are separate decisions with their own evidence; this note records only the delegation paragraph.
## Verification
@@ -29,7 +29,7 @@ Status: implemented
**把说明移进工具描述。** 这段指导按可见委派工具集合组装并列出工具名,工具描述只描述单个工具。移动这句会改变归属,但不改变它阻止的行为。
**把与这句一起被回退的所有提示词都恢复。** read 指导与 `run_code` 参数顺序是各自有证据的独立决定;本记录只覆盖委派段。
**把与这句一起被回退的所有提示词都恢复。** read 指导([2026-10-26 note](2026-10-26-read-guidance-line-ranges.zh.md))与 `run_code` 参数顺序是各自有证据的独立决定;本记录只覆盖委派段。
## 验证
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-26-read-guidance-line-ranges.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.md
/agent-note-read-guidance-names-shell-readers-and-line-ranges:
en: 1926710614598539
zh: b0106d4a6a2d648d
/agent-note-read-guidance-names-shell-readers-and-line-ranges/problem:
en: 4189d516419d8d12
zh: 30d8496064dd44e3
/agent-note-read-guidance-names-shell-readers-and-line-ranges/decision:
en: 34f18307f7ad87af
zh: 21c466c6ce6c598d
/agent-note-read-guidance-names-shell-readers-and-line-ranges/alternatives-considered:
en: aea7ffb1159ab81a
zh: 4842ca79dc30654d
/agent-note-read-guidance-names-shell-readers-and-line-ranges/verification:
en: cbc16bfba4abe862
zh: 9a71e6c020c01f69
/agent-note-read-guidance-names-shell-readers-and-line-ranges/consequences:
en: cf55161ea197a7a9
zh: fa10ca413f8fd726
@@ -0,0 +1,35 @@
# Agent Note: Read guidance names shell readers and line ranges
Status: implemented
English | [中文](2026-10-26-read-guidance-line-ranges.zh.md)
## Problem
The read guidance named only `cat` as the shell command the read tool replaces, and described `offset`/`limit` as continuing a large file rather than reading a line range. #6057 recorded a long Session reading ranges with `sed -n X,Yp` 60 times. #6054 added `sed`, `head`, `tail`, and the line-range wording; a later prompt revert removed them together with unrelated changes.
The 2026-10-23 evaluation of the SDK profile (native tools, six benchmarks) measured this sentence as the only change: Pass@1 mean −0.03pp and Pass@3 mean +0.70pp over the five benchmarks that report Pass@3 (ProgramBench-New has none) versus the base commit, with mixed step and token deltas (ProgramBench-New −15.5 steps, Terminal-Bench-v4 +13.5 steps). The PTC mode runs did not include this variant; the section renders in every request where `read` is visible.
## Decision
Restore the #6054 wording, byte-identical: `Use the read tool — not shell commands like cat, sed, head, or tail — to inspect text files. Use offset and limit to read a line range or continue a large file.` The read tool and its guidance are owned by `@deepseek-ai/dsh-tool-fs`; write and edit guidance are unchanged.
## Alternatives considered
**Rely on the read tool schema.** The schema already documents `offset` and `limit`, but #6057 shows the model chose `sed` while that schema existed; the guidance is the only model-visible place that names `sed`, `head`, and `tail`.
**Restore every prompt phrase reverted together with this sentence.** The subagent end-turn sentence, the `run_code` argument order, the PTC syntax ownership, and the SDK persona are separate decisions; the first is already restored ([subagent end-turn note](2026-10-21-subagent-end-turn-guidance.md)), and the others carry their own evidence.
**Say "line range" without naming shell commands.** The observed failure was choosing shell commands, so the sentence names them.
## Verification
- `packages/fs/tool-fs/tests/tools.spec.ts` asserts the rendered guidance text.
- Every recorded `system-prompt*.expected.md` sidecar that renders the read guidance carries the restored sentence: `test:snapshot` replays the session and SDK set, and `pnpm run test:web:ci` replays the `snapshots/web` set (the `node 24 / snapshots and artifacts` lane runs both). Two pinned historical sidecars (`web/cordis-tool-round`, `web/schedule-catalog`) retain their recorded text.
- The evaluation above measured this single change on the native SDK profile.
## Consequences
- Every request with the read tool carries three more command names and a line-range clause, about 10 tokens.
- The model is directed to the read tool for line ranges instead of `cat`, `sed`, `head`, or `tail`.
- The benchmark measured this sentence as neutral; keeping it rests on the #6057 production observation, not on a measured quality gain.
@@ -0,0 +1,35 @@
# Agent Note: read 指导点名 shell 读取命令与行区间
Status: implemented
[English](2026-10-26-read-guidance-line-ranges.md) | 中文
## 问题
read 指导只把 `cat` 点名为 read 工具替代的 shell 命令,并把 `offset`/`limit` 描述成继续读大文件,而不是读行区间。#6057 记录了一个长 Session 用 `sed -n X,Yp` 读行区间 60 次。#6054 增加了 `sed`、`head`、`tail` 和行区间措辞;后来的一次提示词回退把它们和不相关的改动一起删掉了。
2026-10-23 对 SDK profile(native 工具、六个 benchmark)的评测里,这句是唯一改动:相对 base commit,Pass@1 均值 −0.03pp、Pass@3 均值 +0.70pp(该均值取报告 Pass@3 的五个 benchmark;ProgramBench-New 没有 Pass@3),步数与 token 变化方向不一(ProgramBench-New −15.5 步、Terminal-Bench-v4 +13.5 步)。PTC 模式的运行没有包含这个变体;只要 `read` 可见,这段就会进入每个请求。
## 决策
逐字恢复 #6054 的措辞:`Use the read tool — not shell commands like cat, sed, head, or tail — to inspect text files. Use offset and limit to read a line range or continue a large file.` read 工具及其指导归 `@deepseek-ai/dsh-tool-fs` 所有;write 与 edit 的指导不变。
## 考虑过的替代方案
**只依赖 read 工具 schema。** schema 已经写明 `offset` 与 `limit`,但 #6057 显示在那个 schema 存在时模型仍选择 `sed`;指导是唯一点名 `sed`、`head`、`tail` 的模型可见位置。
**把与这句一起被回退的所有提示词都恢复。** subagent 结束本轮句、`run_code` 参数顺序、PTC 语法归属、SDK persona 是各自独立的决定;第一项已经恢复([subagent 结束本轮 note](2026-10-21-subagent-end-turn-guidance.zh.md)),其余各有自己的证据。
**只说“行区间”,不点名 shell 命令。** 观测到的失败就是模型选择 shell 命令,所以这句点名它们。
## 验证
- `packages/fs/tool-fs/tests/tools.spec.ts` 断言渲染出的指导文本。
- 所有渲染 read 指导的 `system-prompt*.expected.md` sidecar 都带有恢复后的句子:`test:snapshot` 回放 session 与 SDK 集合,`pnpm run test:web:ci` 回放 `snapshots/web` 集合(`node 24 / snapshots and artifacts` lane 两条都跑)。两个显式 pin 的历史 sidecar(`web/cordis-tool-round`、`web/schedule-catalog`)保留录制文本。
- 上面的评测在 native SDK profile 上只改了这一句进行测量。
## 影响
- 只要 read 工具存在,每个请求多三个命令名和一句行区间说明,约 10 token。
- 模型读行区间时被引导到 read 工具,而不是 `cat`、`sed`、`head`、`tail`。
- benchmark 上这句测得为中性;保留它依据的是 #6057 的生产观测,而不是测到的质量收益。
@@ -19,7 +19,7 @@
zh: 993378f78f5a4120
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/two-driving-tiers:
en: 0b9303333bf482ae
zh: 6dfc68ad9e2924c3
zh: 8a2ffe660bbe8730
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/default-visibility:
en: 754705367c350b31
zh: e10b6541458c4a45
@@ -32,7 +32,7 @@ owner 在 DSH 里为某一个会话创建分享。分享发布到 Liveshare 服
每个分享带一个 `ViewerSessionMode`,只有两个值,且两档都会驱动共享会话:
- **view** —— 读者通过普通 prompt 路径驱动共享会话的 loop,在授予工作区的只读沙箱里运行,因此会话的工具绝不写文件。读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)都限制在授权范围内:工具运行前检查其声明的每个路径,无法检查的调用一律拒绝。
- **view** —— 读者通过普通 prompt 路径驱动共享会话的 loop,在授予工作区的只读沙箱里运行,因此会话的工具绝不写文件。读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)都限制在授权范围内:工具运行前检查其声明的每个路径,无法检查的调用一律拒绝。两档也都准入 PTC 传输工具 `run_code`:程序运行在所有者自己的常驻文件策略下而不是分享档位下,因此可以触碰授权范围之外的文件,而其嵌套调用仍保留同样的守卫与路径检查;分享对话框会就此向所有者提示。
- **write** —— 读者在受宿主写入上限约束的可写沙箱里驱动同一个 loop,沙箱也会修改授予工作区内的文件。
沙箱安装在会话上,但只约束分享查看者发起的轮次:`agent/pre-step` 记录一个已准入轮次的进入输入是否带有 share-viewer principal,守卫与替代定义对该轮次的每次执行读取这一结论。所有者自己发起的轮次保留会话自己的工具;所有者在查看者轮次中追加的输入仍留在该受限轮次内。该标记只存在于 Host 内存,按轮次重算,不写入会话日志。所有分享停止或全部过期后执行层被释放,会话恢复自己的工具。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-11-02-liveshare-plugin-method-catalog.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md
/agent-note-liveshare-plugin-method-catalog:
en: 1daaf32c7ea8d3cd
zh: 70a94350263d22c9
/agent-note-liveshare-plugin-method-catalog/problem:
en: 2122ac5fdd75f900
zh: dc135aaa0d1dddc0
/agent-note-liveshare-plugin-method-catalog/decision:
en: 6e0b897ba6460894
zh: f05866ccde01a5af
/agent-note-liveshare-plugin-method-catalog/alternatives-considered:
en: 57896311cada7804
zh: 01fbd1cbd05353ef
/agent-note-liveshare-plugin-method-catalog/consequences:
en: e2069a5f9801f767
zh: 0698f0b9e1e49201
@@ -0,0 +1,23 @@
# Agent Note: LiveShare plugin method catalog
Status: implemented
English | [中文](2026-11-02-liveshare-plugin-method-catalog.zh.md)
## Problem
LiveShare used to expose plugin endpoints through a declaration API. The API let a plugin name arbitrary endpoints, so a catalog mistake could widen a viewer's reach without a corresponding Gateway check. The replacement needs one source of truth that says which plugin methods a share may expose.
## Decision
The catalog is enumerated from the Typert local registry at runtime. One entry is emitted per bundle, using the bundle's localized title when available and the component package title otherwise. Endpoint descriptions come from the registered Remote descriptors. A share stores only method-level policies: the Host intersects each policy with the catalog before the Gateway dispatches a call.
The catalog excludes the DSH core bundles. Their methods remain governed by the existing Session and Workspace sharing roles, not by plugin permissions. The core bundle names are derived from `PROFILE_TEMPLATES`, so the exclusion follows the shipped profile composition instead of a second hand-maintained list. LiveShare's own bundle is excluded because it is the sharing implementation, not a capability a share can grant itself.
## Alternatives considered
Keeping the declaration API would leave two sources of truth: a plugin-declared endpoint and the Gateway's own classification. A hand-maintained allowlist in LiveShare would drift from the shipped profile templates. Deriving the exclusion from the actual profile templates keeps the catalog aligned with composition without a second list.
## Consequences
A method that is not in the Typert local registry cannot be selected or called through plugin permissions. A plugin that uses an owner-only exact route is absent from the catalog; the viewer sees an `owner-route-not-shared` notice when it calls that route. Adding a shareable method is therefore a plugin-side registration decision, not a LiveShare catalog edit.
@@ -0,0 +1,23 @@
# Agent Note:LiveShare 插件方法目录
Status: implemented
[English](2026-11-02-liveshare-plugin-method-catalog.md) | 中文
## 问题
LiveShare 过去通过声明 API 暴露插件端点。该 API 允许插件声明任意端点,因此目录写错时,viewer 的可达范围会在没有对应 Gateway 校验的情况下被扩大。替代方案需要一个唯一事实来源,明确一条分享可以暴露哪些插件方法。
## 决定
目录在运行时从 Typert 本地注册表枚举。每个 bundle 生成一行,优先使用 bundle 的本地化标题,没有时使用组件包标题;端点说明来自已注册的 Remote descriptor。分享记录只保存方法级策略,Host 在 Gateway 派发前把策略与目录取交集。
目录排除 DSH 核心 bundle。它们的方法仍由原有的 Session 和 Workspace 分享角色控制,而不是插件权限。核心 bundle 名从 `PROFILE_TEMPLATES` 派生,使排除规则跟随实际下发的 profile 组合,而不是再维护一份手写名单。LiveShare 自身的 bundle 也被排除,因为它是分享实现,不是分享可以授予自己的能力。
## 备选方案
保留声明 API 会留下两个事实来源:插件声明的端点和 Gateway 自己的分类。由 LiveShare 维护手写白名单又会与实际下发的 profile 模板漂移。从真实 profile 模板派生排除规则,让目录跟随组合而不需要第二份名单。
## 影响
不在 Typert 本地注册表中的方法无法被插件权限选中或调用。使用 owner-only exact route 的插件不会出现在目录中;viewer 调用该路由时会看到 `owner-route-not-shared` 提示。因此新增可分享方法是插件侧的注册决定,而不是修改 LiveShare 目录。
@@ -21,11 +21,11 @@
en: f07bb6fbbdd01fe4
zh: 9313a0ef2a22af12
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate:
en: 37faaffb4662fd54
zh: df0a4bf016393be9
en: 3ceb80f455e9a47d
zh: 89d8bd11b42d31b0
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered:
en: 559a8037caa9e3aa
zh: 4f3719ed09445cc2
en: b5bfb4cfa231ce0f
zh: 631b9f2978241bc1
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences:
en: 8f9d25b72d3e7660
zh: e5c6ae0194905876
@@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md)
| Item | Content |
|---|---|
| Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Absolute time budgets use each side's minimum sample mean, with the mean process CPU time from that sample. Doubling checks take the median of the five within-round ratios, keeping the adjacent samples paired through aggregation. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval |
| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character |
| Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report |
@@ -54,7 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi
## Alternatives considered
- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the existing sample counts, minimum-of-five aggregation, GC inclusion, workloads, and limits. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion.
- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the sample counts, GC inclusion, workloads, and limits. Independent minima can still select rounds at different speeds; the median of paired ratios prevents one faster half-sized sample from deciding the scaling result. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion.
- **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit.
- **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state.
- **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure.
@@ -43,7 +43,7 @@ Status: implemented
| 项 | 内容 |
|---|---|
| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。绝对耗时预算取两侧样本均值的最小值,并保留对应样本的平均进程 CPU 时间。翻倍检查取五个同轮次耗时比的中位数,使相邻样本在汇总时仍保持配对。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC |
| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 |
| 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
@@ -54,7 +54,7 @@ Status: implemented
## 考虑过的替代方案
- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留既有样本数、五组取最小值的汇总方式、GC 计入规则、负载与上限。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。
- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留样本数、GC 计入规则、负载与上限。独立选取最小值仍可能选中运行速度不同的轮次;配对比值的中位数避免单个更快的小输入样本决定翻倍结果。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。
- **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。
- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。
- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。
@@ -0,0 +1,78 @@
---
name: dsh-cua-macos-signing
description: Proactively recommend a personal fixed signing key to macOS DSH developers using Computer Use from source, rebuilding the helper, or switching worktrees, even before they ask about repeated permissions. After they choose it, generate or reuse the key outside DSH home and configure its path. Also handles unlocking and removal; installed release users do not need this setup.
---
# macOS CUA development signing
## Recognize developer use and recommend signing
When a macOS developer uses DSH Computer Use from a source checkout, proactively recommend a reusable personal signing identity. Relevant signals include launching DSH from source, rebuilding the helper, preparing another worktree, or investigating repeated permission prompts. The developer does not need to be editing CUA code, and does not need to ask about certificates first. Before a cross-worktree permission-retention test, establish that both builds select the same fixed identity.
Use the task context and available configuration to choose the recommendation. If an identity is already configured, recommend reusing it and unlock it when needed; do not propose another key for each worktree. If signing is unset or `ad-hoc`, briefly explain that code-hash changes can require fresh grants, recommend creating one local identity under `~/.cua-macos-keys/`, and offer to configure `cordis.patch.yml` to select it. Mention that the first macOS grant remains manual. Follow the authorization rule below before creating keys or editing configuration; a recommendation alone does not authorize setup.
For example: “Since you are using Computer Use from source and switching worktrees, I recommend creating one personal signing identity and reusing it across builds to reduce repeated authorization. I can generate it under `~/.cua-macos-keys/` and configure this DSH home to use it. You will still grant macOS permissions once.” Adapt the wording to the user's language and task.
Do not recommend developer keys merely because a user is using the installed DSH app, working on an unrelated repository, or developing without Computer Use. If they choose to keep ad-hoc signing, continue their task and do not repeat the recommendation without a relevant change in circumstances.
## Understand the signing choices
Source builds default to `sourceSigning: ad-hoc`. They need no developer certificate. An ad-hoc designated requirement includes the code hash, so changed binaries, clean rebuilds, or different worktrees can require fresh Accessibility and Screen Recording grants even when their sources are unchanged. For developers who want fewer repeated grants, configure one fixed certificate and use it across builds. The first grant is still manual; verify actual permission retention rather than claiming that matching signatures alone prove it.
The [macOS provider](../../../packages/experimental/cua-macos/README.md#use-this-package) owns the two configuration choices: `ad-hoc`, or `{ identity, keychain }`. The build consumes an existing certificate; it never creates or unlocks one. Developer key material stays outside DSH home. DSH home stores only the configuration selecting it.
## Create or reuse an identity
Run from a repository checkout on macOS with the selected Apple command-line tools. An explicit request to set up developer signing or reduce repeated development grants authorizes setup and the corresponding local configuration edit. For advice-only requests, explain the choices without creating an identity.
```sh
python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py setup
```
The script creates or reuses `~/.cua-macos-keys/dev/signing.keychain-db`, stores its password and certificate fingerprint in the adjacent `identity.json` with mode `0600`, and keeps the directory private. It adds only its dedicated keychain to the user search list and enables `codesign` access without prompting for the login password. A sibling `dev.lock` serializes setup and removal across worktrees; custom directory names keep their full name before `.lock`. Supply `--directory <absolute identity directory>` when the developer chose another location outside DSH home and the checkout.
Setup claims only a new or empty directory and writes `.dsh-cua-macos-signing` as its ownership marker. Setup and removal refuse directories without that marker, unexpected entries, or symlinked identity files before changing keychains or identity files. Preserve unmarked identities from earlier tooling; do not add a marker automatically. They can still be selected as existing user keychains.
Setup prints only the certificate fingerprint and absolute keychain path as JSON. Keep the password record out of chat, logs, commits, and configuration. Other processes running as this user can read the stored password and use this development identity; this is the local developer setup's access model.
If an existing record is damaged or its keychain/certificate is missing, setup stops. Do not silently replace that identity: replacement changes the certificate and can require new grants. Follow the reported remove/setup commands only when identity replacement is authorized.
## Configure the source build
Resolve the DSH home used by the developer (`DSH_HOME`, otherwise `~/.dsh`). Merge the two printed values into its `cordis.patch.yml`, targeting the entry that loads `@deepseek-ai/dsh-experimental-cua-macos`. The shipped entry ID is `cua`:
```yaml
- id: cua
config:
sourceSigning:
identity: "<fingerprint printed by setup>"
keychain: "/Users/<user>/.cua-macos-keys/dev/signing.keychain-db"
```
Use the printed absolute path, not a literal `~`. Preserve unrelated patches and every existing field in this entry's effective config: a Cordis patch replaces the complete `config` object rather than deep-merging it. If the provider has `helperPath`, source signing is bypassed; remove that override only when the developer intends to test the source-built helper. Ensure the profile actually loads the provider before adding a patch for its ID.
Launch through the developer's existing `dsh` profile. The home-level patch shares the selection across profiles and worktrees that use that home. Other DSH homes can explicitly select the same certificate. Record the paths changed and the selected public fingerprint, never the password.
## Unlock and verify
After a restart or a manual keychain lock, rerun `setup`; it unlocks and reuses the existing identity. Do not ask the developer for the login-keychain password or store one. A source build does not automatically unlock the keychain and can fail or prompt if it is locked.
Grant Accessibility and Screen Recording to `DSH Computer Use Dev.app` in System Settings. Call `getCapabilities`, change and rebuild the Swift helper, then test another worktree with the same configuration. Report the observed permission flags for each step. Matching `codesign -d -r- <application>` requirements confirm signature continuity but do not replace the permission checks.
For a temporary, permission-free signing mechanism check, run:
```sh
python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py
```
It creates an isolated identity, tests reuse and unlocking, signs changed binaries, then removes its keychain and checks search-list restoration. Run it without concurrent keychain-list mutations.
## Remove an identity
First remove the selecting configuration or set `sourceSigning: ad-hoc` in each affected home. For an authorized removal, run:
```sh
python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py remove
```
Use the same `--directory` for a custom location. Removal deletes only the marked identity’s files and its search-list entry, then removes the empty directory; the empty sibling lock remains so waiting processes still coordinate. Deleting DSH home alone leaves the developer identity intact. System Settings permission entries are separate; remove them only when requested.
@@ -0,0 +1,77 @@
"""Real development signing keeps one designated requirement across changed builds.
Creates a temporary development identity, adds its keychain to the user search list for the duration
of the check, and removes both afterwards. No system permission or password dialog is requested.
"""
import importlib.util
import json
from pathlib import Path
import plistlib
import subprocess
import sys
import tempfile
NATIVE = Path(__file__).resolve().parents[4] / "packages/experimental/cua-macos/native"
def module(name):
spec = importlib.util.spec_from_file_location(name, NATIVE / "build_app.py" if name == "build_app" else Path(__file__).with_name("signing_identity.py"))
loaded = importlib.util.module_from_spec(spec)
spec.loader.exec_module(loaded)
return loaded
def requirement(application):
output = subprocess.run(["/usr/bin/codesign", "-d", "-r-", str(application)], check=True, capture_output=True, text=True)
return next(line.split("=> ", 1)[1] for line in (output.stdout + output.stderr).splitlines() if "designated =>" in line)
def main():
builder, signing = module("build_app"), module("dev_signing")
before = signing.user_keychains()
with tempfile.TemporaryDirectory(prefix="cua-dev-signing-e2e-") as temporary:
root = Path(temporary)
directory = root / "keys/dev"
try:
command = [sys.executable, str(Path(__file__).with_name("signing_identity.py")), "setup", "--directory", str(directory)]
configuration = json.loads(subprocess.run(command, check=True, capture_output=True, text=True).stdout)
identity = signing.setup(directory)
assert configuration == {"identity": identity.sha1, "keychain": str(identity.keychain.resolve())}
assert signing.setup(directory) == identity
sha1, keychain = identity.sha1, identity.keychain
assert (directory / "identity.json").stat().st_mode & 0o777 == 0o600
signing.security("lock-keychain", str(keychain))
assert signing.setup(directory) == identity, "setup must unlock an existing developer keychain"
requirements = []
for status in (0, 1):
(root / "main.c").write_text(f"int main(void) {{ return {status}; }}\n")
subprocess.run(["/usr/bin/xcrun", "clang", "-o", str(root / "helper"), str(root / "main.c")], check=True)
executable = builder.publish(root / "helper", root / f"checkout-{status}", sha1, NATIVE / "Info.plist", "dev", keychain)
application = executable.parents[2]
assert application.name == "DSH Computer Use Dev.app", application
subprocess.run(["/usr/bin/codesign", "--verify", "--strict", str(application)], check=True)
info = plistlib.loads((application / "Contents/Info.plist").read_bytes())
assert info["CFBundleIdentifier"] == "com.deepseek.harness.computer-use.dev", info
requirements.append((application, requirement(application)))
(first, first_requirement), (second, second_requirement) = requirements
assert first != second, "changed builds must publish separate generations"
assert first_requirement == second_requirement, requirements
assert "certificate leaf" in first_requirement and "cdhash" not in first_requirement, first_requirement
subprocess.run(["/usr/bin/codesign", "--verify", f"-R={first_requirement}", str(second)], check=True)
selected = json.loads((root / "checkout-1/current.json").read_text())["bundle"]
assert second.parent.name == selected
for index, (certificate, selected_keychain) in enumerate(((sha1, keychain), (signing.COMMON_NAME, keychain))):
custom = builder.publish(root / "helper", root / f"custom-{index}", certificate, NATIVE / "Info.plist", "dev", selected_keychain)
assert requirement(custom.parents[2]) == first_requirement
print(f"PASS: developer setup reuses and unlocks its identity; configured signatures across changed builds share {first_requirement}")
finally:
try:
signing.remove(directory)
finally:
# This fixture owns the temporary directory even if ownership validation fails.
signing.clear_identity(directory)
assert signing.user_keychains() == before, "the user keychain search list was not restored"
if __name__ == "__main__":
main()
@@ -0,0 +1,258 @@
"""Manage an explicitly requested developer identity outside DSH home.
macOS keeps Accessibility and Screen Recording grants for a bundle identifier together with the
designated requirement of the signature that was granted. An ad-hoc signature's requirement is the
code hash, so every changed build loses the grant. A certificate signature's requirement names the
bundle identifier and the certificate, preserving the requirement across changed builds.
The identity lives in a dedicated keychain whose password is stored beside it with owner-only
permissions, so developer setup can unlock it without a system dialog. Any process running as the same
user can read that password and sign code that inherits the development grants; the grants belong
to the development bundle identifier only, never to the release helper.
"""
import argparse
from contextlib import contextmanager
from dataclasses import dataclass
import fcntl
import json
import os
from pathlib import Path
import re
import secrets
import shlex
import subprocess
import sys
import tempfile
COMMON_NAME = "DSH Computer Use Dev (local)"
OWNER_MARKER = ".dsh-cua-macos-signing"
OWNER_VERSION = "1\n"
SECURITY = "/usr/bin/security"
OPENSSL = "/usr/bin/openssl"
SETUP_COMMAND = "python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py setup"
REMOVE_COMMAND = "python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py remove"
CERTIFICATE_CONFIG = f"""[req]
distinguished_name = subject
x509_extensions = extensions
prompt = no
[subject]
CN = {COMMON_NAME}
[extensions]
basicConstraints = critical,CA:false
keyUsage = critical,digitalSignature
extendedKeyUsage = critical,codeSigning
"""
@dataclass(frozen=True)
class SigningIdentity:
"""An unlocked certificate identity that codesign can use without user interaction."""
sha1: str
keychain: Path
def private_command(command, **kwargs):
"""Run signing tools without exposing secret arguments or their echoed output in diagnostics."""
operation = f"{Path(command[0]).name} {command[1]}"
try:
return subprocess.run(command, check=True, capture_output=True, text=True, timeout=60, **kwargs).stdout
except subprocess.CalledProcessError as error:
raise RuntimeError(f"{operation} failed (exit {error.returncode})") from None
except subprocess.TimeoutExpired:
raise RuntimeError(f"{operation} timed out after 60 seconds") from None
except OSError as error:
raise RuntimeError(f"{operation} could not run (errno {error.errno})") from None
def security(*args):
return private_command([SECURITY, *args])
@contextmanager
def identity_lock(directory):
"""Serialize setup and removal across worktrees; the lock inode survives identity removal."""
directory.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
with os.fdopen(os.open(directory.parent / f"{directory.name}.lock", os.O_WRONLY | os.O_CREAT, 0o600), "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
yield
def user_keychains():
"""Read the user keychain search list that codesign consults for identities."""
return [line.strip().strip('"') for line in security("list-keychains", "-d", "user").splitlines() if line.strip()]
def listed(keychain, keychains):
"""Compare canonical paths; security reports /private/var for /var and resolves other links."""
return any(os.path.realpath(path) == os.path.realpath(keychain) for path in keychains)
def record_path(directory):
return directory / "identity.json"
def keychain_path(directory):
return directory / "signing.keychain-db"
def default_directory():
"""Keep developer credentials independent of DSH profiles, homes, and worktrees."""
return Path.home() / ".cua-macos-keys" / "dev"
def owned_directory(directory, create=False):
"""Refuse unowned or mixed-content directories before changing files or keychains."""
marker = directory / OWNER_MARKER
if create:
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
if not any(directory.iterdir()):
directory.chmod(0o700)
with os.fdopen(os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as file:
file.write(OWNER_VERSION)
if not directory.exists():
return
if marker.is_symlink() or not marker.is_file() or marker.read_bytes() != OWNER_VERSION.encode():
raise RuntimeError(f"Refusing unowned signing directory: {directory}; choose a new or empty dedicated directory")
allowed = {OWNER_MARKER, record_path(directory).name, record_path(directory).with_suffix(".tmp").name, keychain_path(directory).name}
for entry in directory.iterdir():
# security creates empty .flXXXXXXXX locks and removes them with delete-keychain.
system_lock = re.fullmatch(r"\.fl[0-9A-Fa-f]{8}", entry.name) and entry.is_file() and entry.stat().st_size == 0
if (entry.name not in allowed and not system_lock) or entry.is_symlink() or not entry.is_file():
raise RuntimeError(f"Refusing signing directory with unrelated entries: {directory}; preserve those entries before retrying")
def load(directory):
"""Unlock the configured identity before signing.
Returns None when setup has not run. Raises when setup ran but its keychain is missing or no
longer on the search list, because signing would otherwise fail or prompt for a password.
"""
directory_argument = shlex.quote(str(directory))
setup_command = f"{SETUP_COMMAND} --directory {directory_argument}"
remove_command = f"{REMOVE_COMMAND} --directory {directory_argument}"
record = record_path(directory)
if not record.exists():
return None
try:
fields = json.loads(record.read_text())
valid = isinstance(fields, dict) and isinstance(fields.get("password"), str) and isinstance(fields.get("sha1"), str) and bool(fields["password"]) and bool(fields["sha1"])
except (ValueError, OSError):
valid = False
if not valid:
raise RuntimeError(f"Computer Use development signing record is invalid; run {remove_command}, then {setup_command}")
keychain = keychain_path(directory)
if not keychain.is_file():
raise RuntimeError(f"Computer Use development signing keychain is missing: {keychain}; run {remove_command}, then {setup_command}")
if not listed(keychain, user_keychains()):
raise RuntimeError(f"Computer Use development signing keychain is not on the user keychain search list; run {setup_command}")
# A locked keychain makes codesign show a password dialog, so unlock it explicitly every time.
security("unlock-keychain", "-p", fields["password"], str(keychain))
if fields["sha1"] not in security("find-identity", str(keychain)):
raise RuntimeError(f"Computer Use development signing certificate is missing; run {remove_command}, then {setup_command}")
return SigningIdentity(sha1=fields["sha1"], keychain=keychain)
def create_certificate(directory, password):
"""Create the key pair inside the dedicated keychain and return the certificate SHA-1."""
keychain = keychain_path(directory)
with tempfile.TemporaryDirectory(prefix=".certificate-", dir=directory) as temporary:
work = Path(temporary)
(work / "openssl.cnf").write_text(CERTIFICATE_CONFIG)
export_password = secrets.token_hex(16)
for command in (
["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "key.pem", "-out", "certificate.pem",
"-days", "3650", "-config", "openssl.cnf"],
["pkcs12", "-export", "-inkey", "key.pem", "-in", "certificate.pem", "-out", "identity.p12",
"-passout", f"pass:{export_password}"],
):
private_command([OPENSSL, *command], cwd=work)
fingerprint = private_command([OPENSSL, "x509", "-in", "certificate.pem", "-noout", "-fingerprint", "-sha1"], cwd=work)
security("create-keychain", "-p", password, str(keychain))
security("unlock-keychain", "-p", password, str(keychain))
# No arguments disables the inactivity timeout and lock on sleep.
security("set-keychain-settings", str(keychain))
security("import", str(work / "identity.p12"), "-k", str(keychain), "-P", export_password, "-T", "/usr/bin/codesign")
# Without a partition list, codesign asks the user to allow access to the private key.
security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, str(keychain))
match = re.search(r"=([0-9A-F:]+)", fingerprint)
if match is None:
raise RuntimeError("openssl returned no certificate fingerprint")
sha1 = match.group(1).replace(":", "")
if sha1 not in security("find-identity", str(keychain)):
raise RuntimeError("the imported development signing identity is not usable")
return sha1
def setup(directory):
"""Create an identity only without a record; preserve recorded identities when their keychain is missing."""
with identity_lock(directory):
return setup_locked(directory)
def setup_locked(directory):
owned_directory(directory, create=True)
keychain = keychain_path(directory)
if record_path(directory).exists():
if keychain.is_file():
keychains = user_keychains()
if not listed(keychain, keychains):
security("list-keychains", "-d", "user", "-s", *keychains, str(keychain))
return load(directory)
clear_identity(directory)
password = secrets.token_hex(32)
sha1 = create_certificate(directory, password)
security("list-keychains", "-d", "user", "-s", *user_keychains(), str(keychain))
record = record_path(directory)
with os.fdopen(os.open(record.with_suffix(".tmp"), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as file:
json.dump({"sha1": sha1, "password": password}, file)
file.write("\n")
os.replace(record.with_suffix(".tmp"), record)
return load(directory)
def remove(directory):
"""Delete the keychain, its search-list entry and the stored password."""
with identity_lock(directory):
remove_locked(directory)
def remove_locked(directory):
owned_directory(directory)
clear_identity(directory)
if directory.exists():
(directory / OWNER_MARKER).unlink()
directory.rmdir()
def clear_identity(directory):
"""Remove only the identity files and its exact keychain search-list entry."""
keychain = keychain_path(directory)
keychains = user_keychains()
if listed(keychain, keychains):
security("list-keychains", "-d", "user", "-s", *[path for path in keychains if not listed(keychain, [path])])
if keychain.exists():
security("delete-keychain", str(keychain))
record_path(directory).unlink(missing_ok=True)
record_path(directory).with_suffix(".tmp").unlink(missing_ok=True)
def main():
parser = argparse.ArgumentParser(description="Manage the local signing identity for source-built Computer Use applications.")
parser.add_argument("action", choices=["setup", "remove"])
parser.add_argument("--directory", type=Path, default=default_directory(), help="identity directory; defaults to ~/.cua-macos-keys/dev")
args = parser.parse_args()
directory = args.directory.expanduser().resolve()
if args.action == "setup":
identity = setup(directory)
print(json.dumps({"identity": identity.sha1, "keychain": str(identity.keychain)}, indent=2))
else:
remove(directory)
print("Removed the development signing identity; remove its sourceSigning configuration before the next build")
if __name__ == "__main__":
try:
main()
except RuntimeError as error:
print(error, file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,335 @@
"""Development signing setup keeps one unlocked identity on the user keychain search list."""
import contextlib
import io
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("dev_signing", Path(__file__).with_name("signing_identity.py"))
signing = importlib.util.module_from_spec(spec)
spec.loader.exec_module(signing)
class FakeSecurity:
"""Record security commands and model the user search list."""
def __init__(self, keychains):
self.keychains = list(keychains)
self.commands = []
def __call__(self, *args):
self.commands.append(args)
if args[:3] == ("list-keychains", "-d", "user") and len(args) == 3:
return "".join(f' "{path}"\n' for path in self.keychains)
if args[:4] == ("list-keychains", "-d", "user", "-s"):
self.keychains = list(args[4:])
if args[0] == "delete-keychain":
Path(args[1]).unlink()
if args[0] == "find-identity":
return "ABC123 NEW456"
return ""
class DevelopmentSigningTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(prefix="cua-dev-signing-")
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name).resolve()
self.directory = self.root / "signing"
self.keychain = self.directory / "signing.keychain-db"
self.login = "/Users/example/Library/Keychains/login.keychain-db"
def configure(self, listed=True):
self.directory.mkdir(mode=0o700)
(self.directory / signing.OWNER_MARKER).write_text(signing.OWNER_VERSION)
self.keychain.write_bytes(b"keychain")
(self.directory / "identity.json").write_text(json.dumps({"sha1": "ABC123", "password": "secret"}))
return self.fake([self.login, str(self.keychain)] if listed else [self.login])
def fake(self, keychains):
security = FakeSecurity(keychains)
patcher = patch.object(signing, "security", security)
patcher.start()
self.addCleanup(patcher.stop)
return security
def test_setup_and_remove_preserve_unowned_directory_contents(self):
security = self.fake([self.login])
self.directory.mkdir()
unrelated = self.directory / "notes.txt"
unrelated.write_text("keep this document")
for operation in (signing.setup, signing.remove):
with self.subTest(operation=operation.__name__):
with self.assertRaisesRegex(RuntimeError, "Refusing unowned signing directory"):
operation(self.directory)
self.assertEqual(unrelated.read_text(), "keep this document")
self.assertEqual(list(self.directory.iterdir()), [unrelated])
self.assertEqual(security.commands, [])
def test_unrelated_files_in_an_owned_directory_block_setup_and_removal(self):
security = self.configure()
unrelated = self.directory / "notes.txt"
unrelated.write_text("keep this document")
before = {p.name: p.read_bytes() for p in self.directory.iterdir()}
for operation in (signing.setup, signing.remove):
with self.subTest(operation=operation.__name__):
with self.assertRaisesRegex(RuntimeError, "unrelated entries"):
operation(self.directory)
self.assertEqual({p.name: p.read_bytes() for p in self.directory.iterdir()}, before)
self.assertEqual(security.commands, [])
def test_system_keychain_locks_are_removed_by_security(self):
security = self.configure()
lock = self.directory / ".flD3051040"
lock.touch()
def remove_keychain(*args):
result = security(*args)
if args[0] == "delete-keychain":
lock.unlink()
return result
with patch.object(signing, "security", side_effect=remove_keychain):
signing.remove(self.directory)
self.assertFalse(self.directory.exists())
def test_nonempty_files_with_a_system_lock_name_are_preserved(self):
security = self.configure()
lock = self.directory / ".flD3051040"
lock.write_text("unrelated contents")
for operation in (signing.setup, signing.remove):
with self.assertRaisesRegex(RuntimeError, "unrelated entries"):
operation(self.directory)
self.assertEqual(lock.read_text(), "unrelated contents")
self.assertEqual(security.commands, [])
def test_missing_or_invalid_marker_cannot_adopt_an_existing_identity(self):
security = self.configure()
marker = self.directory / signing.OWNER_MARKER
for contents in (None, "different tool", "\ufffd"):
if contents is None:
marker.unlink()
else:
marker.write_text(contents)
for operation in (signing.setup, signing.remove):
with self.assertRaisesRegex(RuntimeError, "unowned signing directory"):
operation(self.directory)
self.assertTrue(self.keychain.is_file())
self.assertEqual(security.commands, [])
def test_symlinked_identity_files_do_not_delete_another_directorys_data(self):
security = self.configure()
outside = self.root / "outside.keychain-db"
outside.write_bytes(b"other identity")
self.keychain.unlink()
self.keychain.symlink_to(outside)
for operation in (signing.setup, signing.remove):
with self.assertRaisesRegex(RuntimeError, "unrelated entries"):
operation(self.directory)
self.assertEqual(outside.read_bytes(), b"other identity")
self.assertEqual(security.commands, [])
def test_empty_directory_gets_an_ownership_marker_before_generation(self):
self.fake([self.login])
self.directory.mkdir()
with patch.object(signing, "create_certificate", side_effect=RuntimeError("generation interrupted")):
with self.assertRaisesRegex(RuntimeError, "generation interrupted"):
signing.setup(self.directory)
self.assertEqual((self.directory / signing.OWNER_MARKER).read_text(), signing.OWNER_VERSION)
signing.remove(self.directory)
self.assertFalse(self.directory.exists())
def test_removing_the_only_search_entry_leaves_no_unrelated_entries_to_restore(self):
security = self.configure()
security.keychains = [str(self.keychain)]
signing.remove(self.directory)
self.assertEqual(security.keychains, [])
self.assertFalse(self.directory.exists())
def test_dotted_identity_directories_keep_separate_lock_files(self):
directories = [self.root / name for name in ("dev", "dev.keys", "keys.v1", "keys.v2")]
for directory in directories:
with signing.identity_lock(directory):
pass
self.assertEqual({p.name for p in self.root.iterdir()}, {f"{p.name}.lock" for p in directories})
def test_default_identity_is_outside_dsh_home(self):
home = self.root / "user"
with patch.object(Path, "home", return_value=home):
for value in ("", str(self.root / "custom-dsh-home")):
with patch.dict(signing.os.environ, {"DSH_HOME": value}):
self.assertEqual(signing.default_directory(), home / ".cua-macos-keys/dev")
self.assertFalse(home.exists())
def test_setup_and_remove_target_the_explicit_identity_directory(self):
directory = self.root / "keys/dev"
identity = signing.SigningIdentity("ABC123", directory / "signing.keychain-db")
for action in ("setup", "remove"):
with patch.object(signing.sys, "argv", ["signing_identity.py", action, "--directory", str(directory)]), \
patch.object(signing, action, return_value=identity) as operation, contextlib.redirect_stdout(io.StringIO()) as output:
signing.main()
operation.assert_called_once_with(directory)
if action == "setup":
self.assertEqual(json.loads(output.getvalue()), {"identity": "ABC123", "keychain": str(identity.keychain)})
def test_missing_setup_leaves_builds_unconfigured(self):
self.fake([self.login])
self.assertIsNone(signing.load(self.directory))
def test_load_unlocks_the_keychain_before_returning_the_identity(self):
security = self.configure()
identity = signing.load(self.directory)
self.assertEqual((identity.sha1, identity.keychain), ("ABC123", self.keychain))
self.assertIn(("unlock-keychain", "-p", "secret", str(self.keychain)), security.commands)
def test_listed_keychains_match_through_symbolic_links(self):
(self.root / "alias").symlink_to(self.root, target_is_directory=True)
self.configure()
signing.security.keychains = [self.login, str(self.root / "alias/signing/signing.keychain-db")]
self.assertEqual(signing.load(self.directory).sha1, "ABC123")
def test_unlisted_or_missing_keychains_fail_with_the_setup_command(self):
security = self.configure(listed=False)
with self.assertRaisesRegex(RuntimeError, "search list; run python3"):
signing.load(self.directory)
self.keychain.unlink()
with self.assertRaisesRegex(RuntimeError, "keychain is missing"):
signing.load(self.directory)
self.assertFalse(any(command[0] == "unlock-keychain" for command in security.commands))
def test_setup_relists_an_existing_identity_without_creating_another(self):
security = self.configure(listed=False)
with patch.object(signing, "create_certificate") as create:
self.assertEqual(signing.setup(self.directory).sha1, "ABC123")
create.assert_not_called()
self.assertEqual(security.keychains, [self.login, str(self.keychain)])
def test_setup_replaces_only_an_incomplete_owned_identity(self):
security = self.configure()
(self.directory / "identity.json").unlink()
def create(directory, password):
self.keychain.write_bytes(b"new keychain")
return "NEW456"
with patch.object(signing, "create_certificate", side_effect=create):
identity = signing.setup(self.directory)
self.assertEqual(identity.sha1, "NEW456")
self.assertEqual(self.keychain.read_bytes(), b"new keychain")
self.assertEqual(oct((self.directory / "identity.json").stat().st_mode & 0o777), "0o600")
self.assertEqual(oct(self.directory.stat().st_mode & 0o777), "0o700")
self.assertEqual(security.keychains, [self.login, str(self.keychain)])
def test_setup_preserves_the_record_when_the_keychain_is_missing(self):
security = self.configure()
record = self.directory / "identity.json"
before = record.read_bytes()
self.keychain.unlink()
with patch.object(signing, "create_certificate") as create:
with self.assertRaisesRegex(RuntimeError, "keychain is missing: .*; run .*remove --directory .*, then .*setup --directory "):
signing.setup(self.directory)
create.assert_not_called()
self.assertEqual(record.read_bytes(), before)
self.assertFalse(self.keychain.exists())
self.assertEqual(security.commands, [])
def test_setup_cli_preserves_a_recorded_identity_missing_its_keychain(self):
directory = self.root / "keys/dev"
directory.mkdir(parents=True)
(directory / signing.OWNER_MARKER).write_text(signing.OWNER_VERSION)
record = directory / "identity.json"
before = json.dumps({"sha1": "ABC123", "password": "secret-password"}).encode()
record.write_bytes(before)
result = subprocess.run([sys.executable, str(Path(signing.__file__)), "setup", "--directory", str(directory)],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 1)
self.assertEqual(len(result.stderr.splitlines()), 1)
self.assertIn("keychain is missing", result.stderr)
self.assertIn(f"run {signing.REMOVE_COMMAND} --directory {directory}, then {signing.SETUP_COMMAND} --directory {directory}", result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertNotIn("secret-password", result.stderr)
self.assertEqual(record.read_bytes(), before)
self.assertFalse(signing.keychain_path(directory).exists())
def test_remove_restores_the_other_search_list_entries_and_deletes_the_password(self):
security = self.configure()
signing.remove(self.directory)
self.assertEqual(security.keychains, [self.login])
self.assertFalse(self.directory.exists())
signing.remove(self.directory)
self.assertEqual(security.keychains, [self.login])
def test_missing_certificate_requires_explicit_removal_instead_of_changing_identity(self):
self.configure()
(self.directory / "identity.json").write_text(json.dumps({"sha1": "MISSING", "password": "secret"}))
with self.assertRaisesRegex(RuntimeError, "certificate is missing; run .*remove --directory .*, then .*setup --directory "):
signing.setup(self.directory)
self.assertTrue(self.keychain.is_file())
def test_invalid_identity_record_reports_recovery_without_its_contents(self):
self.configure()
(self.directory / "identity.json").write_text("secret invalid json")
with self.assertRaisesRegex(RuntimeError, "record is invalid; run .*remove") as error:
signing.setup(self.directory)
self.assertNotIn("secret", str(error.exception))
def test_private_command_failures_never_echo_arguments_or_output(self):
for tool, action in ((signing.SECURITY, "unlock-keychain"), (signing.OPENSSL, "pkcs12")):
command = [tool, action, "-p", "secret-password"]
for failure in (subprocess.TimeoutExpired(command, 60, stderr="secret-password"),
subprocess.CalledProcessError(1, command, stderr="secret-password"),
OSError(2, "secret-password")):
with self.subTest(tool=tool, failure=type(failure).__name__), \
patch.object(signing.subprocess, "run", side_effect=failure):
with self.assertRaises(RuntimeError) as error:
signing.private_command(command)
self.assertNotIn("secret-password", str(error.exception))
self.assertTrue(error.exception.__suppress_context__)
def test_setup_and_remove_hold_the_same_cross_process_lock(self):
self.configure()
probe = """import fcntl, os, sys
with open(sys.argv[1], 'w') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
sys.exit(0)
sys.exit(1)
"""
setup_locked, remove_locked = signing.setup_locked, signing.remove_locked
def check_lock(operation):
def run(directory):
result = subprocess.run([sys.executable, "-c", probe, str(directory.parent / f"{directory.name}.lock")],
capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
return operation(directory)
return run
with patch.object(signing, "setup_locked", side_effect=check_lock(setup_locked)), \
patch.object(signing, "remove_locked", side_effect=check_lock(remove_locked)):
signing.setup(self.directory)
signing.remove(self.directory)
with signing.identity_lock(self.directory):
pass
def test_setup_cli_reports_signing_timeout_without_traceback_or_password(self):
script = """import runpy, sys, subprocess
from unittest.mock import patch
from pathlib import Path
test_home = Path(sys.argv[2])
Path.home = lambda: test_home
sys.argv = [sys.argv[1], 'setup', '--directory', sys.argv[2]]
with patch('subprocess.run', side_effect=subprocess.TimeoutExpired(['security', 'unlock-keychain', '-p', 'secret-password'], 60)):
runpy.run_path(sys.argv[0], run_name='__main__')
"""
# Fail at the first security command, before setup can modify any keychain.
result = subprocess.run([sys.executable, "-c", script, str(Path(signing.__file__)), str(self.root / "home")],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 1)
self.assertEqual(len(result.stderr.splitlines()), 1)
self.assertIn("timed out", result.stderr)
self.assertNotIn("secret-password", result.stderr)
self.assertNotIn("Traceback", result.stderr)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -4,6 +4,6 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win
The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes.
The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check`, which also publishes the review verdict as the check run `Policy / Reviewer Check`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each job reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, and `MERGE_POLICY_HEAD_DIFF`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `MERGE_POLICY_HEAD_DIFF` decides whether `Policy / Approve Check` runs and fails; while it runs, `Policy / Reviewer Check` is published whatever that mode says, with `success` when every file that needs review has one and `action_required` with the reviewers to ask otherwise. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates.
The merge checks run from three workflows named `Policy`, which run on `pull_request_target` and cover pull requests into master or a `release/dsh-x.y.z` branch, so their definitions come from the base branch, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check`, which also publishes the review verdict as the check run `Policy / Reviewer Check`, and `pr-check-on-base-branch.yml` on pushes to master or a release branch re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each job reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, and `MERGE_POLICY_HEAD_DIFF`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `MERGE_POLICY_HEAD_DIFF` decides whether `Policy / Approve Check` runs and fails; while it runs, `Policy / Reviewer Check` is published whatever that mode says, with `success` when every file that needs review has one and `action_required` with the reviewers to ask otherwise. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates.
The manual Desktop publication workflow selects a successful package run and attempt, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job.
+17
View File
@@ -127,6 +127,23 @@ jobs:
args=()
if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi
pnpm run package:desktop:mac:x64 --check --build-version "$PACKAGE_VERSION" "${args[@]}"
- name: Build and stage notarized Computer Use helpers
# A source whose dsh ships the Computer Use bundle packs the workspace provider into the Desktop runtime,
# which therefore needs the same notarized helper npm publication stages.
run: |
if ! node -e "process.exit(require('./apps/cli/package.json').dependencies?.['@deepseek-ai/dsh-cua-bundle'] ? 0 : 1)"; then
echo 'This source does not ship Computer Use in Desktop; no helper is staged.'
exit 0
fi
if [[ ! -f scripts/cua-helper/package.ts ]]; then
echo '::warning::This source ships Computer Use but predates helper packaging; Computer Use cannot prepare its helper in this package.'
exit 0
fi
mkdir -p dist
pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper
mkdir dist/cua-helper/extracted
tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted
pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted
- name: Build signed installers
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
+17
View File
@@ -124,6 +124,23 @@ jobs:
args=()
if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi
pnpm run package:desktop:mac:arm64 --check --build-version "$PACKAGE_VERSION" "${args[@]}"
- name: Build and stage notarized Computer Use helpers
# A source whose dsh ships the Computer Use bundle packs the workspace provider into the Desktop runtime,
# which therefore needs the same notarized helper npm publication stages.
run: |
if ! node -e "process.exit(require('./apps/cli/package.json').dependencies?.['@deepseek-ai/dsh-cua-bundle'] ? 0 : 1)"; then
echo 'This source does not ship Computer Use in Desktop; no helper is staged.'
exit 0
fi
if [[ ! -f scripts/cua-helper/package.ts ]]; then
echo '::warning::This source ships Computer Use but predates helper packaging; Computer Use cannot prepare its helper in this package.'
exit 0
fi
mkdir -p dist
pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper
mkdir dist/cua-helper/extracted
tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted
pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted
- name: Build signed installers
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
+1 -1
View File
@@ -1,7 +1,7 @@
name: Policy
on:
pull_request_target:
branches: [master]
branches: [master, 'release/dsh-[0-9]+.[0-9]+.[0-9]+']
types: [opened, synchronize, reopened, ready_for_review]
pull_request_review:
types: [submitted, dismissed]
@@ -1,7 +1,7 @@
name: Policy
on:
push:
branches: [master]
branches: [master, 'release/dsh-[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
permissions: {}
concurrency:
@@ -1,7 +1,7 @@
name: Policy
on:
pull_request_target:
branches: [master]
branches: [master, 'release/dsh-[0-9]+.[0-9]+.[0-9]+']
types: [opened, synchronize, reopened, ready_for_review, review_requested, auto_merge_enabled, labeled]
permissions:
contents: read
+1
View File
@@ -106,6 +106,7 @@
"@deepseek-ai/dsh-experimental-liveshare": "workspace:*",
"@deepseek-ai/dsh-experimental-session-search": "workspace:*",
"@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*",
"@deepseek-ai/dsh-cua-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*",
"@deepseek-ai/dsh-personalized-recommendations": "workspace:*",
"@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*"
+2 -2
View File
@@ -15,8 +15,8 @@
en: 127ea90553cb6af7
zh: 7b1c5aec8558c684
/deepseek-harness-desktop/application-overview:
en: 1667e3d6ffddea4a
zh: 4661a42b9ef4f673
en: d4afa46403997884
zh: 9b9fe126afda70f0
/deepseek-harness-desktop/terminal-command:
en: 5f2d25a1d8472647
zh: 2acb5c5d7d1917e2
+1 -1
View File
@@ -35,7 +35,7 @@ Electron caches the last confirmed UI language in `userData/desktop/locale-cache
Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited.
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it.
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it. Native Host requests send the current Host's authentication cookie explicitly, and no Electron session stores it; each Host start removes `dsh-auth-*` cookies that earlier releases left in the default session, because cookies ignore ports and would otherwise accompany every request to `127.0.0.1`.
Desktop binds `127.0.0.1`, the address Electron dials for the ready URL and the WebSocket credential filter. WebSocket streams use `ws:` or `wss:` according to the Host listener; credentials require matching authority and scheme.
+1 -1
View File
@@ -35,7 +35,7 @@ Electron 在 `userData/desktop/locale-cache` 缓存最近确认的界面语言
桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。原生 Host 请求会显式发送当前 Host 的认证 Cookie,任何 Electron session 都不保存该 Cookie;每次启动 Host 时都会移除早期版本遗留在默认 session 中的 `dsh-auth-*` Cookie,因为 Cookie 不区分端口,否则这些 Cookie 会随每个发往 `127.0.0.1` 的请求一并发送。
Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过滤器拨号的地址。WebSocket 流按 Host 监听器使用 `ws:` 或 `wss:`;附加凭据要求 authority 与 scheme 均匹配。
+10 -10
View File
@@ -27,17 +27,17 @@
en: 253e17998951931c
zh: b582b377391d2ec2
/desktop-packaging/macos-self-hosted-packaging:
en: f4cc5209bdaeefba
zh: 1db959d1ed345fcc
en: b7e03e2e9807a62c
zh: e5b0c43910e085d8
/desktop-packaging/unsigned-windows-test-installer:
en: f429bc0c280e2dfa
zh: 4adbcc9e906bdf80
/desktop-packaging/local-macos-dmg-without-release-signing:
en: 2feaa0412247d81f
zh: 461dde8e13fd4734
en: 1146bbc11be43960
zh: 7c13543a0513c059
/desktop-packaging/macos-release-signing-and-diagnostics:
en: 29fa6bd8919f3ec0
zh: 9e31e037fa3d9378
en: a0b4aa1080ecf6fb
zh: 37d4b8934f89bee8
/desktop-packaging/directory-builds-and-preparation:
en: 5860c306375ddd63
zh: a76e3cdbbe25c79e
@@ -45,11 +45,11 @@
en: b42f103358d0dfd7
zh: a534164ad9eb00da
/desktop-packaging/runtime-file-selection-and-installation-checks:
en: 83e1075417039410
zh: 3088a7ae0a2cc3c5
en: 44044a5c14b75533
zh: 39d91a369c4ec2fa
/desktop-packaging/desktop-runtime-preparation:
en: ab73b67d899e611d
zh: d77b0525c6b81e91
en: 84be38ab3136fff7
zh: bb4ed12ee0e1dfa0
/desktop-packaging/installation-and-release-verification:
en: eb35eb973b796643
zh: c1c52311632b7296
+8 -6
View File
@@ -125,7 +125,7 @@ Installer uploads store the already-compressed release files without ZIP recompr
## macOS self-hosted packaging
The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG.
The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. When the selected source's dsh ships the Computer Use bundle, each workflow builds, notarizes and stages the helper for both architectures with `scripts/cua-helper/package.ts` and `stage.ts` after the configuration check, the steps npm publication uses; `skip_notarization` does not skip the helper's notarization. A source that predates helper packaging only emits a warning, and Computer Use cannot prepare its helper in that package. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG.
All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing and cache measurement options follow these common fields.
@@ -172,19 +172,21 @@ pnpm run package:desktop:mac:x64:unsigned
Add `--check` to validate local configuration and tools without building, or `--dir` to stop at the unpacked application. The DMG is `apps/desktop/.desktop-build/targets/<target>/unsigned-artifacts/deepseek-harness-<version>-mac-<arch>-unsigned.dmg`. After all checks pass, the command prints `DMG:` followed by its absolute path. Open it and copy the application to a local test directory.
These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application.
These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. These builds stage no Computer Use helper, so Computer Use reports the missing helper when it prepares.
<a id="macos-release-signing"></a>
## macOS release signing and diagnostics
The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. Ephemeral CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job; self-hosted machine presets retain private files outside the checkout. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file outside the notarized Computer Use helper bundles; for those bundles it verifies the deep signature, release identity, stapled ticket, and Gatekeeper acceptance without re-signing their files. A signed local build of a source that ships Computer Use therefore needs the notarized helper staged first with `scripts/cua-helper/package.ts` and `stage.ts`; an ad-hoc `build:native` output under `native/bin` fails this verification; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. Ephemeral CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job; self-hosted machine presets retain private files outside the checkout. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements.
macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets/<target>/signature-cache` unless `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` selects external storage. An explicit path must be absolute and already exist, contain no linked ancestors, and be owned by the current account with mode `0700` and read/write/search access; invalid or empty settings fail preflight without fallback. Omit the field to retain target-local storage. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts.
Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together.
Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted, and environments are not recorded. Apple command failures retain their exit code, signal, stdout and stderr in redacted form; the adapter also writes these details to stderr when no journal is available. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together.
CUA helper packaging prints `CUA_PACKAGING_RECORD` and creates its own record in the same directory before desktop installer startup. Its `run.json` identifies the helper target, product version and source commit; `events.jsonl` retains command and notarization phases, submission IDs and Apple results. `result.json` records success or failure. These records survive temporary payload cleanup, and the Desktop Actions diagnostic upload includes them even when helper preparation fails.
App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log.
@@ -246,7 +248,7 @@ Desktop packs workspace packages locally and installs external dependencies thro
The [Electron shell file list](../scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](../tests/shell-file-selection.spec.ts) check retained bytes and exclusions.
The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in).
The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). The Computer Use helper applications under `@deepseek-ai/dsh-experimental-cua-macos/native/bin` are also unpacked whole under `resources/app.asar.unpacked/dsh/node_modules/`, and the provider opens that copy.
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](../tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](../tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](../scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization.
@@ -280,7 +282,7 @@ Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies
Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration.
Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files, and verify the notarized Computer Use helper bundles without re-signing them, before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
Windows runtime sealing reads at most eight files concurrently, before and after native signing. A read failure drains outstanding reads without replacing the descriptor; successful sealing still requires the subsequent complete integrity and smoke checks.
+8 -6
View File
@@ -125,7 +125,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
## macOS 自托管打包
[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。所选源码的 dsh 随附 Computer Use 组合包时,两者都在配置检查之后用 `scripts/cua-helper/package.ts` 和 `stage.ts` 构建、公证并放入两种架构的 helper,与 npm 发布的步骤相同;`skip_notarization` 不会跳过 helper 的公证。源码早于 helper 打包支持时只输出警告,该安装包中的 Computer Use 无法准备 helper。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量选项位于公共字段之后。
@@ -172,19 +172,21 @@ pnpm run package:desktop:mac:x64:unsigned
添加 `--check` 仅验证本地配置和工具而不构建,或添加 `--dir` 只生成解包应用。DMG 路径为 `apps/desktop/.desktop-build/targets/<target>/unsigned-artifacts/deepseek-harness-<version>-mac-<arch>-unsigned.dmg`。所有检查通过后,命令打印 `DMG:` 及其绝对路径。打开后将应用复制到本地测试目录。
这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。
这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。这类构建不放入 Computer Use helper,Computer Use 准备时会报告 helper 缺失。
<a id="macos-release-signing"></a>
## macOS 发布签名与诊断
macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。临时 CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者;自托管机器 preset 在 checkout 外保留私有文件。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到已公证 Computer Use helper 应用包之外的每个内嵌 Mach-O 文件;对这些应用包,则校验深度签名、发布身份、钉票和 Gatekeeper 评估结果,不重签其中的文件。因此,打包会随附 Computer Use 的源码时,本地签名构建需要先用 `scripts/cua-helper/package.ts` 和 `stage.ts` 放入已公证的 helper;`native/bin` 下由 `build:native` 生成的临时签名产物无法通过此校验;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。临时 CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者;自托管机器 preset 在 checkout 外保留私有文件。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。
macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets/<target>/signature-cache`,除非通过 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 选择外置存储。显式路径必须是已存在的绝对目录,祖先路径不能包含链接,目录须归当前账户所有、权限为 `0700` 且可读写及遍历;非法或空配置在预检时失败,不回退。省略该字段保留目标本地缓存。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。
Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。
Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖,不记录环境变量全集。Apple 命令失败时保留脱敏后的退出码、信号、stdout 和 stderr;没有日志目录时,适配器也会将这些详情写入 stderr。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。
CUA helper 打包通过 `CUA_PACKAGING_RECORD` 输出同一目录下的独立记录路径,在桌面安装包开始构建前创建。其 `run.json` 记录 helper 目标、产品版本和源码提交,`events.jsonl` 保留命令与公证阶段、已返回的提交 ID 和 Apple 处理结果,`result.json` 记录成功或失败。这些记录不会随临时 payload 清理而删除;即使 helper 准备失败,Desktop Actions 的诊断上传步骤也会收集它们。
App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。
@@ -246,7 +248,7 @@ Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安
[Electron 外壳文件清单](../scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](../tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。
[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。
[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。`@deepseek-ai/dsh-experimental-cua-macos/native/bin` 下的 Computer Use helper 应用也整体解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下,提供方打开该副本。
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](../tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](../tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](../scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。
@@ -280,7 +282,7 @@ Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补
Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。
每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,并在不重签的前提下校验已公证的 Computer Use helper 应用包,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
Windows 运行时在原生签名前后封存文件清单,每次最多并发读取八个文件。读取失败时会等待未完成的读取结束,不替换描述符;封存成功后仍须完成后续的完整性与 smoke 检查。
@@ -76,7 +76,10 @@ export function createElectronBuilderConfig(
let dshDestination
let windowsCode = []
const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep-*/bin/rg',
`**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`]
`**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`,
// LaunchServices opens the Computer Use helper as an application, so every file of its bundles stays on disk.
// Both architectures ship: they form one notarized payload whose manifest staging verifies as a whole.
'**/node_modules/@deepseek-ai/dsh-experimental-cua-macos/native/bin/**/*']
const windowsSigner = packagesWindows && !unsigned
? createWindowsTokenSigner({
certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE,
+10 -6
View File
@@ -5,7 +5,7 @@ import { basename, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { recordPackagingEvent, packagingOutputRedactor } from './packaging-run.mjs'
import { packagingStep } from './packaging-step.mjs'
import { packagingErrorDetails, packagingStep } from './packaging-step.mjs'
const exec = promisify(execFile)
const AUTH_FLAGS = new Set(['--apple-id', '--password', '--team-id', '--key', '--key-id', '--issuer', '--keychain', '--keychain-profile'])
@@ -17,7 +17,8 @@ async function execute(args) {
} catch (error) {
// Apple can report rejection with a nonzero exit; electron-notarize needs its JSON ID to retrieve diagnostics.
if (args[0] === 'wait' && typeof error.code === 'number' && typeof error.stdout === 'string' && error.stdout.trim()) return error.stdout
throw error
throw new Error(`notarytool ${args[0]} failed (code: ${String(error.code ?? 'unknown')}, signal: ${String(error.signal ?? 'none')})\n`
+ `stdout: ${error.stdout ?? ''}\nstderr: ${error.stderr ?? ''}`, { cause: error })
}
}
@@ -74,10 +75,13 @@ export async function runLoggedNotarytool(args, directory, invoke = execute) {
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
try { process.stdout.write(await runLoggedNotarytool(process.argv.slice(2), process.env.DSH_DESKTOP_PACKAGING_RUN_DIR)) }
catch {
// execFile errors include credential-bearing argv; detailed failures are redacted in the journal.
process.stderr.write('desktop notarization: Apple command failed; see packaging events.jsonl\n')
const args = process.argv.slice(2)
const directory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
try { process.stdout.write(await runLoggedNotarytool(args, directory)) }
catch (error) {
const credentials = args.filter((_, index) => AUTH_FLAGS.has(args[index - 1]))
process.stderr.write(`desktop notarization: Apple command failed${directory ? `; see ${directory}/events.jsonl` : ''}\n`
+ `${packagingErrorDetails(error, credentials)}\n`)
process.exitCode = 1
}
}
+21 -4
View File
@@ -8,9 +8,15 @@ import { inventoryDesktopRuntime } from '../src/runtime-tree.ts'
import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
import { cachedMacOSSignature, pruneMacOSSignatureCache } from './macos-signature-cache.ts'
import { macOSCachePolicy } from './macos-cache-policy.ts'
import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs'
import { signMacOSRuntimeCode, verifyMacOSNotarizedApplication, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs'
const MACH_O_MAGICS = new Set(['cafebabe', 'cafebabf', 'cefaedfe', 'cffaedfe', 'feedface', 'feedfacf', 'bebafeca', 'bfbafeca'])
/**
* The notarized Computer Use helper bundle containing a runtime path, as staged by `scripts/cua-helper/stage.ts`:
* `native/bin/darwin-<arch>/<generation>/<name>.app`. Other bundles, such as LibreOffice's unsigned application,
* are not matched and keep per-file runtime signing.
*/
const CUA_HELPER_BUNDLE = /^(node_modules\/@deepseek-ai\/dsh-experimental-cua-macos\/native\/bin\/darwin-[^/]+\/[^/]+\/[^/]+\.app)\//u
function magic(path: string): string {
const descriptor = openSync(path, 'r')
@@ -33,21 +39,32 @@ async function primaryRuntimeFiles(root: string): Promise<string[]> {
}
/**
* Sign and verify every materialized Mach-O file, awaiting all signers on failure.
* Sign and verify every materialized Mach-O file, awaiting all signers on failure. Files inside the notarized Computer
* Use helper bundles are not re-signed, because signing them breaks the bundle seal and changes the identity macOS
* grants permissions to; each such bundle's deep signature, release identity, stapled ticket, and Gatekeeper
* acceptance are verified before any file is signed.
* @param root - Self-contained production runtime.
* @param appId - Release application identifier.
* @param expected - Required signing identity.
* @param arch - Target runtime architecture, independent of the signing host.
* @param cacheDirectory - Optional content-addressed cache; requires the keychain-owned signing probe.
* @param kind - Primary payloads admit validated Python aliases; Desktop application runtimes reject every link.
* @returns Number of signed native files.
* @returns Number of signed native files, excluding files inside verified Computer Use helper bundles.
* @throws When a helper bundle fails verification, or when any signer fails.
*/
export async function signMacOSRuntime(
root: string, appId: string, expected: MacOSSigningEnvironment, arch: 'arm64' | 'x64', cacheDirectory?: string,
kind: 'desktop' | 'primary' = 'desktop',
): Promise<number> {
const inventory = kind === 'primary' ? await primaryRuntimeFiles(root) : inventoryDesktopRuntime(root).map(file => file.path)
const files = inventory.filter(path => MACH_O_MAGICS.has(magic(join(root, path))))
const bundles = new Set<string>()
const files: string[] = []
for (const path of inventory) {
const bundle = CUA_HELPER_BUNDLE.exec(path)?.[1]
if (bundle !== undefined) bundles.add(bundle)
else if (MACH_O_MAGICS.has(magic(join(root, path)))) files.push(path)
}
for (const bundle of [...bundles].sort()) verifyMacOSNotarizedApplication(join(root, bundle), expected)
const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '')
let hits = 0
let misses = 0
+27 -30
View File
@@ -80,10 +80,10 @@ export interface DesktopAccountBackend {
* Connect native account operations to the standard authenticated Web backend.
* @param origin - Host Web origin.
* @param invoke - validated unary RPC caller.
* @param cookies - Electron session cookie reader.
* @param cookie - Host authentication Cookie header for the account WebSocket.
* @returns account operations; watch callers own their subscriptions.
*/
export function desktopAccountBackend(origin: string, invoke: AccountInvoke, cookies: () => Promise<string>): DesktopAccountBackend {
export function desktopAccountBackend(origin: string, invoke: AccountInvoke, cookie: string): DesktopAccountBackend {
const call = async (method: string, args: Record<string, unknown> = {}): Promise<AccountView> =>
accountView(await invoke({ namespace: 'account', method, args }))
return {
@@ -98,34 +98,31 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
const streamId = randomUUID()
const expiryStreamId = randomUUID()
const analyticsPolicyStreamId = randomUUID()
void cookies().then((cookie) => {
if (closed) return
const url = new URL(REMOTE_STREAM_MUX_PATH, origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 })
socket.on('open', () => {
if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } }))
socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } }))
socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } }))
})
socket.on('message', (data) => {
try {
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data)
const frame = parseRemoteStreamServerMessage(bytes.toString('utf8'))
if (frame.streamId === analyticsPolicyStreamId) {
if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value)
else onAnalyticsEnabledChanged?.(false)
return
}
if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return }
if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream')
if (frame.type === 'item') listener(accountView(frame.value))
else socket?.close()
} catch { socket?.close() }
})
socket.on('error', () => { socket?.close() })
socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
}).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
const url = new URL(REMOTE_STREAM_MUX_PATH, origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 })
socket.on('open', () => {
if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } }))
socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } }))
socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } }))
})
socket.on('message', (data) => {
try {
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data)
const frame = parseRemoteStreamServerMessage(bytes.toString('utf8'))
if (frame.streamId === analyticsPolicyStreamId) {
if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value)
else onAnalyticsEnabledChanged?.(false)
return
}
if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return }
if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream')
if (frame.type === 'item') listener(accountView(frame.value))
else socket?.close()
} catch { socket?.close() }
})
socket.on('error', () => { socket?.close() })
socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
}
connect()
return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() }
+6 -4
View File
@@ -16,7 +16,6 @@ import {
powerMonitor,
nativeImage,
nativeTheme,
net,
protocol,
session,
shell,
@@ -39,7 +38,7 @@ import { desktopUpdateChannel } from './update-channel.ts'
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
import { prepareDesktopUpdateWindow } from './update-window.ts'
import { DesktopCommandManager } from './command-management.ts'
import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
import { serveWebDocument, authenticateWebHost, forwardWebRequest, removeLegacyHostCookies } from './web-document.ts'
import { DesktopFatalRecovery } from './fatal-recovery.ts'
import { pruneCrashReports, RendererConsoleTail, writeCrashReport, type CrashReportSource } from './crash-report.ts'
import { OperationJournal } from './operation-journal.ts'
@@ -529,11 +528,14 @@ async function main(): Promise<void> {
return {
start: async () => {
const ready = await host.start()
hostCookie = await authenticateWebHost(ready.url)
const cookie = await authenticateWebHost(ready.url)
hostCookie = cookie
hostUrl = ready.url
void removeLegacyHostCookies(session.defaultSession.cookies, ready.url)
.catch((error: unknown) => { console.error('desktop: legacy Host cookie cleanup failed', error) })
if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
injections = ready.injections
welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; '))
welcomeBackend = connectDesktopWelcome(ready.url, cookie)
analyticsEnabled = await welcomeBackend.analyticsEnabled().catch(() => false)
if (!reportedLaunch) { reportedLaunch = true; void track('desktop_app_launch', {}) }
stopAccount?.()
+20
View File
@@ -49,6 +49,26 @@ export async function authenticateWebHost(url: string): Promise<string> {
return end < 0 ? cookie : cookie.slice(0, end)
}
/** Cookie store operations used to remove legacy Host authentication cookies. */
export interface HostCookieStore {
get(filter: { url: string }): Promise<readonly { readonly name: string }[]>
remove(url: string, name: string): Promise<void>
}
/**
* Remove every `dsh-auth-*` cookie that earlier releases left in a cookie store for the Host's hostname.
* Cookies ignore ports, so cookies from earlier random-port Hosts would otherwise travel with every
* request to that hostname until they expire.
* @param cookies - Electron session cookie store.
* @param url - Host URL; cookies for its hostname on every port are removed.
*/
export async function removeLegacyHostCookies(cookies: HostCookieStore, url: string): Promise<void> {
const origin = new URL(url).origin
for (const { name } of await cookies.get({ url: origin })) {
if (name.startsWith('dsh-auth-')) await cookies.remove(origin, name)
}
}
/**
* Response headers not relayed to the renderer. `set-cookie` would hand the
* Host's authentication cookie to the page's cookie jar, which the shell owns
+13 -15
View File
@@ -35,20 +35,18 @@ function record(value: unknown): value is Record<string, unknown> {
}
/**
* Authenticate the native HTTP client through the Web application's launch URL.
* @param authenticatedUrl - URL supplied by the running Desktop Host.
* @param send - Electron session fetch, retaining the Web authentication cookie.
* Connect the native HTTP client to the Desktop Host; every request sends `cookie` explicitly and uses no cookie jar.
* @param hostUrl - URL of the running Desktop Host; only its origin is used.
* @param cookie - Cookie header returned by `authenticateWebHost` for this Host.
* @param send - HTTP client for Host requests.
* @returns metadata reads and write-only credential operations over standard RPC.
*/
export async function connectDesktopWelcome(
authenticatedUrl: string,
send: (input: string, init?: RequestInit) => Promise<Response>,
cookies: () => Promise<string> = () => Promise.resolve(''),
): Promise<DesktopWelcomeBackend> {
const origin = new URL(authenticatedUrl).origin
const authenticated = await send(authenticatedUrl, { credentials: 'include' })
await authenticated.body?.cancel()
if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed')
export function connectDesktopWelcome(
hostUrl: string,
cookie: string,
send: (input: string, init?: RequestInit) => Promise<Response> = fetch,
): DesktopWelcomeBackend {
const origin = new URL(hostUrl).origin
const invoke = async (
request: { namespace: string; method: string; args: Record<string, unknown> },
signal?: AbortSignal,
@@ -56,8 +54,8 @@ export async function connectDesktopWelcome(
const rpcId = randomUUID()
const method = `${request.namespace}/${request.method}`
const response = await send(new URL(`/api/${method}`, origin).href, {
method: 'POST', credentials: 'include', redirect: 'error', ...(signal === undefined ? {} : { signal }),
headers: { 'content-type': 'application/json' },
method: 'POST', redirect: 'error', ...(signal === undefined ? {} : { signal }),
headers: { 'content-type': 'application/json', cookie },
body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }),
})
if (!response.ok) throw new Error('desktop welcome: Web request failed')
@@ -68,7 +66,7 @@ export async function connectDesktopWelcome(
}
return envelope.result.value
}
const account = desktopAccountBackend(origin, invoke, cookies)
const account = desktopAccountBackend(origin, invoke, cookie)
const settingsAndReference = async () => {
const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} })
if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces')
+5 -3
View File
@@ -1,4 +1,5 @@
import { once } from 'node:events'
import type { IncomingMessage } from 'node:http'
import { WebSocketServer } from 'ws'
import { expect, it, onTestFinished, vi } from 'vitest'
import { accountView, desktopAccountBackend } from '../src/account-backend.ts'
@@ -28,7 +29,7 @@ it('uses account Remote commands without returning additional wire fields', asyn
const backend = desktopAccountBackend('http://127.0.0.1:1234', (request) => {
requests.push(request)
return Promise.resolve({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null, token: 'private' })
}, () => Promise.resolve(''))
}, '')
const client = { version: '1.2.3', locale: 'en', timezoneOffsetSeconds: 28_800 }
expect(await backend.start(client)).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })
expect(await backend.signOut(client)).toMatchObject({ status: 'signed-out' })
@@ -59,12 +60,13 @@ it('receives live expiry separately from account snapshots', async () => {
const snapshot = { status: 'signed-out', attempt: null,
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }
const received = Promise.withResolvers<undefined>()
const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve(''))
const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), 'dsh-auth-fixture=signed')
const stop = backend.watch(
(value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, onAnalyticsEnabledChanged,
)
onTestFinished(stop)
await connected
const [, upgrade] = await connected as [unknown, IncomingMessage]
expect(upgrade.headers.cookie).toBe('dsh-auth-fixture=signed')
// Logical streams share the socket; observe their opening frames before sending data.
const streams = new Map<string, string>()
const opened = Promise.withResolvers<undefined>()
@@ -1,4 +1,4 @@
/** Cold pnpm requests exercise on-demand CUA dependency/platform metadata with inert package contents. */
/** Cold pnpm requests exercise shipped CUA dependency/platform metadata with inert package contents. */
import { createHash } from 'node:crypto'
import { createServer } from 'node:http'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@ -37,14 +37,14 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
const cli = await readManifest('apps/cli')
const bundle = await readManifest('packages/experimental/cua-bundle')
const native = await readManifest('packages/experimental/cua-macos')
expect(cli.dependencies).not.toHaveProperty(bundleName)
expect(cli.dependencies).toHaveProperty(bundleName)
expect(bundle.dependencies).not.toHaveProperty(providerName)
expect(bundle.optionalDependencies).toEqual({ [providerName]: 'workspace:*' })
expect(native.os).toEqual(['darwin'])
const version = '1.0.0'
const bundleDependencies = Object.fromEntries(Object.keys(bundle.dependencies as Record<string, string>).map(name => [name, version]))
const manifests = [
{ name: cli.name, version },
{ name: cli.name, version, dependencies: { [bundleName]: version } },
{ name: bundleName, version, dependencies: bundleDependencies, optionalDependencies: { [providerName]: version } },
...Object.keys(bundleDependencies).map(name => ({ name, version })),
{ name: providerName, version, os: native.os, files: native.files },
@@ -102,13 +102,13 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
const address = server.address()
if (!address || typeof address === 'string') throw new Error('fixture registry has no TCP address')
for (const target of ['darwin-base', 'darwin', 'win32', 'linux', 'linux-unrestricted']) {
for (const target of ['darwin', 'win32', 'linux', 'linux-unrestricted']) {
unrestricted = target === 'linux-unrestricted'
const project = join(root, target)
await mkdir(project)
await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [target === 'darwin-base' ? String(cli.name) : bundleName]: version } }))
await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [String(cli.name)]: version } }))
await writeFile(join(project, 'pnpm-workspace.yaml'), [
'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target === 'darwin-base' ? 'darwin' : target}]`,
'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target}]`,
' cpu: [arm64]', 'minimumReleaseAge: 0', '',
].join('\n'))
requests.length = 0
@@ -117,8 +117,8 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
`--config.registry=http://127.0.0.1:${address.port}/`, `--config.store-dir=${join(project, 'store')}`,
`--config.cache-dir=${join(project, 'cache')}`, `--config.state-dir=${join(project, 'state')}`,
], project)
expect(requests.includes(`/${bundleName}.tgz`)).toBe(target !== 'darwin-base')
expect(requests.includes('/@deepseek-ai/dsh-cua.tgz')).toBe(target !== 'darwin-base')
expect(requests).toContain(`/${bundleName}.tgz`)
expect(requests).toContain('/@deepseek-ai/dsh-cua.tgz')
expect(requests.includes(`/${providerName}.tgz`), `${target}: ${JSON.stringify(requests)}\n${output}`)
.toBe(target === 'darwin' || unrestricted)
}
@@ -1,5 +1,10 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, it, vi } from 'vitest'
import { runLoggedNotarytool } from '../scripts/logged-notarytool.mjs'
import { createPackagingRun } from '../scripts/packaging-run.mjs'
const command = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>>())
vi.mock('node:child_process', async (importOriginal) => {
@@ -28,6 +33,52 @@ it('does not treat failed upload output as a completed submission', async () =>
command.mockRejectedValueOnce(Object.assign(new Error('upload failed'), {
code: 1, stdout: JSON.stringify({ id }), stderr: '',
}))
await expect(runLoggedNotarytool(args)).rejects.toThrow('upload failed')
const work = runLoggedNotarytool(args)
await expect(work).rejects.toThrow('notarytool submit failed (code: 1')
await expect(work).rejects.toHaveProperty('cause.message', 'upload failed')
expect(command).toHaveBeenCalledOnce()
})
it.each([false, true])('prints the redacted Apple failure with a journal=%s and leaves stdout for protocol replies', async (recorded) => {
const root = await mkdtemp(join(tmpdir(), 'notary-cli-'))
const directory = recorded ? createPackagingRun(root, {}).directory : undefined
const savedArgv = process.argv
const savedExit = process.exitCode
const savedDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
try {
if (directory === undefined) delete process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
else process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = directory
process.argv = [process.execPath, fileURLToPath(new URL('../scripts/logged-notarytool.mjs', import.meta.url)),
'submit', '/fixture/app.zip', '--apple-id', 'private@example.test', '--password', 'secret-sentinel',
'--team-id', 'private-team', '--wait', '--output-format', 'json']
command.mockReset()
command.mockRejectedValueOnce(Object.assign(new Error('connection closed: private@example.test secret-sentinel private-team'), {
code: 69, signal: null, stdout: 'Apple response: secret-sentinel', stderr: 'HTTP 503 Service Unavailable',
}))
vi.resetModules()
await import('../scripts/logged-notarytool.mjs')
expect(command).toHaveBeenCalledOnce()
expect(process.exitCode).toBe(1)
expect(stdout).not.toHaveBeenCalled()
const output = stderr.mock.calls.map(([chunk]) => String(chunk)).join('')
const diagnostics = [output, ...directory === undefined ? [] : [await readFile(join(directory, 'events.jsonl'), 'utf8')]]
for (const diagnostic of diagnostics) {
expect(diagnostic).toContain('code: 69')
expect(diagnostic).toContain('Apple response: [REDACTED]')
expect(diagnostic).toContain('HTTP 503 Service Unavailable')
expect(diagnostic).toContain('connection closed')
for (const secret of ['private@example.test', 'secret-sentinel', 'private-team']) expect(diagnostic).not.toContain(secret)
}
expect(output.includes('events.jsonl')).toBe(recorded)
} finally {
process.argv = savedArgv
process.exitCode = savedExit
if (savedDirectory === undefined) delete process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
else process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = savedDirectory
stderr.mockRestore()
stdout.mockRestore()
await rm(root, { recursive: true, force: true })
}
})
+32 -2
View File
@@ -3,9 +3,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { signMacOSRuntime } from '../scripts/macos-runtime.ts'
import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from '../scripts/verify-macos-signature.mjs'
import { signMacOSRuntimeCode, verifyMacOSNotarizedApplication, verifyMacOSRuntimeCode } from '../scripts/verify-macos-signature.mjs'
vi.mock('../scripts/verify-macos-signature.mjs', () => ({ signMacOSRuntimeCode: vi.fn(), verifyMacOSRuntimeCode: vi.fn() }))
vi.mock('../scripts/verify-macos-signature.mjs', () => ({
signMacOSRuntimeCode: vi.fn(), verifyMacOSNotarizedApplication: vi.fn(), verifyMacOSRuntimeCode: vi.fn(),
}))
const roots: string[] = []
function root(): string {
const path = mkdtempSync(join(tmpdir(), 'desktop-signing-'))
@@ -26,6 +28,34 @@ it('signs Mach-O files in their final locations and verifies each signature', as
expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity)
})
const helperBundle = 'node_modules/@deepseek-ai/dsh-experimental-cua-macos/native/bin/darwin-arm64/generation/DSH Computer Use.app'
function machO(path: string): void {
mkdirSync(join(path, '..'), { recursive: true })
writeFileSync(path, Buffer.from('cffaedfe00000000', 'hex'))
}
it('verifies the notarized Computer Use helper as a bundle and signs other bundles per file', async () => {
const path = root()
machO(join(path, helperBundle, 'Contents/MacOS/dsh-cua-native'))
writeFileSync(join(path, helperBundle, 'Contents/Info.plist'), '<plist/>')
// LibreOffice ships an unsigned application bundle whose executables are signed in place.
const office = join(path, 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/program/LibreOfficeDev.app/Contents/MacOS/soffice')
machO(office)
machO(join(path, 'addon.node'))
await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).resolves.toBe(2)
expect(verifyMacOSNotarizedApplication).toHaveBeenCalledExactlyOnceWith(join(path, helperBundle), identity)
expect(vi.mocked(signMacOSRuntimeCode).mock.calls.map(([file]) => file).sort()).toEqual([join(path, 'addon.node'), office].sort())
})
it('rejects a helper bundle that fails notarized-application verification before signing any file', async () => {
const path = root()
machO(join(path, helperBundle, 'Contents/MacOS/dsh-cua-native'))
machO(join(path, 'addon.node'))
vi.mocked(verifyMacOSNotarizedApplication).mockImplementationOnce(() => { throw new Error('stapler validate exited with 65') })
await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).rejects.toThrow('stapler validate')
expect(signMacOSRuntimeCode).not.toHaveBeenCalled()
})
// File symlinks in these macOS fixtures require privileges unavailable to ordinary Windows processes.
it.skipIf(process.platform === 'win32')('signs a shared Python interpreter once and retains both executable aliases', async () => {
const path = root()
@@ -73,6 +73,7 @@ describe('desktop macOS release signature', () => {
expect(config.asarUnpack).toEqual(expect.arrayContaining([
'**/*.{node,dylib,dll,so,exe}',
'**/@vscode/ripgrep-*/bin/rg',
'**/node_modules/@deepseek-ai/dsh-experimental-cua-macos/native/bin/**/*',
]))
expect(config).toMatchObject({
appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID,
+34 -13
View File
@@ -22,7 +22,10 @@ type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown
const fileDrag = vi.hoisted(() => ({ dispose: vi.fn<() => Promise<void>>(), install: vi.fn() }))
vi.mock('../src/file-drag.ts', () => ({ installDesktopFileDrag: () => { fileDrag.install(); return fileDrag.dispose } }))
vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
vi.mock('../src/web-document.ts', () => ({
authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn(),
removeLegacyHostCookies: harness.removeLegacyHostCookies,
}))
vi.mock('../src/operation-report-job.ts', () => ({ runOperationReportJob: vi.fn(async () => ({ appended: false, timedOut: false, cancelled: false })) }))
// Report persistence has its own unit tests; here it resolves within microtasks so the fatal
// dialog never outlives the test that triggered it.
@@ -215,7 +218,8 @@ const harness = await vi.hoisted(async () => {
failWindow(error: Error) { windowFailure = error },
windows, views, hosts, handlers, app, FakeWindow, FakeView, FakeHost, powerMonitor, nativeTheme, analytics,
trays, FakeTray, backgroundNotice, shellDialog,
menu, popup, socketHeaders: vi.fn(), loginShell, readLoginShell, updateCheck, updateDownload, updateInstall,
menu, popup, socketHeaders: vi.fn(), connectWelcome: vi.fn(), removeLegacyHostCookies: vi.fn(async () => {}), hostCookies: {},
loginShell, readLoginShell, updateCheck, updateDownload, updateInstall,
platformDispose,
platformCloseAndWait,
@@ -306,7 +310,6 @@ vi.mock('electron', () => ({
dialog: harness.dialog,
shell: { openExternal: harness.openExternal },
nativeTheme: harness.nativeTheme,
net: { fetch: vi.fn() },
ipcMain: {
on: harness.ipcOn,
handle: (channel: string, handler: InvokeHandler) => {
@@ -317,7 +320,7 @@ vi.mock('electron', () => ({
},
Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu },
session: { defaultSession: {
on: vi.fn(),
on: vi.fn(), cookies: harness.hostCookies,
setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: harness.socketHeaders },
} },
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle },
@@ -389,14 +392,17 @@ vi.mock('../src/platform-view.ts', async importOriginal => ({
},
}))
vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({
analyticsEnabled: async () => harness.analyticsEnabled,
report: harness.analytics,
readLocalePreference: async () => null,
read: async (): Promise<unknown> => (await harness.hosts.at(-1)!.fetch()).json() as Promise<unknown>,
save: async () => ({ ok: true }),
account: { watch: harness.watchAccount, state: async () => ({ status: 'signed-out', attempt: null }) },
}),
connectDesktopWelcome: (...args: unknown[]) => {
harness.connectWelcome(...args)
return {
analyticsEnabled: async () => harness.analyticsEnabled,
report: harness.analytics,
readLocalePreference: async () => null,
read: async (): Promise<unknown> => (await harness.hosts.at(-1)!.fetch()).json() as Promise<unknown>,
save: async () => ({ ok: true }),
account: { watch: harness.watchAccount, state: async () => ({ status: 'signed-out', attempt: null }) },
}
},
}))
function configureTestAuthPopup(allow: boolean): void {
@@ -1126,7 +1132,7 @@ describe('desktop main startup', () => {
} finally { harness.app.name = originalName }
})
it.each(['http:', 'https:'] as const)('attaches %s Host socket credentials only to the owned application origin and window', async (protocol) => {
it.each(['http:', 'https:'] as const)('passes the %s Host cookie explicitly and attaches socket credentials only to the owned application origin and window', async (protocol) => {
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
@@ -1134,6 +1140,8 @@ describe('desktop main startup', () => {
harness.hosts[0]!.url = `${protocol}//127.0.0.1:3080/?token=test`
harness.hosts[0]!.ready.resolve()
await Promise.resolve(invoke(DESKTOP_IPC.boot))
expect(harness.connectWelcome).toHaveBeenCalledWith(`${protocol}//127.0.0.1:3080/?token=test`, 'test-cookie')
expect(harness.removeLegacyHostCookies).toHaveBeenCalledWith(harness.hostCookies, `${protocol}//127.0.0.1:3080/?token=test`)
const handler = harness.socketHeaders.mock.calls[0]![1] as (
details: { url: string; webContentsId: number; requestHeaders: Record<string, string> },
callback: (result: unknown) => void,
@@ -1155,6 +1163,19 @@ describe('desktop main startup', () => {
expect(callback).toHaveBeenLastCalledWith({})
})
it('logs a legacy Host cookie cleanup failure without blocking boot', async () => {
const failure = new Error('cookie store unavailable')
harness.removeLegacyHostCookies.mockRejectedValueOnce(failure)
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
await harness.hostStarted.promise
harness.hosts[0]!.ready.resolve()
await Promise.resolve(invoke(DESKTOP_IPC.boot))
await vi.waitFor(() => { expect(console.error).toHaveBeenCalledWith('desktop: legacy Host cookie cleanup failed', failure) })
expect(harness.connectWelcome).toHaveBeenCalled()
})
it('shares login key discovery with onboarding and rejects foreign renderers', async () => {
await readyForUpdate()
const window = harness.windows[0]!
+12 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { authenticateWebHost, forwardWebRequest, serveWebDocument } from '../src/web-document.ts'
import { authenticateWebHost, forwardWebRequest, removeLegacyHostCookies, serveWebDocument } from '../src/web-document.ts'
const roots: string[] = []
afterEach(async () => {
@@ -86,3 +86,14 @@ it('refuses another page origin without forwarding its request', async () => {
expect(response.status).toBe(403)
expect(fetch).not.toHaveBeenCalled()
})
it('removes only legacy Host authentication cookies for the Host hostname', async () => {
const removed: [string, string][] = []
const cookies = {
get: vi.fn(async () => [{ name: 'dsh-auth-old' }, { name: 'other-app' }, { name: 'dsh-auth-older' }]),
remove: vi.fn(async (url: string, name: string) => { removed.push([url, name]) }),
}
await removeLegacyHostCookies(cookies, 'http://127.0.0.1:1234/?token=secret')
expect(cookies.get).toHaveBeenCalledExactlyOnceWith({ url: 'http://127.0.0.1:1234' })
expect(removed).toEqual([['http://127.0.0.1:1234', 'dsh-auth-old'], ['http://127.0.0.1:1234', 'dsh-auth-older']])
})
+2 -11
View File
@@ -12,6 +12,7 @@ import { DesktopProjectManager } from '../src/project-manager.ts'
import { resolveDesktopPaths } from '../src/paths.ts'
import { desktopClientMetadata } from '../src/client-metadata.ts'
import { connectDesktopWelcome, type DesktopWelcomeBackend } from '../src/welcome-backend.ts'
import { authenticateWebHost } from '../src/web-document.ts'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import { prepareDevelopmentProject } from '../scripts/development-project.ts'
@@ -129,17 +130,7 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
host = new DesktopHostProcess(process.execPath, project, paths.profile)
const { url } = await host.start()
hostOrigin = new URL(url).origin
let cookie = ''
const send: typeof fetch = async (input, init) => {
const headers = new Headers(init?.headers)
if (cookie !== '') headers.set('cookie', cookie)
const response = await fetch(input, { ...init, headers, redirect: 'manual' })
if (response.status !== 303) return response
cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
await response.body?.cancel()
return fetch(new URL(response.headers.get('location')!, url), { headers: { cookie } })
}
backend = await connectDesktopWelcome(url, send, () => Promise.resolve(cookie))
backend = connectDesktopWelcome(url, await authenticateWebHost(url))
}
const status = async () => backend.read()
const fingerprint = (): string => createHash('sha256')
+16 -22
View File
@@ -9,9 +9,8 @@ function transport(preference?: string) {
{ ns: 'llm-pi-ai', value: { profiles: { example: { apiKeyEnv: 'EXAMPLE_API_KEY' } } } },
{ ns: 'locale', value: preference === undefined ? {} : { preference } },
]
const send = vi.fn<Parameters<typeof connectDesktopWelcome>[1]>(async (_input, init) => {
if (init?.method !== 'POST') return new Response('index')
const { rpcId, method, payload } = JSON.parse(init.body as string) as {
const send = vi.fn<NonNullable<Parameters<typeof connectDesktopWelcome>[2]>>(async (_input, init) => {
const { rpcId, method, payload } = JSON.parse(init!.body as string) as {
rpcId: string
method: string
payload: { args: { ref: string; value: string; refs: string[] } }
@@ -29,27 +28,28 @@ function transport(preference?: string) {
return { send, keys, namespaces }
}
const url = 'http://127.0.0.1:19387/?token=fixture'
const url = 'http://127.0.0.1:19387/'
const cookie = 'dsh-auth-fixture=signed'
describe('desktop welcome Web operations', () => {
it('authenticates through Web and stores only through the configured credential reference', async () => {
it('authenticates with the explicit Host cookie and stores only through the configured credential reference', async () => {
const host = transport()
const backend = await connectDesktopWelcome(url, host.send)
expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' })
const backend = connectDesktopWelcome(url, cookie, host.send)
expect(await backend.analyticsEnabled()).toBe(true)
expect(await backend.save('sk-example')).toEqual({ ok: true })
expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example')
expect(await backend.read()).toEqual({ loggedIn: false, hasApiKey: true, writable: true, localePreference: null })
for (const [input, init] of host.send.mock.calls.slice(1)) {
for (const [input, init] of host.send.mock.calls) {
expect(input).toMatch(/^http:\/\/127\.0\.0\.1:19387\/api\//u)
expect(init).toMatchObject({ credentials: 'include', redirect: 'error' })
expect(init).toMatchObject({ redirect: 'error', headers: { cookie } })
expect(init).not.toHaveProperty('credentials')
}
})
it('reads the explicit language preference and recognizes another provider key', async () => {
const host = transport('zh')
host.keys.set('EXAMPLE_API_KEY', 'sk-other')
const backend = await connectDesktopWelcome(url, host.send)
const backend = connectDesktopWelcome(url, cookie, host.send)
expect(await backend.read()).toMatchObject({ localePreference: 'zh', hasApiKey: true })
host.keys.clear()
expect(await backend.read()).toMatchObject({ hasApiKey: false })
@@ -58,7 +58,7 @@ describe('desktop welcome Web operations', () => {
it('reads language without querying account or model providers', async () => {
const host = transport('zh')
const backend = await connectDesktopWelcome(url, host.send)
const backend = connectDesktopWelcome(url, cookie, host.send)
host.send.mockClear()
expect(await backend.readLocalePreference()).toBe('zh')
expect(host.send).toHaveBeenCalledOnce()
@@ -68,7 +68,7 @@ describe('desktop welcome Web operations', () => {
it('allows profiles without the official provider and retains custom key detection', async () => {
const host = transport()
host.namespaces.splice(0, 1)
const backend = await connectDesktopWelcome(url, host.send)
const backend = connectDesktopWelcome(url, cookie, host.send)
expect(await backend.read()).toMatchObject({ hasApiKey: false, writable: false })
host.keys.set('EXAMPLE_API_KEY', 'custom-key')
expect(await backend.read()).toMatchObject({ hasApiKey: true, writable: false })
@@ -78,7 +78,7 @@ describe('desktop welcome Web operations', () => {
it.each(['', 'bad key', 'key\n'])('rejects malformed keys without contacting a provider: %s', async (value) => {
const host = transport()
const backend = await connectDesktopWelcome(url, host.send)
const backend = connectDesktopWelcome(url, cookie, host.send)
host.send.mockClear()
expect(await backend.save(value)).toEqual({ ok: false })
expect(host.send).not.toHaveBeenCalled()
@@ -86,28 +86,22 @@ describe('desktop welcome Web operations', () => {
it('keeps provider diagnostics out of failures and rejects unmatched RPC envelopes', async () => {
const host = transport()
const backend = await connectDesktopWelcome(url, host.send)
const backend = connectDesktopWelcome(url, cookie, host.send)
host.send.mockRejectedValueOnce(new Error('private credential sk-must-not-leak'))
expect(await backend.save('sk-example')).toEqual({ ok: false })
host.send.mockResolvedValueOnce(Response.json({ type: 'server-response', rpcId: 'other', result: { ok: true } }))
await expect(backend.read()).rejects.toThrow('Web RPC failed')
})
it('submits native analytics through authenticated RPC with a bounded request', async () => {
const host = transport()
const backend = await connectDesktopWelcome(url, host.send)
const backend = connectDesktopWelcome(url, cookie, host.send)
const event = { eventName: 'desktop_app_launch' as const, timestamp: 100, attributes: {} }
await backend.report(event)
const [input, init] = host.send.mock.calls.at(-1)!
expect(input).toBe('http://127.0.0.1:19387/api/productAnalytics/report')
expect(init?.credentials).toBe('include')
expect(init?.headers).toMatchObject({ cookie })
expect(init?.signal).toBeInstanceOf(AbortSignal)
expect(JSON.parse(init!.body as string)).toMatchObject({ payload: { args: { event } } })
})
it('refuses an unauthenticated Web launch', async () => {
const send = vi.fn<Parameters<typeof connectDesktopWelcome>[1]>(async () => new Response(null, { status: 401 }))
await expect(connectDesktopWelcome(url, send)).rejects.toThrow('Web authentication failed')
})
})
+5 -4
View File
@@ -1,4 +1,6 @@
vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
vi.mock('../src/web-document.ts', () => ({
authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn(), removeLegacyHostCookies: async () => {},
}))
/** Welcome startup uses the Host before transitioning to the workspace. */
import { afterEach, expect, it, vi } from 'vitest'
@@ -84,10 +86,9 @@ vi.mock('electron', () => ({
showInactive = state.showInactiveWorkspace
async loadURL(url: string) { state.contents = this.webContents; await state.loadWorkspace(url); this.ready?.() }
},
net: { fetch: vi.fn() },
nativeTheme: state.nativeTheme,
session: { defaultSession: {
on: vi.fn(),
on: vi.fn(), cookies: {},
setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: vi.fn() },
} },
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() },
@@ -124,7 +125,7 @@ vi.mock('../src/host-process.ts', () => ({
},
}))
vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({
connectDesktopWelcome: () => ({
analyticsEnabled: async () => false,
readLocalePreference: async () => state.preference,
read: async () => {
+2 -2
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/web/tests/README.md
/apps-web-browser-e2e:
en: 2deef0df72a6465c
zh: b24989ffb2d8586a
en: 88e00180b920a42e
zh: 8ddba0f4bbb4f1f6
/apps-web-browser-e2e/completion-observations:
en: 8c139966983959cc
zh: 54f09f9b7ac84cda
+2
View File
@@ -16,6 +16,8 @@ Ordinary scenarios begin with no registered Workspace or Session and a durable m
Same-day message goldens use the shared `WEB_FIXTURE_TIME`: seeded transcripts pass it as `createdAt` and pin the browser calendar with `pinBrowserClock`; scenarios that submit live messages also use `pinHostClock` and restore it after teardown. These clocks advance with real elapsed time while keeping native timers intact, so midnight does not change a scenario's date labels.
Every scenario requires the whole client composition to activate. The product boot only warns about inactive entries outside its required list, but the `watchConsole` tripwire, the [real-host smoke](smoke-real.e2e.ts), and the [assembled jsdom boot](assembled-boot.ts) treat a `web boot: warning` as a failure. No scenario option relaxes this: an inactive plugin in a product composition is a defect to fix.
## Completion observations
State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Model-picker persistence assertions wait for the saved default, independently of menu closure. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. The [CI test reliability workflow](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state) owns these synchronization rules.
+2
View File
@@ -16,6 +16,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps
当天消息的 golden 使用共享的 `WEB_FIXTURE_TIME`:预置会话将它传为 `createdAt`,并通过 `pinBrowserClock` 固定浏览器日期;提交实时消息的场景还使用 `pinHostClock`,在清理后恢复。固定后的时钟随真实耗时前进,保留原生定时器,因此跨午夜不会改变场景的日期标签。
每个场景都要求整个客户端组合全部激活。产品启动只对必需名单外的未激活 entry 告警,但 `watchConsole` 绊线、[真实 Host 冒烟](smoke-real.e2e.ts)与[组装 jsdom 启动](assembled-boot.ts)把 `web boot: warning` 视为失败。没有任何场景选项放宽这一点:产品组合中出现未激活插件就是需要修复的缺陷。
## 完成状态观察
依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。模型选择器的持久化断言等待默认设置保存完成,不以菜单关闭作为完成信号。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。[CI 测试可靠性工作流](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state)负责这些同步规则。
+15 -1
View File
@@ -196,13 +196,16 @@ class EventSourceStub {
const win = window as FixtureWindow
let unmount: (() => Promise<void>) | undefined
let mountedRemote: RemoteMock | undefined
/** Boot audit warnings of the current test; the product boot tolerates inactive optional entries, this lane does not. */
let bootWarnings: string[] = []
/**
* Register the per-test jsdom setup and teardown the assembled boot needs:
* English pinned before boot so role/text locators stay deterministic across
* localized component migrations (the newEnglishPage e2e convention), the
* observers, font events, and frame callbacks jsdom lacks, and a full reset of the document,
* the boot globals, and the injected plugin styles afterwards.
* the boot globals, and the injected plugin styles afterwards. A boot audit warning about an
* inactive entry fails the test at teardown: every assembled composition must activate completely.
*/
export function installAssembledBootEnv(): void {
// jsdom implements no scroll geometry: the trigger menu reveals its
@@ -219,7 +222,15 @@ export function installAssembledBootEnv(): void {
})
}
let fontsDescriptor: PropertyDescriptor | undefined
let restoreWarn: (() => void) | undefined
beforeEach(() => {
bootWarnings = []
const warn = console.warn.bind(console)
const spy = vi.spyOn(console, 'warn').mockImplementation((message?: unknown, ...rest: unknown[]) => {
if (typeof message === 'string' && message.startsWith('web boot: warning')) bootWarnings.push(message)
warn(message, ...rest)
})
restoreWarn = () => { spy.mockRestore() }
fontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts')
Object.defineProperty(document, 'fonts', { configurable: true, value: new EventTarget() })
localStorage.clear()
@@ -267,6 +278,9 @@ export function installAssembledBootEnv(): void {
vi.unstubAllGlobals()
if (fontsDescriptor === undefined) Reflect.deleteProperty(document, 'fonts')
else Object.defineProperty(document, 'fonts', fontsDescriptor)
restoreWarn?.()
restoreWarn = undefined
if (bootWarnings.length > 0) failures.push(new Error(`assembled boot: every entry must activate\n${bootWarnings.join('\n')}`))
if (failures.length > 0) throw new AggregateError(failures, 'assembled boot teardown failed')
})
}
+1 -1
View File
@@ -44,7 +44,7 @@ it('disables unsupported bundles, shows the Host restriction, and refuses direct
const names = [name]
await page.locator('[data-plugin-panel]').getByRole('button', { name: '更多', exact: true }).click()
const cua = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]')
expect(await cua.count()).toBe(0)
expect(await cua.count()).toBe(process.platform === 'darwin' ? 1 : 0)
if (process.platform !== 'darwin') {
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle'))
.toMatchObject({ listedByDefault: false, enabled: false, error: { code: 'unsupported-platform' } })
+235
View File
@@ -0,0 +1,235 @@
/** Saved credentials and endpoints reach the assembled editor and provider requests. */
import { once } from 'node:events'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import { expect, it, onTestFinished } from 'vitest'
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode } from './scaffold.ts'
import { openSettings, ZH_BROWSER_LOCALE } from './support.ts'
it.each([false, true])('edits saved DeepSeek keys and restores environment detection (environment present: %s)', async (hasEnvironment) => {
const scaffold = await launchWebScaffold({
deepSeekMissingCredential: true,
extraOverlayPath: fileURLToPath(new URL('./fixtures/credential-editor.patch.yml', import.meta.url)),
openInAppEnvironment: createLaunchEnvironmentSnapshot([{
source: 'process', values: hasEnvironment ? { DEEPSEEK_API_KEY: 'fixture-environment-key' } : {},
}]),
})
try {
const browser = await chromium.launch()
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
const ref = credentialRef('DEEPSEEK_API_KEY')
await scaffold.ctx.credentials.set(ref, 'fixture-saved-deepseek-key')
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await openSettings(page, 'zh')
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '模型', exact: true }).click()
const edit = dialog.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)', exact: true })
await edit.click()
const key = dialog.getByLabel('API 密钥', { exact: true })
await expect.poll(() => key.inputValue()).toBe('fixture-saved-deepseek-key')
expect(await key.getAttribute('type')).toBe('password')
await compareOrRefreshGolden(
fileURLToPath(new URL('./expected/credential-editor/saved-key.expected.md', import.meta.url)),
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(),
)
await dialog.getByRole('button', { name: '显示密钥', exact: true }).click()
expect(await key.getAttribute('type')).toBe('text')
await key.fill('fixture-cancelled-key')
await dialog.getByRole('button', { name: '取消', exact: true }).click()
expect(await scaffold.ctx.credentials.readStored(ref)).toBe('fixture-saved-deepseek-key')
await edit.click()
await expect.poll(() => key.inputValue()).toBe('fixture-saved-deepseek-key')
expect(await key.getAttribute('type')).toBe('password')
await key.fill('fixture-replacement-key')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor()
expect(await scaffold.ctx.credentials.readStored(ref)).toBe('fixture-replacement-key')
await page.reload({ waitUntil: 'load' })
await openSettings(page, 'zh')
await dialog.getByRole('button', { name: '模型', exact: true }).click()
await edit.click()
await expect.poll(() => key.inputValue()).toBe('fixture-replacement-key')
await key.fill('')
expect(await key.getAttribute('placeholder')).toBe('留空使用环境认证(DEEPSEEK_API_KEY)')
await dialog.getByText('环境凭据状态尚未确认', { exact: true }).waitFor()
expect(await scaffold.ctx.credentials.readStored(ref)).toBe('fixture-replacement-key')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor()
expect(await scaffold.ctx.credentials.readStored(ref)).toBeUndefined()
// A missing credential opens the first-run settings card automatically;
// an environment credential keeps an ordinary row whose editor is closed.
if (hasEnvironment) await edit.click()
await expect.poll(() => key.inputValue()).toBe('')
expect(await key.getAttribute('placeholder')).toBe('留空使用环境认证(DEEPSEEK_API_KEY)')
const hint = hasEnvironment
? '已检测到环境变量(DEEPSEEK_API_KEY)'
: '未检测到环境变量。请填写 API 密钥,或设置环境变量 DEEPSEEK_API_KEY。'
const detection = dialog.getByText(hint, { exact: true })
await detection.waitFor()
const colors = await detection.evaluate((element, present) => {
const label = element as HTMLElement
const actual = getComputedStyle(label).color
const previous = label.style.color
label.style.color = present ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-label)'
const expected = getComputedStyle(label).color
label.style.color = previous
return { actual, expected }
}, hasEnvironment)
expect(colors.actual).toBe(colors.expected)
expect(await dialog.getByRole('button', { name: '显示密钥', exact: true }).count()).toBe(0)
expect((await scaffold.ctx.credentials.resolve(ref))?.source).toBe(hasEnvironment ? 'env' : undefined)
await compareOrRefreshGolden(
fileURLToPath(new URL(`./expected/credential-editor/${hasEnvironment ? 'detected' : 'missing'}-environment.expected.md`, import.meta.url)),
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(),
)
} finally { await browser.close() }
} finally { await scaffold.close() }
})
/** Local provider endpoint recording the destination path and authentication actually received. */
async function connectionProbe(messages: boolean) {
const calls: { path: string; key: string | undefined }[] = []
const server = createServer((request, response) => {
request.on('end', () => {
const key = messages ? request.headers['x-api-key'] : request.headers.authorization
calls.push({ path: request.url ?? '', key: typeof key === 'string' ? key : undefined })
const events = messages ? [
{ type: 'message_start', message: { id: 'probe', type: 'message', role: 'assistant', model: 'credential-editor-model', content: [], usage: { input_tokens: 1, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'connection-ok' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } },
{ type: 'message_stop' },
] : [
{ choices: [{ delta: { role: 'assistant', content: 'connection-ok' }, index: 0, finish_reason: null }] },
{ choices: [{ delta: {}, index: 0, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } },
]
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(events.map(event => `${'type' in event ? `event: ${event.type}\n` : ''}data: ${JSON.stringify(event)}\n\n`).join('')
+ (messages ? '' : 'data: [DONE]\n\n'))
})
request.resume()
})
onTestFinished(async () => {
await new Promise<void>((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() })
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('connection probe did not bind TCP')
return { url: `http://127.0.0.1:${String(address.port)}`, calls }
}
it.each([
{ provider: 'deepseek-official', ref: 'DEEPSEEK_API_KEY', messages: true },
{ provider: 'openai', ref: 'OPENAI_API_KEY', messages: false },
{ provider: 'anthropic', ref: 'ANTHROPIC_API_KEY', messages: true },
{ provider: 'credential-fixture', ref: 'CREDENTIAL_FIXTURE_API_KEY', messages: false },
])('uses the saved endpoint and key on the wire for $provider, then restores fallbacks', async ({ provider, ref, messages }) => {
const ambient = await connectionProbe(messages)
const saved = await connectionProbe(messages)
const root = await mkdtemp(join(tmpdir(), 'dsh-credential-routing-'))
onTestFinished(() => rm(root, { recursive: true, force: true }))
const overlay = join(root, 'connection.patch.yml')
const models = [{ id: 'credential-editor-model' }]
const baseSuffix = provider === 'anthropic' ? '' : '/v1'
const operation = provider === 'anthropic' ? '/v1/messages?beta=true' : messages ? '/messages' : '/chat/completions'
const ambientURL = `${ambient.url}/ambient${baseSuffix}`
await writeFile(overlay, JSON.stringify([
{ id: 'llm-deepseek', config: { models } },
...provider === 'deepseek-official' ? [] : [{
id: 'llm-pi-ai', config: { providers: { [provider]: {
api: messages ? 'anthropic-messages' : 'openai-completions',
baseURL: ambientURL, models,
...provider === 'credential-fixture' ? {} : { apiKeyEnv: ref },
} } },
}],
]))
const scaffold = await launchWebScaffold({
deepSeekMissingCredential: true,
extraOverlayPath: overlay,
openInAppEnvironment: createLaunchEnvironmentSnapshot([{
source: 'process', values: {
[ref]: 'fixture-environment-key',
DEEPSEEK_BASE_URL: ambientURL,
OPENAI_BASE_URL: ambientURL,
ANTHROPIC_BASE_URL: ambientURL,
},
}]),
})
try {
const browser = await chromium.launch()
try {
const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await openSettings(page, 'zh')
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '模型', exact: true }).click()
const edit = dialog.getByRole('button', {
name: `编辑 ${provider === 'deepseek-official' ? 'DeepSeek (deepseek-official)' : provider}`, exact: true,
})
const key = dialog.getByLabel('API 密钥', { exact: true })
const endpoint = dialog.getByLabel('API 地址', { exact: true })
const savedURL = `${saved.url}/configured${baseSuffix}`
const generate = async (): Promise<void> => {
const assembler = new BlockAssembler()
for await (const chunk of scaffold.ctx.llm.stream({ provider, model: 'credential-editor-model', messages: [] })) assembler.push(chunk)
expect(assembler.message({ provider, model: 'credential-editor-model' }).content).toContainEqual({ type: 'text', text: 'connection-ok' })
}
await edit.click()
await key.fill('fixture-saved-key')
await dialog.getByText('自定义设置', { exact: true }).click()
await endpoint.fill(savedURL)
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await expect.poll(() => key.count()).toBe(0)
await generate()
expect(saved.calls).toEqual([{ path: `/configured${baseSuffix}${operation}`, key: messages ? 'fixture-saved-key' : 'Bearer fixture-saved-key' }])
expect(ambient.calls).toEqual([])
await page.reload({ waitUntil: 'load' })
await openSettings(page, 'zh')
await dialog.getByRole('button', { name: '模型', exact: true }).click()
await edit.click()
expect(await key.inputValue()).toBe('fixture-saved-key')
expect(await key.getAttribute('type')).toBe('password')
await dialog.getByText('自定义设置', { exact: true }).click()
expect(await endpoint.inputValue()).toBe(savedURL)
await generate()
expect(saved.calls).toHaveLength(2)
expect(saved.calls[1]).toEqual(saved.calls[0])
expect(ambient.calls).toEqual([])
await key.fill('')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await expect.poll(() => key.count()).toBe(0)
await generate()
expect(saved.calls).toHaveLength(3)
expect(saved.calls[2]).toEqual({ path: `/configured${baseSuffix}${operation}`, key: messages ? 'fixture-environment-key' : 'Bearer fixture-environment-key' })
expect(ambient.calls).toEqual([])
await edit.click()
await key.fill('fixture-saved-key')
await dialog.getByText('自定义设置', { exact: true }).click()
await endpoint.fill('')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await expect.poll(() => key.count()).toBe(0)
await generate()
expect(saved.calls).toHaveLength(3)
expect(ambient.calls).toEqual([{ path: `/ambient${baseSuffix}${operation}`, key: messages ? 'fixture-saved-key' : 'Bearer fixture-saved-key' }])
await edit.click()
await key.fill('')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await expect.poll(() => key.count()).toBe(0)
await generate()
expect(saved.calls).toHaveLength(3)
expect(ambient.calls[1]).toEqual({ path: `/ambient${baseSuffix}${operation}`, key: messages ? 'fixture-environment-key' : 'Bearer fixture-environment-key' })
expect(ambient.calls).toHaveLength(2)
expect(await scaffold.ctx.credentials.readStored(credentialRef(ref))).toBeUndefined()
} finally { await browser.close() }
} finally { await scaffold.close() }
})
+8 -20
View File
@@ -53,22 +53,12 @@ it.skipIf(process.platform !== 'darwin').each([
await plugins.getByRole('button', { name: '更多', exact: true }).waitFor()
expect(await page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0)
await plugins.getByRole('button', { name: '更多', exact: true }).click()
// The installation ships the bundle off; the Experimental plugins list offers it without an installation step.
const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]')
expect(await card.count()).toBe(0)
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle'))
.toMatchObject({ listedByDefault: false, enabled: false, installed: false })
await plugins.getByRole('button', { name: '返回插件', exact: true }).click()
const offerCua = async () => {
await plugins.getByRole('button', { name: '添加插件', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '添加插件', exact: true })
await dialog.getByRole('textbox', { name: '包名或地址', exact: true }).fill('@deepseek-ai/dsh-cua-bundle')
await dialog.getByRole('button', { name: '安装', exact: true }).click()
const ready = page.getByRole('dialog', { name: '已安装', exact: true })
await ready.waitFor()
expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(0)
return ready.getByRole('button', { name: '立即启用', exact: true })
}
const enableNow = await offerCua()
await plugins.locator('[data-plugin-group="more"] [data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').waitFor()
const listed = (await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')
expect(listed).toMatchObject({ availability: 'installation', optional: true, enabled: false, installed: false })
expect(listed).not.toHaveProperty('listedByDefault')
let toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true })
const originalAgent = scaffold.ctx.agents.list()[0]!
const ptcAgent = (await scaffold.ctx.agents.create({
@@ -97,12 +87,10 @@ it.skipIf(process.platform !== 'darwin').each([
return applying
})
try {
await enableNow.click()
await expect.poll(() => enableNow.isEnabled()).toBe(false)
expect(await enableNow.getAttribute('aria-busy')).toBe('true')
expect(await card.count()).toBe(0)
await toggle.click()
await card.getByRole('status').getByText('正在应用 Computer Use 的更改', { exact: true }).waitFor()
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/enabling.expected.md', import.meta.url)),
await captureStableAria(page, '[data-install-phase="done"] button[aria-busy]', scaffold.workspaceCwd), webSnapshotMode())
await captureStableAria(page, '[data-plugin-package="@deepseek-ai/dsh-cua-bundle"] [role="status"]', scaffold.workspaceCwd), webSnapshotMode())
} finally {
enabling.resolve(undefined)
enableCall.mockRestore()
+1 -1
View File
@@ -42,7 +42,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
})
resources.scaffold = scaffold
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle'))
.toMatchObject({ listedByDefault: false, enabled: true, installed: true })
.toMatchObject({ availability: 'installation', enabled: true, installed: true })
const browser = await chromium.launch()
resources.browser = browser
const page = await newEnglishPage(browser), tripwire = watchConsole(page)
+2 -1
View File
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { connectDesktopWelcome } from '../../desktop/src/welcome-backend.ts'
import { authenticateWebHost } from '../../desktop/src/web-document.ts'
import { chromium, type Browser, type Page } from 'playwright'
import { afterEach, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
@@ -107,7 +108,7 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () =>
async function desktopPage(locale = ZH_BROWSER_LOCALE): Promise<Page> {
const opened = await browser.newPage({ viewport: { width: 1280, height: 840 }, locale, reducedMotion: 'reduce' })
const welcome = await connectDesktopWelcome(scaffold.authenticatedUrl, (input, init) => scaffold.hostFetch(input, init))
const welcome = connectDesktopWelcome(scaffold.baseUrl, await authenticateWebHost(scaffold.authenticatedUrl))
await opened.exposeFunction('__onboardingHasApiKey', async () => (await welcome.read()).hasApiKey)
await opened.addInitScript(() => {
Object.defineProperty(globalThis, 'dshOnboarding', { value: {
@@ -0,0 +1,25 @@
- dialog "设置":
- text: 设置
- navigation:
- button "通用设置"
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list:
- listitem:
- text: DeepSeek
- img "API 密钥已配置"
- button "编辑 DeepSeek (deepseek-official)": 编辑
- text: DeepSeek deepseek-official API 密钥
- button "认证说明": "?"
- textbox "API 密钥":
- /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY)
- paragraph: 已检测到环境变量(DEEPSEEK_API_KEY)
- group: 自定义设置
- button "取消"
- button "保存"
- button "添加模型提供商"
@@ -0,0 +1,23 @@
- dialog "设置":
- text: 设置
- navigation:
- button "通用设置"
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- status: 已保存 DeepSeek (deepseek-official)。
- list:
- listitem:
- text: DeepSeek deepseek-official API 密钥
- button "认证说明": "?"
- textbox "API 密钥":
- /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY)
- paragraph: 未检测到环境变量。请填写 API 密钥,或设置环境变量 DEEPSEEK_API_KEY。
- group: 自定义设置
- button "取消"
- button "保存"
- button "添加模型提供商"
@@ -0,0 +1,26 @@
- dialog "设置":
- text: 设置
- navigation:
- button "通用设置"
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list:
- listitem:
- text: DeepSeek
- img "API 密钥已配置"
- button "编辑 DeepSeek (deepseek-official)": 编辑
- text: DeepSeek deepseek-official API 密钥
- button "认证说明": "?"
- textbox "API 密钥":
- /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY)
- text: fixture-saved-deepseek-key
- button "显示密钥"
- group: 自定义设置
- button "取消"
- button "保存"
- button "添加模型提供商"
@@ -1 +1 @@
- button "立即启用" [disabled]
- status: 正在应用 Computer Use 的更改
@@ -17,7 +17,9 @@
- text: DeepSeek deepseek-official API 密钥
- button "认证说明": "?"
- textbox "API 密钥":
- /placeholder: 留空使用已保存凭据
- /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY)
- text: sk-messages-onboarding
- button "显示密钥"
- group:
- text: 自定义设置 API 地址
- textbox "API 地址":
@@ -17,7 +17,9 @@
- text: DeepSeek deepseek-official API 密钥
- button "认证说明": "?"
- textbox "API 密钥":
- /placeholder: 留空使用已保存凭据
- /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY)
- text: dsh_onboarding_fixture_key
- button "显示密钥"
- group:
- text: 自定义设置 API 地址
- textbox "API 地址":
@@ -17,7 +17,9 @@
- text: DeepSeek deepseek-official API 密钥
- button "认证说明": "?"
- textbox "API 密钥":
- /placeholder: 留空使用已保存凭据
- /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY)
- text: dsh_onboarding_fixture_key
- button "显示密钥"
- group:
- text: 自定义设置 API 地址
- textbox "API 地址":
+5
View File
@@ -0,0 +1,5 @@
- id: llm-deepseek
config:
baseURL: http://127.0.0.1
models:
- id: credential-editor-model
+30 -8
View File
@@ -102,6 +102,13 @@ describe('web e2e: settled Markdown math rendering', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await page.getByText(DONE, { exact: true }).waitFor({ timeout: 15_000 })
}, 120_000)
afterAll(async () => {
@@ -111,14 +118,6 @@ describe('web e2e: settled Markdown math rendering', () => {
it.skipIf(MODE === 'record')('renders the settled reply without KaTeX errors', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-math-rendering'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6)
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
expect(await page.locator('.katex-error').count()).toBe(0)
@@ -141,4 +140,27 @@ describe('web e2e: settled Markdown math rendering', () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 60_000)
it.skipIf(MODE === 'record')('copies a selection with formulas as TeX source', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-math-copy'))
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
const sentinel = 'clipboard-before-copy'
await page.evaluate(value => navigator.clipboard.writeText(value), sentinel)
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(sentinel)
await page.evaluate(() => {
const paragraph = [...document.querySelectorAll('p')].find(item => item.textContent.startsWith('Inline dollar'))!
const visual = paragraph.parentElement!.querySelector('.katex-display .katex-html')!
const range = document.createRange()
range.setStart(paragraph.firstChild!, 0)
range.setEnd(visual.firstChild!, 1)
document.getSelection()!.removeAllRanges()
document.getSelection()!.addRange(range)
})
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(
'Inline dollar $\\theta$ and backslash $\\frac{1}{5}$.\n\n$$\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}$$',
)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 30_000)
})
+5 -4
View File
@@ -2,7 +2,7 @@
// wire — the one add card offers the dormant pi-ai catalog on its third-party
// mode and the create form on its custom-API mode, a blank key saves a
// reference-free profile for provider-native auth, and typing an API key later
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
// stores it under the derived reference (`MINIMAX_CN_API_KEY`)
// while the settings document records only that reference. Each saved row
// appears after route topology invalidation without presenting liveness as
// provider status. The customized-settings fold writes its curated fields —
@@ -154,8 +154,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)
// Clearing it restores submit: an empty field means "keep what is stored",
// never a refusal, or editing any other setting would demand the key.
// An empty field leaves this new profile on native authentication.
await key.fill('')
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true)
expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0)
@@ -221,7 +220,9 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
await dialog.getByPlaceholder('留空使用已保存凭据', { exact: true }).waitFor({ timeout: 10_000 })
const savedKey = dialog.getByLabel('API 密钥', { exact: true })
await expect.poll(() => savedKey.inputValue(), { timeout: 10_000 }).toBe('sk-e2e-minimax')
expect(await savedKey.getAttribute('type')).toBe('password')
expect(await dialog.textContent()).not.toContain('API 密钥来源:')
expect(await dialog.textContent()).not.toMatch(/oauth/i)
await dialog.getByText('自定义设置').click()
@@ -2,7 +2,6 @@
// credential is absent, both ordered steps share the shipped modal chrome,
// and the inline key write lands in an isolated harness home without a reload
// or model call.
import { randomBytes } from 'node:crypto'
import { assertModelInputLayout } from './model-input-layout.ts'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
@@ -33,6 +32,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const browserConsole: string[] = []
const secret = 'dsh_onboarding_fixture_key'
beforeAll(async () => {
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
@@ -50,7 +50,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await scaffold?.close()
})
it('stores a key write-only and observes configured state without restarting', async () => {
it('stores a key and loads it masked only in an explicitly opened editor', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
await welcome.waitFor({ timeout: 15_000 })
@@ -86,7 +86,6 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}`
await keyInput.fill(secret)
await credentialStep.getByRole('button', { name: '保存并继续' }).click()
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
@@ -101,8 +100,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`)
// The ordinary Models surface reuses the refreshed join and exposes the
// configured write-only placeholder without a reload.
// Opening the ordinary Models editor loads the saved key masked.
await openSettings(page, 'zh')
const settings = page.getByRole('dialog', { name: '设置' })
await settings.waitFor({ timeout: 10_000 })
@@ -113,9 +111,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
const configuredInput = settings.getByLabel('API 密钥', { exact: true })
await configuredInput.waitFor({ timeout: 10_000 })
await expect.poll(
() => configuredInput.getAttribute('placeholder'),
() => configuredInput.inputValue(),
{ timeout: 10_000 },
).toBe('留空使用已保存凭据')
).toBe(secret)
expect(await configuredInput.getAttribute('type')).toBe('password')
const secondReloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
@@ -255,9 +254,9 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
await expect.poll(
() => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'),
() => settings.getByLabel('API 密钥', { exact: true }).inputValue(),
{ timeout: 10_000 },
).toBe('留空使用已保存凭据')
).toBe(secret)
const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE)
await settings.getByRole('button', { name: '保存', exact: true }).click()
+14 -10
View File
@@ -855,9 +855,12 @@ describe('web e2e: plugin manager', () => {
.toBe(bundles.filter(bundle => bundle.name !== '@deepseek-ai/dsh-experimental-terminal-bundle'
&& bundle.name !== '@deepseek-ai/dsh-experimental-liveshare'
&& bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length)
// Computer Use ships with the installation and is listed only on the macOS Hosts it supports.
const cuaSupported = process.platform === 'darwin'
expect(bundles.find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle'))
.toMatchObject({ enabled: false, optional: false, availability: 'missing', listedByDefault: false })
expect(await panel.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0)
.toMatchObject({ enabled: false, optional: true, availability: 'installation', ...cuaSupported ? {} : { listedByDefault: false } })
expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count())
.toBe(cuaSupported ? 1 : 0)
for (const name of ['@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) {
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name))
.toMatchObject({ enabled: false, optional: true, availability: 'installation', listedByDefault: false })
@@ -874,6 +877,7 @@ describe('web e2e: plugin manager', () => {
['@deepseek-ai/dsh-experimental-forge-bundle', 1],
['@deepseek-ai/dsh-personalized-recommendations', 1],
['@deepseek-ai/dsh-experimental-inspector-profile', 1],
...cuaSupported ? [['@deepseek-ai/dsh-cua-bundle', 1]] : [],
['@deepseek-ai/dsh-experimental-tool-worktree', 1],
['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1],
['@deepseek-ai/dsh-experimental-session-titles-bundle', 1],
@@ -1433,8 +1437,8 @@ describe('web e2e: plugin manager', () => {
// on, so writing the patch file recomposes the running loader; each step first waits for that
// recomposition and then re-reads the Host bundle list through the page's own Refresh.
const patchFile = join(visibilityScaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml')
const declarationsRow = (): boolean => [...visibilityScaffold.ctx.loader.entries()]
.some(entry => entry.options.id === 'liveshare-viewer-declarations')
const shareHostRow = (): boolean => [...visibilityScaffold.ctx.loader.entries()]
.some(entry => entry.options.id === 'liveshare-share-host')
const refresh = panel.getByRole('button', { name: '刷新', exact: true })
// Default install: the package ships, but no row it declares runs, so neither list offers it.
@@ -1452,12 +1456,12 @@ describe('web e2e: plugin manager', () => {
// selects no such bundle: the card joins the experimental list.
await writeFile(patchFile, [
'- insert:',
' - id: liveshare-viewer-declarations',
" name: '@deepseek-ai/dsh-experimental-liveshare/viewer-declarations'",
' - id: liveshare-share-host',
" name: '@deepseek-ai/dsh-experimental-liveshare'",
'',
].join('\n'))
await expect.poll(declarationsRow, {
timeout: 30_000, message: 'the profile patch did not compose the LiveShare declarations row',
await expect.poll(shareHostRow, {
timeout: 30_000, message: 'the profile patch did not compose the LiveShare share-host row',
}).toBe(true)
await refresh.click()
await expect.poll(() => card.count(), {
@@ -1466,8 +1470,8 @@ describe('web e2e: plugin manager', () => {
// Removing the row stops it: the card leaves both lists again.
await writeFile(patchFile, '[]\n')
await expect.poll(declarationsRow, {
timeout: 30_000, message: 'the profile patch did not remove the LiveShare declarations row',
await expect.poll(shareHostRow, {
timeout: 30_000, message: 'the profile patch did not remove the LiveShare share-host row',
}).toBe(false)
await refresh.click()
await expect.poll(() => card.count(), {
+9 -7
View File
@@ -694,7 +694,6 @@ async function toggleOptionalBundles(page: Page): Promise<void> {
})
}
return {
cua: bundles.filter(entry => entry.name === '@deepseek-ai/dsh-cua-bundle'),
optional: bundles.filter(entry => entry.optional).map(entry => ({
name: entry.name, enabled: entry.enabled, availability: entry.availability, error: entry.error ?? null,
title: typeof entry.meta?.title, icon: typeof entry.meta?.icon, metaError: entry.meta?.error ?? null,
@@ -706,16 +705,19 @@ async function toggleOptionalBundles(page: Page): Promise<void> {
})
expect(result.optional.map(entry => entry.name).sort()).toEqual([...OPTIONAL_BUNDLES].sort())
expect(result.optional.filter(entry => entry.enabled || entry.availability !== 'installation')).toEqual([])
expect(result.optional.filter(entry => entry.error !== null)).toEqual([])
expect(result.cua).toHaveLength(1)
expect(result.cua[0]).toMatchObject({ name: '@deepseek-ai/dsh-cua-bundle', optional: false,
availability: 'missing', error: { code: 'unsupported-platform' } })
expect(result.optional.filter(entry => entry.error !== null)).toEqual([
expect.objectContaining({ name: '@deepseek-ai/dsh-cua-bundle', error: { code: 'unsupported-platform' } }),
])
// Metadata resolves through the Worker's Node internal modules: every optional bundle shows its title and icon.
expect(result.optional.filter(entry => entry.metaError !== null || entry.title !== 'object' || entry.icon !== 'string')).toEqual([])
expect(result.inventoryErrors).toEqual([])
console.info(`preview boot: optional bundle toggles ${JSON.stringify(result.toggles.map(({ name, enable, disable, error }) => ({ name, enable, disable, error })))}`)
expect(result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')).toBeUndefined()
const supported = result.toggles
expect(result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')).toMatchObject({
enable: 'failed', disable: 'applied', error: { code: 'unsupported-platform' },
changed: 0, inactive: [], restored: true,
})
// Computer Use refuses enablement on this non-macOS worker Host, as asserted above, so the generic check omits it.
const supported = result.toggles.filter(toggle => toggle.name !== '@deepseek-ai/dsh-cua-bundle')
expect(supported.filter(toggle => toggle.enable !== 'applied' || toggle.disable !== 'applied')
.map(({ name, enable, disable }) => ({ name, enable, disable }))).toEqual([])
// Each bundle changes Loader rows, every enabled row it changes runs, and switching it off restores the prior rows.
+5 -3
View File
@@ -1761,10 +1761,12 @@ export async function assertFixtureInventory(dir: string, expected: string[]): P
/**
* Console tripwires: reconnect/gap-repair self-healing or a pageerror must
* fail the scenario, not mask a dead wire behind eventual consistency.
* fail the scenario, not mask a dead wire behind eventual consistency. A boot
* audit warning fails it too: the product boot tolerates inactive optional
* entries, but every e2e composition must activate completely.
*/
export interface WebConsoleTripwire {
/** Console warnings matching reconnect/gap-repair/discontinuity copy. */
/** Console warnings matching reconnect/gap-repair/discontinuity copy or the boot audit's inactive-entry warning. */
warnings: string[]
/** Uncaught page errors. */
pageErrors: string[]
@@ -1780,7 +1782,7 @@ export function watchConsole(page: Page): WebConsoleTripwire {
const pageErrors: string[] = []
page.on('console', (message) => {
const text = message.text()
if (/connection lost|gap repair|discontinuous/i.test(text)) warnings.push(text)
if (/connection lost|gap repair|discontinuous|web boot: warning/i.test(text)) warnings.push(text)
})
page.on('pageerror', (error) => { pageErrors.push(String(error)) })
return { warnings, pageErrors }
+91 -2
View File
@@ -1,5 +1,7 @@
/** Browser geometry for sidebar controls, text clearance, and drag previews. */
import { readFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
@@ -179,6 +181,44 @@ describe('web e2e: sidebar search alignment and row action targets', () => {
expect(tripwire.warnings).toEqual([])
})
it.each(['Search sessions', 'View options', 'Add workspace'])('keeps the %s tooltip visible across its full bounds', async (name) => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-toolbar-tooltip'))
const trigger = page.getByRole('button', { name, exact: true })
await trigger.hover()
const tooltip = page.getByRole('tooltip')
await tooltip.waitFor({ state: 'visible', timeout: 10_000 })
const bounds = await tooltip.evaluate((element: HTMLElement) => {
const rect = element.getBoundingClientRect()
const sidebar = document.querySelector('[class*="sidebarCol"]')!.getBoundingClientRect()
const originalStyle = element.getAttribute('style')
try {
// Tooltips ignore pointer input; enabling it only for this read exposes ancestor clipping.
element.style.pointerEvents = 'auto'
const points = [
[rect.left + 2, rect.top + rect.height / 2],
[rect.right - 2, rect.top + rect.height / 2],
[rect.left + rect.width / 2, rect.top + 2],
[rect.left + rect.width / 2, rect.bottom - 2],
]
return {
outsideSidebar: rect.right > sidebar.right,
hits: points.map(([x, y]) => {
const hit = document.elementFromPoint(x!, y!)
return hit !== null && element.contains(hit)
}),
}
} finally {
if (originalStyle === null) element.removeAttribute('style')
else element.setAttribute('style', originalStyle)
}
})
if (name === 'Add workspace') expect(bounds.outsideSidebar).toBe(true)
expect(bounds.hits).toEqual([true, true, true, true])
await page.mouse.move(1000, 400)
await tooltip.waitFor({ state: 'detached' })
expect(tripwire.pageErrors).toEqual([])
})
it('interpolates header spacing with the search expansion and collapse', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-search-transition'))
const header = page.locator('[class*="sectionHeader"]').filter({ has: page.getByRole('button', { name: 'Search sessions', exact: true }) })
@@ -368,6 +408,55 @@ describe('web e2e: sidebar search alignment and row action targets', () => {
expect(tripwire.pageErrors).toEqual([])
})
it('spans the first root workspace with its insertion marker during a native drag', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-first-workspace-drop'))
const directory = await mkdtemp(join(tmpdir(), 'dsh-sidebar-drag-'))
try {
const extra = await scaffold.ctx.workspaceRegistry.create(directory, 'Sidebar drag workspace')
try {
const rows = page.locator('[role="tree"][aria-label="Sessions"] [data-row-key^="workspace:"]')
await expect.poll(() => rows.count()).toBe(2)
const before = await rows.evaluateAll(elements => elements.map(element => element.getAttribute('data-row-key')))
await rows.last().hover()
const source = await rows.last().boundingBox()
const target = await rows.first().boundingBox()
if (source === null || target === null) throw new Error('Workspace drag rows have no layout boxes')
await page.mouse.move(source.x + 30, source.y + source.height / 2)
await page.mouse.down()
try {
await page.mouse.move(source.x + 40, source.y + source.height / 2, { steps: 3 })
await page.mouse.move(target.x + 30, target.y + 2, { steps: 5 })
await page.mouse.move(target.x + 31, target.y + 2)
const marker = page.locator('[class*="listTopDropIndicator"]')
await marker.waitFor({ state: 'attached', timeout: 10_000 })
const geometry = await marker.evaluate((element) => {
const rect = element.getBoundingClientRect()
const tree = element.parentElement!.querySelector('[role="tree"]')!.getBoundingClientRect()
const first = element.parentElement!.querySelector('[data-row-key^="workspace:"]')!.getBoundingClientRect()
return {
width: rect.width, height: rect.height,
treeWidth: tree.width, leftInset: rect.left - first.left,
rightInset: first.right - rect.right, topOffset: rect.top - first.top,
}
})
expect(geometry.width).toBeGreaterThan(0)
expect(geometry.width).toBe(geometry.treeWidth)
expect(geometry).toMatchObject({ height: 12, leftInset: 0, rightInset: 0, topOffset: -8 })
} finally {
await page.mouse.up()
}
await expect.poll(() => rows.evaluateAll(elements => elements.map(element => element.getAttribute('data-row-key'))))
.toEqual(before.toReversed())
} finally {
await scaffold.ctx.workspaceRegistry.delete(extra.id)
}
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.each(['session', 'workspace'] as const)('retains %s row spacing in the native drag preview', async (kind) => {
onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-${kind}-drag-preview`))
const row = kind === 'session'
@@ -496,7 +585,7 @@ describe('web e2e: sidebar search alignment and row action targets', () => {
await row.getByRole('button', { name: labels[0], exact: true }).waitFor()
const metrics = await measureRowActions(row)
expect(await row.boundingBox()).toEqual(restingBox)
expect(metrics.height).toBe(kind === 'session' ? 32 : 34)
expect(metrics.height).toBe(32)
expect(metrics.overflows).toBe(false)
expect(metrics.actions.map(action => action.label)).toEqual(labels)
for (const [index, action] of metrics.actions.entries()) {
+160 -22
View File
@@ -56,7 +56,7 @@ interface ListMetrics {
*/
function measureList(page: Page): Promise<ListMetrics> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
const list = document.querySelector<HTMLElement>('[data-sidebar-list-area]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const time = list.querySelector<HTMLElement>('[class*="time"]')
if (time === null) throw new Error('no row relative-time element in the sidebar list')
@@ -94,8 +94,8 @@ function measureList(page: Page): Promise<ListMetrics> {
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
const listRect = list.getBoundingClientRect()
const listParentRight = list.parentElement?.getBoundingClientRect().right
if (listParentRight === undefined) throw new Error('sidebar session list has no layout parent')
const sidebarEdge = list.closest('[class*="sidebarCol"]')?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
return {
gutter: style.scrollbarGutter,
width: pseudoWidth,
@@ -107,9 +107,9 @@ function measureList(page: Page): Promise<ListMetrics> {
hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
overflows: list.scrollHeight > list.clientHeight,
band: listRect.width - list.clientWidth,
scrollbarEdgeOffset: listParentRight - listRect.right,
rowEdgeInset: listParentRight - row.getBoundingClientRect().right,
rowPaintInset: listParentRight - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth),
scrollbarEdgeOffset: sidebarEdge - listRect.right,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
rowPaintInset: sidebarEdge - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth),
clientRight: listRect.left + list.clientWidth,
borderRight: listRect.right,
timeRight: time.getBoundingClientRect().right,
@@ -133,16 +133,16 @@ function measureList(page: Page): Promise<ListMetrics> {
*/
function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset' | 'rowPaintInset'>> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
const list = document.querySelector<HTMLElement>('[data-sidebar-list-area]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
const listParentRight = list.parentElement?.getBoundingClientRect().right
if (listParentRight === undefined) throw new Error('sidebar session list has no layout parent')
const sidebarEdge = list.closest('[class*="sidebarCol"]')?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
return {
overflows: list.scrollHeight > list.clientHeight,
rowEdgeInset: listParentRight - row.getBoundingClientRect().right,
rowPaintInset: listParentRight - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth),
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
rowPaintInset: sidebarEdge - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth),
}
})
}
@@ -195,9 +195,9 @@ function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
`- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`,
`- list overflows: ${String(metrics.overflows)}`,
`- reserved band: ${String(metrics.band)}px`,
`- scrollbar inset from the list parent edge: ${String(metrics.scrollbarEdgeOffset)}px`,
`- row hitbox inset from the list parent edge: ${String(metrics.rowEdgeInset)}px`,
`- row background inset from the list parent edge: ${String(metrics.rowPaintInset)}px`,
`- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
`- row hitbox inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
`- row background inset from the sidebar edge: ${String(metrics.rowPaintInset)}px`,
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
@@ -220,7 +220,7 @@ function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
*/
function resolveThumb(page: Page): Promise<string> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
const list = document.querySelector<HTMLElement>('[data-sidebar-list-area]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const probe = document.createElement('span')
probe.style.color = 'var(--dsh-scrollbar-thumb)'
@@ -243,7 +243,7 @@ const NO_THUMB = 'rgba(0, 0, 0, 0)'
* of the viewport (the conversation column).
*/
async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
const box = await page.locator('[role="tree"][aria-label="Sessions"]').boundingBox()
const box = await page.locator('[data-sidebar-list-area]').boundingBox()
if (box === null) throw new Error('sidebar session list has no layout box')
const viewport = page.viewportSize()
if (viewport === null) throw new Error('page has no viewport')
@@ -272,18 +272,63 @@ async function expandSeededSessions(page: Page): Promise<void> {
}
const showMore = page.getByRole('button', { name: /Show \d+ more sessions/ })
if (await bucket.getAttribute('aria-expanded') === 'true'
&& await rows.count() <= SEED_COUNT / 2
&& await rows.count() < SEED_COUNT
&& await showMore.count() > 0) {
await showMore.click()
}
if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return
if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() >= SEED_COUNT) return
if (Date.now() > deadline) {
throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`)
throw new Error(`Ungrouped bucket never revealed all ${SEED_COUNT} rows`)
}
await page.waitForTimeout(200)
}
}
/**
* Scroll positions of the sidebar viewport and the session list, with the fixed controls around them.
* @param page - the page under test.
* @returns both scroll offsets and ranges, plus the toolbar, navigation, and fixed-control edges.
*/
function nestedScrollGeometry(page: Page) {
return page.evaluate(() => {
const viewport = document.querySelector<HTMLElement>('[data-sidebar-scrollport]')!
const list = document.querySelector<HTMLElement>('[data-sidebar-list-area]')!
const column = viewport.closest('[class*="sidebarCol"]')!
const edge = (selector: string) => column.querySelector(selector)!.getBoundingClientRect()
return {
viewportTop: viewport.getBoundingClientRect().top,
viewportScroll: viewport.scrollTop,
viewportRange: viewport.scrollHeight - viewport.clientHeight,
listScroll: list.scrollTop,
listRange: list.scrollHeight - list.clientHeight,
headerTop: edge('[data-sidebar-workspace-header]').top,
navBottom: edge('[class*="panelList"]').bottom,
buttonTop: edge('button[class*="newSession"]').top,
footerTop: edge('[class*="footArea"]').top,
}
})
}
/**
* Return both scrollers to their tops.
* @param page - the page under test.
*/
async function resetNestedScroll(page: Page): Promise<void> {
await page.locator('[data-sidebar-list-area]').evaluate((el) => { el.scrollTop = 0 })
await page.locator('[data-sidebar-scrollport]').evaluate((el) => { el.scrollTop = 0 })
}
/**
* Park the pointer at the center of the first element matching `selector`.
* @param page - the page under test.
* @param selector - the element to point at.
*/
async function pointInto(page: Page, selector: string): Promise<void> {
const box = await page.locator(selector).first().boundingBox()
if (box === null) throw new Error(`${selector} has no layout box`)
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
}
describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thumb)', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -322,6 +367,12 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
await expect.poll(async () => (await measureList(page)).overflows, { timeout: 10_000 }).toBe(true)
const metrics = await measureList(page)
expect(metrics.gutter).toBe('stable')
const horizontal = await page.locator('[data-sidebar-scrollport]').evaluate(viewport => ({
overflow: getComputedStyle(viewport).overflowX,
scrollbarHeight: viewport.getBoundingClientRect().height - viewport.clientHeight,
}))
expect(horizontal.overflow).toBe('hidden')
expect(horizontal.scrollbarHeight).toBe(0)
// Pin presence, not width, because the width is platform-dependent.
expect(metrics.band).toBeGreaterThan(0)
expect(metrics.scrollbarEdgeOffset).toBe(2)
@@ -354,7 +405,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
// leaves the column quiet. This is the one deliberate loss, and
// it is pinned here rather than only described, so making a scroll
// re-reveal the bar has to be a decision rather than a side effect.
await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 })
await page.locator('[data-sidebar-list-area]').evaluate((el) => { el.scrollTop += 200 })
await page.waitForTimeout(500)
expect(await resolveThumb(page)).toBe(NO_THUMB)
await pointAt(page, 'list')
@@ -362,16 +413,103 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the row background inset when overflow disappears', async () => {
it('removes both scroll ranges without moving row edges when grouped content fits', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
const viewport = page.locator('[data-sidebar-list-area]')
const height = await viewport.evaluate(el => el.scrollHeight)
expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 8, rowPaintInset: 11 })
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
await bucket.click()
try {
await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false)
await expect.poll(async () => viewport.evaluate(el => el.scrollHeight)).toBeLessThan(height)
await expect.poll(async () => (await measureRowInset(page)).overflows).toBe(false)
expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 8, rowPaintInset: 11 })
// A section that fits leaves the sidebar viewport nothing to scroll, so the navigation stays.
expect((await nestedScrollGeometry(page)).viewportRange).toBe(0)
} finally {
await expandSeededSessions(page)
await viewport.evaluate((el) => { el.scrollTop = 0 })
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('hands one downward wheel event from the navigation to the session list and back', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-nested-scroll'))
const size = page.viewportSize()!
try {
await page.setViewportSize({ width: size.width, height: 500 })
await resetNestedScroll(page)
const before = await nestedScrollGeometry(page)
expect(before.viewportRange).toBeGreaterThan(0)
expect(before.listRange).toBeGreaterThan(60)
expect(before.headerTop).toBeGreaterThan(before.viewportTop)
await pointInto(page, '[data-sidebar-list-area] [role="treeitem"]')
await page.mouse.wheel(0, before.viewportRange + 60)
await expect.poll(async () => (await nestedScrollGeometry(page)).listScroll).toBe(60)
const after = await nestedScrollGeometry(page)
expect(after.viewportScroll).toBe(before.viewportRange)
expect(after.headerTop).toBe(after.viewportTop)
expect(after.navBottom).toBeLessThanOrEqual(after.viewportTop)
expect(after.buttonTop).toBe(before.buttonTop)
expect(after.footerTop).toBe(before.footerTop)
await page.mouse.wheel(0, -110)
await expect.poll(async () => (await nestedScrollGeometry(page)).viewportScroll).toBe(before.viewportRange - 50)
expect((await nestedScrollGeometry(page)).listScroll).toBe(0)
} finally {
await page.setViewportSize(size)
await resetNestedScroll(page)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('continues a wheel event that starts over the navigation into the session list', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-nested-scroll-navigation'))
const size = page.viewportSize()!
try {
await page.setViewportSize({ width: size.width, height: 500 })
await resetNestedScroll(page)
const before = await nestedScrollGeometry(page)
await pointInto(page, '[data-sidebar-scrollport] [class*="panelList"]')
await page.mouse.wheel(0, before.viewportRange + 30)
await expect.poll(async () => (await nestedScrollGeometry(page)).listScroll).toBe(30)
expect((await nestedScrollGeometry(page)).viewportScroll).toBe(before.viewportRange)
} finally {
await page.setViewportSize(size)
await resetNestedScroll(page)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the toolbar at the top through empty search results', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-nested-scroll-search'))
const header = page.locator('[data-sidebar-workspace-header]')
const input = header.locator('input')
const size = page.viewportSize()!
try {
await page.setViewportSize({ width: size.width, height: 500 })
await resetNestedScroll(page)
const range = (await nestedScrollGeometry(page)).viewportRange
await pointInto(page, '[data-sidebar-list-area] [role="treeitem"]')
await page.mouse.wheel(0, range)
await expect.poll(async () => {
const geometry = await nestedScrollGeometry(page)
return geometry.headerTop === geometry.viewportTop
}).toBe(true)
const searchButton = await page.getByRole('button', { name: 'Search sessions', exact: true }).boundingBox()
if (searchButton === null) throw new Error('Search sessions has no layout box')
await page.mouse.click(searchButton.x + searchButton.width / 2, searchButton.y + searchButton.height / 2)
expect(await input.evaluate(el => el === document.activeElement)).toBe(true)
await input.fill('no-sidebar-scroll-matches-9f4c')
await page.locator('[class*="searchTree"]').locator('..').locator('[class*="empty"]').waitFor()
const filtered = await nestedScrollGeometry(page)
expect(filtered.viewportScroll).toBe(range)
expect(filtered.headerTop).toBe(filtered.viewportTop)
expect((await header.boundingBox())?.height).toBe(36)
} finally {
if (await input.isVisible()) { await input.fill(''); await input.press('Escape') }
await page.setViewportSize(size)
await expandSeededSessions(page)
await resetNestedScroll(page)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
+21 -2
View File
@@ -606,6 +606,7 @@ describe('dsh web keyless CLI smoke', () => {
it.each(['native', 'ptc'] as const)('selects the %s executor through profile overlays and ignores the retired environment selector', async (mode) => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-ptc-'))
const promptMarker = `WEB_EXECUTOR_${mode}_${randomUUID()}`
interface PtcModeProviderRequest {
system?: string
@@ -621,7 +622,11 @@ describe('dsh web keyless CLI smoke', () => {
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
resolveProviderRequest(JSON.parse(body) as PtcModeProviderRequest)
const parsed = JSON.parse(body) as PtcModeProviderRequest
const messages = JSON.stringify(parsed.messages ?? [])
if (messages.includes(promptMarker) && !messages.includes(TITLE_PROMPT_MARKER)) {
resolveProviderRequest(parsed)
}
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(messagesResponse('done'))
})
@@ -653,11 +658,19 @@ describe('dsh web keyless CLI smoke', () => {
try {
const baseUrl = await waitForReadyLine(child)
const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} })
// Title requests share the provider; exercise auxiliary-first arrival explicitly.
await fetch(`http://127.0.0.1:${address.port}`, {
method: 'POST',
body: JSON.stringify({ messages: [{
role: 'user',
content: [{ type: 'text', text: `${TITLE_PROMPT_MARKER}\n${JSON.stringify([promptMarker])}` }],
}] }),
}).then(response => response.text())
await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: {
requestId: randomUUID(),
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'go' }],
content: [{ type: 'text', text: promptMarker }],
} })
const captured = await Promise.race([
providerRequest,
@@ -693,6 +706,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
let browser: Browser
let page: Page
const pageErrors: string[] = []
// The product boot only warns about inactive optional entries; this lane requires the whole composition.
const bootWarnings: string[] = []
beforeAll(async () => {
requireDist()
@@ -729,6 +744,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
browser = await chromium.launch()
page = await newEnglishPage(browser)
page.on('pageerror', e => pageErrors.push(String(e)))
page.on('console', (message) => {
if (message.text().startsWith('web boot: warning')) bootWarnings.push(message.text())
})
await page.goto(baseUrl, { waitUntil: 'load' })
}, 120_000)
@@ -747,6 +765,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
onTestFailed(() => saveFailureShot(page, 'w5-cold-start'))
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
expect(bootWarnings).toEqual([])
const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns)
expect(template.split(' ').length).toBe(3)
await screen(page, '01-cold-start')
+1
View File
@@ -23,6 +23,7 @@
// this client-registered project must not also hold them (one program
// cannot see both sides of the cordis Context merges).
"exclude": [
"tests/credential-editor.e2e.ts",
"tests/sidebar-drawer.e2e.ts",
"tests/creation-recommendations.e2e.ts",
"tests/plugin-recommendations.e2e.ts",
+26 -2
View File
@@ -92,6 +92,25 @@ async function worker<Report>(args: readonly string[], semiSpaceMb?: number): Pr
return run.report
}
/** Compare adjacent scaling samples before aggregating, so unrelated fastest rounds cannot set the ratio. */
function doublingRatio(half: readonly number[], full: readonly number[]): number {
const ratios = half.map((duration, index) => full[index]! / duration).sort((a, b) => a - b)
return ratios[Math.floor(ratios.length / 2)]!
}
describe('credential-matcher scaling measurement', () => {
it('compares adjacent rounds when the runner changes speed', () => {
const half = [1, 2, 2, 2, 2]
const full = [4, 4, 4, 4, 4]
expect(Math.min(...full) / Math.min(...half)).toBeGreaterThan(MAX_DOUBLING_SCALING)
expect(doublingRatio(half, full)).toBe(2)
})
it('rejects superlinear growth even when one round is faster', () => {
expect(doublingRatio([1, 2, 2, 2, 2], [2, 6, 6, 6, 6])).toBeGreaterThan(MAX_DOUBLING_SCALING)
})
})
describe('sent user text with the credential matcher', () => {
let timings: ReadonlyMap<string, UserTextWorkloadTiming>
let allocations: ReadonlyMap<string, UserTextWorkloadAllocation>
@@ -115,6 +134,11 @@ describe('sent user text with the credential matcher', () => {
measures: `${benchmark.measures} Minimum of ${ATTEMPTS} per-sample means of ${measured.evaluationsPerSample} fresh evaluations after warm-up.`,
}, { withSecretMs: { ms: measured.withSecretMs, cpuMs: measured.withSecretCpuMs } }, { withSecretMs: budgetMs })
}
const expectLinear = (half: UserTextWorkloadTiming, full: UserTextWorkloadTiming): void => {
const ratio = doublingRatio(half.samples.map(sample => sample.withSecretMs), full.samples.map(sample => sample.withSecretMs))
console.log(JSON.stringify({ benchmark: `user-text/${full.id}/scaling`, ratio, budget: MAX_DOUBLING_SCALING }))
expect(ratio).toBeLessThanOrEqual(MAX_DOUBLING_SCALING)
}
it.each(Object.entries(UNMATCHED_EXPECTED))('evaluates %s without credentials within its budget and ratio', (id, expected) => {
const measured = timing(id)
@@ -139,7 +163,7 @@ describe('sent user text with the credential matcher', () => {
const half = timing('env-keys-1000')
const full = timing('env-keys-2000')
expect([half.secrets, full.secrets]).toEqual([1_000, 2_000])
expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING)
expectLinear(half, full)
})
it.each(ADVERSARIAL)('scans adversarial %s input linearly without backtracking', (name) => {
@@ -148,7 +172,7 @@ describe('sent user text with the credential matcher', () => {
const budgetMs = ciTimeBudget(ADVERSARIAL_EXPECTED_MS)
record(benchmarkCase(`adversarial-${name}`, `One 128 KiB document repeating a ${name} unit.`), full, budgetMs)
expect(full.withSecretMs).toBeLessThanOrEqual(budgetMs)
expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING)
expectLinear(half, full)
})
it.each(workloadIds())('allocates only result nodes and masks for %s', (id) => {
+2 -2
View File
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
en: 3c972fccec17a890
zh: 94c82847a3c8b83c
en: 6215d15e0e273fa4
zh: dec5c7779e0a42f5
+1 -1
View File
@@ -698,7 +698,7 @@ flowchart LR
| `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. |
| `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. |
| `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session. |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free metadata, writes, and owner-only reads of stored keys. |
| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The Host owns browser authorization and local credentials; UI consumers receive state without tokens. |
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. |
| `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | Accepts selected Desktop events, enriches available login identity, and observes live compaction under the live Host collection policy. |
+1 -1
View File
@@ -700,7 +700,7 @@ flowchart LR
| `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. |
| `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. |
| `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | 拥有默认关闭的设置命名空间;Agent 作用域的委派工具会在组合新顶层 Session 时读取它。 |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的元数据和写入操作,并仅向所有者读取已保存密钥。 |
| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Host 负责浏览器授权和本地凭证;UI 使用方只接收不含 token 的状态。 |
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 |
| `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | 根据 Host 动态采集策略接收指定桌面事件、补充可用登录身份并监听实时压缩。 |
+16 -2
View File
@@ -1110,10 +1110,24 @@ export interface Config {
- `source`: [`packages/experimental/cua-macos/src/index.ts:18`](../packages/experimental/cua-macos/src/index.ts)
```ts config-catalog
/** Native preparation, operation bounds, and profile-local application grants. */
/** Native preparation, source-build signing, operation bounds, and profile-local application grants. */
export interface Config extends CuaConfig {
/** Interval in milliseconds between permission and position checks while the native guide is open. */
permissionPollIntervalMs: number
/**
* Source-build signing: `ad-hoc` (default) binds grants to the code hash;
* an object selects an existing certificate in an explicitly configured keychain.
* Installed packages and `helperPath` keep their own signature.
*/
sourceSigning: SourceSigning
}
/** Signature selected for source builds; certificates are created and unlocked by the developer. */
export type SourceSigning = 'ad-hoc' | {
/** Existing codesign certificate name or SHA-1 fingerprint, without a leading dash or whitespace. */
identity: string
/** Absolute keychain path. */
keychain: string
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-cua-macos -->
@@ -4309,7 +4323,7 @@ export interface Config {
- `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections`
- `refs`: [`AgentOptions`](subsystems/core.md)
- `source`: [`packages/subagent/tool-subagent/src/index.ts:46`](../packages/subagent/tool-subagent/src/index.ts)
- `source`: [`packages/subagent/tool-subagent/src/index.ts:45`](../packages/subagent/tool-subagent/src/index.ts)
```ts config-catalog
/** Config: which registered provider this tool delegates to, plus child defaults. */
+16 -2
View File
@@ -1112,10 +1112,24 @@ export interface Config {
- `source`: [`packages/experimental/cua-macos/src/index.ts:18`](../packages/experimental/cua-macos/src/index.ts)
```ts config-catalog
/** Native preparation, operation bounds, and profile-local application grants. */
/** Native preparation, source-build signing, operation bounds, and profile-local application grants. */
export interface Config extends CuaConfig {
/** Interval in milliseconds between permission and position checks while the native guide is open. */
permissionPollIntervalMs: number
/**
* Source-build signing: `ad-hoc` (default) binds grants to the code hash;
* an object selects an existing certificate in an explicitly configured keychain.
* Installed packages and `helperPath` keep their own signature.
*/
sourceSigning: SourceSigning
}
/** Signature selected for source builds; certificates are created and unlocked by the developer. */
export type SourceSigning = 'ad-hoc' | {
/** Existing codesign certificate name or SHA-1 fingerprint, without a leading dash or whitespace. */
identity: string
/** Absolute keychain path. */
keychain: string
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-cua-macos -->
@@ -4311,7 +4325,7 @@ export interface Config {
- `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections`
- `refs`: [`AgentOptions`](subsystems/core.zh.md)
- `source`: [`packages/subagent/tool-subagent/src/index.ts:46`](../packages/subagent/tool-subagent/src/index.ts)
- `source`: [`packages/subagent/tool-subagent/src/index.ts:45`](../packages/subagent/tool-subagent/src/index.ts)
```ts config-catalog
/** Config: which registered provider this tool delegates to, plus child defaults. */
+2 -2
View File
@@ -9,8 +9,8 @@
en: 0083032b9d19467d
zh: 15d7a7520bac094e
/user-credentials/resolution:
en: baaab4bca76bea6b
zh: 54ccdc03ffd44f63
en: 0583aeab49d80a4b
zh: 1ffd60d7e79f67cf
/user-credentials/description:
en: f7b09588c0f34952
zh: 1b93d673c5bf6a80
+19 -3
View File
@@ -20,6 +20,8 @@ type CredentialRef = Branded<'CredentialRef'>
`resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism.
`readStored(ref)` returns only the provider-managed value for editing, or `undefined` when none is stored. It never reads environment fallbacks. The Remote editor operation restricts this read to the owner.
```ts type-equiv
/** One resolved credential value and the source layer that supplied it. */
interface ResolvedCredential {
@@ -152,6 +154,13 @@ A CredentialKey answers "what credential does this plugin hold for this id". Not
*/
abstract resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined>
/**
* Read the provider-managed value without consulting environment fallbacks.
* @param ref - the reference to read.
* @returns the stored secret, or undefined when no non-empty value is stored.
*/
abstract readStored(ref: CredentialRef): Promise<string | undefined>
/**
* Describe one reference for configuration surfaces without exposing the
* value.
@@ -228,7 +237,7 @@ Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credent
### `ctx.credentialsController` — `CredentialsController`
Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Secret values cross in one direction only — no method here returns one.
Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Metadata reads exclude secrets; readStored returns only a provider-managed value for an explicitly opened editor.
```ts cordis-catalog
/**
@@ -243,8 +252,15 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie
@Remote async describe(refs: string[]): Promise<Record<string, CredentialInfo>>
/**
* Store one value from a configuration surface. The value crosses the wire in
* this direction only: no read path returns it.
* Read one stored value for a credential editor, excluding environment fallbacks.
* @param ref - reference name to read.
* @returns the stored secret, or undefined when the provider stores none.
* @throws RemoteError for a non-owner Remote caller, an invalid reference, or an absent provider.
*/
@Remote async readStored(ref: string): Promise<string | undefined>
/**
* Store one value from a configuration surface.
* @param ref - reference name to store under.
* @param value - the non-empty secret value.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
+19 -3
View File
@@ -20,6 +20,8 @@ type CredentialRef = Branded<'CredentialRef'>
`resolve(ref)` 返回值及提供该值的来源层(由提供方定义);未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这种按操作进行的读取正是热更新机制。
`readStored(ref)` 仅返回用于编辑的提供方托管值;未存储时返回 `undefined`。它不读取环境后备值。编辑器的 Remote 操作仅允许所有者执行此读取。
```ts type-equiv
/** One resolved credential value and the source layer that supplied it. */
interface ResolvedCredential {
@@ -152,6 +154,13 @@ A CredentialKey answers "what credential does this plugin hold for this id". Not
*/
abstract resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined>
/**
* Read the provider-managed value without consulting environment fallbacks.
* @param ref - the reference to read.
* @returns the stored secret, or undefined when no non-empty value is stored.
*/
abstract readStored(ref: CredentialRef): Promise<string | undefined>
/**
* Describe one reference for configuration surfaces without exposing the
* value.
@@ -228,7 +237,7 @@ Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credent
### `ctx.credentialsController` — `CredentialsController`
Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Secret values cross in one direction only — no method here returns one.
Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Metadata reads exclude secrets; readStored returns only a provider-managed value for an explicitly opened editor.
```ts cordis-catalog
/**
@@ -243,8 +252,15 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie
@Remote async describe(refs: string[]): Promise<Record<string, CredentialInfo>>
/**
* Store one value from a configuration surface. The value crosses the wire in
* this direction only: no read path returns it.
* Read one stored value for a credential editor, excluding environment fallbacks.
* @param ref - reference name to read.
* @returns the stored secret, or undefined when the provider stores none.
* @throws RemoteError for a non-owner Remote caller, an invalid reference, or an absent provider.
*/
@Remote async readStored(ref: string): Promise<string | undefined>
/**
* Store one value from a configuration surface.
* @param ref - reference name to store under.
* @param value - the non-empty secret value.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
+2 -2
View File
@@ -9,8 +9,8 @@
en: f6144f8e81359a9c
zh: 1924869ac7475b59
/liveshare/share-modes:
en: 985cb7a5c20c1cbd
zh: a5f5372dddfefaa3
en: 08ce9269b4556bdd
zh: 7b0e6e74e2f60865
/liveshare/forks-and-their-ceiling:
en: 357a2147f48f5bac
zh: a8a297603f984ede
+1 -1
View File
@@ -16,7 +16,7 @@ The plugin manager lists the card under **Experimental plugins** only while at l
## Share modes
`ViewerSessionMode` has two tiers, and both name a sandbox: `view` reads the shared Session and drives its loop inside a read-only sandbox in the granted workspace, so it never writes a file; it may use the read and search surface (`read`, `read_image`, `glob`, `grep`, `present`) and the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`), with every declared path checked against the grant. `write` runs in a bounded writable sandbox that also modifies files inside that workspace by adding bounded `edit` and `write`, under the Host's write ceiling. Forking is not a tier: the share carries its own `allowsFork` switch, independent of the tier. Attachment upload is not a tier either: every Session a share serves the reader accepts files, whatever the tier and the fork switch. The withdrawn `fork-readonly` name is not read back as `view`: a persisted owner record that names it is rejected when the owner state loads. Nothing infers `write`, and a share whose mode the host cannot establish stays readable but is never driven.
`ViewerSessionMode` has two tiers, and both name a sandbox: `view` reads the shared Session and drives its loop inside a read-only sandbox in the granted workspace, so it never writes a file; it may use the read and search surface (`read`, `read_image`, `glob`, `grep`, `present`) and the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`), with every declared path checked against the grant. Both tiers also admit the PTC transport `run_code`: the program runs under the owner's standing file policy rather than the share tier, so it can read and write outside the grant, while its nested tool calls still pass the same guard and path checks. `write` runs in a bounded writable sandbox that also modifies files inside that workspace by adding bounded `edit` and `write`, under the Host's write ceiling. Forking is not a tier: the share carries its own `allowsFork` switch, independent of the tier. Attachment upload is not a tier either: every Session a share serves the reader accepts files, whatever the tier and the fork switch. The withdrawn `fork-readonly` name is not read back as `view`: a persisted owner record that names it is rejected when the owner state loads. Nothing infers `write`, and a share whose mode the host cannot establish stays readable but is never driven.
## Forks and their ceiling
+1 -1
View File
@@ -16,7 +16,7 @@
## 分享模式
`ViewerSessionMode` 只有两档,两档都对应一个沙箱:`view` 读取共享会话,并在授予工作区的只读沙箱里驱动它的 loop,绝不写文件;它可以使用读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)和无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`),每个声明路径都按授予范围检查。`write` 在受宿主写入上限约束的可写沙箱里运行,通过有界 `edit` 与 `write` 修改该工作区内的文件。fork 不是一档:分享自带独立的 `allowsFork` 开关,与档位无关。附件上传也不是一档:分享向读者提供的每条会话都接受文件,与档位和分叉开关无关。已退场的 `fork-readonly` 名称不会读回为 `view`:持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。任何环节都不会推断出 `write`,而宿主机无法确定模式的分享仍可读取,但绝不会被驱动。
`ViewerSessionMode` 只有两档,两档都对应一个沙箱:`view` 读取共享会话,并在授予工作区的只读沙箱里驱动它的 loop,绝不写文件;它可以使用读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)和无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`),每个声明路径都按授予范围检查。`write` 在受宿主写入上限约束的可写沙箱里运行,通过有界 `edit` 与 `write` 修改该工作区内的文件。fork 不是一档:分享自带独立的 `allowsFork` 开关,与档位无关。附件上传也不是一档:分享向读者提供的每条会话都接受文件,与档位和分叉开关无关。已退场的 `fork-readonly` 名称不会读回为 `view`:持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。任何环节都不会推断出 `write`,而宿主机无法确定模式的分享仍可读取,但绝不会被驱动。两档也都准入 PTC 传输工具 `run_code`:程序运行在所有者自己的常驻文件策略下而不是分享档位下,因此可以读写授权范围之外的文件;它发起的嵌套工具调用仍走同样的守卫与路径检查。
## fork 与其上限
+2 -2
View File
@@ -9,8 +9,8 @@
en: c0454bca818f86ee
zh: 427582150fd3b976
/web-client-architecture/browser-boot:
en: 5ac7d3aed7bf273a
zh: e34869c8ad64052d
en: cd68c46a3f365235
zh: 9ac2f7c65cabb144
/web-client-architecture/remote-communication:
en: 426b09164210b77a
zh: ce576099ee668984

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