Merge remote-tracking branch 'origin/master' into feat/workspace-dependencies-package
This commit is contained in:
commit
1e3afc63ad
213 files changed
+2971
-797
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
|
||||
2026-09-07-composer-session-stats-pills.md: d165aab471bc5958cb7a0b287d278a0ae48485ee
|
||||
2026-09-07-composer-session-stats-pills.zh.md: dac136d4e60e50b026ed082eb7a006a80059c328
|
||||
2026-09-07-composer-session-stats-pills.md: 50d3d3294ed012773657ba9849e45835c361d8be
|
||||
2026-09-07-composer-session-stats-pills.zh.md: 97b91b601e62e8b06ab13a764babb4dd3b25bfce
|
||||
@@ -10,6 +10,8 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co
|
||||
|
||||
## Decision
|
||||
|
||||
The [performance and usage preference](2026-09-16-performance-usage-preference.md) governs statistic visibility and removes the completed-turn elapsed-time action.
|
||||
|
||||
`StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed.
|
||||
|
||||
- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutlineRegular`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutlineRegular`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [StatsPills](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) owns the zero cache-write omission.
|
||||
|
||||
@@ -10,6 +10,8 @@ Status: implemented
|
||||
|
||||
## 决定
|
||||
|
||||
[性能与用量偏好](2026-09-16-performance-usage-preference.zh.md)管理统计可见性,并移除已完成轮次的耗时操作。
|
||||
|
||||
`StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。
|
||||
|
||||
- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutlineRegular`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutlineRegular`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[StatsPills](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx)负责缓存写入为零时省略该行的规则。
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md
|
||||
2026-09-16-open-in-default-app-for-sidebar-files.md: fa7506be77e8a62e5dfd97b59de6ebc4be510ae0
|
||||
2026-09-16-open-in-default-app-for-sidebar-files.zh.md: af124182dc1b38658ff55736277e499e963a6281
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Open Sidebar-previewed files in the default application
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-open-in-default-app-for-sidebar-files.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Opening a file in its Host default application or showing it in the file manager existed only on the delivery cards, through routes and a fetch controller private to `ui-deliverables` and addressed by Session, declaration-event sequence, and file index. The right Sidebar's document preview offered no native handoff: a video, an archive, an office document, a file the reader rejected as non-text, or an oversized file dead-ended on an explanation, with Retry as the only control even where a second read could not help (issue #3932). Those files have no delivery event to address, so the existing plumbing could not serve a second surface. A first implementation (PR #4120, by Yifffan) added a new `ui-open-locally` package with its own Host routes and moved the delivery cards onto it; product review asked instead that file opening live in the existing open-in-app feature and accepted an intermediate state for this iteration.
|
||||
|
||||
## Decision
|
||||
|
||||
[ui-open-in-app](../../../../packages/client/ui-open-in-app/README.md) owns the browser controls. The document preview of [ui-sidebar-documentpreview](../../../../packages/client/ui-sidebar-documentpreview/README.md) declares two Session-scoped list child slots whose owner props carry the file’s absolute execution-environment path, rendered only once the file's metadata reports that path: `sidebar.right.tab.document.actions` after the header's own controls, in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` where Retry would stand. The preview classifies an empty-state read failure: `not-text` and `too-large` are readable files the preview cannot render and offer the unpreviewable slot; `not-found` and `not-regular-file` offer only their explanation; every other failure keeps Retry because a second read may resolve it. The unsupported-suffix empty state, which never reads, offers the same slot.
|
||||
|
||||
ui-open-in-app fills both slots: an "Open ▾" split button whose main button opens the file in the default application and whose menu adds the file-manager reveal, and an "Open in default app" empty-state button. Neither control renders until the Host answered that a desktop exists. The controls call the Session Remote the Host already publishes, `session.canOpenWorkspacePath` once per page and `session.openWorkspacePath` per gesture with the absolute path and, for reveal, `action: 'reveal'`; no new Host route exists. Both controls share one gesture hook: only the control the user pressed disables while its call settles, a failed call resolves as a failure kind, and that control announces it once through a transient toast, so no failure state persists on a control.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A new `ui-open-locally` package with its own Host routes** (PR #4120) is where the slot pair, the failure classification, the split-button design, and the toast rule come from. It was set aside because the repository already has an open-in-app feature and a second package for opening things locally duplicates it; the shared Session Remote owns verification of the Host path through the composed filesystem.
|
||||
|
||||
**Host routes in `dsh-host-open-in-app`** serving desktop facts and a path open would let the Host name the file manager and distinguish a missing file from a failed launch. They would make the Host half depend on `sessionController`, `workspaceFiles`, and `fs` for two calls the Session Remote already publishes, so the Remote is used directly and the reveal label stays generic.
|
||||
|
||||
**Moving the delivery cards onto these controls now** would repeat the largest part of PR #4120, including the cross-plugin runtime import its review rejected and the recorded delivery scenarios it had to migrate. The cards keep their own routes this iteration.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every previewed file with a verified Host mapping can be opened locally from the Sidebar, including files that were never delivered. Paths without that mapping are refused before native opening. The delivery cards and the preview run two openers over one Host capability; unifying them, and deciding per surface between the full application list and the default application only, is the next step the product direction names. The reveal menu item says "Show file location" on every platform because the Session Remote reports no file manager. Default-application open carries the Host's file-association execution semantics for what it opens; the guard is the explicit user gesture on a file the Session can already read, and no new Host authority was granted.
|
||||
|
||||
Unit tests cover the failure classification, the preview's rendering of each recourse and its withholding of the seats before a Host path is known, the path controller's desktop read and gesture outcomes, both controls' visibility, busy isolation, and toast, and the plugin's registration and fiber-disposal removal. The keyless Web document-preview scenario drives the unsupported-suffix controls against a stubbed opener on POSIX hosts, and the seeded-history file-preview golden pins the header control.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note:用默认应用打开 Sidebar 预览的文件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-open-in-default-app-for-sidebar-files.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
用 Host 默认应用打开文件、或在文件管理器中显示文件,此前只存在于交付卡片上,走的是 `ui-deliverables` 私有的路由与 fetch 控制器,按 Session、声明事件序号和文件下标定位。右侧 Sidebar 的文档预览没有任何本地交接:视频、压缩包、office 文档、被读取器判为非文本的文件、过大的文件,都停在一行说明上,唯一的控件是重试,哪怕再读一次也无济于事(issue #3932)。这些文件没有交付事件可供定位,所以现有管线无法服务第二个界面。第一版实现(PR #4120,作者 Yifffan)新增了 `ui-open-locally` 包,自带 Host 路由,并把交付卡片也迁了过去;产品评审要求改为把文件打开放进现有的 open-in-app 功能,并接受本期先落一个中间态。
|
||||
|
||||
## 决定
|
||||
|
||||
[ui-open-in-app](../../../../packages/client/ui-open-in-app/README.zh.md) 拥有浏览器侧的控件。[ui-sidebar-documentpreview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 的文档预览声明两个 Session 作用域的 list 子 slot,owner props 携带文件在执行环境中的绝对路径,只在文件元数据报出该路径后渲染:`sidebar.right.tab.document.actions` 在每个显示头部的状态里跟在头部自有控件之后,`sidebar.right.tab.document.unpreviewable` 占据原本放重试的位置。预览对空态的读取失败分类:`not-text` 与 `too-large` 是可读但预览无法渲染的文件,提供 unpreviewable slot;`not-found` 与 `not-regular-file` 只给说明;其余失败保留重试,因为再读一次可能解决。不支持后缀的空态不发起读取,提供同一个 slot。
|
||||
|
||||
ui-open-in-app 填满这两个 slot:一个「打开 ▾」分体按钮,主按钮用默认应用打开文件,菜单再加文件管理器定位;一个空态的「用默认应用打开」按钮。Host 回答有桌面之前,两个控件都不渲染。控件调用 Host 早已发布的 Session Remote:每页调用一次 `session.canOpenWorkspacePath`,每个手势调用一次 `session.openWorkspacePath`,带上绝对路径,定位时再带 `action: 'reveal'`;没有新增任何 Host 路由。两个控件共用一个手势 hook:只有用户按下的那个控件在调用结算期间禁用,失败的调用解析为一个失败种类,由该控件通过短暂 toast 提示一次,控件上不残留失败状态。
|
||||
|
||||
## 考虑过的备选
|
||||
|
||||
**新建自带 Host 路由的 `ui-open-locally` 包**(PR #4120)是这对 slot、失败分类、分体按钮设计和 toast 规则的来源。放弃它是因为仓库已经有 open-in-app 功能,再来一个负责本地打开的包属于重复;共享的 Session Remote 负责通过当前文件系统验证 Host 路径。
|
||||
|
||||
**在 `dsh-host-open-in-app` 里加 Host 路由**提供桌面信息和按路径打开,能让 Host 报出文件管理器的名字,并区分文件不存在和启动失败。但这会让 Host 半边为了 Session Remote 已经发布的两个调用而依赖 `sessionController`、`workspaceFiles` 与 `fs`,所以直接使用 Remote,定位标签保持通用说法。
|
||||
|
||||
**现在就把交付卡片迁到这些控件上**会重做 PR #4120 里最大的那部分,包括评审否决的跨插件运行时导入,以及它不得不迁移的交付录制场景。本期卡片保留自己的路由。
|
||||
|
||||
## 后果
|
||||
|
||||
每个有经过验证的 Host 映射的预览文件都能从 Sidebar 本地打开,包括从未交付过的文件。没有这种映射的路径会在原生打开前被拒绝。交付卡片和预览在同一项 Host 能力上跑着两套打开器;把它们统一起来,并按界面决定展示完整应用列表还是只展示默认应用,是产品方向点名的下一步。定位菜单项在所有平台都写「显示文件位置」,因为 Session Remote 不报告文件管理器。用默认应用打开带有 Host 文件关联的执行语义;护栏是用户对 Session 已可读文件的明确手势,没有授予任何新的 Host 权限。
|
||||
|
||||
单元测试覆盖失败分类、预览对每种出路的渲染以及在 Host 路径未知时对座位的保留、路径控制器的桌面读取与手势结果、两个控件的可见性、忙碌隔离与 toast,以及插件的注册与 fiber 释放时的移除。无密钥的 Web 文档预览场景在 POSIX 主机上用桩打开器驱动不支持后缀的控件,seeded-history 的文件预览 golden 固定住头部控件。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-performance-usage-preference.md
|
||||
2026-09-16-performance-usage-preference.md: 36ab30561fccc6c824ee8483f5a533a9178f11c5
|
||||
2026-09-16-performance-usage-preference.zh.md: ecfd19c41c861116d18829b355bf8c8df4ea929a
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Performance and usage detail preference
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-performance-usage-preference.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Always-visible counts and accounting controls add density to Chat even when readers only need output speed and cache effectiveness.
|
||||
|
||||
## Decision
|
||||
|
||||
Chat owns `ui-chat.performanceUsage` in its existing settings namespace. General Settings offers Compact and Detailed, with Detailed as the default to preserve access to accounting. Both the composer and turn-tail renderer consume the same accepted settings source through framework-bound hooks. Host settings own persistence, validation, and failed-write recovery.
|
||||
|
||||
Compact retains only available output speed and cache-hit percentage under the composer. These readings are plain text with no statistics dialog. Detailed retains session statistic dialogs and per-Turn usage. Neither mode renders the elapsed-time action in the completed-turn footer. This preference changes presentation, never telemetry collection, accounting, or Session events.
|
||||
|
||||
The [composer statistics decision](2026-09-07-composer-session-stats-pills.md) remains authoritative for accounting sources, dialog exclusivity, and composer spacing in Detailed mode.
|
||||
|
||||
The requested footer design prioritizes message actions and token accounting in both modes. Completed turns therefore expose no per-turn elapsed-time control; Detailed restores accounting detail, not that control. The running clock and session-wide timing statistics remain independent of this choice.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Default to Compact.** This would hide accounting controls for existing users without an explicit choice. The request defines both modes but no default, so Detailed preserves their access.
|
||||
|
||||
**Filter telemetry when Compact is selected.** This would discard information needed after switching to Detailed or reopening history. A display preference must not change recorded data.
|
||||
|
||||
## Consequences
|
||||
|
||||
Unavailable speed or cache-hit data is omitted rather than replaced with invented values. The settings scope provides one accepted value to all consumers without duplicating subscription state. Component coverage checks mode-specific visibility; the [recorded-session browser scenario](../../../../apps/web/tests/message-actions.e2e.ts) exercises selection, reload persistence, and Compact output.
|
||||
|
||||
On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note:性能与用量详细程度偏好
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-performance-usage-preference.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
当读者只需要输出速度和缓存效果时,始终显示的计数和记账控件会增加聊天界面的密度。
|
||||
|
||||
## Decision
|
||||
|
||||
Chat 在既有设置命名空间中拥有 `ui-chat.performanceUsage`。通用设置提供简洁和详细选项,默认详细以保留记账入口。输入框和轮次页脚渲染器通过框架绑定的 Hook 消费同一个已接受设置源。Host 设置负责持久化、校验和写入失败恢复。
|
||||
|
||||
简洁模式仅在输入框下方保留可用的输出速度和缓存命中率。这些读数为纯文本,不提供统计卡片。详细模式保留会话统计卡片和每轮用量。两种模式均不渲染已完成轮次页脚中的耗时操作。该偏好只改变展示,不改变遥测收集、记账或 Session 事件。
|
||||
|
||||
[输入框统计决策](2026-09-07-composer-session-stats-pills.zh.md)仍负责详细模式中的记账来源、卡片互斥和输入框间距。
|
||||
|
||||
所要求的页脚设计在两种模式下均优先展示消息操作和 token 记账。因此,已完成轮次不提供单轮耗时控件;详细模式恢复记账详情,不恢复该控件。运行中的时钟和会话级计时统计独立于此选择。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**默认简洁。** 这会在已有用户未明确选择时隐藏记账控件。需求定义了两种模式但未指定默认值,因此详细模式保留既有入口。
|
||||
|
||||
**选择简洁时过滤遥测。** 这会丢弃切换回详细模式或重新打开历史时需要的信息。展示偏好不能改变记录数据。
|
||||
|
||||
## Consequences
|
||||
|
||||
不可用的速度或缓存命中数据会被省略,不填入虚构值。设置作用域为所有消费者提供同一个已接受值,无需复制订阅状态。组件覆盖检查不同模式的可见性;[录制会话浏览器场景](../../../../apps/web/tests/message-actions.e2e.ts)验证选择、刷新持久化和简洁模式输出。
|
||||
|
||||
在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md
|
||||
2026-09-17-windows-runtime-signature-cache.md: 909e39b5c06ed9d529df7553de324295aadf43fe
|
||||
2026-09-17-windows-runtime-signature-cache.zh.md: c7ee5e1ca5105c21e3a7f4b43342dee20331e88b
|
||||
2026-09-17-windows-runtime-signature-cache.md: 911e0277199bac55165fbc4adebb22e2599043b9
|
||||
2026-09-17-windows-runtime-signature-cache.zh.md: bb8cdd6bdca445c94e8e77cddb68008d7b27f7ad
|
||||
@@ -12,6 +12,12 @@ Windows packaging reconstructs unsigned native dependencies on each build. Signi
|
||||
|
||||
Cache complete signed runtime files by original content, public certificate, SignTool bytes, signing-script bytes and cache format. Keep valid vendor signatures. Restore a cache hit through a private staged copy only after checking the input record, signed-file digest, Windows trust, timestamp and configured certificate; reject invalid entries without automatic repair or hardware fallback. Publish complete entries atomically, and retain an existing valid entry when concurrent publishers collide.
|
||||
|
||||
Share the cache only within the current Windows account. Require a fixed local drive, current-account ownership and a private destination DACL; reject linked or redirected paths. Programs running as that account remain trusted. Explicit migration copies complete integrity-checked entries without modifying its source or replacing an existing valid entry. Different timestamp bytes at the same input/policy key do not require replacement; every actual restoration still verifies Windows trust.
|
||||
|
||||
The default cache lives beside the account's existing signing state under the user profile. AppData is unsuitable for this default because an MSIX launcher can redirect new files into its private package storage, separating otherwise identical worktrees launched from different programs. A validated explicit directory remains available for isolated build storage.
|
||||
|
||||
Serialize each complete signing stage and cache-maintenance operation with an exclusive Windows file handle. The lock covers preflight, primary-runtime signing, application-runtime signing and artifact creation separately, leaving compilation and preparation concurrent. A waiter rechecks the cache after acquisition. Stage ownership and the retained hardware-attempt interlock have different lifetimes: closing the stage handle releases contention, while hardware failures and interruptions retain the existing attempt evidence and never authorize an automatic retry.
|
||||
|
||||
Group public-key inspection into bounded batches of 32 files with at most four processes. Require one ordered result for every requested path and await every process in a failed batch. Preserve verification immediately after each hardware signature, before the next signing request.
|
||||
|
||||
The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own runtime contents, vendor-signature preservation and execution checks. The [release decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity and signing. Neither is superseded. The cache must preserve their supervised preflight, per-user signing interlock, single-file hardware calls, signed inventory and final packaged-runtime validation.
|
||||
@@ -24,12 +30,18 @@ The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md)
|
||||
|
||||
**Whole signed-runtime artifacts only.** They can also avoid extraction and copying, but require assembly and dependency-graph identities. Per-file caching can reuse unchanged native dependencies when application code or one dependency changes.
|
||||
|
||||
**Per-entry locks only.** They avoid duplicate signatures for one key but do not coordinate different files, preflight or installer signing against the same hardware token. Stage-wide ownership covers those calls without holding a lock during unrelated compilation.
|
||||
|
||||
**Cache-age expiry.** Age alone does not establish that a timestamped signature is invalid. Rechecking current Windows trust preserves the validation requirement without periodically repeating unchanged hardware signatures.
|
||||
|
||||
## Consequences
|
||||
|
||||
Repeated builds with unchanged native inputs avoid hardware calls for cached files while retaining preflight and final artifact signing. Changed content or signing policy misses the cache. Corrupt, incomplete, untrusted or incorrectly timestamped entries fail without replacing the target or retrying hardware. Concurrent publication and interrupted processes cannot expose incomplete entries or damage another build's files.
|
||||
|
||||
Six complete signed Windows x64 builds cover an empty signature cache, repeated inputs, application changes and one controlled native-file content change. Warm builds reduce hardware calls from 379 to 17 and elapsed time from 37:38 to 12:36–12:42 on the measured host, while final runtime checks pass. Download caches remain populated; the controlled file change tests invalidation, not a dependency-version upgrade. Installer execution and update qualification remain separate.
|
||||
Complete signed Windows x64 builds on one host measure an empty shared cache and reuse from a separate fresh worktree: hardware calls fall from 379 to 17, elapsed time from 37:03 to 13:45, and all 362 runtime entries hit. Cache restoration takes 132 seconds, including 130 seconds of current trust verification. Both builds pass final runtime smoke and signed-artifact checks. Downloads are prepared before timing; each scenario is one complete run. A separate controlled native-file change produces miss/hit/miss with two hardware calls; it tests invalidation, not a dependency-version upgrade. Installer execution and update qualification remain separate.
|
||||
|
||||
## Risks
|
||||
|
||||
The cache consumes local disk and depends on the build account controlling its files and records. It is not a distribution format for untrusted remote caches. Signature validation still depends on Windows trust services; cache hits do not authorize clearing a signing interlock or retrying a failed token operation. A timestamp-service failure can interrupt a cold build even after a successful preflight.
|
||||
|
||||
No automatic capacity eviction runs. Explicit maintenance waits for stage ownership and retires complete entries before deleting their files; interrupted staging directories remain distinguishable from published entries. Runtime summaries count actual publications separately from retained collisions, and report cache verification time separately from signing time. They do not measure the entire package's verification cost.
|
||||
@@ -12,6 +12,12 @@ Windows 打包每次都会重新生成未签名的原生依赖。为未变化的
|
||||
|
||||
按原始内容、公开证书、SignTool 字节、签名脚本字节和缓存格式缓存完整已签名运行时文件。保留有效厂商签名。缓存命中后,通过私有暂存副本核对输入记录、已签名文件摘要、Windows 信任、时间戳和配置的证书,再恢复目标文件;无效缓存项直接失败,不自动修复或回退到硬件签名。完整缓存项原子发布,并发发布冲突时保留已有有效项。
|
||||
|
||||
缓存仅在当前 Windows 账户内共享。要求固定本地磁盘、当前账户所有权及私有目标目录 DACL;拒绝带链接或被重定向的路径。同账户运行的程序仍受信任。显式迁移复制通过完整性检查的完整缓存项,不修改源目录,也不替换已有有效项。同一输入和策略键下的时间戳字节不同,不构成替换理由;每次实际恢复仍验证 Windows 信任。
|
||||
|
||||
默认缓存位于用户目录下,与账户已有签名状态放在一起。AppData 不适合作为此默认位置,因为 MSIX 启动器可能将新文件重定向到私有包存储,使不同程序启动的相同 worktree 使用不同缓存。独立构建存储仍可指定经过验证的显式目录。
|
||||
|
||||
通过 Windows 独占文件句柄串行执行每个完整签名阶段及缓存维护操作。锁分别覆盖预检、主要运行时签名、应用运行时签名及产物生成,编译和准备仍可并行。等待者在获取锁后重新检查缓存。阶段所有权与持久保留的硬件尝试互锁具有不同生命周期:关闭阶段句柄释放竞争锁,而硬件失败或中断保留现有尝试证据,绝不构成自动重试授权。
|
||||
|
||||
公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。
|
||||
|
||||
[主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。
|
||||
@@ -24,12 +30,18 @@ Windows 打包每次都会重新生成未签名的原生依赖。为未变化的
|
||||
|
||||
**仅复用整个已签名运行时制品。** 该方案还可以省去解压和复制,但需要标识组装流程及依赖图。逐文件缓存可以在应用代码或单个依赖变化时复用其余未变化的原生依赖。
|
||||
|
||||
**仅使用逐项锁。** 它能避免同一键的重复签名,却无法协调不同文件、预检或安装器签名对同一硬件令牌的访问。阶段级所有权覆盖这些调用,不在无关编译期间持锁。
|
||||
|
||||
**按缓存年龄过期。** 年龄本身不能说明带时间戳的签名无效。重新检查当前 Windows 信任可以保留验证要求,同时避免周期性重复未变化的硬件签名。
|
||||
|
||||
## Consequences
|
||||
|
||||
原生输入未变化的重复构建不再为缓存覆盖的文件调用硬件签名,但仍执行预检和最终制品签名。内容或签名策略变化时缓存未命中。损坏、不完整、不可信或时间戳不合格的缓存项必须失败,不替换目标文件,也不重试硬件。并发发布和进程中断不能暴露不完整缓存项或损坏其他构建的文件。
|
||||
|
||||
六轮完整 Windows x64 签名构建覆盖空签名缓存、重复输入、应用变化和单个原生文件的受控内容变化。在测量主机上,热构建将硬件调用从 379 次降至 17 次,耗时从 37:38 降至 12:36–12:42,且最终运行时检查通过。下载缓存保持已填充;受控文件变化验证缓存失效,不代表依赖版本升级。安装器执行和更新验收仍需单独进行。
|
||||
同一主机上的完整 Windows x64 签名构建测量空共享缓存与另一个全新 worktree 的复用:硬件调用从 379 次降至 17 次,耗时从 37:03 降至 13:45,362 个运行时缓存项全部命中。缓存恢复耗时 132 秒,其中当前信任验证占 130 秒。两轮均通过最终运行时 smoke 及签名产物检查。下载内容在计时前准备好;每种场景均为一次完整运行。另一次受控原生文件变更产生未命中、命中、未命中的结果,合计两次硬件调用;它验证缓存失效,不代表依赖版本升级。安装器执行和更新验收仍需单独进行。
|
||||
|
||||
## Risks
|
||||
|
||||
缓存占用本地磁盘,并依赖构建账户控制缓存文件及记录。它不是用于不可信远程缓存的分发格式。签名验证仍依赖 Windows 信任服务;缓存命中不构成清除签名互锁或重试失败令牌操作的授权。即使预检成功,时间戳服务故障仍可能中断冷构建。
|
||||
|
||||
不执行自动容量淘汰。显式维护等待阶段所有权,在删除文件前先将完整缓存项移出已发布名称;中断的暂存目录仍可与已发布项区分。运行时摘要区分实际发布和冲突保留,并将缓存验证耗时与签名耗时分别记录;它不测量整个安装包的验证成本。
|
||||
@@ -1,5 +1,5 @@
|
||||
# Copy to .env.windows (Git ignored). Use UTF-8 and quote secrets containing # or spaces.
|
||||
# Paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
|
||||
# Credential file paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DOWNLOAD_TEST_ORIGIN=
|
||||
@@ -16,6 +16,9 @@ DSH_DESKTOP_WINDOWS_SIGNTOOL=
|
||||
DSH_DESKTOP_WINDOWS_KEY_CONTAINER=
|
||||
DSH_DESKTOP_WINDOWS_TOKEN_PIN=
|
||||
|
||||
# Optional absolute, private local directory; defaults to %USERPROFILE%\.dsh-desktop-signing\signature-cache\v1.
|
||||
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=C:\Users\build\.dsh-desktop-signing\signature-cache\v1
|
||||
|
||||
# Optional upload configuration; packaging does not require these credentials.
|
||||
# DOWNLOAD_TEST_COS_BUCKET=
|
||||
# DOWNLOAD_TEST_COS_SECRET_ID=
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: 3cad6155a0cba43decd2854c73a256fe0c50e6f2
|
||||
README.zh.md: c29a0f6cc21f5fe76cd5bbfce7c880f6eb968bbd
|
||||
README.md: 9eda54fba2c7cb7965397b93e9c1dfb8a4d4351e
|
||||
README.zh.md: 17d5851c903d11f10a1bb54f568690cac36fd041
|
||||
@@ -210,7 +210,13 @@ Run `pnpm --dir apps/desktop run test:installer` from the repository root on an
|
||||
|
||||
### Windows EV signing
|
||||
|
||||
Runtime signing caches complete signed files under `.desktop-build/targets/win-x64/signature-cache`. Entries identify the original bytes, public certificate and signing toolchain. Restoring an entry checks its digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. The cache requires trusted local build storage and retains entries until removed. Preflight, hardware interlocks and final artifact signing still apply. Cache qualification and design limits are tracked in the [runtime signature cache decision](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md).
|
||||
Runtime signing shares complete signed files across the current Windows account's worktrees. `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` in `.env.windows` selects an absolute fixed-drive directory; the default is `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`. Cache directories must belong to the current account and exclude other ordinary accounts from their access permissions; linked paths are rejected. Entries identify the original bytes, public certificate and signing toolchain. Every restoration checks the digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. Cache age alone does not trigger signing. The cache trusts programs running as the same account and does not defend against administrators. The [runtime signature cache decision](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md) owns qualification and design limits.
|
||||
|
||||
Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery.
|
||||
|
||||
Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately.
|
||||
|
||||
Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from <absolute-old-cache>` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory <absolute-cache>` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly.
|
||||
|
||||
Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately.
|
||||
|
||||
|
||||
@@ -211,7 +211,13 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI
|
||||
|
||||
### Windows EV 签名
|
||||
|
||||
运行时签名将完整的已签名文件缓存在 `.desktop-build/targets/win-x64/signature-cache`。缓存项标识原始字节、公钥证书和签名工具链。恢复缓存项时,先检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。缓存要求受信任的本地构建存储,并保留缓存项直到被移除。预检、硬件互锁和最终产物签名仍然执行。[运行时签名缓存决策](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)记录缓存验收要求和设计限制。
|
||||
运行时签名在当前 Windows 账户的各 worktree 间共享完整的已签名文件。`.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` 指定固定本地磁盘上的绝对目录;默认值为 `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`。缓存目录必须属于当前账户,访问权限不得向其他普通账户开放;带链接的路径会被拒绝。缓存项标识原始字节、公钥证书和签名工具链。每次恢复都检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。不会仅因缓存较旧而重新签名。缓存信任同账户运行的程序,不防御管理员。[运行时签名缓存决策](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)定义验收要求和设计限制。
|
||||
|
||||
预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。
|
||||
|
||||
每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。
|
||||
|
||||
使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from <absolute-old-cache>` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory <absolute-cache>` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。
|
||||
|
||||
Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"prepare:runtime": "tsx scripts/prepare-runtime.ts",
|
||||
"prepare:primary-runtime": "tsx scripts/prepare-primary-runtime.ts",
|
||||
"sign:primary-runtime": "tsx scripts/sign-primary-runtime.ts",
|
||||
"cache:windows-signatures": "node scripts/manage-windows-signature-cache.mjs",
|
||||
"prepare:packages": "tsx scripts/prepare-package-set.ts",
|
||||
"prepare:dsh": "tsx scripts/prepare-dsh.ts",
|
||||
"prepare:package": "tsx scripts/package-target.ts --prepare-only",
|
||||
@@ -51,6 +52,7 @@
|
||||
"electron-builder": "^26.15.3",
|
||||
"extract-zip": "^2.0.1",
|
||||
"js-yaml": "^4.2.0",
|
||||
"koffi": "^3.1.0",
|
||||
"pnpm": "11.7.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSS
|
||||
import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
|
||||
import { createWindowsTokenSigner } from './windows-sign.mjs'
|
||||
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
|
||||
import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
|
||||
|
||||
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
|
||||
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u
|
||||
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR)$/u
|
||||
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
|
||||
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
|
||||
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
|
||||
@@ -85,6 +86,7 @@ export function validateDesktopPackageEnvironment(environment, target, options =
|
||||
tokenPin: environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
|
||||
keyContainer: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
|
||||
})
|
||||
if (!options.prepareOnly) resolveWindowsSignatureCacheDirectory(environment)
|
||||
} else {
|
||||
resolveMacOSSigningEnvironment(environment)
|
||||
const strategies = [
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Explicit current-account migration and storage inspection under the signing-stage lock. */
|
||||
import { parseArgs } from 'node:util'
|
||||
import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
|
||||
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
|
||||
import { maintainSignatureCache, migrateSignatureCache } from './windows-signature-cache.mjs'
|
||||
import { withWindowsSigningStage } from './windows-signing-stage.mjs'
|
||||
|
||||
const { values } = parseArgs({ options: {
|
||||
from: { type: 'string' }, usage: { type: 'boolean' }, clear: { type: 'boolean' }, directory: { type: 'string' },
|
||||
} })
|
||||
if ([values.from !== undefined, values.usage === true, values.clear === true].filter(Boolean).length !== 1) {
|
||||
throw new Error('Specify exactly one of --from <old-cache>, --usage or --clear')
|
||||
}
|
||||
const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32')
|
||||
: { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: values.directory }
|
||||
const root = resolveWindowsSignatureCacheDirectory(environment)
|
||||
const record = event => process.stdout.write(`${JSON.stringify(event)}\n`)
|
||||
await withWindowsSigningStage({ stage: values.from !== undefined ? 'cache-migration' : 'cache-maintenance', record }, async () => {
|
||||
await prepareWindowsSignatureCacheDirectory(root)
|
||||
if (values.from !== undefined) {
|
||||
const source = resolveWindowsSignatureCacheDirectory({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: values.from })
|
||||
await prepareWindowsSignatureCacheDirectory(source, true)
|
||||
record({ type: 'signature-cache-migration-summary', ...await migrateSignatureCache({ source, root, record }) })
|
||||
return
|
||||
}
|
||||
record({ type: values.clear ? 'signature-cache-cleared' : 'signature-cache-usage', root,
|
||||
...await maintainSignatureCache(root, values.clear) })
|
||||
})
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts'
|
||||
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
|
||||
import { createPackagingRun } from './packaging-run.mjs'
|
||||
import { createPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
|
||||
import { withWindowsSigningStage } from './windows-signing-stage.mjs'
|
||||
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
|
||||
import { withMacOSSigningKeychain } from './macos-signing-keychain.mjs'
|
||||
|
||||
const APP_ROOT = resolve(import.meta.dirname, '..')
|
||||
@@ -22,6 +24,7 @@ const WINDOWS_SIGNING_ENV_NAMES = [
|
||||
'DSH_DESKTOP_WINDOWS_KEY_CONTAINER',
|
||||
'DSH_DESKTOP_WINDOWS_SIGNTOOL',
|
||||
'DSH_DESKTOP_WINDOWS_TOKEN_PIN',
|
||||
'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR',
|
||||
] as const
|
||||
const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([
|
||||
'DOWNLOAD_TEST_COS_SECRET_ID',
|
||||
@@ -332,11 +335,33 @@ export async function packageTarget(
|
||||
if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name]
|
||||
}
|
||||
const signPrimaryRuntime = target.platform === 'win32' && !invocation.unsigned && !invocation.prepareOnly
|
||||
const signedStage = async (stage: string, operation: () => Promise<void>): Promise<void> => {
|
||||
if (!signPrimaryRuntime) return operation()
|
||||
if (run === undefined) throw new Error('desktop package: signed Windows packaging requires a supervised run')
|
||||
const controller = new AbortController()
|
||||
const interrupted = (): void => controller.abort()
|
||||
const detach = (): void => {
|
||||
process.removeListener('SIGINT', interrupted)
|
||||
process.removeListener('SIGTERM', interrupted)
|
||||
}
|
||||
process.once('SIGINT', interrupted)
|
||||
process.once('SIGTERM', interrupted)
|
||||
try {
|
||||
const options = { stage, signal: controller.signal, record: (event: object): void => recordPackagingEvent(run.directory, event) }
|
||||
await withWindowsSigningStage(options, async () => {
|
||||
detach()
|
||||
await operation()
|
||||
})
|
||||
} finally { detach() }
|
||||
}
|
||||
if (signPrimaryRuntime) {
|
||||
if (run === undefined) throw new Error('desktop package: signed Windows packaging requires a supervised run')
|
||||
await run.run('preflight:windows-signing', process.execPath,
|
||||
['--import', 'tsx/esm', join(APP_ROOT, 'scripts/windows-signing-preflight.ts')],
|
||||
{ cwd: APP_ROOT, env: electronBuilderEnv, timeoutMs: 60_000 })
|
||||
await signedStage('preflight', async () => {
|
||||
await prepareWindowsSignatureCacheDirectory(resolveWindowsSignatureCacheDirectory(environment))
|
||||
await run.run('preflight:windows-signing', process.execPath,
|
||||
['--import', 'tsx/esm', join(APP_ROOT, 'scripts/windows-signing-preflight.ts')],
|
||||
{ cwd: APP_ROOT, env: electronBuilderEnv, timeoutMs: 60_000 })
|
||||
})
|
||||
}
|
||||
await execute(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
|
||||
await execute(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
|
||||
@@ -376,7 +401,7 @@ export async function packageTarget(
|
||||
environment: electronBuilderEnv,
|
||||
}, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv))
|
||||
} else {
|
||||
await execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
|
||||
await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv))
|
||||
}
|
||||
if (signPrimaryRuntime) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv)
|
||||
if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
|
||||
|
||||
@@ -5,6 +5,8 @@ import { join, resolve } from 'node:path'
|
||||
import { createWindowsTokenSigner } from './windows-sign.mjs'
|
||||
import { inspectWindowsRuntimeSignature, signWindowsCode, type WindowsCodeSigningOptions } from './windows-runtime-signature.mjs'
|
||||
import { createCachedSigner, signatureCacheIdentity } from './windows-signature-cache.mjs'
|
||||
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
|
||||
import { withWindowsSigningStage } from './windows-signing-stage.mjs'
|
||||
import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
|
||||
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
|
||||
@@ -50,30 +52,38 @@ async function main(): Promise<void> {
|
||||
const certificateFile = process.env.DSH_DESKTOP_WINDOWS_CER_FILE
|
||||
if (!certificateFile) throw new Error('primary runtime signing requires the configured certificate')
|
||||
const thumbprint = new X509Certificate(await readFile(certificateFile)).fingerprint.replaceAll(':', '')
|
||||
const record = (event: object): void => { recordPackagingEvent(runDirectory, event) }
|
||||
try {
|
||||
const paths = resolveDesktopTargetBuildPaths()
|
||||
const record = (event: object): void => { recordPackagingEvent(runDirectory, event) }
|
||||
const sign = createWindowsTokenSigner({ certificateFile, signTool: process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
|
||||
keyContainer: process.env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER, tokenPin: process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN })
|
||||
const identity = await signatureCacheIdentity([
|
||||
await realpath(certificateFile), await realpath(process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL!),
|
||||
join(import.meta.dirname, 'windows-sign.cmd'), join(import.meta.dirname, 'windows-sign.mjs'),
|
||||
join(import.meta.dirname, 'windows-timestamp.mjs'),
|
||||
])
|
||||
const options = {
|
||||
thumbprint,
|
||||
sign: createCachedSigner({ root: join(paths.root, 'signature-cache'), identity, thumbprint,
|
||||
sign, inspect: inspectWindowsRuntimeSignature, record }),
|
||||
record,
|
||||
}
|
||||
if (process.argv.includes('--dsh')) {
|
||||
const version = JSON.parse(await readFile(join(paths.dsh, 'package.json'), 'utf8')).version as string
|
||||
await signWindowsDesktopRuntime(paths.dsh, version, { ...options,
|
||||
smoke: descriptor => smokePreparedRuntime(paths.dsh, join(paths.electron, 'electron.exe'), paths.runtime, descriptor),
|
||||
})
|
||||
} else {
|
||||
await signWindowsPrimaryRuntime(join(paths.runtime, 'primary-runtime'), { ...options, smoke: smokePrimaryRuntime })
|
||||
}
|
||||
await withWindowsSigningStage({ stage: process.argv.includes('--dsh') ? 'dsh-runtime' : 'primary-runtime', record }, async () => {
|
||||
const paths = resolveDesktopTargetBuildPaths()
|
||||
const sign = createWindowsTokenSigner({ certificateFile, signTool: process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
|
||||
keyContainer: process.env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER, tokenPin: process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN })
|
||||
const identity = await signatureCacheIdentity([
|
||||
await realpath(certificateFile), await realpath(process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL!),
|
||||
join(import.meta.dirname, 'windows-sign.cmd'), join(import.meta.dirname, 'windows-sign.mjs'),
|
||||
join(import.meta.dirname, 'windows-timestamp.mjs'),
|
||||
])
|
||||
const cacheRoot = resolveWindowsSignatureCacheDirectory(process.env)
|
||||
await prepareWindowsSignatureCacheDirectory(cacheRoot)
|
||||
const cachedSign = createCachedSigner({ root: cacheRoot, identity, thumbprint,
|
||||
sign, inspect: inspectWindowsRuntimeSignature, record })
|
||||
const options = { thumbprint, sign: cachedSign, record }
|
||||
record({ type: 'signature-cache-open', root: cachedSign.summary().root, identity })
|
||||
try {
|
||||
if (process.argv.includes('--dsh')) {
|
||||
const version = JSON.parse(await readFile(join(paths.dsh, 'package.json'), 'utf8')).version as string
|
||||
await signWindowsDesktopRuntime(paths.dsh, version, { ...options,
|
||||
smoke: descriptor => smokePreparedRuntime(paths.dsh, join(paths.electron, 'electron.exe'), paths.runtime, descriptor),
|
||||
})
|
||||
} else {
|
||||
await signWindowsPrimaryRuntime(join(paths.runtime, 'primary-runtime'), { ...options, smoke: smokePrimaryRuntime })
|
||||
}
|
||||
} finally {
|
||||
const summary = cachedSign.summary()
|
||||
record({ type: 'signature-cache-summary', ...summary })
|
||||
process.stdout.write(`SIGNATURE_CACHE_SUMMARY ${JSON.stringify(summary)}\n`)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
failPackagingRun(runDirectory, 'primary-runtime-signing-or-smoke-failed')
|
||||
throw error
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Resolve the current account's cache or a file-owned absolute local override.
|
||||
* @param environment Packaging configuration; an absent override uses the current account's signing-state directory.
|
||||
* @returns Normalized drive path; rejects relative, network, device and alternate-stream paths.
|
||||
*/
|
||||
export function resolveWindowsSignatureCacheDirectory(environment: NodeJS.ProcessEnv): string
|
||||
|
||||
/**
|
||||
* Validate directory ownership, links, drive type and destination access permissions.
|
||||
* @param path Absolute directory to create or inspect.
|
||||
* @param existingSource Validate an existing migration source without changing its permissions.
|
||||
* @returns Resolves after validation; never reads cache entries from another account's directory.
|
||||
*/
|
||||
export function prepareWindowsSignatureCacheDirectory(path: string, existingSource?: boolean): Promise<void>
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Resolve and validate the current account's shared runtime-signature cache. */
|
||||
import { execFile } from 'node:child_process'
|
||||
import { homedir } from 'node:os'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { join, resolve, win32 } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { scrubWindowsSigningEnvironment } from './windows-sign.mjs'
|
||||
|
||||
/**
|
||||
* Resolve the file-owned override or the current account's signing-state cache without reading its contents.
|
||||
* @param {NodeJS.ProcessEnv} environment File-owned packaging configuration.
|
||||
* @returns {string} Absolute local cache path; relative, network, device and alternate-stream paths are rejected.
|
||||
*/
|
||||
export function resolveWindowsSignatureCacheDirectory(environment) {
|
||||
const path = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR
|
||||
?? win32.join(homedir(), '.dsh-desktop-signing', 'signature-cache', 'v1')
|
||||
if (!/^[a-z]:[\\/]/iu.test(path) || path.slice(2).includes(':') || /[\0\r\n]/u.test(path)) {
|
||||
throw new Error('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR must be an absolute local drive directory')
|
||||
}
|
||||
return win32.resolve(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create private current-account storage or validate an explicitly selected migration source.
|
||||
* @param {string} path Absolute local directory.
|
||||
* @param {boolean} existingSource Require an existing, current-account-owned migration source without changing its permissions.
|
||||
* @returns {Promise<void>} Resolves after checking drive type, ancestor links, ownership and destination permissions.
|
||||
*/
|
||||
export async function prepareWindowsSignatureCacheDirectory(path, existingSource = false) {
|
||||
if (process.platform !== 'win32') throw new Error('Windows signature cache directory validation requires Windows')
|
||||
await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
|
||||
join(import.meta.dirname, 'windows-signature-cache-directory.ps1'), '-CachePath', resolve(path),
|
||||
...(existingSource ? ['-ExistingSource'] : []),
|
||||
], { env: scrubWindowsSigningEnvironment(process.env), windowsHide: true, timeout: 60_000 })
|
||||
if ((await realpath(path)).toLowerCase() !== resolve(path).toLowerCase()) {
|
||||
throw new Error('Signature cache directory is redirected; configure DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR outside redirected storage')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Validate account ownership before accessing cache contents; new caches receive a private DACL.
|
||||
param([Parameter(Mandatory=$true)][string]$CachePath, [switch]$ExistingSource)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
|
||||
Import-Module "$PSHOME/Modules/Microsoft.PowerShell.Security/Microsoft.PowerShell.Security.psd1" -ErrorAction Stop
|
||||
$currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$fullPath = [System.IO.Path]::GetFullPath($CachePath)
|
||||
if ($fullPath -notmatch '^[A-Za-z]:\\' -or $CachePath -notmatch '^[A-Za-z]:[\\/]') { throw 'Signature cache requires an absolute local drive path' }
|
||||
$drive = [System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($fullPath))
|
||||
if ($drive.DriveType -ne [System.IO.DriveType]::Fixed) { throw 'Signature cache requires a fixed local drive' }
|
||||
for ($ancestor = [System.IO.DirectoryInfo]::new($fullPath); $null -ne $ancestor; $ancestor = $ancestor.Parent) {
|
||||
if ($ancestor.Exists -and ($ancestor.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||
throw 'Signature cache cannot use reparse points in its path'
|
||||
}
|
||||
}
|
||||
if ([System.IO.File]::Exists($fullPath)) { throw 'Signature cache path must be a directory' }
|
||||
if (-not [System.IO.Directory]::Exists($fullPath)) {
|
||||
if ($ExistingSource) { throw 'Signature cache migration source does not exist' }
|
||||
$null = [System.IO.Directory]::CreateDirectory($fullPath)
|
||||
$acl = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
$acl.SetOwner($currentSid)
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
foreach ($sid in @($currentSid.Value, 'S-1-5-18', 'S-1-5-32-544')) {
|
||||
$identity = [System.Security.Principal.SecurityIdentifier]::new($sid)
|
||||
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
||||
$acl.AddAccessRule($rule)
|
||||
}
|
||||
Set-Acl -LiteralPath $fullPath -AclObject $acl
|
||||
}
|
||||
$acl = Get-Acl -LiteralPath $fullPath
|
||||
if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $currentSid.Value) {
|
||||
throw 'Signature cache directory must belong to the current Windows account'
|
||||
}
|
||||
if (-not $ExistingSource) {
|
||||
foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {
|
||||
if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -notin @($currentSid.Value, 'S-1-5-18', 'S-1-5-32-544')) {
|
||||
throw 'Signature cache permissions must exclude other ordinary Windows accounts'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,53 @@ export interface WindowsSignatureCacheOptions {
|
||||
*/
|
||||
export function signatureCacheIdentity(files: readonly string[]): Promise<string>
|
||||
|
||||
/** Counters include failed attempts; verification covers both restored and newly signed files, while restore time includes its verification and staging cleanup. */
|
||||
export interface WindowsSignatureCacheSummary {
|
||||
root: string
|
||||
identity: string
|
||||
hits: number
|
||||
misses: number
|
||||
published: number
|
||||
retained: number
|
||||
signingCalls: number
|
||||
avoidedSigningCalls: number
|
||||
validationFailures: number
|
||||
verificationMs: number
|
||||
restoreMs: number
|
||||
signingMs: number
|
||||
}
|
||||
|
||||
/** Serial cache callback with a snapshot of this instance's completed and failed operations. */
|
||||
export type WindowsCachedSigner = ReturnType<typeof createWindowsTokenSigner> & {
|
||||
/** @returns Current counters and elapsed times; does not wait for queued operations. */
|
||||
summary(): WindowsSignatureCacheSummary
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse complete signed files only after input, integrity, identity and timestamp checks.
|
||||
* @param options Cache policy, supervised signer, public-key verifier and audit sink.
|
||||
* @returns Serial signer that stops its queue on any failure and never repairs invalid cache entries.
|
||||
*/
|
||||
export function createCachedSigner(options: WindowsSignatureCacheOptions): ReturnType<typeof createWindowsTokenSigner>
|
||||
export function createCachedSigner(options: WindowsSignatureCacheOptions): WindowsCachedSigner
|
||||
|
||||
/** The caller validates ownership and holds the user-wide signing-stage lock throughout migration. */
|
||||
export interface SignatureCacheMigrationOptions {
|
||||
source: string
|
||||
root: string
|
||||
record: (event: object) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy complete entries without changing the source or replacing valid destination entries.
|
||||
* @param options Validated directories and credential-free audit sink.
|
||||
* @returns Counts of published, retained and skipped entries; corruption rejects without repair. Actual use rechecks trust.
|
||||
*/
|
||||
export function migrateSignatureCache(options: SignatureCacheMigrationOptions): Promise<{ published: number, retained: number, skipped: number }>
|
||||
|
||||
/**
|
||||
* Inspect or explicitly clear complete entries under the caller's signing-stage lock.
|
||||
* @param root Validated current-account cache directory.
|
||||
* @param clear Remove complete entries after atomic retirement; incomplete staging directories remain untouched.
|
||||
* @returns Count and bytes of complete entries, plus the number of skipped incomplete entries.
|
||||
*/
|
||||
export function maintainSignatureCache(root: string, clear?: boolean): Promise<{ entries: number, bytes: number, incomplete: number }>
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Cache complete signed files; corrupt entries stop the caller before hardware access. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFile, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve, toNamespacedPath } from 'node:path'
|
||||
|
||||
const digest = bytes => createHash('sha256').update(bytes).digest('hex')
|
||||
@@ -22,7 +22,8 @@ async function regularFile(path) {
|
||||
|
||||
async function directory(path) {
|
||||
const stat = await lstat(path)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== resolve(path)) {
|
||||
const normalize = value => process.platform === 'win32' ? toNamespacedPath(resolve(value)).toLowerCase() : resolve(value)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || normalize(await realpath(path)) !== normalize(path)) {
|
||||
throw new Error(`signature cache: expected unlinked directory: ${path}`)
|
||||
}
|
||||
}
|
||||
@@ -32,21 +33,113 @@ function validSignature(signature, thumbprint) {
|
||||
&& signature.thumbprint?.toUpperCase() === thumbprint.toUpperCase()
|
||||
}
|
||||
|
||||
async function readCacheEntry(entry, key) {
|
||||
await directory(entry)
|
||||
const record = JSON.parse((await regularFile(join(entry, 'record.json'))).toString('utf8'))
|
||||
if (record === null || typeof record !== 'object' || record.version !== 1 || record.key !== key
|
||||
|| typeof record.inputDigest !== 'string' || !/^[a-f\d]{64}$/u.test(record.inputDigest)
|
||||
|| typeof record.identity !== 'string' || typeof record.signedDigest !== 'string'
|
||||
|| !/^[a-f\d]{64}$/u.test(record.signedDigest)
|
||||
|| digest(JSON.stringify({ version: 1, inputDigest: record.inputDigest, identity: record.identity })) !== key) {
|
||||
throw new Error('signature cache: invalid input record')
|
||||
}
|
||||
if (digest(await regularFile(join(entry, 'payload'))) !== record.signedDigest) throw new Error('signature cache: corrupt signed payload')
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* Import complete integrity-checked entries while the caller holds the signing-stage lock.
|
||||
* @param {import('./windows-signature-cache.mjs').SignatureCacheMigrationOptions} options Validated source, private destination and audit sink.
|
||||
* @returns {Promise<{published: number, retained: number, skipped: number}>} Counts; existing valid entries win even when timestamp bytes differ. Trust is rechecked at actual use.
|
||||
*/
|
||||
export async function migrateSignatureCache(options) {
|
||||
if (resolve(options.source).toLowerCase() === resolve(options.root).toLowerCase()) throw new Error('signature cache: migration source equals destination')
|
||||
await directory(options.source)
|
||||
await mkdir(options.root, { recursive: true })
|
||||
await directory(options.root)
|
||||
const counts = { published: 0, retained: 0, skipped: 0 }
|
||||
for (const key of await readdir(options.source)) {
|
||||
if (!/^[a-f\d]{64}$/u.test(key)) {
|
||||
counts.skipped++
|
||||
options.record({ type: 'signature-cache-migration-skipped', reason: 'not-complete-entry-name' })
|
||||
continue
|
||||
}
|
||||
const source = join(options.source, key)
|
||||
await readCacheEntry(source, key)
|
||||
const target = join(options.root, key)
|
||||
let exists = false
|
||||
try { await lstat(target); exists = true }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
if (exists) {
|
||||
await readCacheEntry(target, key)
|
||||
counts.retained++
|
||||
continue
|
||||
}
|
||||
const staging = await mkdtemp(toNamespacedPath(join(options.root, '.publish-')))
|
||||
try {
|
||||
await copyFile(join(source, 'record.json'), join(staging, 'record.json'))
|
||||
await copyFile(join(source, 'payload'), join(staging, 'payload'))
|
||||
await readCacheEntry(staging, key)
|
||||
await rename(staging, target)
|
||||
counts.published++
|
||||
} finally { await rm(staging, { recursive: true, force: true }) }
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* Count or explicitly clear complete entries while the caller holds the signing-stage lock.
|
||||
* @param {string} root Validated, current-account cache directory.
|
||||
* @param {boolean} clear Retire each complete entry atomically before removing its two regular files.
|
||||
* @returns {Promise<{entries: number, bytes: number, incomplete: number}>} Complete-entry size and count; incomplete entries are left untouched.
|
||||
*/
|
||||
export async function maintainSignatureCache(root, clear = false) {
|
||||
await directory(root)
|
||||
let entries = 0
|
||||
let bytes = 0
|
||||
let incomplete = 0
|
||||
for (const name of await readdir(root)) {
|
||||
if (!/^[a-f\d]{64}$/u.test(name)) { incomplete++; continue }
|
||||
const entry = join(root, name)
|
||||
await directory(entry)
|
||||
const files = await readdir(entry)
|
||||
if (files.length !== 2 || !files.includes('record.json') || !files.includes('payload')) {
|
||||
throw new Error('signature cache: unexpected files prevent cache maintenance')
|
||||
}
|
||||
for (const file of files) {
|
||||
const stat = await lstat(join(entry, file))
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('signature cache: expected a regular entry file')
|
||||
bytes += stat.size
|
||||
}
|
||||
if (clear) {
|
||||
const retired = join(root, `.clear-${randomUUID()}`)
|
||||
await rename(entry, retired)
|
||||
for (const file of files) await unlink(join(retired, file))
|
||||
await rmdir(retired)
|
||||
}
|
||||
entries++
|
||||
}
|
||||
return { entries, bytes, incomplete }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a supervised signer with immutable entries keyed by unsigned bytes and signing policy.
|
||||
* @param {import('./windows-signature-cache.mjs').WindowsSignatureCacheOptions} options Cache policy and existing supervised operations.
|
||||
* @returns {ReturnType<typeof import('./windows-sign.mjs').createWindowsTokenSigner>} Serial, fail-stop signing callback; cache hits still verify trust and timestamp.
|
||||
* @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop callback with counters; cache hits still verify trust and timestamp.
|
||||
*/
|
||||
export function createCachedSigner(options) {
|
||||
let pending = Promise.resolve()
|
||||
const statistics = { hits: 0, misses: 0, published: 0, retained: 0, signingCalls: 0, validationFailures: 0,
|
||||
verificationMs: 0, restoreMs: 0, signingMs: 0 }
|
||||
async function verify(path) {
|
||||
const start = performance.now()
|
||||
try {
|
||||
if (!validSignature(await options.inspect(path), options.thumbprint)) throw new Error('signature cache: invalid signature')
|
||||
} finally { statistics.verificationMs += performance.now() - start }
|
||||
}
|
||||
async function readEntry(entry, key, inputDigest) {
|
||||
await directory(entry)
|
||||
const record = JSON.parse((await regularFile(join(entry, 'record.json'))).toString('utf8'))
|
||||
if (record === null || typeof record !== 'object' || record.version !== 1 || record.key !== key || record.inputDigest !== inputDigest
|
||||
|| record.identity !== options.identity || typeof record.signedDigest !== 'string'
|
||||
|| !/^[a-f\d]{64}$/u.test(record.signedDigest)) throw new Error('signature cache: invalid input record')
|
||||
const payload = await regularFile(join(entry, 'payload'))
|
||||
if (digest(payload) !== record.signedDigest) throw new Error('signature cache: corrupt signed payload')
|
||||
const record = await readCacheEntry(entry, key)
|
||||
if (record.inputDigest !== inputDigest || record.identity !== options.identity) throw new Error('signature cache: invalid input record')
|
||||
return record
|
||||
}
|
||||
async function sign(configuration) {
|
||||
@@ -60,38 +153,66 @@ export function createCachedSigner(options) {
|
||||
try { await lstat(entry); hit = true }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
if (hit) {
|
||||
const record = await readEntry(entry, key, inputDigest)
|
||||
const staging = await mkdtemp(toNamespacedPath(join(dirname(configuration.path), '.signature-restore-')))
|
||||
const start = performance.now()
|
||||
try {
|
||||
const candidate = join(staging, basename(configuration.path))
|
||||
await copyFile(join(entry, 'payload'), candidate)
|
||||
if (digest(await regularFile(candidate)) !== record.signedDigest) throw new Error('signature cache: restore changed signed bytes')
|
||||
if (!validSignature(await options.inspect(candidate), options.thumbprint)) throw new Error('signature cache: invalid cached signature')
|
||||
if (digest(await regularFile(configuration.path)) !== inputDigest) throw new Error('signature cache: target changed before restore')
|
||||
await rename(candidate, configuration.path)
|
||||
} finally { await rm(staging, { recursive: true, force: true }) }
|
||||
const record = await readEntry(entry, key, inputDigest)
|
||||
const staging = await mkdtemp(toNamespacedPath(join(dirname(configuration.path), '.signature-restore-')))
|
||||
try {
|
||||
const candidate = join(staging, basename(configuration.path))
|
||||
await copyFile(join(entry, 'payload'), candidate)
|
||||
if (digest(await regularFile(candidate)) !== record.signedDigest) throw new Error('signature cache: restore changed signed bytes')
|
||||
try { await verify(candidate) }
|
||||
catch (error) { throw new Error('signature cache: invalid cached signature', { cause: error }) }
|
||||
if (digest(await regularFile(configuration.path)) !== inputDigest) throw new Error('signature cache: target changed before restore')
|
||||
await rename(candidate, configuration.path)
|
||||
} finally { await rm(staging, { recursive: true, force: true }) }
|
||||
} catch (error) {
|
||||
statistics.validationFailures++
|
||||
options.record({ type: 'signature-cache-validation-failed', key, path: configuration.path })
|
||||
throw error
|
||||
} finally { statistics.restoreMs += performance.now() - start }
|
||||
statistics.hits++
|
||||
options.record({ type: 'signature-cache-hit', key, path: configuration.path })
|
||||
return
|
||||
}
|
||||
statistics.misses++
|
||||
options.record({ type: 'signature-cache-miss', key, path: configuration.path })
|
||||
await options.sign(configuration)
|
||||
if (!validSignature(await options.inspect(configuration.path), options.thumbprint)) throw new Error('signature cache: new signature verification failed')
|
||||
const start = performance.now()
|
||||
statistics.signingCalls++
|
||||
try { await options.sign(configuration) }
|
||||
finally { statistics.signingMs += performance.now() - start }
|
||||
try { await verify(configuration.path) }
|
||||
catch (error) {
|
||||
statistics.validationFailures++
|
||||
options.record({ type: 'signature-cache-validation-failed', key, path: configuration.path })
|
||||
throw new Error('signature cache: new signature verification failed', { cause: error })
|
||||
}
|
||||
const staging = await mkdtemp(toNamespacedPath(join(options.root, '.publish-')))
|
||||
try {
|
||||
await copyFile(configuration.path, join(staging, 'payload'))
|
||||
const signedDigest = digest(await regularFile(join(staging, 'payload')))
|
||||
if (signedDigest !== digest(await regularFile(configuration.path))) throw new Error('signature cache: signed file changed during publication')
|
||||
await writeFile(join(staging, 'record.json'), JSON.stringify({ version: 1, key, inputDigest, identity: options.identity, signedDigest }), { flag: 'wx', flush: true })
|
||||
try { await rename(staging, entry) }
|
||||
let published = false
|
||||
try { await rename(staging, entry); published = true }
|
||||
catch (error) {
|
||||
if (!['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error.code)) throw error
|
||||
await readEntry(entry, key, inputDigest)
|
||||
try { await readEntry(entry, key, inputDigest) }
|
||||
catch (validationError) {
|
||||
statistics.validationFailures++
|
||||
options.record({ type: 'signature-cache-validation-failed', key, path: configuration.path })
|
||||
throw validationError
|
||||
}
|
||||
}
|
||||
options.record({ type: 'signature-cache-published', key, path: configuration.path })
|
||||
if (published) statistics.published++
|
||||
else statistics.retained++
|
||||
options.record({ type: published ? 'signature-cache-published' : 'signature-cache-retained', key, path: configuration.path })
|
||||
} finally { await rm(staging, { recursive: true, force: true }) }
|
||||
}
|
||||
return configuration => {
|
||||
const signer = configuration => {
|
||||
pending = pending.then(() => sign(configuration))
|
||||
return pending
|
||||
}
|
||||
signer.summary = () => ({ root: options.root, identity: options.identity, ...statistics, avoidedSigningCalls: statistics.hits })
|
||||
return signer
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Stage-wide ownership is independent of the retained hardware-attempt interlock. */
|
||||
export interface WindowsSigningStageOptions {
|
||||
stage: string
|
||||
record: (event: object) => void
|
||||
signal?: AbortSignal
|
||||
/** Test-only storage isolates independent processes from the real account's signing state. */
|
||||
stateDirectory?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one complete signing or cache-maintenance stage under the account's exclusive file handle.
|
||||
* @param options Audit sink, optional wait cancellation and isolated test storage.
|
||||
* @param operation Stage that awaits all owned children before settling.
|
||||
* @returns The stage result, after releasing the handle; acquisition errors never run the stage.
|
||||
*/
|
||||
export function withWindowsSigningStage<T>(options: WindowsSigningStageOptions, operation: () => Promise<T>): Promise<T>
|
||||
@@ -0,0 +1,62 @@
|
||||
/** Serialize complete signing and cache-maintenance stages without clearing hardware failure evidence. */
|
||||
import { lstat, mkdir, realpath } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve, toNamespacedPath } from 'node:path'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
let bindings
|
||||
async function windows() {
|
||||
if (process.platform !== 'win32') throw new Error('Windows signing stages require Windows')
|
||||
if (bindings !== undefined) return bindings
|
||||
const koffi = (await import('koffi')).default
|
||||
const kernel = koffi.load('kernel32.dll')
|
||||
bindings = {
|
||||
open: kernel.func('__stdcall', 'CreateFileW', 'intptr', ['str16', 'uint', 'uint', 'void*', 'uint', 'uint', 'intptr']),
|
||||
close: kernel.func('__stdcall', 'CloseHandle', 'int', ['intptr']),
|
||||
error: kernel.func('__stdcall', 'GetLastError', 'uint', []),
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold one account-wide exclusive file handle until a stage and its children have settled.
|
||||
* @template T
|
||||
* @param {import('./windows-signing-stage.mjs').WindowsSigningStageOptions} options Audit sink, cancellation and isolated test storage.
|
||||
* @param {() => Promise<T>} operation Entire stage, including verification and cache publication.
|
||||
* @returns {Promise<T>} Stage result; contention waits, all other acquisition errors stop before the operation.
|
||||
*/
|
||||
export async function withWindowsSigningStage(options, operation) {
|
||||
const api = await windows()
|
||||
const root = options.stateDirectory ?? join(homedir(), '.dsh-desktop-signing')
|
||||
await mkdir(root, { recursive: true })
|
||||
const stat = await lstat(root)
|
||||
const normalize = value => toNamespacedPath(resolve(value)).toLowerCase()
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || normalize(await realpath(root)) !== normalize(root)) {
|
||||
throw new Error('Windows signing stage requires an unlinked local state directory')
|
||||
}
|
||||
const path = join(root, 'stage.lock')
|
||||
try {
|
||||
const file = await lstat(path)
|
||||
if (!file.isFile() || file.isSymbolicLink()) throw new Error('Windows signing stage requires a regular lock file')
|
||||
} catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
const start = performance.now()
|
||||
let handle
|
||||
let waiting = false
|
||||
for (;;) {
|
||||
options.signal?.throwIfAborted()
|
||||
// OPEN_ALWAYS retains the same file; denying sharing prevents concurrent holders and deletion.
|
||||
handle = api.open(toNamespacedPath(path), 0xc0000000, 0, null, 4, 0x80, 0)
|
||||
if (handle !== -1) break
|
||||
const code = api.error()
|
||||
if (code !== 32) throw new Error(`Windows signing stage cannot open its lock (Win32 ${code})`)
|
||||
if (!waiting) { options.record({ type: 'signing-stage-wait', stage: options.stage }); waiting = true }
|
||||
await setTimeout(100, undefined, { signal: options.signal })
|
||||
}
|
||||
try {
|
||||
options.record({ type: 'signing-stage-acquired', stage: options.stage, waitMs: performance.now() - start })
|
||||
return await operation()
|
||||
} finally {
|
||||
if (api.close(handle) === 0) throw new Error(`Windows signing stage cannot release its lock (Win32 ${api.error()})`)
|
||||
options.record({ type: 'signing-stage-released', stage: options.stage })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Independent process exercising stage ownership and cache reuse with a hardware-free signer. */
|
||||
import { appendFile, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { withWindowsSigningStage } from '../../scripts/windows-signing-stage.mjs'
|
||||
import { createCachedSigner, maintainSignatureCache } from '../../scripts/windows-signature-cache.mjs'
|
||||
|
||||
const [root, input, mode] = process.argv.slice(2)
|
||||
const release = new Promise(resolve => process.once('message', resolve))
|
||||
await withWindowsSigningStage({ stateDirectory: join(root, 'state'), stage: 'fixture', record: event => process.send(event) }, async () => {
|
||||
process.send({ type: 'entered' })
|
||||
await release
|
||||
if (mode === 'clear') {
|
||||
await maintainSignatureCache(join(root, 'cache'), true)
|
||||
return
|
||||
}
|
||||
const thumbprint = 'A'.repeat(40)
|
||||
const signer = createCachedSigner({ root: join(root, 'cache'), identity: 'fixture', thumbprint,
|
||||
record: event => process.send(event),
|
||||
inspect: async path => ({ status: (await readFile(path, 'utf8')).startsWith('signed:') ? 'Valid' : 'NotSigned', timestamped: true, thumbprint }),
|
||||
sign: async ({ path }) => {
|
||||
await appendFile(join(root, 'hardware-calls'), 'call\n')
|
||||
await writeFile(path, `signed:${await readFile(path, 'utf8')}`)
|
||||
},
|
||||
})
|
||||
await signer({ path: input, hash: 'sha256', isNest: false })
|
||||
})
|
||||
process.disconnect()
|
||||
@@ -1,6 +1,16 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts'
|
||||
import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs'
|
||||
import { prepareWindowsSignatureCacheDirectory } from '../scripts/windows-signature-cache-directory.mjs'
|
||||
|
||||
vi.mock('../scripts/windows-signing-stage.mjs', () => ({
|
||||
withWindowsSigningStage: vi.fn(async (_options: object, operation: () => Promise<void>) => operation()),
|
||||
}))
|
||||
vi.mock('../scripts/windows-signature-cache-directory.mjs', () => ({
|
||||
prepareWindowsSignatureCacheDirectory: vi.fn(async () => {}),
|
||||
resolveWindowsSignatureCacheDirectory: vi.fn(() => 'C:\\fixture-cache'),
|
||||
}))
|
||||
|
||||
// Keep the real orchestration and manifest reads; this suite owns no release directories or subprocesses.
|
||||
vi.mock('node:fs', async importOriginal => ({
|
||||
@@ -29,6 +39,8 @@ it('requires one signing preflight before building, then records only the comple
|
||||
await packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run)
|
||||
expect(stages.slice(0, 2)).toEqual(['preflight:windows-signing', 'run build:official'])
|
||||
expect(stages.filter(stage => stage === 'preflight:windows-signing')).toHaveLength(1)
|
||||
expect(vi.mocked(withWindowsSigningStage).mock.calls.map(([options]) => options.stage))
|
||||
.toEqual(['preflight', 'artifacts'])
|
||||
expect(run.run.mock.calls[0]![3]).toMatchObject({ env: { DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' }, timeoutMs: 60_000 })
|
||||
expect(run.run.mock.calls[1]![3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
|
||||
expect(stages.indexOf('run sign:primary-runtime --dsh')).toBeGreaterThan(stages.indexOf('run prepare:dsh --defer-runtime-smoke'))
|
||||
@@ -41,6 +53,16 @@ it('requires one signing preflight before building, then records only the comple
|
||||
expect(writeFileSync).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('initializes shared storage only after acquiring the preflight stage lock', async () => {
|
||||
const { run } = supervisor()
|
||||
vi.mocked(withWindowsSigningStage).mockImplementationOnce(async (_options, operation) => {
|
||||
expect(prepareWindowsSignatureCacheDirectory).not.toHaveBeenCalled()
|
||||
await operation()
|
||||
expect(prepareWindowsSignatureCacheDirectory).toHaveBeenCalledOnce()
|
||||
})
|
||||
await packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run)
|
||||
})
|
||||
|
||||
it.each(['preflight:windows-signing', 'run build:official', 'run sign:primary-runtime', 'run prepare:dsh --defer-runtime-smoke', 'run sign:primary-runtime --dsh',
|
||||
'exec tsx scripts/smoke-packaged-runtime.ts',
|
||||
'exec electron-builder --config electron-builder.config.mjs --win --x64 --publish never'])
|
||||
@@ -59,6 +81,7 @@ it.each(['--unsigned', '--prepare-only'])('keeps %s hardware-free and creates no
|
||||
expect(stages).not.toContain('preflight:windows-signing')
|
||||
expect(stages).not.toContain('run sign:primary-runtime')
|
||||
expect(stages).not.toContain('run sign:primary-runtime --dsh')
|
||||
expect(withWindowsSigningStage).not.toHaveBeenCalled()
|
||||
for (const call of run.run.mock.calls) expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -163,6 +163,7 @@ describe('Windows token signing', () => {
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
|
||||
DSH_DESKTOP_WINDOWS_CER_FILE: certificateFile, DSH_DESKTOP_WINDOWS_SIGNTOOL: signTool,
|
||||
DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin', DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'fixture-container',
|
||||
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: 'C:\\fixture\\signature-cache',
|
||||
}, { platform: 'win32', arch: 'x64' })
|
||||
expect(execFile).not.toHaveBeenCalled()
|
||||
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdtemp, rm, symlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { promisify } from 'node:util'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from '../scripts/windows-signature-cache-directory.mjs'
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('resolves an account cache independently of checkout and LocalAppData virtualization', () => {
|
||||
expect(resolveWindowsSignatureCacheDirectory({ LOCALAPPDATA: 'C:\\Users\\build\\AppData\\Local' }))
|
||||
.toBe(join(homedir(), '.dsh-desktop-signing', 'signature-cache', 'v1'))
|
||||
})
|
||||
|
||||
it('accepts an explicit local cache directory', () => {
|
||||
expect(resolveWindowsSignatureCacheDirectory({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: 'D:/private-cache' }))
|
||||
.toBe('D:\\private-cache')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('prepares storage when the inherited process execution policy is Restricted', async (t) => {
|
||||
const root = await mkdtemp(join(import.meta.dirname, 'directory-test-'))
|
||||
t.onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
vi.stubEnv('PSExecutionPolicyPreference', 'Restricted')
|
||||
t.onTestFinished(() => { vi.unstubAllEnvs() })
|
||||
await prepareWindowsSignatureCacheDirectory(join(root, 'private'))
|
||||
expect(process.env.PSExecutionPolicyPreference).toBe('Restricted')
|
||||
})
|
||||
|
||||
it.each(['', 'cache', 'C:cache', '\\cache', '\\\\server\\share', '\\\\?\\C:\\cache', 'C:\\cache:stream'])
|
||||
('rejects unsupported cache path %j', (path) => {
|
||||
expect(() => resolveWindowsSignatureCacheDirectory({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: path })).toThrow(/absolute local/u)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('creates private storage and rejects linked or publicly readable cache roots', async (t) => {
|
||||
const root = await mkdtemp(join(import.meta.dirname, 'directory-test-'))
|
||||
t.onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const cache = join(root, 'private')
|
||||
await prepareWindowsSignatureCacheDirectory(cache)
|
||||
await prepareWindowsSignatureCacheDirectory(cache)
|
||||
const link = join(root, 'linked')
|
||||
await symlink(cache, link, 'junction')
|
||||
await expect(prepareWindowsSignatureCacheDirectory(join(link, 'child'))).rejects.toThrow(/reparse/u)
|
||||
await promisify(execFile)('icacls.exe', [cache, '/grant', '*S-1-1-0:(OI)(CI)R'], { windowsHide: true })
|
||||
await expect(prepareWindowsSignatureCacheDirectory(cache)).rejects.toThrow(/permissions/u)
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { it as test, type TestContext } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile, mkdir, symlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { createCachedSigner, signatureCacheIdentity } from '../scripts/windows-signature-cache.mjs'
|
||||
import { createCachedSigner, maintainSignatureCache, migrateSignatureCache, signatureCacheIdentity } from '../scripts/windows-signature-cache.mjs'
|
||||
|
||||
async function fixture(t: TestContext) {
|
||||
const root = await mkdtemp(join(import.meta.dirname, 'cache-test-'))
|
||||
@@ -34,6 +34,20 @@ test('unchanged bytes restored in another file avoid another hardware call', asy
|
||||
assert.equal(f.state.events.filter(e => 'type' in e && e.type === 'signature-cache-hit').length, 1)
|
||||
})
|
||||
|
||||
test('cache summaries distinguish avoided signatures from actual signing attempts', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const signer = createCachedSigner(f.options)
|
||||
await signer(f.request)
|
||||
await writeFile(f.path, 'original')
|
||||
await signer(f.request)
|
||||
const { verificationMs, restoreMs, signingMs, ...summary } = signer.summary()
|
||||
assert.deepEqual(summary, { root: f.cache, identity: f.options.identity, hits: 1, misses: 1,
|
||||
published: 1, retained: 0, signingCalls: 1, avoidedSigningCalls: 1, validationFailures: 0 })
|
||||
assert(verificationMs >= 0 && restoreMs >= 0 && signingMs >= 0)
|
||||
assert.equal(f.state.hardwareCalls, summary.signingCalls)
|
||||
assert.equal(await readFile(f.path, 'utf8'), 'signed:original')
|
||||
})
|
||||
|
||||
test('restores cached signatures into deeply nested dependency paths', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
@@ -64,7 +78,10 @@ for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops
|
||||
const entry = (await readdir(f.cache)).find(name => !name.startsWith('.'))
|
||||
assert(entry)
|
||||
await writeFile(join(f.cache, entry, corrupt), 'corrupt')
|
||||
await assert.rejects(createCachedSigner(f.options)(f.request))
|
||||
const signer = createCachedSigner(f.options)
|
||||
await assert.rejects(signer(f.request))
|
||||
assert.equal(signer.summary().validationFailures, 1)
|
||||
assert.equal(signer.summary().avoidedSigningCalls, 0)
|
||||
assert.equal(f.state.hardwareCalls, 1)
|
||||
assert.equal(await readFile(f.path, 'utf8'), 'original')
|
||||
})
|
||||
@@ -89,6 +106,8 @@ test('hardware failure rejects queued work without publishing an entry or retryi
|
||||
assert(results.every(result => result.status === 'rejected'))
|
||||
assert.equal(f.state.hardwareCalls, 1)
|
||||
assert.deepEqual(await readdir(f.cache), [])
|
||||
assert.equal(signer.summary().signingCalls, 1)
|
||||
assert.equal(signer.summary().validationFailures, 0)
|
||||
})
|
||||
|
||||
test('a failed new signature verification never publishes a cache entry', async (t) => {
|
||||
@@ -140,6 +159,8 @@ test('two writers publish one complete entry without overwriting another writer'
|
||||
createCachedSigner(options)({ ...f.request, path: second }),
|
||||
])
|
||||
for (const result of results) if (result.status === 'rejected') throw result.reason
|
||||
assert.equal(f.state.events.filter(e => 'type' in e && e.type === 'signature-cache-published').length, 1)
|
||||
assert.equal(f.state.events.filter(e => 'type' in e && e.type === 'signature-cache-retained').length, 1)
|
||||
assert.equal((await readdir(f.cache)).length, 1)
|
||||
await writeFile(f.path, 'original')
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
@@ -166,3 +187,67 @@ test('a target changed during verification is never replaced with stale cache by
|
||||
assert.equal(f.state.hardwareCalls, 1)
|
||||
assert(!(await readdir(f.root)).some(name => name.startsWith('.signature-restore-')))
|
||||
})
|
||||
|
||||
test('migration preserves different valid timestamp bytes at the same destination key', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
const destination = join(f.root, 'shared')
|
||||
await writeFile(f.path, 'original')
|
||||
await createCachedSigner({ ...f.options, root: destination,
|
||||
sign: async ({ path }) => writeFile(path, 'signed:original:another-timestamp'),
|
||||
})(f.request)
|
||||
const key = (await readdir(f.cache))[0]!
|
||||
const sourceRecord = await readFile(join(f.cache, key, 'record.json'), 'utf8')
|
||||
const counts = await migrateSignatureCache({ source: f.cache, root: destination, record: () => {} })
|
||||
assert.deepEqual(counts, { published: 0, retained: 1, skipped: 0 })
|
||||
assert.equal(await readFile(join(destination, key, 'payload'), 'utf8'), 'signed:original:another-timestamp')
|
||||
assert.equal(await readFile(join(f.cache, key, 'record.json'), 'utf8'), sourceRecord)
|
||||
})
|
||||
|
||||
test('migration copies complete entries and skips staging directories without reading them', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
await mkdir(join(f.cache, '.publish-incomplete'))
|
||||
const destination = join(f.root, 'shared')
|
||||
assert.deepEqual(await migrateSignatureCache({ source: f.cache, root: destination, record: () => {} }),
|
||||
{ published: 1, retained: 0, skipped: 1 })
|
||||
await writeFile(f.path, 'original')
|
||||
await createCachedSigner({ ...f.options, root: destination })(f.request)
|
||||
assert.equal(f.state.hardwareCalls, 1)
|
||||
assert.equal(await readFile(f.path, 'utf8'), 'signed:original')
|
||||
assert.equal((await readdir(destination)).length, 1)
|
||||
})
|
||||
|
||||
test('migration refuses a corrupt existing destination instead of replacing it', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
const destination = join(f.root, 'shared')
|
||||
await migrateSignatureCache({ source: f.cache, root: destination, record: () => {} })
|
||||
const key = (await readdir(destination))[0]!
|
||||
await writeFile(join(destination, key, 'payload'), 'corrupt')
|
||||
await assert.rejects(migrateSignatureCache({ source: f.cache, root: destination, record: () => {} }), /corrupt/u)
|
||||
assert.equal(await readFile(join(destination, key, 'payload'), 'utf8'), 'corrupt')
|
||||
})
|
||||
|
||||
test('maintenance counts and clears complete entries while preserving incomplete staging', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
await mkdir(join(f.cache, '.publish-incomplete'))
|
||||
const usage = await maintainSignatureCache(f.cache)
|
||||
assert.equal(usage.entries, 1)
|
||||
assert(usage.bytes > 0)
|
||||
assert.equal(usage.incomplete, 1)
|
||||
assert.deepEqual(await maintainSignatureCache(f.cache, true), usage)
|
||||
assert.deepEqual(await readdir(f.cache), ['.publish-incomplete'])
|
||||
assert.equal(await readFile(f.path, 'utf8'), 'signed:original')
|
||||
})
|
||||
|
||||
test('maintenance refuses unexpected entry contents without deleting them', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
const key = (await readdir(f.cache))[0]!
|
||||
const unexpected = join(f.cache, key, 'unrecognized')
|
||||
await writeFile(unexpected, 'keep')
|
||||
await assert.rejects(maintainSignatureCache(f.cache, true), /unexpected files/u)
|
||||
assert.equal(await readFile(unexpected, 'utf8'), 'keep')
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, type TestContext } from 'vitest'
|
||||
import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs'
|
||||
|
||||
const test = it.skipIf(process.platform !== 'win32')
|
||||
|
||||
interface StageChild {
|
||||
process: ChildProcess
|
||||
events: string[]
|
||||
closed: Promise<{ code: number | null; signal: NodeJS.Signals | null }>
|
||||
wait: (event: string) => Promise<void>
|
||||
}
|
||||
|
||||
async function fixture(t: TestContext) {
|
||||
const root = await mkdtemp(join(import.meta.dirname, 'stage-test-'))
|
||||
const children: StageChild[] = []
|
||||
t.onTestFinished(async () => {
|
||||
for (const item of children) {
|
||||
if (item.process.exitCode === null && item.process.signalCode === null) item.process.kill()
|
||||
await item.closed
|
||||
}
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
function child(input: string, mode = 'sign'): StageChild {
|
||||
const process = fork(join(import.meta.dirname, 'fixtures/windows-signing-stage.mjs'), [root, input, mode], {
|
||||
execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
})
|
||||
const events: string[] = []
|
||||
let stderr = ''
|
||||
process.stderr!.on('data', (chunk) => { stderr += String(chunk) })
|
||||
const waiters = new Map<string, () => void>()
|
||||
process.on('message', (message: { type: string }) => { events.push(message.type); waiters.get(message.type)?.() })
|
||||
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
||||
process.once('close', (code, signal) => { resolve({ code, signal }) })
|
||||
})
|
||||
const failed = new Promise<never>((_resolve, reject) => { process.once('error', reject) })
|
||||
const item = { process, events, closed,
|
||||
wait: (event: string) => Promise.race([
|
||||
events.includes(event) ? Promise.resolve() : new Promise<void>(resolve => waiters.set(event, resolve)),
|
||||
closed.then((result) => { throw new Error(`fixture exited before ${event}: ${JSON.stringify(result)} ${stderr}`) }),
|
||||
failed,
|
||||
]),
|
||||
}
|
||||
children.push(item)
|
||||
return item
|
||||
}
|
||||
const first = join(root, 'first.node')
|
||||
const second = join(root, 'second.node')
|
||||
await writeFile(first, 'original')
|
||||
await writeFile(second, 'original')
|
||||
return { root, first, second, child }
|
||||
}
|
||||
|
||||
test('independent signing stages wait and recheck the cache before accessing hardware', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const first = f.child(f.first)
|
||||
await first.wait('entered')
|
||||
const second = f.child(f.second)
|
||||
await second.wait('signing-stage-wait')
|
||||
expect(second.events).not.toContain('entered')
|
||||
first.process.send('release')
|
||||
expect(await first.closed).toEqual({ code: 0, signal: null })
|
||||
await second.wait('entered')
|
||||
second.process.send('release')
|
||||
expect(await second.closed).toEqual({ code: 0, signal: null })
|
||||
expect(await readFile(join(f.root, 'hardware-calls'), 'utf8')).toBe('call\n')
|
||||
expect(await readFile(f.second, 'utf8')).toBe('signed:original')
|
||||
expect(second.events).toContain('signature-cache-hit')
|
||||
})
|
||||
|
||||
test('an interrupted holder releases only the stage lock and retains hardware failure evidence', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const first = f.child(f.first)
|
||||
await first.wait('entered')
|
||||
const evidence = join(f.root, 'state/attempt.json')
|
||||
await writeFile(evidence, 'retained hardware attempt')
|
||||
first.process.kill()
|
||||
await first.closed
|
||||
await withWindowsSigningStage({ stateDirectory: join(f.root, 'state'), stage: 'inspect', record: () => {} }, async () => {
|
||||
expect(await readFile(evidence, 'utf8')).toBe('retained hardware attempt')
|
||||
})
|
||||
})
|
||||
|
||||
test('a failed stage releases its handle before another process enters', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await expect(withWindowsSigningStage({ stateDirectory: join(f.root, 'state'), stage: 'failed', record: () => {} }, async () => {
|
||||
throw new Error('stage failed')
|
||||
})).rejects.toThrow('stage failed')
|
||||
const next = f.child(f.first)
|
||||
await next.wait('entered')
|
||||
next.process.send('release')
|
||||
expect(await next.closed).toEqual({ code: 0, signal: null })
|
||||
})
|
||||
|
||||
test('accepts a state directory whose spelling differs only in Windows path case', async (t) => {
|
||||
const f = await fixture(t)
|
||||
let entered = false
|
||||
await withWindowsSigningStage({ stateDirectory: join(f.root, 'state').toUpperCase(), stage: 'case', record: () => {} }, async () => {
|
||||
entered = true
|
||||
})
|
||||
expect(entered).toBe(true)
|
||||
})
|
||||
|
||||
test('a cancelled waiter never enters and does not release another process lock', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const holder = f.child(f.first)
|
||||
await holder.wait('entered')
|
||||
const controller = new AbortController()
|
||||
let entered = false
|
||||
await expect(withWindowsSigningStage({ stateDirectory: join(f.root, 'state'), stage: 'cancelled', signal: controller.signal,
|
||||
record: (event) => { if ('type' in event && event.type === 'signing-stage-wait') controller.abort() },
|
||||
}, async () => { entered = true })).rejects.toThrow()
|
||||
expect(entered).toBe(false)
|
||||
holder.process.send('release')
|
||||
expect(await holder.closed).toEqual({ code: 0, signal: null })
|
||||
})
|
||||
|
||||
test('cache clearing waits until another process finishes signing and publishing', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const signer = f.child(f.first)
|
||||
await signer.wait('entered')
|
||||
const cleaner = f.child(f.second, 'clear')
|
||||
await cleaner.wait('signing-stage-wait')
|
||||
expect(cleaner.events).not.toContain('entered')
|
||||
signer.process.send('release')
|
||||
expect(await signer.closed).toEqual({ code: 0, signal: null })
|
||||
await cleaner.wait('entered')
|
||||
cleaner.process.send('release')
|
||||
expect(await cleaner.closed).toEqual({ code: 0, signal: null })
|
||||
expect(await readdir(join(f.root, 'cache'))).toEqual([])
|
||||
expect(await readFile(f.first, 'utf8')).toBe('signed:original')
|
||||
})
|
||||
@@ -157,6 +157,43 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' })
|
||||
await expect.poll(() => drawn(review.locator('[data-diff-side="left"]'))).toEqual(['del:1# 示例项目', 'context:2', 'context:3一个用于演示的仓库。'])
|
||||
expect(await drawn(review.locator('[data-diff-side="right"]'))).toEqual(['del:1# 项目说明', 'context:2', 'context:3一个用于演示的仓库。'])
|
||||
// Constrain the recorded three-row diff to exercise unequal horizontal ranges and classic scrollbars.
|
||||
const scrollLayout = await page.addStyleTag({ content: `
|
||||
[data-changes-review] { height: 120px !important; }
|
||||
[data-diff-side] { scrollbar-width: auto; }
|
||||
[data-diff-side]::-webkit-scrollbar { width: 16px; height: 16px; }
|
||||
[data-diff-side="left"] { width: 65px; }
|
||||
` })
|
||||
try {
|
||||
const left = review.locator('[data-diff-side="left"]')
|
||||
const right = review.locator('[data-diff-side="right"]')
|
||||
const metrics = (side: typeof left) => side.evaluate(element => ({
|
||||
x: element.scrollLeft, y: element.scrollTop,
|
||||
maxX: element.scrollWidth - element.clientWidth,
|
||||
maxY: element.scrollHeight - element.clientHeight,
|
||||
bottom: element.lastElementChild!.getBoundingClientRect().bottom,
|
||||
}))
|
||||
const initialLeft = await metrics(left)
|
||||
const initialRight = await metrics(right)
|
||||
expect(initialLeft.maxX).toBeGreaterThan(0)
|
||||
expect(initialRight.maxX).toBe(0)
|
||||
expect(initialLeft.maxY).toBeGreaterThan(0)
|
||||
expect(initialLeft.maxY).toBe(initialRight.maxY)
|
||||
await left.evaluate((element) => { element.scrollLeft = element.scrollWidth })
|
||||
await expect.poll(async () => (await metrics(left)).x).toBe(initialLeft.maxX)
|
||||
await left.evaluate((element) => { element.scrollTop = element.scrollHeight })
|
||||
await expect.poll(async () => (await metrics(right)).y).toBe(initialLeft.maxY)
|
||||
// Two frames allow the browser-generated peer event to run before checking the source.
|
||||
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { resolve() }))))
|
||||
expect((await metrics(left)).x).toBe(initialLeft.maxX)
|
||||
expect((await metrics(left)).y).toBe(initialLeft.maxY)
|
||||
expect((await metrics(left)).bottom).toBeCloseTo((await metrics(right)).bottom, 1)
|
||||
await right.evaluate((element) => { element.scrollTop = 0 })
|
||||
await expect.poll(async () => (await metrics(left)).y).toBe(0)
|
||||
expect((await metrics(left)).x).toBe(initialLeft.maxX)
|
||||
} finally {
|
||||
await scrollLayout.evaluate(element => element.parentNode!.removeChild(element))
|
||||
}
|
||||
await review.getByRole('button', { name: '自动换行' }).click()
|
||||
await review.locator('[data-review-view][data-review-wrap]').waitFor({ state: 'visible' })
|
||||
await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。'])
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/** Keyless document-preview smoke through a real Session, Files tab, and shipped renderers. */
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
/** Keyless document-preview smoke through a real Session, Files tab, shipped renderers, and the default-application controls. */
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative } from 'node:path'
|
||||
import { nativeFileManager } from '@deepseek-ai/dsh-native-command'
|
||||
import { delimiter, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
|
||||
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import { realOfficeBytes } from './office-fixture.ts'
|
||||
import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
|
||||
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
@@ -19,6 +21,8 @@ const PAGE_LINES = 64
|
||||
const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url))
|
||||
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
|
||||
const MODE = webSnapshotMode()
|
||||
/** The stubbed opener runs as a POSIX script; Windows and WSL keep their real file associations out of the lane. */
|
||||
const STUB_OPENER = nativeFileManager() !== 'explorer'
|
||||
const TINY_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
@@ -83,11 +87,33 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let outsideRoot: string | undefined
|
||||
let nativeRoot: string | undefined
|
||||
let openLog = ''
|
||||
const opened = async (): Promise<Array<{ path: string; action: 'open' | 'reveal' }>> =>
|
||||
(await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' })
|
||||
|
||||
beforeAll(async () => {
|
||||
outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-'))
|
||||
if (STUB_OPENER) {
|
||||
// Exercise the built Host through its actual OS command, replacing only the desktop application.
|
||||
nativeRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-native-'))
|
||||
openLog = join(nativeRoot, 'opened.jsonl')
|
||||
await writeFile(openLog, '')
|
||||
const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
|
||||
await writeFile(join(nativeRoot, command), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2];
|
||||
const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open';
|
||||
fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '\\n');
|
||||
`, { mode: 0o700 })
|
||||
vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`)
|
||||
}
|
||||
// The Open In rows carry the default-application controls; the SSH marker
|
||||
// keeps the host's application catalog empty, so the Session-header split
|
||||
// button stays off every platform while the pinned desktop serves the file controls.
|
||||
scaffold = await launchWebScaffold({
|
||||
developerTools: false, replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH,
|
||||
developerTools: false, replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: [PAGING_PATCH, fileURLToPath(new URL('./fixtures/native-open-on.patch.yml', import.meta.url))],
|
||||
openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]),
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
@@ -103,7 +129,9 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
try {
|
||||
await scaffold?.close()
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
if (outsideRoot !== undefined) await rm(outsideRoot, { recursive: true, force: true })
|
||||
if (nativeRoot !== undefined) await rm(nativeRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -669,13 +697,39 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'clip.mp4', exact: true }).click()
|
||||
const unsupported = column.locator('[data-textpreview-state="unsupported"]')
|
||||
await unsupported.waitFor({ timeout: 15_000 })
|
||||
const unsupportedLine = await unsupported.locator('[data-textpreview-unsupported]').innerText()
|
||||
const unsupportedLine = await unsupported.locator('[data-textpreview-unsupported] p').innerText()
|
||||
expect(unsupportedLine).toContain('Preview is not available for this file type yet.')
|
||||
expect(await unsupported.locator('[data-textpreview-path]').innerText()).toContain('clip.mp4')
|
||||
expect(await unsupported.locator('[data-document-viewer-menu]').count()).toBe(0)
|
||||
expect(await unsupported.locator('[data-textpreview-tool="reload"]').count()).toBe(0)
|
||||
// The default-application controls land once the Host answered the pinned desktop read.
|
||||
const headerOpen = unsupported.locator('[data-open-path-open]')
|
||||
await headerOpen.waitFor({ timeout: 15_000 })
|
||||
const emptyOpen = unsupported.locator('[data-textpreview-unsupported] [data-open-path-unpreviewable]')
|
||||
await emptyOpen.waitFor({ timeout: 15_000 })
|
||||
await successShot(page, 'unsupported')
|
||||
sections.push(['## Unviewable binary', '', '- State: unsupported', `- Line: ${unsupportedLine.trim()}`].join('\n'))
|
||||
sections.push([
|
||||
'## Unviewable binary', '',
|
||||
'- State: unsupported',
|
||||
`- Line: ${unsupportedLine.trim()}`,
|
||||
`- Header control: ${await headerOpen.innerText()}`,
|
||||
`- Empty-state control: ${await emptyOpen.innerText()}`,
|
||||
].join('\n'))
|
||||
if (STUB_OPENER) {
|
||||
// Real Host gestures against the stubbed opener: default application from the empty state, reveal from the header menu.
|
||||
const clip = join(cwd, 'clip.mp4')
|
||||
await emptyOpen.click()
|
||||
await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(1)
|
||||
await unsupported.locator('[data-open-path-more]').click()
|
||||
await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click()
|
||||
await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(2)
|
||||
const gestures = await opened()
|
||||
expect(gestures[0]).toEqual({ path: clip, action: 'open' })
|
||||
expect(gestures[1]?.action).toBe('reveal')
|
||||
expect([clip, cwd]).toContain(gestures[1]?.path)
|
||||
// Gesture facts stay out of the golden: the stub does not run on Windows.
|
||||
expect(await page.getByRole('alert').count()).toBe(0)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE)
|
||||
|
||||
@@ -146,7 +146,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: {{clock}}
|
||||
- button "Back to bottom"
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 9/1 {{clock}}
|
||||
- button "Thought for a while"
|
||||
- paragraph: The Cordis Plugin is running.
|
||||
@@ -46,7 +45,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 9/1 {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. 9/1 {{clock}}
|
||||
- button "Copy"
|
||||
- button "1 tool call" [expanded]
|
||||
@@ -58,7 +56,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 9/1 {{clock}}
|
||||
- button "Back to bottom"
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: {{clock}}
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: {{clock}}
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: {{clock}}
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: {{clock}}
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: {{clock}}
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: {{clock}}
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
- button "返回插件列表": 插件列表
|
||||
- switch "启用 missing-bundle" [checked]
|
||||
- heading "missing-bundle" [level=3]
|
||||
- text: 异常
|
||||
- paragraph:
|
||||
- code: "@fixture/missing-bundle"
|
||||
- paragraph: 暂无描述。
|
||||
- status: "原因: dsh: cannot resolve profile bundle \"@fixture/missing-bundle\" from the dsh installation or {{home}}/profiles/scaffold; run 'dsh plugin --profile scaffold install' if its dependency is not installed"
|
||||
- heading "包含的组件" [level=4]
|
||||
- paragraph: 这个插件包不包含任何组件。
|
||||
@@ -21,6 +21,8 @@
|
||||
- button "Decrease font size"
|
||||
- text: px Conversation display Controls process content in completed turns
|
||||
- button "Compact"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Developer tools Show tools and information for debugging and troubleshooting
|
||||
- switch "Developer tools"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
- button "减小字号"
|
||||
- text: px 对话显示 控制已完成轮次的过程内容
|
||||
- button "紧凑"
|
||||
- text: 性能与用量 选择性能与用量信息展示的详细程度
|
||||
- button "详细"
|
||||
- text: 开发者工具 显示用于调试和排查问题的工具与信息
|
||||
- switch "开发者工具"
|
||||
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: {{clock}}
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: {{clock}}
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m2 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r2
|
||||
@@ -50,7 +49,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m3 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r3
|
||||
@@ -58,7 +56,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m4 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r4
|
||||
@@ -66,7 +63,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m5 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r5
|
||||
@@ -74,7 +70,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m6 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r6
|
||||
@@ -82,7 +77,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m7 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r7
|
||||
@@ -90,7 +84,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m8 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r8
|
||||
@@ -98,7 +91,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m9 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r9
|
||||
@@ -106,7 +98,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m10 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r10
|
||||
@@ -114,7 +105,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m11 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r11
|
||||
@@ -122,7 +112,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m12 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r12
|
||||
@@ -130,7 +119,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m13 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r13
|
||||
@@ -138,7 +126,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m14 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r14
|
||||
@@ -146,7 +133,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m15 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r15
|
||||
@@ -154,7 +140,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m16 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r16
|
||||
@@ -162,7 +147,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m17 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r17
|
||||
@@ -170,7 +154,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m18 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r18
|
||||
@@ -178,7 +161,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m19 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r19
|
||||
@@ -186,7 +168,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m20 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r20
|
||||
@@ -194,7 +175,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m21 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r21
|
||||
@@ -202,7 +182,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m22 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r22
|
||||
@@ -210,7 +189,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m23 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r23
|
||||
@@ -218,7 +196,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m24 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r24
|
||||
@@ -226,7 +203,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m25 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r25
|
||||
@@ -234,7 +210,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m26 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r26
|
||||
@@ -242,7 +217,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m27 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r27
|
||||
@@ -250,7 +224,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}} m28 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- paragraph: r28
|
||||
@@ -258,7 +231,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: 7/25 {{clock}}
|
||||
- button "Back to bottom"
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: {{clock}}
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- button "Ran for {{duration}}"
|
||||
- text: {{clock}}
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Pin desktop availability so native-open controls render identically in headless CI.
|
||||
- id: session-controller
|
||||
config:
|
||||
nativeOpen: true
|
||||
@@ -11,7 +11,7 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, parseSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
@@ -313,6 +313,35 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('persists performance detail and hides statistics in Compact', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-performance-usage'))
|
||||
const stats = page.locator('[data-composer-stats]')
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
const row = dialog.getByText('Performance & usage', { exact: true }).locator('../..')
|
||||
await row.getByRole('button', { name: 'Detailed', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
|
||||
await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ performanceUsage: 'compact' })
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect.poll(() => stats.locator('button').count()).toBe(0)
|
||||
expect(await stats.textContent()).not.toContain('turns')
|
||||
expect(await stats.textContent()).toContain('Cache hit')
|
||||
await stats.hover()
|
||||
expect(await page.getByRole('dialog').count()).toBe(0)
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'compact.expected.md'), await captureStableAria(page, '[data-composer-stats]', scaffold.workspaceCwd), MODE)
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
await row.getByRole('button', { name: 'Compact', exact: true }).waitFor()
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await row.getByRole('button', { name: 'Compact', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Detailed', exact: true }).click()
|
||||
await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ performanceUsage: 'detailed' })
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect.poll(() => stats.locator('button').count()).toBe(2)
|
||||
expect(await page.locator('[data-turn-tail]').getByRole('button', { name: /Ran for/ }).count()).toBe(0)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork'))
|
||||
// The second answer is followed by another completed user turn in the source.
|
||||
@@ -380,6 +409,6 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['fork.expected.md', 'ui.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['compact.expected.md', 'fork.expected.md', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -30,7 +30,7 @@ describe('web e2e: plugin manager', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] },
|
||||
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }], bundles: ['@fixture/missing-bundle'] },
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
@@ -66,6 +66,29 @@ describe('web e2e: plugin manager', () => {
|
||||
return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '')
|
||||
}
|
||||
|
||||
it('starts with an unavailable selected bundle and lets the user clear its error', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle'))
|
||||
const panel = await openPluginsPanel()
|
||||
await panel.getByRole('button', { name: '查看 missing-bundle', exact: true }).click()
|
||||
const toggle = panel.getByRole('switch', { name: '启用 missing-bundle', exact: true })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await toggle.isDisabled()).toBe(false)
|
||||
await panel.getByText(/cannot resolve profile bundle/).waitFor({ timeout: 10_000 })
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(row => row.name === '@fixture/missing-bundle'))
|
||||
.toMatchObject({ enabled: true, error: { code: 'operation-error' }, rows: [] })
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'missing-bundle.expected.md'),
|
||||
await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
|
||||
replacements: [[scaffold.harnessHome, '{{home}}']],
|
||||
}), MODE)
|
||||
await toggle.click()
|
||||
await expect.poll(async () => (JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as {
|
||||
dsh: { profile: { bundles: string[] } }
|
||||
}).dsh.profile.bundles, { timeout: 10_000 }).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
|
||||
await expect.poll(() => panel.getByText(/cannot resolve profile bundle/).count(), { timeout: 10_000 }).toBe(0)
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).some(row => row.name === '@fixture/missing-bundle')).toBe(false)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('lists the installed bundles with their switches and leaves the installation\'s own to Settings', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list'))
|
||||
const panel = await openPluginsPanel()
|
||||
@@ -258,7 +281,7 @@ describe('web e2e: plugin manager', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'live-enabled.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ export interface LaunchOptions {
|
||||
* the scaffold's hermetic test patches, matching the launcher's `--patch`
|
||||
* ordering.
|
||||
*/
|
||||
extraOverlayPath?: string
|
||||
extraOverlayPath?: string | readonly string[]
|
||||
/**
|
||||
* Additional package manifests whose dependency closures supply experimental
|
||||
* profile layers named by {@link extraOverlayPath}.
|
||||
@@ -334,6 +334,8 @@ export interface LaunchOptions {
|
||||
profile?: {
|
||||
hmr?: boolean
|
||||
packages: { dir: string; enabled?: boolean }[]
|
||||
/** Additional selected names, including bundles unavailable after an upgrade. */
|
||||
bundles?: readonly string[]
|
||||
}
|
||||
/**
|
||||
* Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
|
||||
@@ -455,7 +457,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
requireDist()
|
||||
const {
|
||||
auditStartupEntries, composeEntries, createProfileResolutionGeneration, healProfilesModuleFallback, initProfile,
|
||||
mountRootInclude, readProfileManifest, readProfilePatches, loadOverlayPatches, PluginPackages,
|
||||
mountRootInclude, readProfileManifest, readProfilePatches, loadProfileDirectory, loadOverlayPatches, PluginPackages,
|
||||
} = appBoot()
|
||||
const mode = webSnapshotMode()
|
||||
const replayFixture = options.replayFixture === undefined
|
||||
@@ -542,7 +544,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
const surfacePatches = loadOverlayPatches('web e2e scaffold', WEB_PATCH_PATH)
|
||||
const extraOverlayPatches = options.extraOverlayPath === undefined
|
||||
? []
|
||||
: loadOverlayPatches('web e2e scaffold', options.extraOverlayPath)
|
||||
: (typeof options.extraOverlayPath === 'string' ? [options.extraOverlayPath] : options.extraOverlayPath)
|
||||
.flatMap(path => loadOverlayPatches('web e2e scaffold', path))
|
||||
const composedRows = composeEntries([basePatches, surfacePatches, extraOverlayPatches])
|
||||
const webRuntimeConfig = composedRows.find(row => row.id === 'web-runtime')?.config as {
|
||||
surfaceContext?: boolean
|
||||
@@ -730,7 +733,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// A real profile: the shipped web bundles plus each fixture package,
|
||||
// installed the way `dsh plugin add` leaves them.
|
||||
const dependencies: Record<string, string> = {}
|
||||
const bundles = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
|
||||
const bundles = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', ...options.profile.bundles ?? []]
|
||||
for (const entry of options.profile.packages) {
|
||||
const manifest = JSON.parse(await readFile(join(entry.dir, 'package.json'), 'utf8')) as { name: string }
|
||||
dependencies[manifest.name] = `file:${entry.dir}`
|
||||
@@ -745,7 +748,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
await writeFile(join(profileDir, 'package.json'), JSON.stringify(manifest, null, 2) + '\n')
|
||||
profileContext = {
|
||||
name: 'scaffold', dir: profileDir, patchPath: profile.patchPath, installAnchor: INSTALL_ANCHOR,
|
||||
cwd: workspaceCwd, home: harnessHome, startedBundles: bundles,
|
||||
cwd: workspaceCwd, home: harnessHome,
|
||||
startedBundles: loadProfileDirectory('dsh', profileDir, INSTALL_ANCHOR).layers.map(layer => layer.packageName),
|
||||
overlays: overlayPatches, telemetryDisabledEnv: undefined,
|
||||
}
|
||||
// HMR gates file-driven reloads on application readiness, which the
|
||||
|
||||
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -207,8 +208,16 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
let seededThroughSeq = -1
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold(process.platform === 'win32' ? {} : {
|
||||
extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)),
|
||||
// The POSIX terminal fixture stays off Windows; the pinned desktop applies
|
||||
// everywhere. The Open In rows carry the document header's file controls,
|
||||
// and the SSH marker keeps the application catalog empty so the
|
||||
// Session-header split button stays out of every golden.
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: [
|
||||
...process.platform === 'win32' ? [] : [fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url))],
|
||||
fileURLToPath(new URL('./fixtures/native-open-on.patch.yml', import.meta.url)),
|
||||
],
|
||||
openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]),
|
||||
})
|
||||
// Composer recording uses a child workspace; seedSession owns the scaffold root.
|
||||
const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd
|
||||
@@ -500,6 +509,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await expect.poll(() => path.textContent()).toBe(absolutePath)
|
||||
expect(await path.getAttribute('title')).toBe(absolutePath)
|
||||
await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
|
||||
await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 })
|
||||
const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE)
|
||||
} finally {
|
||||
@@ -794,12 +804,14 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
|
||||
const tabId = await column.locator('[data-dockkit-tab]').getAttribute('data-dockkit-tab')
|
||||
await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 })
|
||||
const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
|
||||
expect(await column.locator('[data-dockkit-tab]').getAttribute('data-dockkit-tab')).toBe(tabId)
|
||||
await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 })
|
||||
const restoredPreview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
|
||||
expect(restoredPreview).toBe(preview)
|
||||
})
|
||||
|
||||
@@ -70,7 +70,6 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => {
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
|
||||
.replace(/\{\{date\}\} (?=\{\{clock\}\} Ran for)/g, '')
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
@@ -177,11 +177,9 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
const trigger = page.getByRole('button', { name: /Usage 15\.8K tok/ })
|
||||
await expect.poll(() => trigger.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
// The usage pill carries the icon and the turn total; the time pill beside
|
||||
// it carries the run time, and both keep their details dialog-only.
|
||||
expect(await trigger.textContent()).toBe('Usage 15.8K tok')
|
||||
const timeTrigger = page.getByRole('button', { name: /^Ran for \S+$/ })
|
||||
expect(await timeTrigger.count()).toBe(1)
|
||||
expect(await timeTrigger.count()).toBe(0)
|
||||
expect(await page.locator('[data-turn-tail]').getByText(/tok\/s|TTFT/).count()).toBe(0)
|
||||
expect(await page.getByRole('dialog').count()).toBe(0)
|
||||
|
||||
@@ -198,12 +196,6 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
await page.keyboard.press('Escape')
|
||||
expect(await page.getByRole('dialog').count()).toBe(0)
|
||||
|
||||
await timeTrigger.click()
|
||||
const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
|
||||
expect(await timeDialog.count()).toBe(1)
|
||||
expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
|
||||
expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
|
||||
await page.keyboard.press('Escape')
|
||||
await trigger.click()
|
||||
|
||||
const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
@@ -211,12 +203,9 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await expect.poll(() => timeTrigger.count(), { timeout: 15_000 }).toBe(1)
|
||||
await expect.poll(() => trigger.count(), { timeout: 15_000 }).toBe(1)
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await timeTrigger.click()
|
||||
expect(await timeDialog.count()).toBe(1)
|
||||
expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
|
||||
expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
|
||||
expect(await timeTrigger.count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 9ec804d699226e3983ae56c354aa1531d9d286b1
|
||||
config-catalog.zh.md: 772de7157e6bb2f2a90a068624c6935290586d0b
|
||||
config-catalog.md: 584ec7d3c844d4a4ee7fb0bad4034c9451181447
|
||||
config-catalog.zh.md: 9c2a3317396c63428a9f1696756f629128cdd691
|
||||
@@ -203,7 +203,7 @@ Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/in
|
||||
|
||||
## `@deepseek-ai/dsh-api-session-controller`
|
||||
|
||||
Requires: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
|
||||
Requires: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
|
||||
|
||||
```ts config-catalog
|
||||
/** Session Controller deployment policy. */
|
||||
@@ -213,7 +213,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/api/session-controller/src/index.ts:71`](../packages/api/session-controller/src/index.ts)
|
||||
Source: [`packages/api/session-controller/src/index.ts:73`](../packages/api/session-controller/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-settings-controller"></a>
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-api-session-controller`
|
||||
|
||||
需要:`agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
|
||||
需要:`agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
|
||||
|
||||
```ts config-catalog
|
||||
/** Session Controller deployment policy. */
|
||||
@@ -215,7 +215,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/api/session-controller/src/index.ts:71`](../packages/api/session-controller/src/index.ts)
|
||||
来源:[`packages/api/session-controller/src/index.ts:73`](../packages/api/session-controller/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-settings-controller"></a>
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: fcf95b4268cc41b84099af351f1d6fdb07c80fc4
|
||||
event-producer-consumer.zh.md: bd466a2e39f4a268e4a8b1567ae5a5f22d250977
|
||||
event-producer-consumer.md: e57d974de1e35d26a709a3d978dc7cd2c3c29ed7
|
||||
event-producer-consumer.zh.md: 62abea80c12cc64b017c3b15787ad3b17c054146
|
||||
@@ -51,9 +51,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:188`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:194`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:200`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:189`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:195`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:201`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
|
||||
@@ -53,9 +53,9 @@
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:188`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:194`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:200`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:189`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:195`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:201`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/session.md
|
||||
session.md: 7c7e244bfca17c8e22cabe45fc69126113b685d6
|
||||
session.zh.md: fecae92809bdba93588e163447d372c7a42d0260
|
||||
session.md: 69f0ee565e47191e2468a857d12eb7d255c9ecdb
|
||||
session.zh.md: 56a0502fcbccf89625833d116cb2eb2a1fcb49f7
|
||||
@@ -818,11 +818,11 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
|
||||
workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | 'explorer' | 'directory' | null }
|
||||
|
||||
/**
|
||||
* Open one path prepared by a Session-aware caller on the Host desktop.
|
||||
* Verify one path through the composed filesystem and open it on the Host desktop.
|
||||
* @param request - path after best-effort Session workspace resolution.
|
||||
* @param signal - caller lifetime; abort terminates the native command.
|
||||
* @returns confirmation after the native opener accepts the path.
|
||||
* @throws RemoteError when the request is invalid, cancelled, or the opener fails.
|
||||
* @throws RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.
|
||||
*/
|
||||
@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>
|
||||
|
||||
|
||||
@@ -822,11 +822,11 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
|
||||
workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | 'explorer' | 'directory' | null }
|
||||
|
||||
/**
|
||||
* Open one path prepared by a Session-aware caller on the Host desktop.
|
||||
* Verify one path through the composed filesystem and open it on the Host desktop.
|
||||
* @param request - path after best-effort Session workspace resolution.
|
||||
* @param signal - caller lifetime; abort terminates the native command.
|
||||
* @returns confirmation after the native opener accepts the path.
|
||||
* @throws RemoteError when the request is invalid, cancelled, or the opener fails.
|
||||
* @throws RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.
|
||||
*/
|
||||
@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
|
||||
README.md: 0ab2a452587da8e818e2744e59232fe67f15f79f
|
||||
README.zh.md: 6819f07ae0a495896937a7097c5be2dcb0fbefc9
|
||||
README.md: 498f3028ff55184a1a43681e78a48c565f69d22c
|
||||
README.zh.md: d5e6c5d4c624955b59c59f644fa17a921e92c7cc
|
||||
@@ -29,7 +29,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
|
||||
|
||||
The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
|
||||
|
||||
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the default application.
|
||||
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs.
|
||||
|
||||
Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank.
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
Client journal 在发布 follow 快照、live entry 或历史页之前验证精确的 V3 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
|
||||
|
||||
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时打开默认应用。
|
||||
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。
|
||||
|
||||
Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
|
||||
|
||||
import { hostname } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-client-file-upload'
|
||||
import { canOpenNativePath, nativeFileManager, openNativePath, revealNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import { canOpenNativePath, nativeFileManager, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
@@ -90,6 +92,7 @@ export class SessionController extends TypertRemoteService {
|
||||
'agents',
|
||||
'attachments',
|
||||
'fileUploads',
|
||||
'fs',
|
||||
'llm',
|
||||
'sessions',
|
||||
'sessionProjections',
|
||||
@@ -135,7 +138,7 @@ export class SessionController extends TypertRemoteService {
|
||||
}, 'session-controller.promotions')
|
||||
this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
|
||||
this.listState = new ApiSessionList(ctx)
|
||||
this.openPath = internals.openPath ?? openNativePath
|
||||
this.openPath = internals.openPath ?? openNativeAssociatedPath
|
||||
this.revealPath = internals.revealPath ?? revealNativePath
|
||||
this.canOpenPath = internals.canOpenPath
|
||||
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
|
||||
@@ -284,11 +287,11 @@ export class SessionController extends TypertRemoteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one path prepared by a Session-aware caller on the Host desktop.
|
||||
* Verify one path through the composed filesystem and open it on the Host desktop.
|
||||
* @param request - path after best-effort Session workspace resolution.
|
||||
* @param signal - caller lifetime; abort terminates the native command.
|
||||
* @returns confirmation after the native opener accepts the path.
|
||||
* @throws RemoteError when the request is invalid, cancelled, or the opener fails.
|
||||
* @throws RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.
|
||||
*/
|
||||
@Remote('openWorkspacePath')
|
||||
async openWorkspacePath(
|
||||
@@ -304,6 +307,13 @@ export class SessionController extends TypertRemoteService {
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
const hostPath = resolve(request.path)
|
||||
const { fs } = this.ctx
|
||||
const mapped = fs.processPathFromHostPath(hostPath)
|
||||
if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) {
|
||||
throw new Error('Path has no verified Host path')
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (request.action === 'reveal') await this.revealPath(request.path, signal)
|
||||
else await this.openPath(request.path, signal)
|
||||
return { opened: true }
|
||||
|
||||
@@ -2,6 +2,8 @@ import * as nativeCommand from '@deepseek-ai/dsh-native-command'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import FsLocal from '@deepseek-ai/dsh-fs-local'
|
||||
import { onTestFinished } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createSessionTestController,
|
||||
@@ -12,6 +14,8 @@ async function context(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(FsLocal)
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -74,9 +78,9 @@ describe('session/openWorkspacePath', () => {
|
||||
openPath,
|
||||
})
|
||||
|
||||
await remote.openWorkspacePath({ path: '/tmp/result.html' })
|
||||
await remote.openWorkspacePath({ path: '/workspace/result.html' })
|
||||
await remote.openWorkspacePath({ path: 'result.html' })
|
||||
expect(openPath.mock.calls.map(call => call[0])).toEqual(['/tmp/result.html', 'result.html'])
|
||||
expect(openPath.mock.calls.map(call => call[0])).toEqual(['/workspace/result.html', 'result.html'])
|
||||
})
|
||||
|
||||
it('rejects empty paths before opening anything', async () => {
|
||||
@@ -170,3 +174,44 @@ it('uses the native reveal adapter without a test override and respects unsuppor
|
||||
expect(reveal).toHaveBeenCalledOnce()
|
||||
} finally { manager.mockRestore(); reveal.mockRestore(); await ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
|
||||
it.each(['open', 'reveal'] as const)('rejects an unmapped remote path before native %s', async (action) => {
|
||||
const ctx = await context()
|
||||
const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
|
||||
onTestFinished(() => { mapping.mockRestore() })
|
||||
const openPath = vi.fn(async () => {})
|
||||
const revealPath = vi.fn(async () => {})
|
||||
const controller = createSessionTestController(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath,
|
||||
})
|
||||
await expect(controller.openWorkspacePath({ path: '/remote/report.html', ...(action === 'reveal' ? { action } : {}) }, new AbortController().signal))
|
||||
.rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: Path has no verified Host path' })
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
expect(revealPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a filesystem mapping that resolves to another process path', async () => {
|
||||
const ctx = await context()
|
||||
const mapping = vi.spyOn(ctx.fs, 'processPath').mockReturnValue('/different/report.html')
|
||||
onTestFinished(() => { mapping.mockRestore() })
|
||||
const openPath = vi.fn(async () => {})
|
||||
const controller = createSessionTestController(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath,
|
||||
})
|
||||
await expect(controller.openWorkspacePath({ path: '/report.html' }, new AbortController().signal))
|
||||
.rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: Path has no verified Host path' })
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches default-app opening to the association adapter', async () => {
|
||||
const ctx = await context()
|
||||
const open = vi.spyOn(nativeCommand, 'openNativeAssociatedPath').mockResolvedValue(undefined)
|
||||
onTestFinished(() => { open.mockRestore() })
|
||||
const controller = createSessionTestController(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true,
|
||||
})
|
||||
const signal = new AbortController().signal
|
||||
await controller.openWorkspacePath({ path: '/report.html' }, signal)
|
||||
expect(open).toHaveBeenCalledWith('/report.html', signal)
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
|
||||
README.md: 507445c13953d5b38fcc780f0c0e9adaac7a2d9d
|
||||
README.zh.md: 11ab4b9df5df5ca16addda7d58513fdd7869a1b0
|
||||
README.md: 9d9b98ad4669931c32886a73937f4c33ab746b74
|
||||
README.zh.md: f7b092ad2f52f51404b02177059e8e4b0121b7c3
|
||||
@@ -47,7 +47,7 @@ With that entry point, startup keeps every plugin that can activate. An enabled
|
||||
|
||||
Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot adapts `DshPackageManifest` to `ProfileManifest` with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data.
|
||||
|
||||
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. A missing bundle or one without a patch declaration fails startup loudly. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
|
||||
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures print a diagnostic and skip that bundle without changing its selection. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
|
||||
|
||||
Your machine-local preferences also live in the Harness home:
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
|
||||
|
||||
Profile 与组合包的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 将 `DshPackageManifest` 适配为包身份可选的 `ProfileManifest`,因为本地 profile 无需发布版本。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。
|
||||
|
||||
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。缺失组合包或未声明 patch 的组合包会让启动明确失败。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
|
||||
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。组合包解析、manifest 读取或 patch 加载失败时会输出诊断并跳过该组合包,不改变其选择状态。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
|
||||
|
||||
你的机器本地偏好同样位于 harness home 中:
|
||||
|
||||
|
||||
@@ -467,7 +467,7 @@ function profileDependencyNames(manifest: ProfileManifest): string[] {
|
||||
|
||||
/** Resolve the installation generation that every profile must find through the fallback directory. */
|
||||
function resolveModuleFallbackEntries(
|
||||
installAnchor: string, materialize = true,
|
||||
installAnchor: string, materialize = true, skippedBundles: ReadonlySet<string> = new Set(),
|
||||
): {
|
||||
entries: ModuleFallbackEntry[]
|
||||
packageNames: ReadonlySet<string>
|
||||
@@ -499,10 +499,16 @@ function resolveModuleFallbackEntries(
|
||||
// A declared-but-uninstalled dependency cannot be a loader-visible
|
||||
// plugin; skip it rather than fail the whole boot.
|
||||
if (dir === undefined) continue
|
||||
const manifestPath = join(dir, 'package.json')
|
||||
let manifest: ProfileManifest
|
||||
try {
|
||||
manifest = skippedBundles.has(dep) ? readProfileManifest('dsh', dir) : readModuleFallbackManifest(manifestPath)
|
||||
} catch (error) {
|
||||
if (!skippedBundles.has(dep)) throw error
|
||||
continue
|
||||
}
|
||||
links.set(dep, dir)
|
||||
declarers.set(dep, next.anchor)
|
||||
const manifestPath = join(dir, 'package.json')
|
||||
const manifest = readModuleFallbackManifest(manifestPath)
|
||||
versions.set(dep, manifest.version)
|
||||
queue.push({ anchor: manifestPath, manifest })
|
||||
}
|
||||
@@ -574,7 +580,10 @@ export async function healProfilesModuleFallback(
|
||||
const profilesDir = join(home, PROFILES_DIR)
|
||||
const modulesDir = join(profilesDir, 'node_modules')
|
||||
if (materialize) mkdirSync(modulesDir, { recursive: true })
|
||||
const { entries, packageNames, packageDirs, declarers, versions } = resolveModuleFallbackEntries(installAnchor, materialize)
|
||||
const manifest = readOptionalProfileManifest(profile)
|
||||
const { entries, packageNames, packageDirs, declarers, versions } = resolveModuleFallbackEntries(
|
||||
installAnchor, materialize, skippedProfileBundles(profile, manifest),
|
||||
)
|
||||
if (materialize && !moduleFallbackCurrent(modulesDir, entries)) {
|
||||
await withFileLock(modulesDir, () => {
|
||||
if (!moduleFallbackCurrent(modulesDir, entries)) healProfilesModuleFallbackLocked(entries, modulesDir)
|
||||
@@ -583,7 +592,7 @@ export async function healProfilesModuleFallback(
|
||||
}
|
||||
const profileDeclarers = new Map<string, string>()
|
||||
const profileVersions = new Map<string, string | undefined>()
|
||||
const localPackageNames = profile === undefined ? [] : installedProfilePackageNames(profile)
|
||||
const localPackageNames = profile === undefined ? [] : installedProfilePackageNames(profile, manifest)
|
||||
const profilePackages: ReadonlyMap<string, string> = profile === undefined
|
||||
? new Map<string, string>()
|
||||
: healProfileModuleFallback(profile, packageNames, materialize, profileDeclarers, profileVersions)
|
||||
@@ -604,16 +613,27 @@ export async function healProfilesModuleFallback(
|
||||
})
|
||||
}
|
||||
|
||||
/** Return installed direct dependencies that Node resolves before profile fallback. */
|
||||
function installedProfilePackageNames(profile: Profile): string[] {
|
||||
let manifest: ProfileManifest
|
||||
/** Synthetic profiles used by direct callers may have no on-disk manifest. */
|
||||
function readOptionalProfileManifest(profile: Profile | undefined): ProfileManifest | undefined {
|
||||
if (profile === undefined) return undefined
|
||||
try {
|
||||
manifest = readModuleFallbackManifest(join(profile.dir, 'package.json'))
|
||||
return readModuleFallbackManifest(join(profile.dir, 'package.json'))
|
||||
} catch (error) {
|
||||
// Direct helper callers can supply a synthetic Profile without its on-disk manifest.
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Broken selected manifests must not fail again during installation dependency traversal. */
|
||||
function skippedProfileBundles(profile: Profile | undefined, manifest: ProfileManifest | undefined): ReadonlySet<string> {
|
||||
const selected = manifest?.dsh?.profile?.bundles ?? []
|
||||
const loaded = new Set(profile?.layers.map(layer => layer.packageName))
|
||||
return new Set(selected.filter(name => !loaded.has(name)))
|
||||
}
|
||||
|
||||
/** Return installed direct dependencies that Node resolves before profile fallback. */
|
||||
function installedProfilePackageNames(profile: Profile, manifest: ProfileManifest | undefined): string[] {
|
||||
if (manifest === undefined) return []
|
||||
return profileDependencyNames(manifest).filter((name) => {
|
||||
const candidate = join(profile.dir, 'node_modules', name)
|
||||
if (!existsSync(join(candidate, 'package.json'))) return false
|
||||
@@ -639,7 +659,9 @@ export function createProfileResolutionGeneration(
|
||||
* @param options - owning installation package.json and the loaded application profile.
|
||||
*/
|
||||
export function healIsolatedProfileModuleFallback(options: { installAnchor: string; profile: Profile }): void {
|
||||
const installationLinks = resolveModuleFallbackEntries(options.installAnchor, false).packageDirs
|
||||
const installationLinks = resolveModuleFallbackEntries(
|
||||
options.installAnchor, false, skippedProfileBundles(options.profile, readOptionalProfileManifest(options.profile)),
|
||||
).packageDirs
|
||||
healProfileModuleFallback(options.profile, new Set(installationLinks.keys()), true, undefined, undefined, installationLinks)
|
||||
}
|
||||
|
||||
@@ -869,11 +891,12 @@ export function resolveBundleDir(
|
||||
* Load an already initialized profile directory without resolving it through
|
||||
* the shared Harness home. This is used by application-owned profiles whose
|
||||
* package project and lifecycle belong to that application.
|
||||
* Unreadable bundles are reported on stderr and skipped without changing the manifest.
|
||||
* @param binName - the diagnostic prefix on thrown errors.
|
||||
* @param dir - absolute profile package directory.
|
||||
* @param installAnchor - absolute path of the owning dsh app's package.json.
|
||||
* @param options - `userLayer: false` skips reading `cordis.patch.yml`.
|
||||
* @returns the resolved bundle layers and optional user patch layer.
|
||||
* @returns the successfully loaded bundle layers and optional user patch layer.
|
||||
*/
|
||||
export function loadProfileDirectory(
|
||||
binName: string,
|
||||
@@ -883,16 +906,21 @@ export function loadProfileDirectory(
|
||||
): Profile {
|
||||
const manifest = readProfileManifest(binName, dir)
|
||||
const bundles = manifest.dsh?.profile?.bundles ?? []
|
||||
const layers = bundles.map((packageName): ProfileLayer => {
|
||||
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
|
||||
const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest
|
||||
const declared = bundleManifest.dsh?.bundle?.patch
|
||||
if (declared === undefined) {
|
||||
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
|
||||
const layers: ProfileLayer[] = []
|
||||
for (const packageName of bundles) {
|
||||
try {
|
||||
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
|
||||
const bundleManifest = readProfileManifest(binName, packageDir)
|
||||
const declared = bundleManifest.dsh?.bundle?.patch
|
||||
if (declared === undefined) {
|
||||
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
|
||||
}
|
||||
const patchPath = join(packageDir, declared)
|
||||
layers.push({ packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) })
|
||||
} catch (error) {
|
||||
process.stderr.write(`${binName}: skipping profile bundle ${JSON.stringify(packageName)}: ${String(error)}\n`)
|
||||
}
|
||||
const patchPath = join(packageDir, declared)
|
||||
return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
|
||||
})
|
||||
}
|
||||
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
|
||||
const patches = options.userLayer !== false && existsSync(patchPath)
|
||||
? loadOverlayPatches(binName, patchPath)
|
||||
@@ -902,9 +930,8 @@ export function loadProfileDirectory(
|
||||
|
||||
/**
|
||||
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch
|
||||
* layer and parse the profile's own patch file. A listed bundle without a
|
||||
* `dsh.bundle` manifest fails loud — naming a bundle-less package as a layer
|
||||
* is a misconfiguration, not "no patches".
|
||||
* layer and parse the profile's own patch file. Unreadable bundles are reported
|
||||
* on stderr and skipped; profile manifest and user patch errors still throw.
|
||||
* @param binName - the diagnostic prefix on thrown errors.
|
||||
* @param name - the profile name.
|
||||
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
|
||||
|
||||
@@ -12,9 +12,10 @@ import { tmpdir } from 'node:os'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { afterAll, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import {
|
||||
composeEntries,
|
||||
createProfileResolutionGeneration,
|
||||
healProfilesModuleFallback,
|
||||
healIsolatedProfileModuleFallback,
|
||||
initProfile,
|
||||
@@ -308,9 +309,6 @@ describe('loadProfile', () => {
|
||||
const home = tmp()
|
||||
expect(() => loadProfile('t', 'custom', anchor, home))
|
||||
.toThrow('profile "custom" does not exist')
|
||||
// The web template auto-initializes on first load. Bundle resolution
|
||||
// cannot be asserted to fail here: the source-plane test runner resolves
|
||||
// @deepseek-ai/* through tsconfig paths regardless of the staged anchor.
|
||||
expect(PROFILE_TEMPLATES.web?.bundles).toContain('@deepseek-ai/dsh-base')
|
||||
expect(PROFILE_TEMPLATES.acp).toEqual({
|
||||
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'],
|
||||
@@ -321,11 +319,7 @@ describe('loadProfile', () => {
|
||||
expect(PROFILE_TEMPLATES['sdk-minimal']).toEqual({
|
||||
bundles: ['@deepseek-ai/dsh-sdk-minimal'],
|
||||
})
|
||||
try {
|
||||
loadProfile('t', 'web', anchor, home)
|
||||
} catch {
|
||||
// Resolution failure is the plain-Node outcome for this empty anchor.
|
||||
}
|
||||
loadProfile('t', 'web', anchor, home)
|
||||
expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles)
|
||||
.toEqual([...PROFILE_TEMPLATES.web?.bundles ?? []])
|
||||
})
|
||||
@@ -360,12 +354,58 @@ describe('loadProfile', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('fails loud when a listed bundle declares no dsh.bundle', () => {
|
||||
const anchor = stageInstallation({ 'not-a-bundle': {} })
|
||||
const home = tmp()
|
||||
const dir = resolveProfileDir('demo', home)
|
||||
initProfile(dir, ['not-a-bundle'])
|
||||
expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.bundle')
|
||||
it.each(['missing package', 'invalid manifest', 'not a bundle', 'missing patch', 'invalid patch'])(
|
||||
'skips a bundle with %s, retains selections, and retries it on reread', async (failure) => {
|
||||
const anchor = stageInstallation({
|
||||
before: { patch: '- insert: [{ id: a, name: pkg-a }]\n' },
|
||||
broken: { patch: '[]\n' },
|
||||
after: { patch: '- id: a\n config: { value: after }\n' },
|
||||
})
|
||||
const home = tmp()
|
||||
const dir = resolveProfileDir('demo', home)
|
||||
initProfile(dir, ['before', 'broken', 'after'])
|
||||
const bundleDir = join(anchor, '..', 'node_modules', 'broken')
|
||||
const manifestPath = join(bundleDir, 'package.json')
|
||||
const patchPath = join(bundleDir, 'cordis.patch.yml')
|
||||
const original = readFileSync(manifestPath, 'utf8')
|
||||
if (failure === 'missing package') rmSync(bundleDir, { recursive: true })
|
||||
if (failure === 'invalid manifest') writeFileSync(manifestPath, '{')
|
||||
if (failure === 'not a bundle') writeFileSync(manifestPath, '{}')
|
||||
if (failure === 'missing patch') rmSync(patchPath)
|
||||
if (failure === 'invalid patch') writeFileSync(patchPath, '[invalid')
|
||||
const saved = readFileSync(join(dir, 'package.json'), 'utf8')
|
||||
const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
onTestFinished(() => { warn.mockRestore() })
|
||||
|
||||
const profile = loadProfile('t', 'demo', anchor, home)
|
||||
expect(profile.layers.map(layer => layer.packageName)).toEqual(['before', 'after'])
|
||||
expect(composeEntries(profile.layers.map(layer => layer.patches)))
|
||||
.toEqual([{ id: 'a', name: 'pkg-a', config: { value: 'after' } }])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipping profile bundle "broken":'))
|
||||
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(saved)
|
||||
const generation = await createProfileResolutionGeneration({ installAnchor: anchor, profile, home })
|
||||
const unavailable = failure === 'missing package' || failure === 'invalid manifest'
|
||||
expect(generation.entries.map(entry => entry.name))
|
||||
.toEqual(['dsh-app', 'before', ...unavailable ? [] : ['broken'], 'after'])
|
||||
healIsolatedProfileModuleFallback({ installAnchor: anchor, profile })
|
||||
expect(existsSync(join(dir, 'node_modules', 'broken'))).toBe(!unavailable)
|
||||
mkdirSync(bundleDir, { recursive: true })
|
||||
writeFileSync(manifestPath, original)
|
||||
writeFileSync(patchPath, '[]\n')
|
||||
expect(loadProfileDirectory('t', dir, anchor).layers.map(layer => layer.packageName))
|
||||
.toEqual(['before', 'broken', 'after'])
|
||||
},
|
||||
)
|
||||
|
||||
it('still rejects invalid profile manifests and user patches', () => {
|
||||
const anchor = stageInstallation({})
|
||||
const dir = tmp()
|
||||
initProfile(dir, [])
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '[invalid')
|
||||
expect(() => loadProfileDirectory('t', dir, anchor)).toThrow()
|
||||
expect(loadProfileDirectory('t', dir, anchor, { userLayer: false }).layers).toEqual([])
|
||||
writeFileSync(join(dir, 'package.json'), '{')
|
||||
expect(() => loadProfileDirectory('t', dir, anchor)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
|
||||
README.md: 46b5447a8b3548b548bf844e1d753296861b17da
|
||||
README.zh.md: d4c285b72f8d31357a12f222d741323e9b4658e6
|
||||
README.md: d0c78bf23655d14e8df475c67fb4cda6399a7fa9
|
||||
README.zh.md: 46bd8d5fdb62331993adc0d7a0261d9a808878a1
|
||||
@@ -39,6 +39,8 @@ For a deployment without agent presets, enable the tool in the profile patch. Pr
|
||||
|
||||
A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority.
|
||||
|
||||
A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable.
|
||||
|
||||
`inspect(spec)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same registry and proxy settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form. The answer carries the name, version, description, and whether the package declares a bundle, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`. A caller's `signal` or `inspectTimeoutMs` ends the lookup.
|
||||
|
||||
`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`. `cancelInstall(requestId)` stops the run and answers `cancelled` only after pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies a failed run from its exit and output, and `bundle` names the package a finished run added. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read.
|
||||
|
||||
@@ -39,6 +39,8 @@ kind: "package-reference"
|
||||
|
||||
插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。
|
||||
|
||||
已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。
|
||||
|
||||
`inspect(spec)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的注册表与代理设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式。答复携带名称、版本、描述以及该包是否声明组合包,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。
|
||||
|
||||
`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`。`cancelInstall(requestId)` 停止运行,只在 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对失败运行分类,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。
|
||||
|
||||
@@ -136,6 +136,8 @@ export class PluginManager extends TypertRemoteService {
|
||||
lockWaitMs: z.number().step(1).min(0).default(120000),
|
||||
inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
|
||||
})
|
||||
/** Management bundles remain protected if their files become unreadable. */
|
||||
private readonly managementBundles = new Set<string>()
|
||||
private readonly ownerEntryId: string | undefined
|
||||
private readonly packageOperations = new Set<Promise<unknown>>()
|
||||
private readonly profile: ProfileContext
|
||||
@@ -153,6 +155,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
this.ownerEntryId = ctx.fiber.entry?.id
|
||||
this.ownerContext = ctx
|
||||
this.profile = ctx.profileContext
|
||||
for (const name of this.profile.startedBundles) this.protectsManager(name)
|
||||
this.outputBytes = (config as Required<Config>).outputBytes
|
||||
this.lockWaitMs = (config as Required<Config>).lockWaitMs
|
||||
this.inspectTimeoutMs = (config as Required<Config>).inspectTimeoutMs
|
||||
@@ -203,13 +206,14 @@ export class PluginManager extends TypertRemoteService {
|
||||
const optional = OPTIONAL_BUNDLES.includes(name)
|
||||
const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name)
|
||||
const enabled = selected.includes(name)
|
||||
const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined
|
||||
try {
|
||||
const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
|
||||
if (info === undefined) {
|
||||
if (enabled) bundles.push({ name, enabled, installed, optional, removable, error: { code: 'not-bundle' }, rows: [], overrides: [] })
|
||||
if (enabled) bundles.push({ name, enabled, installed, optional, removable: removable && readOnlyReason === undefined,
|
||||
...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] })
|
||||
continue
|
||||
}
|
||||
const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined
|
||||
bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
|
||||
...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
|
||||
enabled, installed, optional, removable: removable && readOnlyReason === undefined,
|
||||
@@ -217,7 +221,8 @@ export class PluginManager extends TypertRemoteService {
|
||||
...this.declaredRows(name, info) })
|
||||
} catch (error) {
|
||||
if (enabled || installed) {
|
||||
bundles.push({ name, enabled, installed, optional, removable, error: managementError(error), rows: [], overrides: [] })
|
||||
bundles.push({ name, enabled, installed, optional, removable: removable && readOnlyReason === undefined,
|
||||
...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: managementError(error), rows: [], overrides: [] })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,8 +429,8 @@ export class PluginManager extends TypertRemoteService {
|
||||
const bundle = (await this.listBundles()).find(item => item.name === name)
|
||||
if (bundle === undefined || !bundle.removable) throw new ManagementFailure('not-removable')
|
||||
if (this.ownerContext.get('hmr') === undefined && (this.profile.startedBundles.includes(name)
|
||||
|| this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
|
||||
.some(entry => entry.options.id === row.id && entry.fiber !== undefined)))) {
|
||||
|| (bundle.error === undefined && this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
|
||||
.some(entry => entry.options.id === row.id && entry.fiber !== undefined))))) {
|
||||
throw new ManagementFailure('stop-profile')
|
||||
}
|
||||
const contributions = bundle.error === undefined ? this.bundleRows(name) : []
|
||||
@@ -523,6 +528,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
if ((enabled || !previous.includes(name)) && bundleManifest(name, this.profile.dir, this.profile.installAnchor) === undefined) {
|
||||
throw new ManagementFailure('not-bundle')
|
||||
}
|
||||
if (enabled) this.bundleRows(name)
|
||||
if (!enabled && previous.includes(name)) {
|
||||
if (this.protectsManager(name)) throw new ManagementFailure('management-required')
|
||||
}
|
||||
@@ -530,6 +536,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
if (JSON.stringify(previous) === JSON.stringify(bundles)) return
|
||||
manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } }
|
||||
await saveManifest(this.profile.dir, manifest)
|
||||
if (enabled) this.protectsManager(name)
|
||||
}
|
||||
|
||||
private bundleRows(name: string): EntryOptions[] {
|
||||
@@ -540,7 +547,15 @@ export class PluginManager extends TypertRemoteService {
|
||||
}
|
||||
|
||||
private protectsManager(name: string): boolean {
|
||||
return this.bundleRows(name).some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)
|
||||
if (this.managementBundles.has(name)) return true
|
||||
let rows: EntryOptions[]
|
||||
try { rows = this.bundleRows(name) } catch (_error) {
|
||||
// Unreadable bundles contribute no new rows; listBundles reports their diagnostics.
|
||||
return false
|
||||
}
|
||||
const protectedBundle = rows.some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)
|
||||
if (protectedBundle) this.managementBundles.add(name)
|
||||
return protectedBundle
|
||||
}
|
||||
|
||||
private configure<T>(operation: () => Promise<T>): Promise<T> {
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface BundleInfo {
|
||||
version?: string
|
||||
/** `description` of the package manifest. */
|
||||
description?: string
|
||||
/** Selected in the profile manifest; a load error means its layer was skipped. */
|
||||
enabled: boolean
|
||||
/** Whether the profile's own dependencies hold the package; false for a bundle the dsh installation supplies. */
|
||||
installed: boolean
|
||||
|
||||
@@ -7,7 +7,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import {
|
||||
boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, reconcileProfilePatches, OPTIONAL_BUNDLES,
|
||||
boot, composeEntries, initProfile, loadProfileDirectory, readProfilePatches, readProfileManifest,
|
||||
reconcileProfilePatches, OPTIONAL_BUNDLES,
|
||||
type ProfileContext,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import PluginManager, { type Config, type PluginChange, type PluginInstallLogChunk, type PluginInstallProgress, type PluginInstallRequestId } from '../src/index.ts'
|
||||
@@ -18,7 +19,7 @@ import { Group } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import * as operations from '../src/operations.ts'
|
||||
import { parse, parseDocument } from 'yaml'
|
||||
|
||||
async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager']) {
|
||||
async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager'], prepareFiles?: (dir: string) => void) {
|
||||
// pnpm resolves workspace roots through native realpath, including Windows 8.3 aliases.
|
||||
const home = await realpath(mkdtempSync(join(tmpdir(), 'plugin-manager-')))
|
||||
const dir = join(home, 'profiles', 'test')
|
||||
@@ -38,11 +39,12 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
|
||||
manifest.dependencies = { extra: '1.0.0' }
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
|
||||
prepareFiles?.(dir)
|
||||
const overlays: PatchOptions[] = overlay ? [{ id: 'managed', disabled: true }] : []
|
||||
const profile: ProfileContext = {
|
||||
name: 'test',
|
||||
...(packageManager === undefined ? {} : { packageManager }),
|
||||
startedBundles: ['core', 'extra'],
|
||||
startedBundles: loadProfileDirectory('test', dir, anchor).layers.map(layer => layer.packageName),
|
||||
dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: anchor, cwd: home, home,
|
||||
overlays, telemetryDisabledEnv: undefined,
|
||||
}
|
||||
@@ -63,6 +65,66 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
|
||||
return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays }
|
||||
}
|
||||
|
||||
it.each(['missing package', 'invalid manifest', 'not a bundle', 'missing patch', 'invalid patch'])(
|
||||
'boots with %s, lists its error, and permits deselection without enabling the broken bundle', async (failure) => {
|
||||
const { manager, dir, ctx } = await fixture('live', false, undefined, {}, undefined, (dir) => {
|
||||
const path = join(dir, 'node_modules', 'extra')
|
||||
if (failure === 'missing package') rmSync(path, { recursive: true })
|
||||
if (failure === 'invalid manifest') writeFileSync(join(path, 'package.json'), '{')
|
||||
if (failure === 'not a bundle') writeFileSync(join(path, 'package.json'), '{}')
|
||||
if (failure === 'missing patch') rmSync(join(path, 'cordis.patch.yml'))
|
||||
if (failure === 'invalid patch') writeFileSync(join(path, 'cordis.patch.yml'), '[invalid')
|
||||
})
|
||||
expect([...ctx.loader.entries()].some(entry => entry.id === 'include:managed')).toBe(false)
|
||||
expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({
|
||||
enabled: true, error: { code: failure === 'not a bundle' ? 'not-bundle' : 'operation-error' }, rows: [],
|
||||
})
|
||||
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' })
|
||||
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core'])
|
||||
expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' })
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['missing files', 'missing declaration'])('keeps a running management bundle protected with %s', async (failure) => {
|
||||
const { manager, dir } = await fixture()
|
||||
if (failure === 'missing files') rmSync(join(dir, 'node_modules', 'core'), { recursive: true })
|
||||
else writeFileSync(join(dir, 'node_modules', 'core', 'package.json'), '{}')
|
||||
expect((await manager.listBundles()).find(row => row.name === 'core')).toMatchObject({
|
||||
enabled: true, readOnlyReason: 'management-required', removable: false, error: { code: failure === 'missing files' ? 'operation-error' : 'not-bundle' },
|
||||
})
|
||||
expect(await manager.setBundleEnabled('core', false)).toMatchObject({
|
||||
changed: false, application: 'failed', error: { code: 'management-required' },
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['live', 'startup'] as const)('removes a bundle skipped at startup in a %s profile', async (mode) => {
|
||||
const { manager, dir } = await fixture(mode, false, undefined, {}, undefined, (dir) => {
|
||||
rmSync(join(dir, 'node_modules', 'extra'), { recursive: true })
|
||||
})
|
||||
const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
|
||||
const manifest = readProfileManifest('test', dir)
|
||||
expect(manifest.dsh?.profile?.bundles).toEqual(['core'])
|
||||
delete manifest.dependencies?.extra
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
||||
return { exitCode: 0, output: 'removed', truncated: false, logPath: join(dir, 'pnpm.log') }
|
||||
})
|
||||
onTestFinished(() => { remove.mockRestore() })
|
||||
expect(await manager.removeBundle('extra')).toMatchObject({ changed: true, application: mode === 'live' ? 'applied' : 'restart-required' })
|
||||
expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
|
||||
})
|
||||
|
||||
it('recomposes the remaining layers while another bundle is broken and mounts it after repair', async () => {
|
||||
const { manager, dir, bundle, ctx } = await fixture()
|
||||
writeFileSync(join(dir, 'node_modules', 'extra', 'cordis.patch.yml'), '[invalid')
|
||||
bundle('another', [])
|
||||
expect(await manager.setBundleEnabled('another', true)).toMatchObject({ application: 'applied' })
|
||||
expect([...ctx.loader.entries()].some(entry => entry.id === 'include:managed')).toBe(false)
|
||||
bundle('extra', [{ id: 'managed', name: './plugin.mjs' }])
|
||||
expect(await manager.setBundleEnabled('another', false)).toMatchObject({ application: 'applied' })
|
||||
expect([...ctx.loader.entries()].find(entry => entry.id === 'include:managed')?.fiber?.state).toBe(2)
|
||||
expect((await manager.listBundles()).find(row => row.name === 'extra')?.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists bundle versions and current-profile plugin targets', async () => {
|
||||
const { manager, dir } = await fixture()
|
||||
const plugins = await manager.listPlugins()
|
||||
|
||||
@@ -51,6 +51,33 @@ it.each(['read-only', 'workspace-write'] as const)('denies every management acti
|
||||
for (const method of Object.values(manager)) expect(method).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves selected bundle load errors in the agent list result', async () => {
|
||||
const { manager, call } = await fixture()
|
||||
const bundles = [{
|
||||
name: 'bundle', enabled: true,
|
||||
error: { code: 'operation-error', diagnostic: 'bundle patch is unreadable' }, rows: [], overrides: [],
|
||||
}]
|
||||
manager.listBundles.mockResolvedValue(bundles)
|
||||
expect(JSON.parse(resultText(await call({ action: 'list_bundles' })))).toMatchInlineSnapshot(`
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"enabled": true,
|
||||
"error": {
|
||||
"code": "operation-error",
|
||||
"diagnostic": "bundle patch is unreadable",
|
||||
},
|
||||
"name": "bundle",
|
||||
"overrides": [],
|
||||
"rows": [],
|
||||
},
|
||||
],
|
||||
"nextOffset": null,
|
||||
"total": 1,
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('checks the calling session on each execution, including after permission is revoked', async () => {
|
||||
const { call, manager } = await fixture()
|
||||
const id = SessionId('manager-permissions')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/README.md
|
||||
README.md: 3a1a54f801090456a1038d0e3d59fe5223374f3c
|
||||
README.zh.md: 231449ab574224a3e4fc5436c57e9ece5b0183b5
|
||||
README.md: 310147c8f767b8d68fb149de4c462a0a8154663b
|
||||
README.zh.md: 0d3e90a87cb99676a51696e753a45d1a35a2e4d1
|
||||
@@ -79,7 +79,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
|
||||
| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — |
|
||||
| [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — |
|
||||
| [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the local Desktop or Host OS chooser | — |
|
||||
| [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — |
|
||||
| [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file | — |
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ kind: "package-group"
|
||||
| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — |
|
||||
| [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — |
|
||||
| [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动本地 Desktop 或 Host OS 选择器的原生目录选择界面 | — |
|
||||
| [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮 | — |
|
||||
| [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮,以及文档预览里用默认应用打开单个文件的控件 | — |
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
|
||||
README.md: 95066b18f125df32a63d4ba6336bd0e83e0518a2
|
||||
README.zh.md: f4697aac4f8ef9c19fe05449affb142ac4e84060
|
||||
README.md: 7f3d905b026edeb538de87dc5e54e0f05cd3c3bc
|
||||
README.zh.md: 118fc29fdb4960dfc1606d7e9cd0a1ccaea9843d
|
||||
@@ -51,7 +51,9 @@ Generic command rows retain the ordinary command glyph in every lifecycle state;
|
||||
|
||||
A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total.
|
||||
|
||||
After Assistant replies settle, the completed-turn timing dialog omits TTFT and decoding speed, both after live replies and after reopening history. Elapsed turn time remains available. The Session Stats pill reads timing independently from its durable projection.
|
||||
Settings → General → Performance & usage stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact.
|
||||
|
||||
On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers.
|
||||
|
||||
<a id="completed-turn-footer"></a>
|
||||
## Completed-turn footer
|
||||
|
||||
@@ -51,7 +51,9 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注
|
||||
|
||||
只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。
|
||||
|
||||
Assistant 回复结算后,已完成轮次的计时对话框不显示 TTFT 和解码速度,实时回复完成后和重新打开历史后都如此。轮次总耗时仍可用。Session Stats 药丸从其持久化投影独立读取计时。
|
||||
设置 → 通用设置 → 性能与用量将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。
|
||||
|
||||
在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。
|
||||
|
||||
<a id="completed-turn-footer"></a>
|
||||
## 已完成轮次的页脚
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Chat transcript preferences stored in the Host user-settings document. */
|
||||
/** Chat display preferences stored in the Host user-settings document. */
|
||||
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
|
||||
@@ -17,13 +17,25 @@ export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number]
|
||||
/** Default preserves the compact process disclosure introduced by Chat. */
|
||||
export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'compact'
|
||||
|
||||
/** Performance and usage detail levels accepted by user settings. */
|
||||
export const PERFORMANCE_USAGE_MODES = ['compact', 'detailed'] as const
|
||||
|
||||
/** Performance and usage presentation. */
|
||||
export type PerformanceUsageMode = typeof PERFORMANCE_USAGE_MODES[number]
|
||||
|
||||
/** Preserve detailed accounting for users without an explicit preference. */
|
||||
export const DEFAULT_PERFORMANCE_USAGE: PerformanceUsageMode = 'detailed'
|
||||
|
||||
/** Durable Chat section shared by the Host schema and browser scope. */
|
||||
export interface ChatSettings {
|
||||
/** Presentation mode for completed Turn process content. */
|
||||
transcriptView: TranscriptViewMode
|
||||
/** Detail level for composer statistics and completed-Turn usage. */
|
||||
performanceUsage: PerformanceUsageMode
|
||||
}
|
||||
|
||||
/** Durable Chat schema; also the wire envelope the browser scope validates against. */
|
||||
export const ChatSettingsSchema: z<ChatSettings> = z.object({
|
||||
performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE),
|
||||
[TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE),
|
||||
})
|
||||
@@ -35,6 +35,8 @@ import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/Tr
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { TranscriptViewPolicy } from './transcript-view.ts'
|
||||
import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts'
|
||||
import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './settings/PerformanceUsageRow.tsx'
|
||||
import { PerformanceUsagePolicy } from './performance-usage.ts'
|
||||
import { useTurnDataValue } from './chat/use-turn-data.ts'
|
||||
|
||||
const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
|
||||
@@ -70,7 +72,6 @@ export function apply(ctx: Context): void {
|
||||
return source
|
||||
}
|
||||
registerConversationNodes(ctx)
|
||||
registerChatNodeRenderers(ctx)
|
||||
ctx.uiSession.provide({
|
||||
hooks: ['chat'],
|
||||
resolve: binding => ({ hooks: { chat: chatSource(binding) } }),
|
||||
@@ -80,9 +81,22 @@ export function apply(ctx: Context): void {
|
||||
const t = ctx.locale.bind(NS)
|
||||
const chatStore = createChatStore()
|
||||
const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
|
||||
const transcriptView = new TranscriptViewPolicy(
|
||||
ctx.settingsScope.bind<ChatSettings>({ namespace: CHAT_SETTINGS_NAMESPACE }),
|
||||
)
|
||||
const chatSettings = ctx.settingsScope.bind<ChatSettings>({ namespace: CHAT_SETTINGS_NAMESPACE })
|
||||
const transcriptView = new TranscriptViewPolicy(chatSettings)
|
||||
const performancePolicy = new PerformanceUsagePolicy(chatSettings)
|
||||
const performanceUsage = performancePolicy.mode
|
||||
registerChatNodeRenderers(ctx, performanceUsage)
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'performance-usage',
|
||||
order: 13,
|
||||
locale: NS,
|
||||
inject: (): PerformanceUsageRowInjected => ({
|
||||
hooks: { performanceUsage },
|
||||
setPerformanceUsage: (mode) => { performancePolicy.setMode(mode) },
|
||||
}),
|
||||
}, PerformanceUsageRow))
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
@@ -179,6 +193,7 @@ export function apply(ctx: Context): void {
|
||||
ctx.slots.inject('conversation.composer.dock', () =>
|
||||
ctx.slots.register({
|
||||
name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS,
|
||||
inject: () => ({ hooks: { performanceUsage } }),
|
||||
}, StatsPills))
|
||||
|
||||
ctx.slots.inject('conversation.approval.detail', () =>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Session stats under the composer, split into two icon pills: a gauge pill
|
||||
// (turn/step counts + output speed) opening the time-and-speed dialog, and a
|
||||
// database pill (total tokens + cache hit) opening the token-usage dialog.
|
||||
// Composer statistics: Compact exposes speed and cache hit as plain readings;
|
||||
// Detailed exposes counts and totals with time and token-usage dialogs.
|
||||
// Settled-node identity prevents stream-delta updates from rerendering the row.
|
||||
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
|
||||
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
|
||||
@@ -9,11 +8,11 @@ import { memo, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { IconDatabaseOutlineRegular, IconGaugeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { InjectFace, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: merges the sessionStats key into SessionProjectionMap for useProjection.
|
||||
import type {} from '@deepseek-ai/dsh-session-stats/client'
|
||||
import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { ChatViewSlotProps, PerformanceUsageInjected } from '../contract/slots.ts'
|
||||
import type { ChatSnapshot } from '../contract/snapshot.ts'
|
||||
import { formatTokensPerSecond } from './message-chrome.ts'
|
||||
import { assistantStepReading } from '../contract/turn-metrics.ts'
|
||||
@@ -121,7 +120,7 @@ export function billedInputTokens(usage: TokenUsageProjection): number {
|
||||
}
|
||||
|
||||
/** Props: the conversation-snapshot selector plus the projection read seat. */
|
||||
export interface StatsPillsProps {
|
||||
export interface StatsPillsProps extends InjectFace<PerformanceUsageInjected> {
|
||||
useChat: SnapshotSelectorHook<ChatSnapshot>
|
||||
useProjection: UseProjection
|
||||
/** The owning dock's locale seat. */
|
||||
@@ -314,7 +313,8 @@ function UsagePill({ usage, t, dialog }: {
|
||||
)
|
||||
}
|
||||
|
||||
export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }: StatsPillsProps) {
|
||||
export const StatsPills = memo(function StatsPills({ useChat, useProjection, usePerformanceUsage, t }: StatsPillsProps) {
|
||||
const mode = usePerformanceUsage(value => value)
|
||||
const settledNodes = useChat(s => s.legacy.nodes)
|
||||
const usage = useProjection('tokenUsage')
|
||||
// One exclusive slot for both dialogs: opening either pill closes the other.
|
||||
@@ -329,9 +329,24 @@ export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }
|
||||
// billing (e.g. every request failed) shows its counts without a usage pill.
|
||||
const hasTokens = usage !== undefined
|
||||
&& (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
|
||||
if (mode === 'compact') {
|
||||
const speed = stats.decodeMs > 0
|
||||
? t('message.tokensPerSecond', { tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)) })
|
||||
: null
|
||||
const cacheHit = hasTokens ? cacheHitPercent(usage) : null
|
||||
if (speed === null && cacheHit === null) return null
|
||||
return (
|
||||
<div className={css.root} data-composer-stats>
|
||||
{speed !== null && <span className={css.pill}><IconGaugeOutlineRegular />{speed}</span>}
|
||||
{cacheHit !== null && (
|
||||
<span className={css.pill}><IconDatabaseOutlineRegular />{t('stats.cacheHit', { percent: cacheHit })}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (stats.steps === 0 && !hasTokens) return null
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.root} data-composer-stats>
|
||||
{stats.steps > 0 && (
|
||||
<TimePill
|
||||
stats={stats}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { memo } from 'react'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ChatNodeViewProps, PerformanceUsageInjected, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { TurnTimePanel, TurnUsagePanel } from './TurnUsagePanel.tsx'
|
||||
import { TurnUsagePanel } from './TurnUsagePanel.tsx'
|
||||
import { assistantText } from './turn-assistant.ts'
|
||||
import css from './TurnTailNodeView.module.css'
|
||||
|
||||
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'>
|
||||
& PropsRenderSlots<'conversation.chat.turnTail' | 'conversation.chat.assistant-actions'>
|
||||
& InjectFace<PerformanceUsageInjected>
|
||||
|
||||
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
|
||||
export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
node, openFile, forkAt, renderSlot, t, useChat,
|
||||
node, openFile, forkAt, renderSlot, t, useChat, usePerformanceUsage,
|
||||
}: TurnTailNodeViewProps) {
|
||||
const detailed = usePerformanceUsage(mode => mode) === 'detailed'
|
||||
const data = node.data
|
||||
const hasLaterChatNode = useChat(snapshot =>
|
||||
snapshot.locations.getTurn(data.turn).at(-1) !== node.key)
|
||||
@@ -25,9 +27,6 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile }
|
||||
const tail = renderSlot('conversation.chat.turnTail', owner)
|
||||
if (closing === null) return tail === null ? null : <div className={css.root} data-turn-tail={data.turn}>{tail}</div>
|
||||
const runMs = turn.start === undefined || turn.end === undefined
|
||||
? undefined
|
||||
: Math.max(0, turn.end.time - turn.start.time)
|
||||
// Interruption-frozen partials carry no messageId, so they address no
|
||||
// durable message and contribute no per-message actions.
|
||||
const messageId = closing.finalNode.messageId
|
||||
@@ -49,19 +48,9 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
|
||||
className={css.actions}
|
||||
extraActions={assistantActions}
|
||||
usageAction={(
|
||||
<>
|
||||
{data.tokenUsage !== undefined && <TurnUsagePanel usage={data.tokenUsage} t={t} />}
|
||||
{runMs !== undefined && (
|
||||
<TurnTimePanel
|
||||
runMs={runMs}
|
||||
tokensPerSecond={data.tokensPerSecond}
|
||||
ttftMs={data.ttftMs}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
usageAction={detailed && data.tokenUsage !== undefined
|
||||
? <TurnUsagePanel usage={data.tokenUsage} t={t} />
|
||||
: null}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// Icon-row Turn-stat actions: a database pill labelled with the turn total
|
||||
// click-opens the per-Turn usage dialog, and a clock pill labelled with the
|
||||
// turn wall time click-opens the Turn-time dialog. Both sit right of the
|
||||
// branch action in the tail's IconActions row, ahead of the plain clock text.
|
||||
/** Completed-Turn token usage action and its accounting details dialog. */
|
||||
|
||||
import { createPortal } from 'react-dom'
|
||||
import { IconClockOutlineRegular, IconDatabaseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconDatabaseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TurnTokenUsage } from '../contract/chat-nodes.ts'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { formatLatencySeconds, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts'
|
||||
import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
|
||||
import { MEASURE_STYLE, useStatDialog } from './stat-dialog.ts'
|
||||
import css from './TurnUsagePanel.module.css'
|
||||
@@ -19,17 +15,6 @@ export interface TurnUsagePanelProps {
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
export interface TurnTimePanelProps {
|
||||
/** Turn wall time in ms, the pill's label. */
|
||||
runMs: number
|
||||
/** Turn decode throughput, a dialog row when known. */
|
||||
tokensPerSecond?: number | undefined
|
||||
/** Turn first-step TTFT in ms, a dialog row when known. */
|
||||
ttftMs?: number | undefined
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
function formatCompactCount(value: number, t: ChatViewSlotProps['t']): string {
|
||||
return t('message.turnUsage.count', { count: formatTokens(value, t) })
|
||||
}
|
||||
@@ -123,60 +108,3 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn-time IconActions pill with a click-open Turn-time details dialog.
|
||||
* @param props - Turn timing facts and locale seat.
|
||||
* @returns The clock-and-duration trigger and, while open, its portaled dialog anchored above the trigger.
|
||||
*/
|
||||
export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePanelProps) {
|
||||
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
|
||||
return (
|
||||
<span ref={rootRef} className={css.root}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<IconClockOutlineRegular />
|
||||
<span className={css.label}>{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}</span>
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={dialogCss.panel}
|
||||
role="dialog"
|
||||
aria-label={t('message.turnTime.title')}
|
||||
style={pos ?? MEASURE_STYLE}
|
||||
>
|
||||
<div className={dialogCss.title}>
|
||||
<span className={dialogCss.titleLabel}>
|
||||
<IconClockOutlineRegular />
|
||||
{t('message.turnTime.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={dialogCss.titleRule} aria-hidden />
|
||||
<dl className={dialogCss.details} data-turn-time-details>
|
||||
<dt>{t('message.turnTime.duration')}</dt>
|
||||
<dd>{formatRunDuration(runMs, t)}</dd>
|
||||
{tokensPerSecond !== undefined && (
|
||||
<>
|
||||
<dt>{t('message.turnTime.speed')}</dt>
|
||||
<dd>{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}</dd>
|
||||
</>
|
||||
)}
|
||||
{ttftMs !== undefined && (
|
||||
<>
|
||||
<dt>{t('message.turnTime.ttft')}</dt>
|
||||
<dd>{t('duration.seconds', { seconds: formatLatencySeconds(ttftMs) })}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function msUntilNextLocalMidnight(ms: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized elapsed-time label shared by running and settled turn chrome.
|
||||
* Localized elapsed-time label for the running conversation clock.
|
||||
* @param ms - Elapsed duration in milliseconds (negatives clamp to zero).
|
||||
* @param t - Translate seat supplying the duration templates.
|
||||
* @returns Display string in whole seconds; minutes and seconds once the
|
||||
@@ -55,17 +55,6 @@ export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
|
||||
: t('duration.seconds', { seconds })
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-turn latency figure: one decimal under ten seconds, whole seconds
|
||||
* beyond. Unit-less so the locale template owns the second suffix.
|
||||
* @param ms - Latency in milliseconds (negatives clamp to zero).
|
||||
* @returns Display number in seconds without unit.
|
||||
*/
|
||||
export function formatLatencySeconds(ms: number): string {
|
||||
const s = Math.max(0, ms) / 1000
|
||||
return s < 10 ? String(Math.round(s * 10) / 10) : String(Math.round(s))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode-throughput figure: whole tokens from ten up, one decimal below.
|
||||
* @param tps - Tokens per second.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { PerformanceUsageMode } from '../../chat-settings.ts'
|
||||
import { NS } from '../locale.ts'
|
||||
import { AssistantNodeView } from './AssistantNodeView.tsx'
|
||||
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
|
||||
@@ -13,8 +15,9 @@ import { TurnTailNodeView } from './TurnTailNodeView.tsx'
|
||||
/**
|
||||
* Register this package's business renderers behind the keyed Chat Node seat.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
* @param performanceUsage - live statistics detail preference.
|
||||
*/
|
||||
export function registerChatNodeRenderers(ctx: Context): void {
|
||||
export function registerChatNodeRenderers(ctx: Context, performanceUsage: ObservableSnapshot<PerformanceUsageMode>): void {
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'user', locale: NS }, UserMessageNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
@@ -47,6 +50,7 @@ export function registerChatNodeRenderers(ctx: Context): void {
|
||||
name: 'conversation.chat.node',
|
||||
key: 'turn-tail',
|
||||
locale: NS,
|
||||
inject: () => ({ hooks: { performanceUsage } }),
|
||||
children: {
|
||||
'conversation.chat.turnTail': { kind: 'list', scope: 'session' },
|
||||
'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
|
||||
|
||||
@@ -91,8 +91,6 @@ export interface TurnTailChatData {
|
||||
readonly closing: FinalAssistantChatData | null
|
||||
/** Whether non-rendered later evidence makes the closing seq non-tail. */
|
||||
readonly branchUnavailable: boolean
|
||||
readonly ttftMs?: number
|
||||
readonly tokensPerSecond?: number
|
||||
/** Exact per-Turn accounting; absent when the loaded evidence is incomplete. */
|
||||
readonly tokenUsage?: TurnTokenUsage
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
|
||||
SlotHookFactory, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation,
|
||||
} from './snapshot.ts'
|
||||
import type { TurnProcessSpec } from './turn-process.ts'
|
||||
import type { TranscriptViewMode } from '../../chat-settings.ts'
|
||||
import type { PerformanceUsageMode, TranscriptViewMode } from '../../chat-settings.ts'
|
||||
|
||||
/** Selector hook over the current Conversation binding's Chat target. */
|
||||
export type UseChat = SnapshotSelectorHook<ChatSnapshot>
|
||||
@@ -128,6 +128,14 @@ export interface ChatScrollPosition {
|
||||
readonly scrollTop: number
|
||||
}
|
||||
|
||||
/** Shared settings source for the performance row, composer, and turn tail. */
|
||||
export interface PerformanceUsageInjected {
|
||||
hooks: {
|
||||
/** Accepted performance and usage detail preference. */
|
||||
performanceUsage: ObservableSnapshot<PerformanceUsageMode>
|
||||
}
|
||||
}
|
||||
|
||||
/** Business callbacks injected into the Chat view. */
|
||||
export interface ChatViewInjected {
|
||||
hooks: {
|
||||
|
||||
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user