Merge origin/master into feat/workspace-dependencies-package

This commit is contained in:
fz committed 2026-09-18 11:42:26 +08:00
commit 3ebd70020c
847 files changed
+7655 -7595

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/architecture/2026-08-23-client-derived-tool-presentation.md
2026-08-23-client-derived-tool-presentation.md: 703960cb24f90eb678fda381f929efb3f94bbd28
2026-08-23-client-derived-tool-presentation.zh.md: 7f161379553f3aca33c72c2edf93e114d27eb7f9
2026-08-23-client-derived-tool-presentation.md: 8befe840b08006a7d2ee0fa1b7875c2c989d4779
2026-08-23-client-derived-tool-presentation.zh.md: 249366f10e6eb06941b344977169609358786413
@@ -64,7 +64,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall
**Client card model** means the pure props data under `ui-tool/src/client/tool/models/` consumed directly by `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, or `ToolRow`.
**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, status dots, and ordinary IN/OUT text remain part of the generic tool row.
**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, lifecycle styling, business glyphs, and ordinary IN/OUT text remain part of the generic tool row.
**Equivalent** means that the same supported input produces the user-visible result and interaction pinned by the existing component, assembly, and browser evidence. It does not require the same intermediate TypeScript types or internal calls.
@@ -301,7 +301,7 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n
| Input | Preserved result |
|---|---|
| running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state |
| successful standard foreground call | terminal output, exit code/signal, and success or failure status dot |
| successful standard foreground call | terminal output, exit code/signal, and settled state presentation |
| `run_in_background:true` | Generic row and raw result |
| tool execution error | Generic IN/OUT and error summary |
| running persistent `bash`/`pwsh` | terminal prompt |
@@ -64,7 +64,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa
**Client card model**指 `ui-tool/src/client/tool/models/` 下直接供 `TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 或 `ToolRow` 使用的纯 props 数据。
**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、状态点和普通 IN/OUT 文本仍属于通用工具行。
**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、生命周期样式、业务图形和普通 IN/OUT 文本仍属于通用工具行。
**对等**指同一受支持输入产生由现有组件、组装与浏览器证据固定的用户可见结果和交互,不要求相同的中间 TypeScript 类型或内部函数调用。
@@ -301,7 +301,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error
| 输入 | 保持的结果 |
|---|---|
| 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 |
| 标准前台 success | terminal output、exit code/signal、成功或失败状态点 |
| 标准前台 success | terminal output、exit code/signal 与结算状态呈现 |
| `run_in_background:true` | Generic 行与原始结果 |
| 工具执行 error | Generic IN/OUT 与错误摘要 |
| persistent `bash`/`pwsh` running | terminal prompt |
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md
2026-09-05-shared-client-control-primitives.md: 14128497cf4152659a62136cbdeb74b92ee3fbf0
2026-09-05-shared-client-control-primitives.zh.md: 66268b48f81887181c3981bf89ead30eb74250af
2026-09-05-shared-client-control-primitives.md: db8cb9a8bc515918867b27b216fe8edb50c392db
2026-09-05-shared-client-control-primitives.zh.md: 6d4b04a21bf224eaa32b5fbdfb2bf1c3a2cd804d
@@ -18,15 +18,15 @@ Neither half of the fix was available to an author. No checklist told them to lo
`Switch` is the two-state toggle, at the 36×20 track and 16px thumb `ui-settings-plugins` established. `label` is required and has no default, so a render site cannot omit the accessible name; `title` carries a lock reason where a deployment disables the control.
`StateDotState` gains `idle`, a static grey dot in the same halo-and-core construction as `done`, `warning`, and `error`. The plugin inventory's `pending` and `unloading` phases mean no activity is in progress, and the four existing states had no member for that; without `idle` those two phases lose their marker entirely. The addition is safe for the eight packages already consuming `StateDot` because each of them produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself.
`StateDotState` includes `idle` for a tracked subject with no activity in progress. The plugin inventory's `pending` and `unloading` phases need that semantic member; without it those two phases lose their marker entirely. The five-state union remains safe for consumers because each package produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself. The current solid-dot palette and rotating `ongoing` loader are owned by the later [StateDot visual language decision](../feature/2026-09-17-state-dot-visual-language.md).
**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. `Pill`'s own header comment previously advertised itself for badges, which contradicted the catalog, and no longer does.
**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. The component comments use the same distinction.
The rule is stated in [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) as the first step of the new-component checklist, and [docs/web-styling.md](../../../../docs/web-styling.md) points at the catalog so an author arriving from the styling side reaches it too.
## Finding the duplicates
A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`, and its rule was byte-identical to the badge two files away. What finds them is the geometry: a CSS Modules rule carrying both `border-radius: 999px` and `padding: 1px 8px`. After this change that signature matches exactly two rules: `Tag` itself and the broken badge below. The unsaved marker keeps only a `flex: none` placement class, which the signature no longer matches.
A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`. Geometry is the reliable probe: search CSS Modules rules carrying both `border-radius: 999px` and `padding: 1px 8px`. That signature belongs to `Tag` and the deliberately local broken badge below; the unsaved marker keeps only its `flex: none` placement class.
<a id="what-stays-local"></a>
## What stays local
@@ -34,7 +34,6 @@ A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` mis
A mechanical search groups these controls with the promoted three. They stay in their own packages, because the grouping is superficial:
- **`ui-trajectory`'s toolbar toggle** carries `role="switch"` but is an 88px labelled control with an inline track, and it currently renders `hidden`. It is not the same widget as `Switch`.
- **`ui-schedule`'s status dot** is a static blue dot for the next run that turns amber when overdue. `StateDot` has no static blue — its only blue is `ongoing`, an animated pixel matrix — and one animation per row in a schedule list would misstate the meaning as well as the appearance.
- **`ui-plan`'s mode chip** and **`ui-conversation`'s `ReferenceChip`** are interactive: the first is a warning-toned button with hover, focus, disabled, and a close affordance; the second is a Lexical atom node with its own truncation. Neither is a read-only badge.
- **`ui-trajectory`'s cell tag** and **`ui-user-questions`'s recommendation badge** use their own geometry — a 6px radius at table density, and a 6px radius at 600 weight on the sidebar accent. Forcing either into the capsule baseline would change a deliberate design, not an accidental one.
- **`TerminalBlock`'s exit-status pill** stays a static `Pill`. It sits on a 24px command line at the pill's own geometry, and `Tag`'s 11px capsule would not fit that row. The read-only/selectable split is the usual guide, but size decides this one, and the catalog says so.
@@ -52,7 +51,7 @@ A mechanical search groups these controls with the promoted three. They stay in
**A two-axis `variant × tone` API for `Tag`.** Rejected. Three variants against six tones describes eighteen combinations of which eight ship, and it lets a caller request combinations with no defined appearance. The flat eight-member union maps each value to exactly one shipped appearance.
**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those rows show a grey dot today, and dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation.
**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those phases need a compact inactive or transitional marker; dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation.
**Putting the reuse rule in the root `AGENTS.md`.** Rejected. The rule governs `packages/client` alone, and the root file sits exactly at its 1950-word ceiling in `scripts/doc-budgets.manifest.json`, so stating it there would have to displace an unrelated repository-wide rule.
@@ -60,15 +59,15 @@ A mechanical search groups these controls with the promoted three. They stay in
`Tag`, `Switch`, and the extended `StateDot` carry component specs in `packages/client/ui-primitives/tests`, inside the per-file 100% coverage gate. `StateDot`'s palette is pinned by reading its stylesheet: CSS Modules resolve to class-name maps in the component suites, so a state whose color rule is missing renders on the inherited color and no render assertion notices.
The migrated render sites keep their existing package specs unchanged. The web e2e goldens are ARIA snapshots, and the full replayed web suite passes without re-recording, because the migration preserves every role, accessible name, and state — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper, since `StateDot` is `aria-hidden`.
The migrated render sites keep package specs for their role, accessible name, state mapping, and interaction. Web ARIA snapshots cover the assembled presentations — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper because `StateDot` is `aria-hidden`.
That is also the limit of the automated evidence. No gate in this repository compares pixels, so the capsule geometry, the dot halo, and the font-weight change are verified by review against the light and dark screenshots in the pull request.
The component and stylesheet specs pin the capsule geometry, StateDot artwork, animation, tokens, and font weight; Web snapshots pin accessible structure. Manual visual inspection remains responsible for the rendered result across themes.
## Consequences
- A new client control now has one place to check and one place to add, and the catalog makes the check a single file read rather than a `grep` over forty exports.
- `TagTone` is eight members wide because eight appearances shipped. Adding a ninth requires a render site that needs it, not a symmetry argument.
- The plugin inventory's tags change from a 5px rectangle to a capsule, and the disabled tag's fill moves from `--dsw-alias-bg-layer-1` to the `neutral` tone's `--dsw-alias-bg-module-platform`, which is a visible grey where it used to be near-transparent. Its phase dots gain a halo and animate through `loading` and `unloading`. The unconfigured-secret badge in the plugin settings fields moves from 400 to the baseline 500 weight. These are deliberate visual changes, recorded here so a later reader does not treat them as regressions.
- The migration removes a literal `#b45309`. The plugin inventory's `conditional` tag read `var(--dsw-alias-state-warning-primary, #b45309)`, and no such alias exists — the real token is `--dsw-alias-state-warn-primary` — so both themes had been painting the hardcoded fallback that [docs/web-styling.md](../../../../docs/web-styling.md) forbids.
- The plugin inventory uses capsule tags; the disabled tag uses the `neutral` tone's visible `--dsw-alias-bg-module-platform` fill. Its phase markers use the shared `StateDot`, including the rotating loader for active transitions. The unconfigured-secret badge in the plugin settings fields uses the baseline 500 weight.
- The plugin inventory palette contains no literal `#b45309`. The conditional tag uses the real `--dsw-alias-state-warn-primary` token rather than the nonexistent `--dsw-alias-state-warning-primary` alias and its hardcoded fallback.
- The rule cannot be checked mechanically. A future author can still copy a control, and only review will catch it. That is the accepted cost of not gating: the alternative rejects legitimate work, and the packages that stay local above are proof that legitimate work exists.
- `ui-primitives` grows two components that one package each consumes today. The switch in particular is a single-consumer primitive, promoted because it is a general control and because the plugin-management work already in flight will adopt it rather than adding two further copies.
- `Tag` and `Switch` are shared across settings and preset packages; future consumers extend these primitives only when an existing prop cannot express a deliberate difference.
@@ -18,15 +18,15 @@ Status: implemented
`Switch` 是双态开关,采用 `ui-settings-plugins` 确立的 36×20 轨道与 16px 滑块。`label` 必填且没有默认值,渲染点无法省略无障碍名称;`title` 在部署禁用该控件时承载锁定原因。
`StateDotState` 增加 `idle`,一个静态灰点,与 `done`、`warning`、`error` 采用同样的光晕加实心核结构。插件清单的 `pending` 与 `unloading` 相位表示没有进行中的活动,原有四态没有对应成员;没有 `idle`,这两个相位会彻底失去标记。这次增补对已经消费 `StateDot` 的八个包是安全的:它们各自从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。
`StateDotState` 包含 `idle`,表示一个被跟踪的对象当前没有进行中的活动。插件清单的 `pending` 与 `unloading` 相位需要这个语义成员;没有 `idle`,这两个相位会彻底失去标记。五态联合对消费方保持安全,因为每个包都从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。当前的纯色点配色与旋转 `ongoing` loading 由后续的 [StateDot 视觉语言决策](../feature/2026-09-17-state-dot-visual-language.zh.md)负责。
**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。`Pill` 自己的头注释此前把自身宣传为可用作徽章,与目录互相矛盾,现已改掉。
**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。组件注释使用同一套区分。
规则写在 [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) 新组件清单的第一步,[docs/web-styling.md](../../../../docs/web-styling.zh.md) 指向该目录,让从样式一侧进来的作者也能到达。
## 如何找出重复
按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`,而它的规则与两个文件之外的徽章逐字节相同。能找出它们的是几何特征:同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。本次改动后,这个特征恰好匹配两条规则:`Tag` 本身与下面那个损坏徽章。未保存标记只剩一个 `flex: none` 布局类,已不再匹配该特征。
按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`。几何特征才是可靠探针:搜索同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。这个特征属于 `Tag` 与下面那个有意保留在原包的损坏徽章;未保存标记只保留自己的 `flex: none` 布局类。
<a id="what-stays-local"></a>
## 保留在原包的控件
@@ -34,7 +34,6 @@ Status: implemented
机械搜索会把下面这些控件归到被提升的那三类里。它们留在各自的包中,因为那种归并只是表面相似:
- **`ui-trajectory` 的工具栏开关**带 `role="switch"`,但它是 88px 宽的带标签控件、轨道内联,而且当前渲染为 `hidden`。它和 `Switch` 不是同一个部件。
- **`ui-schedule` 的状态点**是表示下次运行的静态蓝点,逾期转为琥珀色。`StateDot` 没有静态蓝——它唯一的蓝是 `ongoing`,一个动画像素方阵——日程列表里每行一个动画,既说错了含义也说错了观感。
- **`ui-plan` 的模式 chip** 与 **`ui-conversation` 的 `ReferenceChip`** 都是可交互的:前者是警告色调的按钮,带 hover、focus、disabled 与关闭操作;后者是带自有截断逻辑的 Lexical 原子节点。两者都不是只读徽章。
- **`ui-trajectory` 的单元格 tag** 与 **`ui-user-questions` 的推荐徽章**使用各自的几何——一个是表格密度下的 6px 圆角,一个是侧栏强调色上 600 字重的 6px 圆角。把它们塞进胶囊基准,改掉的是有意的设计,不是意外的分歧。
- **`TerminalBlock` 的退出状态胶囊**保持为静态 `Pill`。它落在 24px 的命令行上、用的是 pill 自己的几何,`Tag` 的 11px 胶囊放不进那一行。只读与可选中的区分是通常的判据,但这一处由尺寸决定,目录里也是这么写的。
@@ -52,7 +51,7 @@ Status: implemented
**给 `Tag` 设计 `variant × tone` 双轴 API。** 否决。三个 variant 乘六个 tone 描述十八种组合,其中八种会发布,而且它允许调用方请求没有定义外观的组合。扁平的八成员联合把每个值映射到恰好一种已发布外观。
**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这两行今天显示灰点,在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。
**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这些阶段需要紧凑的未活动或过渡标记;在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。
**把复用规则写进根 `AGENTS.md`。** 否决。这条规则只管 `packages/client`,而根文件正好处在 `scripts/doc-budgets.manifest.json` 给它的 1950 词上限上,写在那里就必须挤掉一条不相干的仓库级规则。
@@ -60,15 +59,15 @@ Status: implemented
`Tag`、`Switch` 与扩展后的 `StateDot` 在 `packages/client/ui-primitives/tests` 中各有组件测试,处于每文件 100% 覆盖率门禁之内。`StateDot` 的配色通过读取其样式表来钉住:CSS Modules 在组件测试里解析为类名映射,因此某个状态缺了配色规则时会落到继承色上,而任何渲染断言都不会察觉。
迁移后的渲染点保留各自包内原有的测试,未作改动。web e2e 的 golden 是 ARIA 快照,完整的 replay web 套件无需重录即可通过,因为这次迁移保住了每一个 role、无障碍名称与状态——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。
迁移后的渲染点保留包级测试,覆盖 role、无障碍名称、状态映射与交互。Web ARIA 快照覆盖装配后的呈现——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。
这同时也是自动化证据的边界。本仓库没有任何门禁比较像素,因此胶囊几何、状态点光晕与字重变化,只能由评审对照 PR 中的明暗两套截图来确认。
组件与样式表测试固定胶囊几何、StateDot 图稿、动画、token 与字重;Web 快照固定无障碍结构。明暗主题下的最终渲染仍由人工视觉检查负责。
## Consequences
- 新的客户端控件现在有一处可查、一处可加,而目录把这次查询变成读一个文件,而不是在四十多个导出上 `grep`。
- `TagTone` 有八个成员,因为发布了八种外观。加第九个需要一个真的需要它的渲染点,而不是一个对称性论证。
- 插件清单的标签从 5px 矩形变为胶囊,其中"已停用"标签的底色从 `--dsw-alias-bg-layer-1` 变为 `neutral` 色调的 `--dsw-alias-bg-module-platform`,由近乎透明变成可见的灰底。相位点获得光晕,并在 `loading` 与 `unloading` 两个相位动画。插件设置字段中"未配置"徽章的字重从 400 改为基准的 500。这些都是有意的视觉变更,记录在此,以免后来的读者把它们当成回归。
- 本次迁移移除了一个 `#b45309` 字面量。插件清单的 `conditional` 标签写的是 `var(--dsw-alias-state-warning-primary, #b45309)`,而这个别名并不存在——真正的 token 是 `--dsw-alias-state-warn-primary`——所以明暗两套主题一直在绘制那个被 [docs/web-styling.md](../../../../docs/web-styling.zh.md) 禁止的硬编码兜底色。
- 插件清单使用胶囊标签;"已停用"标签使用 `neutral` 色调下可见的 `--dsw-alias-bg-module-platform` 填充。相位标记使用共享 `StateDot`,活动转换采用旋转 loading。插件设置字段中的"未配置"徽章使用基准 500 字重。
- 插件清单调色板不含 `#b45309` 字面量。`conditional` 标签使用真实的 `--dsw-alias-state-warn-primary` token,而不是不存在的 `--dsw-alias-state-warning-primary` 别名及其硬编码兜底色。
- 这条规则无法被机械检查。将来的作者仍然可以复制一个控件,只有评审能拦住。这是不设门禁所接受的代价:另一条路会拒绝正当的工作,而上面那份保留在原包的清单就是正当工作确实存在的证据。
- `ui-primitives` 多出两个各自只有一个消费方的组件。开关尤其如此,提升它是因为它是一个通用控件,也因为正在进行中的插件管理工作会采用它,而不是再加两份拷贝。
- `Tag` 与 `Switch` 由多个设置和预设包共享;只有现有 prop 无法表达明确差异时,后续消费方才扩展这些原语。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md
2026-09-16-size-neutral-product-icon-weights.md: 3850fcbddfef6d096f648d5b3efa73790f40f8c1
2026-09-16-size-neutral-product-icon-weights.zh.md: 84031677e9b13b8dbf3b8e5d959359824e0c94fc
@@ -0,0 +1,29 @@
# Agent Note: Size-neutral product icon weights
Status: implemented
English | [中文](2026-09-16-size-neutral-product-icon-weights.zh.md)
## Problem
Shared product-icon exports encoded one historical render size in each symbol name even though every component accepted a `size` prop. Consumers also thickened selected icons through local CSS or separate drawings, so emphasis had no named library choice and could drift between features.
## Decision
Every shared product glyph exports a size-neutral `Regular` and `Medium` component. `Regular` renders the supplied one-pixel artwork, while `Medium` renders the same paths with a 1.3px inherited stroke; filled regions remain unchanged. The `size` prop controls rendered dimensions, and the former numeric suffix survives only as the default size when a glyph has no 16px source.
`LinkIconRegular` and `LinkIconMedium`, `ReferenceIconRegular` and `ReferenceIconMedium`, and the three `PermissionIcon*Regular`/`PermissionIcon*Medium` pairs follow the same rule. Repository consumers use a named weight instead of a numeric export.
Medium weight is reserved for deliberate emphasis: new-Session controls, Settings trigger and navigation icons, Appearance choices, the composer add button, and clickable artifact-link glyphs. Other existing consumers use Regular. The composer command menu uses `PermissionIconFullAccessRegular` for the permission command because that row names permission elevation rather than the current mode.
## Alternatives considered
**Keep pixel sizes in export names.** Rejected because the suffix duplicated the runtime `size` prop and created separate names for identical geometry.
**Expose one component with a `weight` prop.** Rejected because call sites would hide the chosen weight inside props; explicit component names keep visual emphasis searchable and reviewable.
**Override stroke width in consumer CSS.** Rejected because source SVG elements can carry their own stroke widths, and local overrides would recreate inconsistent weighting outside the library.
## Consequences
The icon API is intentionally breaking while pre-stable: every consumer names `Regular` or `Medium`, and former 14px call sites pass an explicit size when they share geometry with a 16px export. New product glyphs add both weights from one geometry definition. Fill-only artwork exposes both names for API consistency even when stroke weight does not change its appearance. The hierarchy choices above remain explicit at their render sites rather than becoming global size or opacity overrides.
@@ -0,0 +1,29 @@
# Agent Note: 尺寸无关的产品图标线重
Status: implemented
[English](2026-09-16-size-neutral-product-icon-weights.md) | 中文
## 问题
共享产品图标的导出名称包含一个历史渲染尺寸,但每个组件已经接受 `size` prop。消费方还会通过本地 CSS 或独立图稿加粗部分图标,视觉强调没有具名的图标库选项,因而可能在不同功能间漂移。
## 决策
每个共享产品图形都导出尺寸无关的 `Regular` 和 `Medium` 组件。`Regular` 渲染提供的 1px 图稿,`Medium` 使用相同路径并继承 1.3px 描边;填充区域保持不变。`size` prop 控制渲染尺寸;仅当图稿没有 16px 来源时,原数字后缀才作为默认尺寸保留。
`LinkIconRegular` 与 `LinkIconMedium`、`ReferenceIconRegular` 与 `ReferenceIconMedium`,以及三组 `PermissionIcon*Regular`/`PermissionIcon*Medium` 遵循同一规则。仓库内消费方使用具名线重,不再使用带数字的导出名。
Medium 线重只用于明确强调的场景:新建会话控件、设置入口与导航图标、外观选项、输入框加号按钮,以及可点击产物链接的图标。其他既有消费方使用 Regular。编辑器加号菜单中的权限命令使用 `PermissionIconFullAccessRegular`,因为该行表示权限提升,而不是当前权限模式。
## 考虑过的替代方案
**在导出名中保留像素尺寸。** 否决,因为后缀重复了运行时 `size` prop,并为相同几何创建了多个名称。
**只导出一个带 `weight` prop 的组件。** 否决,因为调用点会把线重选择隐藏在 prop 中;显式组件名便于搜索和审查视觉强调。
**在消费方 CSS 中覆盖描边宽度。** 否决,因为源 SVG 元素可能自带描边宽度,本地覆盖会在图标库外重新产生不一致线重。
## 后果
图标 API 在预稳定阶段有意进行破坏性调整:每个消费方明确选择 `Regular` 或 `Medium`;原 14px 调用点与 16px 导出共享几何时会显式传入尺寸。新增产品图形从同一份几何定义同时提供两种线重。仅填充的图稿也为 API 一致性导出两个名称,即使线重不会改变其外观。上述层级选择继续由对应渲染点显式声明,不会变成全局尺寸或透明度覆盖。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
2026-07-27-web-subagent-conversations.md: fd2ff912a83f2535e33472e2b5755b9d059ba674
2026-07-27-web-subagent-conversations.zh.md: 0124ce4143d65fe79dc0f5c66a42058984f5182e
2026-07-27-web-subagent-conversations.md: aacb52eca5680534b74abd42ca07c2b568c23e21
2026-07-27-web-subagent-conversations.zh.md: 30c41f03703004e3175cee8fad9d11e0cf93f06a
@@ -39,7 +39,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha
## Product contract
The descendant-count control is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. On an ordinary session, a slash separates the current title from that count control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the session-summary title, and every missing switcher catalog refreshes automatically. Only the current subagent appends its own descendant-count control. Long titles truncate before the fixed chevron. The count trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies its blue activity indicator and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. With neither present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows.
The descendant-count control is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. On an ordinary session, a slash separates the current title from that count control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the session-summary title, and every missing switcher catalog refreshes automatically. Only the current subagent appends its own descendant-count control. Long titles truncate before the fixed chevron. The count trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies the shared tertiary-grey ongoing loader and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. An ordinary Workspace row with a pending approval, plan review, or question replaces its relative time with the compact label `Approval`, `Plan review`, or `Answer`; hover and assistive details retain the full status and relative time. With neither primary state present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows.
`running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority.
@@ -39,7 +39,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
## 产品约定
只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示后代数量控件。普通会话会用斜杠分隔当前 title 和该数量控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于会话摘要 title,缺失的每个切换器目录都会自动刷新。只有当前 subagent 会追加自己的后代数量控件。过长的 title 会在固定箭头之前截断。数量触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示蓝色活动指示器,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。
只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示后代数量控件。普通会话会用斜杠分隔当前 title 和该数量控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于会话摘要 title,缺失的每个切换器目录都会自动刷新。只有当前 subagent 会追加自己的后代数量控件。过长的 title 会在固定箭头之前截断。数量触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示共享的三级灰色 ongoing loading,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。普通 Workspace 行存在待处理的审批、计划审阅或问题时,会用紧凑的「待批准」「计划待审」或「待回答」替换相对时间;悬停与无障碍详情仍保留完整状态和相对时间。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。
`running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。
@@ -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-04-web-clickable-link-styles.md
2026-09-04-web-clickable-link-styles.md: c4e506294c87c6b959cb449403edce09daa11062
2026-09-04-web-clickable-link-styles.zh.md: 01258674b4b8194ad5e0597cde61cc2186406d49
2026-09-04-web-clickable-link-styles.md: eddceb8af8b39b24ebf85870b893ecd4d7939c96
2026-09-04-web-clickable-link-styles.zh.md: dbb2c9ac34605e45ef0f404628ac710384983b57
@@ -13,13 +13,13 @@ Clickable artifact links in the chat transcript wore four different costumes: ma
One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links:
- Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus.
- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
- A leading category glyph — `LinkIconMedium` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, code and web extensions share the code glyph, and spreadsheet extensions use the document glyph. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
- Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show.
- [Compact Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.md) retains tertiary text color and a resting dotted underline to preserve secondary emphasis.
- Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style).
- In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border.
Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification table), refreshed markdown-dom fixtures, and the `clickable-links-gallery` web e2e — one settled keyless turn rendering every clickable link form — registered in the host compiler face (`tsconfig.host.json`) like its other scaffold-importing siblings.
Coverage: a link-icon unit spec (one distinct glyph per kind, both stroke weights, known-site marks, and the classification table), refreshed markdown-dom fixtures, and the `clickable-links-gallery` web e2e — one settled keyless turn rendering every clickable link form — registered in the host compiler face (`tsconfig.host.json`) like its other scaffold-importing siblings.
## Alternatives considered
@@ -30,6 +30,6 @@ Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification tabl
## Consequences
- A new clickable artifact surface should consume `--dsw-alias-link` and the LinkIcon vocabulary rather than introduce another color or underline form; the rule lives in [docs/web-styling.md](../../../../docs/web-styling.md).
- A new clickable artifact surface should consume `--dsw-alias-link` and the `LinkIconMedium` vocabulary rather than introduce another color or underline form; the rule lives in [docs/web-styling.md](../../../../docs/web-styling.md).
- Long produced-file names take their natural width; when a row overflows, flex shrinks all chips proportionally, so several long names shrink together instead of the last one yielding first.
- mailto links currently share the `url` globe; a distinct mail category is a one-line addition if ever wanted.
@@ -13,13 +13,13 @@ Status: implemented
会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言:
- 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。
- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
- 前置分类图标——ui-primitives 的 `LinkIconMedium`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码和网页扩展名共用 code 图形,表格扩展名使用 document 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
- 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。
- [紧凑 Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md) 保留 tertiary 文字色和默认点状下划线,以保持次级强调。
- 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。
- 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。
覆盖:LinkIcon 单测(每个 kind 一个独立图形、分类表)、刷新后的 markdown-dom 夹具,以及 `clickable-links-gallery` web e2e——一个 settled 的 keyless 回合渲染全部可点击链接形态——像其他引 scaffold 的同类一样注册进 host 编译面(`tsconfig.host.json`)。
覆盖:link-icon 单测(每个 kind 一个独立图形、两种描边字重、已知站点标记和分类表)、刷新后的 markdown-dom 夹具,以及 `clickable-links-gallery` web e2e——一个 settled 的 keyless 回合渲染全部可点击链接形态——像其他引 scaffold 的同类一样注册进 host 编译面(`tsconfig.host.json`)。
## 备选方案
@@ -30,6 +30,6 @@ Status: implemented
## 后果
- 新的可点击产物表面应消费 `--dsw-alias-link` 和 LinkIcon 词汇,而不是引入另一种颜色或下划线形态;规则记录在 [docs/web-styling.md](../../../../docs/web-styling.zh.md)。
- 新的可点击产物表面应消费 `--dsw-alias-link` 和 `LinkIconMedium` 词汇,而不是引入另一种颜色或下划线形态;规则记录在 [docs/web-styling.md](../../../../docs/web-styling.zh.md)。
- 长产物文件名按自然宽度展示;整行溢出时 flex 按比例收缩所有 chip,几个长名字一起收缩,而不是最后一个先让位。
- mailto 链接目前共用 `url` 地球图形;若将来需要独立的邮件类别,一行即可加上。
@@ -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: 22f78dfdde854baaf1ced8c20c55042952df8c26
2026-09-07-composer-session-stats-pills.zh.md: 6b71c6e7cf03cbd32fa4da9edf38f6aa4814310c
2026-09-07-composer-session-stats-pills.md: 49976439e6c30603423278a86dba80c3c48eb39b
2026-09-07-composer-session-stats-pills.zh.md: 09b956c6607ebebb10a525161090f434b735d233
@@ -12,7 +12,8 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co
`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 `IconGaugeOutline16`, 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 (`IconDatabaseOutline16`) 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. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission.
- **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. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission.
- **Shared information tier.** Composer stats and per-turn tail metadata use the secondary font size minus one pixel. Assistant-tail metadata uses the action icons' tertiary label color and starts after an extra 8px separation from the action cluster.
- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy remains owned by ui-conversation's `ContextMeter`, with its ring and percentage after the two stats pills below the input card. The common dock groups statistics together and leaves the toolbar for input actions; ui-chat does not import the context component. The context panel renders through a portal and uses ui-primitives for viewport-clamped positioning and outside-pointer dismissal, including when the statistics contribution is absent.
- **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing.
- **The composer owns dock spacing.** `InputBar` places slot contributions and `ContextMeter` in one centered flex row, supplies 4px above the dock and 4px below it even when the slot has no visible contribution. The hero keeps no bottom padding and hides its empty dock. `StatsPills` supplies shrinkable time and billing content; it does not claim the full row width or add outer padding.
@@ -12,7 +12,8 @@ Status: implemented
`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(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。
- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutlineRegular`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutlineRegular`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。
- **统一信息层级。** 输入框底部统计与单轮回答末尾信息都使用次级字号减 1px。回答末尾信息使用与操作图标一致的 tertiary 文字颜色,并与操作图标组额外拉开 8px。
- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍归 ui-conversation 的 `ContextMeter` 所有,在输入卡片下方、两个统计 pill 之后显示圆环和百分比。共用 dock 将统计信息放在一起,工具栏保留给输入操作;ui-chat 不导入上下文组件。上下文面板通过 portal 渲染,复用 ui-primitives 的视口内定位与外部指针关闭工具,统计贡献项缺席时也不会越界。
- **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。
- **输入框负责 dock 间距。** `InputBar` 将 slot 贡献项和 `ContextMeter` 放在同一个居中的 flex 行中,在 dock 上下各提供 4px 留白,即使 slot 没有可见贡献项也保持该间距。hero 保持无底部留白,并隐藏空 dock。`StatsPills` 提供可收缩的时间与计费内容,不占满整行宽度,也不添加外部 padding。
@@ -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-08-composer-menu-sections-and-localized-rows.md
2026-09-08-composer-menu-sections-and-localized-rows.md: 3cccb3c038ae4c358860f09533e966e9121b987e
2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 31c879b17ac6788bfade50a3245903c3e2e56224
2026-09-08-composer-menu-sections-and-localized-rows.md: 50b97ce2c7e59d53bf5607fef74a799a359d27c8
2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 92bcb04bfe9b4f7b5581c80d7c0b843f98096e52
@@ -18,9 +18,9 @@ A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/
The File row is an `action` contribution: a bare invocation consumes the trigger token and runs a client callback without submitting a message. Conversation owns that registration, its live availability, and the hidden file input. The menu replaces the separate paperclip button; the `+` button's accessible name and tooltip read "Add files or run commands".
`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour.
`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. Aliases and descriptions use a font one pixel below the primary labels. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour.
The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 10 px draggable WebKit rail around a 4 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar.
The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 6 px draggable WebKit rail around a 2 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar.
## Alternatives considered
@@ -18,9 +18,9 @@ composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列
「文件」行是一个 `action` 贡献项:裸调用消费触发 token 后运行客户端回调,不提交消息。Conversation 负责该注册、实时可用性和隐藏文件输入框。菜单取代单独的回形针按钮,`+` 按钮的无障碍名称与提示为「添加文件或调用指令」。
`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。
`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。别名与说明使用比主标签小 1px 的字号。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。
菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 10 px 的可拖动区域承载 4 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。
菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 6 px 的可拖动区域承载 2 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。
## 考虑过的替代方案
@@ -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-08-shared-file-type-icons.md
2026-09-08-shared-file-type-icons.md: 1e3fe7267961a8525bbcdf946eacaa933bcf3e4f
2026-09-08-shared-file-type-icons.zh.md: fd1f38ebb33f80c73d17cc48f6f9640c309f97f2
2026-09-08-shared-file-type-icons.md: f0d3fbdb3a86b5f2c67a438de96348b30b27319f
2026-09-08-shared-file-type-icons.zh.md: d7542592804bc384e4ae5d7359145e70f4d98527
@@ -6,25 +6,25 @@ English | [中文](2026-09-08-shared-file-type-icons.zh.md)
## Problem
Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. `LinkIcon` owned the only extension table and deliberately collapsed paths into six link categories, while attachment cards, sent-message attachments, queued files, and workspace file rows used one generic document glyph. Two of those consumers also carried separate extension-display helpers. Adding a precise file icon anywhere else would have required another extension table or a runtime import between feature plugins.
Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. The package-owned classifier provides the only extension table and deliberately folds detailed file types into six link categories for `LinkIconMedium`, while attachment cards, sent-message attachments, queued files, and workspace file rows use detailed file glyphs. Adding a precise file icon anywhere else would require another extension table or a runtime import between feature plugins.
## Decision
`ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` categories rendered by `CodeFileIcon`. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `kind` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`.
`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`.
`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. The spreadsheet glyph uses the supplied unscaled 1.2px table grid; Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center, and the remaining marked traditional glyphs use 112%. The file body and folded corner retain their source geometry, and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for spreadsheets, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`.
Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name.
Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers use the spreadsheet glyph in file cards, rows, and preview titles; their clickable links use the document category. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name.
`LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change.
`LinkIconMedium` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change.
Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces.
Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIconMedium` because they are link surfaces.
## Alternatives considered
**Use `LinkIcon` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities.
**Use `LinkIconMedium` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities.
**Keep a second extension table beside `LinkIcon`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics.
**Keep a second extension table beside `LinkIconMedium`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics.
**Put the supplied traditional-file colors directly in each SVG path.** Rejected. Traditional SVG geometry stays reusable and follows the icon set's `currentColor` rule; the component stylesheet owns the default category palette, and render sites retain one CSS-variable override instead of rewriting path fills. The technology artwork is exempt because its embedded multicolor marks identify the language or tool rather than decorating a generic file silhouette.
@@ -32,7 +32,7 @@ Attachment upload cards, sent-message file cards, queued-file rows, and workspac
## Testing
The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative.
The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the supplied spreadsheet grid, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The `LinkIconMedium` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative.
## Consequences
@@ -6,25 +6,25 @@ Status: implemented
## 问题
客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。`LinkIcon` 拥有唯一一份扩展名表,并有意把路径折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行则统一使用一个通用文档图形。其中两个消费方还各自带着一份扩展名展示辅助函数。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。
客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。本包拥有唯一一份扩展名分类表,并为 `LinkIconMedium` 将细分文件类型有意折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行使用细分文件图形。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。
## 决策
`ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及由 `CodeFileIcon` 渲染的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `kind` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。
`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。
`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。表格图形使用所提供的未缩放 1.2px 表格网格;Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%。文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,表格使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。
已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。
已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers 在文件卡片、文件行及预览标题中使用表格图标,其可点击链接使用文档类别。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。
`LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。
`LinkIconMedium` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。
附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。
附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行统一使用 `fileExtension`;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIconMedium`,因为它们属于链接表面。
## 备选方案
**所有文件表面都使用 `LinkIcon`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。
**所有文件表面都使用 `LinkIconMedium`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。
**在 `LinkIcon` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。
**在 `LinkIconMedium` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。
**把所提供的传统文件颜色直接写入每一条 SVG path。** 否决。传统 SVG 几何保持可复用,并遵守图标集的 `currentColor` 规则;组件样式表拥有默认分类调色板,渲染点只需一个 CSS 变量即可覆盖颜色,不需要重写 path 填充。技术图稿是例外,因为其内嵌的多色标记用于识别语言或工具,而不是装饰通用文件轮廓。
@@ -32,7 +32,7 @@ Status: implemented
## 测试
`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。
`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、所提供的表格网格、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。`LinkIconMedium` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。
## 后果
@@ -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-10-connection-indicator-refinements.md
2026-09-10-connection-indicator-refinements.md: 3b6b4c7fd0962edbb87be17c2fa45eded46ee86b
2026-09-10-connection-indicator-refinements.zh.md: 0bd5dc3e0b5b69cf2d9491e2d3ce5711c96898f5
2026-09-10-connection-indicator-refinements.md: 7c918b6f9ee65612c627e0406230a99e20cde2af
2026-09-10-connection-indicator-refinements.zh.md: e6a3fcdec50bdd209c73679b47d4b05fde88a9a4
@@ -10,7 +10,7 @@ The sidebar connection pill hid its affordance behind a hover swap: outage and r
## Decision
**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutline14`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha.
**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutlineRegular`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha.
**The shell owns attempt pacing.** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) keeps the connecting pill visible for at least `CONNECTING_MIN_VISIBLE_MS` (800ms) so sub-second retries do not flicker; every attempt, manual or automatic, reads the one label `重新连接中` (`connection.connecting`). The two-second recovery confirmation (`RECOVERY_CONFIRMATION_MS`) starts when the recovered pill becomes visible, so a hold that delays its appearance never shortens the confirmation. Both timings are built-in presentation constants of their owners, not configuration.
@@ -10,7 +10,7 @@ Status: implemented
## Decision
**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutline14`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。
**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutlineRegular`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。
**外壳拥有尝试节奏。** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) 让连接中药丸至少可见 `CONNECTING_MIN_VISIBLE_MS`(800ms),亚秒级重试不再闪动;无论手动还是自动,每次尝试都显示同一个文案`重新连接中`(`connection.connecting`)。2 秒恢复确认(`RECOVERY_CONFIRMATION_MS`)从恢复药丸实际可见时起算,驻留推迟其出现也不会缩短确认时长。两个时长是各自持有方的内置展示常量,不是配置。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md
2026-09-14-desktop-primary-runtime.md: d3bb7d173450d4c27af4b5b65e16f33cc285605a
2026-09-14-desktop-primary-runtime.zh.md: 2df6effdccc09a488e322bfe6f144e18b101369c
2026-09-14-desktop-primary-runtime.md: 51bbaac89c5c2ec9a3a016030e7b320d2b5566b8
2026-09-14-desktop-primary-runtime.zh.md: 4f27d61b5e713f66e2f478894dbd547cd7ecfff4
@@ -18,7 +18,7 @@ Node downloads and hash-verifies the complete locked wheel set and unpacks libra
macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
Windows signed packaging separates materialization from execution with a supervised primary-runtime signing stage. PE inspection excludes foreign-platform Node addons and refuses directory links. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; no failure permits a retry or later stage. Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access.
Windows signed packaging separates materialization from execution with supervised signing stages for the primary runtime and application production dependencies. PE inspection identifies content independently of extensions, excludes non-PE foreign-platform Node addons and refuses directory links. Public-key verification uses batches of 32 files with at most four processes and drains each batch before returning failure; hardware operations remain serial. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; hardware failures prohibit retries and later stages. Public timestamp attempts follow the [signature completion policy](../process/2026-09-17-windows-signature-completion.md). Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. Runtime inventories record signed bytes. Electron-builder unpacks detected dependency PE files and preserves signed copies. Final signature verification and ASAR payload and Host smoke use a private native cache before release completion.
Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes.
@@ -30,8 +30,12 @@ Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped depe
**Independent updates and version-named directories.** Runtime releases are coupled to Desktop, and the requested installation location is stable.
**Multi-file SignTool requests.** A software-certificate probe with a missing first file still signs the second file before returning an error. One process cannot guarantee stopping hardware operations at the first failure; token signing remains one file per supervised request.
**Signing only the interpreter or bypassing native smoke.** Windows code integrity also evaluates DLLs and Python extensions. A signed launcher cannot make an unsigned extension load, and skipping execution would hide unusable installed dependencies. Preserving valid upstream signatures avoids unnecessary hardware operations and retains upstream attribution.
Dependency manifests use electron-builder's own cleanup before inventory sealing. Its archive writer otherwise changes package metadata after signing; pre-applying that transformation keeps the final archive verifiable without replacing the sealed inventory after packaging.
## Consequences
The application carries additional native files and replaces the complete managed payload on upgrade. Running interpreters can prevent replacement on Windows. Native build smoke, install/reuse/recovery tests and a keyless tool-error session cover distinct installation and model-output paths; macOS signing uses the existing native-runtime signer. Interpreter archives and Python wheels are hash-pinned, and licenses remain with their distributions.
@@ -18,7 +18,7 @@ Node 下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site
macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
Windows 签名打包通过受监督的第一方运行时签名阶段,将文件准备与执行分开。PE 检查排除其他平台的 Node 插件,并拒绝目录链接。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;任何失败都不允许重试或继续后续阶段。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。
Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应用生产依赖,将文件准备与执行分开。PE 检查独立于扩展名识别文件内容,排除其他平台的非 PE Node 原生插件,并拒绝目录链接。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程,返回失败前等待当前批次全部结束;硬件操作仍串行执行。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;硬件失败不允许重试或继续后续阶段。公开时间戳尝试遵循[签名完成规则](../process/2026-09-17-windows-signature-completion.zh.md)。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。运行时清单记录签名后的字节。electron-builder 将检测到的依赖 PE 文件放在归档外,并保留已签名的副本。发布完成前,最终签名检查以及 ASAR 载荷和 Host 冒烟检查使用私有原生缓存。
Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。
@@ -30,8 +30,12 @@ Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0
**独立更新和版本目录。** Runtime 发布与 Desktop 绑定,且请求的安装位置固定。
**多文件 SignTool 请求。** 软件证书探针在第一个文件不存在时,仍会先给第二个文件签名,再返回错误。单个进程无法保证首个失败后停止硬件操作;令牌签名仍按每个受监督请求处理一个文件。
**仅签名解释器或跳过本机 smoke。** Windows 代码完整性也检查 DLL 和 Python 扩展。启动程序有签名不能让未签名扩展被加载,跳过执行会隐藏安装后不可用的依赖。保留有效上游签名既减少硬件操作,也保留上游归属信息。
依赖清单在记录完整性清单前使用 electron-builder 自身的清理逻辑。否则其归档写入器会在签名后修改包元数据;提前应用该转换可让最终归档通过校验,无需在打包后替换已封装的完整性清单。
## Consequences
应用携带额外的原生文件,并在升级时替换完整受管产物。Windows 上运行中的解释器可能阻止替换。本机构建 smoke、安装与复用及恢复测试、无密钥工具错误会话分别覆盖安装和模型输出路径;macOS 签名复用现有原生 Runtime 签名器。解释器压缩包和 Python wheel 固定哈希,许可证随各分发包保留。
@@ -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-16-known-site-link-marks.md
2026-09-16-known-site-link-marks.md: 9d029af06f9ca0f3fa8290112c7023ec1b9e0b84
2026-09-16-known-site-link-marks.zh.md: 2c7630d122d1576ba41cb653cf0b86e9c5a2ddb4
2026-09-16-known-site-link-marks.md: 5e4bae0349f28d823c78854a435061de7872b358
2026-09-16-known-site-link-marks.zh.md: 7b57ab0bbc4491d0db597a31f5077db07c4eb142
@@ -10,7 +10,7 @@ Every transcribed anchor led with the same globe, so a transcript full of GitHub
## Decision
`LinkIcon` takes an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site.
`LinkIconRegular` and `LinkIconMedium` take an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site.
`SiteGlyph.tsx` in ui-primitives owns the mapping: forty host suffixes resolve to thirty-four marks. The developer sites a transcript usually cites: GitHub (`github.com`, `github.io`, `raw.githubusercontent.com`), GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News (`news.ycombinator.com`), YouTube (`youtube.com`, `youtu.be`), X (`x.com`, `twitter.com`), Bilibili, Zhihu, Juejin, CSDN. The mainstream sites a general audience links: search (Google, Baidu, DuckDuckGo), video and audio (TikTok, Netflix, Spotify), social and messaging (Facebook, Instagram, Reddit, Telegram with its `t.me` short links, WhatsApp with `wa.me`, WeChat via `weixin.qq.com`, QQ, Weibo), shopping (Taobao, AliExpress, eBay), reference and community (Quora, V2EX), and Apple. A host matches a suffix when it equals it or is a subdomain of it, and the longest matching suffix wins, so `gist.github.com` and `en.wikipedia.org` need no entry while `weixin.qq.com` keeps WeChat rather than the QQ mark its `qq.com` suffix would also select. Only absolute `http:` and `https:` destinations can match; anything else falls back to the globe.
@@ -10,7 +10,7 @@ Status: implemented
## Decision
`LinkIcon` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。
`LinkIconRegular` 与 `LinkIconMedium` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。
ui-primitives 的 `SiteGlyph.tsx` 持有该映射:四十个主机后缀解析为三十四个标记。转写内容常引用的开发者站点:GitHub(`github.com`、`github.io`、`raw.githubusercontent.com`)、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News(`news.ycombinator.com`)、YouTube(`youtube.com`、`youtu.be`)、X(`x.com`、`twitter.com`)、Bilibili、知乎、掘金与 CSDN。普通用户常访问的主流站点:搜索(Google、百度、DuckDuckGo)、视频与音频(TikTok、Netflix、Spotify)、社交与通讯(Facebook、Instagram、Reddit、Telegram 及 `t.me` 短链、WhatsApp 及 `wa.me`、`weixin.qq.com` 上的微信、QQ、微博)、购物(淘宝、速卖通、eBay)、参考资料与社区(Quora、V2EX)以及 Apple。主机等于某后缀或为其子域即匹配,且最长匹配后缀优先,因此 `gist.github.com` 与 `en.wikipedia.org` 无需单独登记,而 `weixin.qq.com` 得到微信标记而不是其 `qq.com` 后缀同样会选中的 QQ 标记。只有绝对 `http:` 与 `https:` 目的地能够匹配,其余一律回退到地球。
@@ -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-17-compact-translucent-menu-surfaces.md
2026-09-17-compact-translucent-menu-surfaces.md: 08fcb2d851e4c75ce9069fc797e8dbcb070acc5b
2026-09-17-compact-translucent-menu-surfaces.zh.md: 8e13634995d46b7414f17bbb51443df85cb3ac3c
@@ -0,0 +1,29 @@
# Agent Note: Compact translucent menu surfaces
Status: implemented
English | [中文](2026-09-17-compact-translucent-menu-surfaces.zh.md)
## Problem
Shared dropdowns and feature-owned menu panels used the same elevated color token but retained unrelated padding, row height, corner radius, and scrollbar geometry. The opaque menu fill also made nested and portalled surfaces read as solid cards rather than one elevation family, while narrowing individual menus locally would keep those differences distributed across packages.
## Decision
`ui-theme` defines `--dsw-specific-menu` as a translucent light or dark fill and defines `--dsw-menu-backdrop-filter` as `blur(40px) saturate(150%)`. Every elevated package surface that paints the menu fill also applies that backdrop filter, uses `border: 0`, and takes an elevation shadow with its rebindable hairline stroke. A surface containing fixed-position overlays paints the fill and filter on an isolated background pseudo-element, because a filtered ancestor would otherwise change those overlays' containing block. Descendant sticky rows may repaint the inherited fill without another filter. Browsers without backdrop filtering still render the theme-owned translucent fill.
The shared `Menu` and the composer input-trigger menu use the same compact baseline: a 16px outer radius, 3px frame padding, 34px ordinary rows, 13px primary text on a 20px line, 6px icon/text gaps, and 8px row radii. Dense and compact variants reduce from that baseline instead of retaining the former ordinary geometry. The composer menu keeps its feature-owned grouping and 400px height cap; its aliases and descriptions use 12px text on an 18px line.
The global WebKit scrollbar width is 5px. The composer menu overrides it with a 6px draggable rail and a 2px visible thumb through the existing scrollbar geometry variables; Firefox continues to use its standard thin scrollbar path. Track insets and elevated l2 thumb colors remain surface-owned rebindings.
## Alternatives considered
**Keep an opaque shared menu fill.** Rejected because every consumer would need a separate translucent override to share one material, recreating the theme branch outside `ui-theme`.
**Compact only the composer command menu.** Rejected because the shared `Menu` renders the same action, settings, and navigation vocabulary; keeping its former 40px rows would preserve two densities for the same control family.
**Use the 6px padded rail for every scrollbar.** Rejected because ordinary scroll regions need the direct 5px thumb, while the command menu needs a wider hit area around a visually quieter thumb. The existing geometry variables express that difference without another scrollbar implementation.
## Consequences
Elevated menu-fill consumers must pair the fill and backdrop-filter tokens in the same rule, either on the surface or on its isolated background pseudo-element; the stylesheet gate rejects a material layer that omits the filter. Shared menu geometry changes affect every `Menu` render site, while the composer menu retains its own content and interaction rules. Browser and component snapshots cover assembled structure, and theme specs pin the tokens, compact metrics, scrollbar geometry, and complete menu-filter pairing.
@@ -0,0 +1,29 @@
# Agent Note: 紧凑半透明菜单表面
Status: implemented
[English](2026-09-17-compact-translucent-menu-surfaces.md) | 中文
## Problem
共享下拉菜单与功能包自有菜单面板使用同一个高层级颜色 token,却保留了不同的内边距、行高、圆角和滚动条几何。不透明菜单填充也让嵌套与 portal 表面像彼此独立的实色卡片;如果只在各功能菜单中局部收紧尺寸,这些差异仍会分散在多个包中。
## Decision
`ui-theme` 将 `--dsw-specific-menu` 定义为明暗主题各自的半透明填充,并将 `--dsw-menu-backdrop-filter` 定义为 `blur(40px) saturate(150%)`。每个绘制菜单填充的包级高层级表面同时应用该 backdrop filter、设置 `border: 0`,并使用带可重绑发丝描边的 elevation 投影。包含 fixed 定位浮层的表面把填充与滤镜绘制在隔离的背景伪元素上,因为带滤镜的祖先会改变这些浮层的包含块。后代 sticky 行可以再次绘制继承的填充,无需重复 filter。不支持 backdrop filtering 的浏览器仍渲染主题持有的半透明填充。
共享 `Menu` 与 composer 输入触发菜单使用同一套紧凑基准:16px 外圆角、3px 边框内距、34px 普通行、13px 主文字配 20px 行高、6px 图文间距,以及 8px 行圆角。dense 与 compact 变体在该基准上继续缩小,不再保留旧的普通几何。composer 菜单保留其功能包持有的分组与 400px 高度上限;别名和说明使用 12px 字号与 18px 行高。
WebKit 系全局滚动条宽度为 5px。composer 菜单通过既有滚动条几何变量覆盖为 6px 可拖动轨道与 2px 可见滑块;Firefox 继续走标准 thin scrollbar 路径。轨道内缩与高层级 l2 滑块颜色仍由表面自行重绑。
## Alternatives considered
**保留不透明的共享菜单填充。** 否决,因为每个消费方都需要单独覆盖成半透明,导致同一种材质再次在 `ui-theme` 之外分叉。
**只收紧 composer 指令菜单。** 否决,因为共享 `Menu` 渲染的是同一套操作、设置与导航词汇;保留原 40px 行会让同一个控件家族继续存在两种密度。
**所有滚动条都使用 6px 带内边距轨道。** 否决,因为普通滚动区域需要直接的 5px 滑块,而指令菜单需要在更安静的可见滑块外保留更宽点击区域。现有几何变量可以表达该差异,无需第二套滚动条实现。
## Consequences
高层级菜单填充消费方必须在同一规则中同时使用填充与 backdrop-filter token,规则可以属于表面本身,也可以属于其隔离的背景伪元素;样式表门禁会拒绝漏掉 filter 的材质层。共享菜单几何影响每个 `Menu` 渲染点,composer 菜单仍保留自身内容与交互规则。浏览器与组件快照覆盖装配结构,主题测试固定 token、紧凑尺寸、滚动条几何与完整的菜单 filter 配对。
@@ -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-17-state-dot-visual-language.md
2026-09-17-state-dot-visual-language.md: 751abbaa33919721c7e71738ad9b6bed744fcad7
2026-09-17-state-dot-visual-language.zh.md: 9746ec9af79e15fc41ea8e6601cc52c1cfafba02
@@ -0,0 +1,31 @@
# Agent Note: StateDot solid marks and loading spinner
Status: implemented
English | [中文](2026-09-17-state-dot-visual-language.zh.md)
## Problem
`StateDot` mixed two drawing languages: settled states used a translucent halo around a solid core, while `ongoing` used an eight-cell pixel chase.
## Decision
`idle`, `done`, `warning`, and `error` render as one solid 6px circle inside the existing 10px layout slot, with no halo. `idle` uses the neutral `--dsw-alias-state-idle-primary` token, `done` remains success green, `warning` remains amber, and `error` remains red.
`ongoing` is the sole non-dot member. Its default edge is 14px, while solid states retain their 10px layout slot. It renders a 25%-opacity complete ring behind a grey arc using the tertiary label token. The glyph rotates continuously over 1.5 seconds while the arc grows from 12 to 24 dash units around its center and returns to 12; the arc offset is zero at both ends, so the browser does not reset a second circular movement at the loop boundary. Reduced-motion environments retain an intermediate static arc. The explicit size override, `data-state`, and `aria-hidden` behavior do not change.
Converted compact status-only presentations use this shared mapping instead of local dots or spinners: waiting or blocked is `warning`, active work is `ongoing`, successful completion is `done`, failure is `error`, and inactive or not-yet-started work is `idle`. Tool rows whose leading slot is a business icon retain the ordinary glyph in every lifecycle state; their collapsed summary turns red for failure and amber for `stopped`, preserving any tool-specific interruption text. The framework-free boot page remains separate because its arc reports aggregate loader progress before React and the shared primitive are available.
## Alternatives considered
**Keep the pixel chase.** Rejected because its block animation did not share the circular language of the settled markers and read as a decorative activity glyph rather than a conventional loading state.
**Fill the complete 10px slot.** Rejected because the former visible core was 6px; retaining that diameter preserves row density while removing only the halo.
## Testing
The component specs pin the four solid-state elements, the two-circle loading artwork, both animation tracks, both size paths, the no-halo stylesheet, and every state token. Tool, Bash, and Skill row specs pin the retained business glyph plus visible red failure and amber interruption summaries. Workspace, Job, Workflow, Subagent, Deliverables, terminal, plugin, Schedule, Todo, Team, approval, document-preview, Trajectory, and connection suites pin their state mappings and accessible labels.
## Consequences
Consumers keep their accessibility labels while status-only presentations share one visual vocabulary. Every settled marker becomes visually quieter and more compact, completion remains green, and every active marker uses the same tertiary-grey rotating loader. Action glyphs such as reconnect and business glyphs such as file or tool types remain available when the icon itself communicates the operation rather than only its state.
@@ -0,0 +1,31 @@
# Agent Note: StateDot 纯色标记与旋转 loading
Status: implemented
[English](2026-09-17-state-dot-visual-language.md) | 中文
## 问题
`StateDot` 混用了两套绘制语言:已结算状态在实心核外带半透明光晕,`ongoing` 则使用八格像素追逐动画。
## 决策
`idle`、`done`、`warning` 与 `error` 在既有 10px 布局槽内渲染一个 6px 纯色圆点,不再带光晕。`idle` 使用中性的 `--dsw-alias-state-idle-primary` token,`done` 保持成功绿色,`warning` 保持琥珀色,`error` 保持红色。
`ongoing` 是唯一不是圆点的成员。它的默认边长为 14px,纯色状态仍保留 10px 布局槽。它在透明度为 25% 的完整圆环上方,使用 tertiary label token 渲染灰色圆弧。图形以 1.5 秒周期持续旋转,圆弧围绕中心从 12 个 dash 单位增长到 24 个、再回到 12 个;圆弧偏移在首尾都为零,因此浏览器不会在循环边界重置第二段圆周运动。减少动态效果的环境保留中间长度的静态圆弧。显式 size 覆盖、`data-state` 与 `aria-hidden` 行为均不改变。
已转换的紧凑纯状态展示使用这套共享映射,不再自绘圆点或 spinner:等待或阻塞为 `warning`,活动工作为 `ongoing`,成功完成为 `done`,失败为 `error`,未活动或尚未开始为 `idle`。前置槽为业务图标的工具行在所有生命周期状态中都保留普通图标;收起摘要在失败时变红,在 `stopped` 时变为琥珀色并保留工具自有的中断文本。无框架的启动页保持独立,因为它会在 React 与共享原语可用前用圆弧表达 Loader 总体进度。
## 考虑过的替代方案
**保留像素追逐动画。** 否决,因为方块动画没有沿用已结算标记的圆形语言,看起来更像装饰性活动图形,而不是常规 loading 状态。
**填满完整 10px 槽位。** 否决,因为原来的可见实心核就是 6px;保留该直径只移除光晕,同时维持行密度。
## 测试
组件测试固定四种纯色状态元素、双圆环 loading 图稿、两条动画轨道、两条尺寸路径、无光晕样式和每个状态 token。Tool、Bash 与 Skill 行测试固定保留的业务图形,以及可见的红色失败摘要与琥珀色中断摘要。Workspace、Job、Workflow、Subagent、Deliverables、终端、插件、Schedule、Todo、Team、审批、文档预览、Trajectory 与连接测试固定各自的状态映射和无障碍 label。
## 后果
消费方保留无障碍 label,纯状态展示共享同一套视觉词汇。所有已结算标记在视觉上更安静、更紧凑,完成态保持绿色,所有活动态使用同一种 tertiary 灰色旋转 loading。重连等动作图标和文件或工具类型等业务图标在图形本身表达操作而不只表达状态时继续保留。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-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
@@ -0,0 +1,35 @@
# Agent Note: Windows runtime signature cache
Status: implemented
English | [中文](2026-09-17-windows-runtime-signature-cache.zh.md)
## Problem
Windows packaging reconstructs unsigned native dependencies on each build. Signing unchanged Python and LibreOffice files repeats hardware-token and timestamp operations. Starting PowerShell for each public-key inspection also repeats process initialization.
## Decision
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.
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.
## Alternatives considered
**Multi-file hardware signing.** The primary-runtime decision records that SignTool can continue after the first file fails. Reducing repeated hardware work through caching preserves the existing stop behavior.
**Detached signature extraction and attachment.** Complete-file caching avoids adding PE-signature manipulation to packaging. Its additional disk use is measurable and confined to build storage.
**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.
## 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.
## 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.
@@ -0,0 +1,35 @@
# Agent Note: Windows 运行时签名缓存
Status: implemented
[English](2026-09-17-windows-runtime-signature-cache.md) | 中文
## Problem
Windows 打包每次都会重新生成未签名的原生依赖。为未变化的 Python 和 LibreOffice 文件签名会重复执行硬件令牌操作和时间戳请求。每个公钥验签任务单独启动 PowerShell,也会重复进程初始化。
## Decision
按原始内容、公开证书、SignTool 字节、签名脚本字节和缓存格式缓存完整已签名运行时文件。保留有效厂商签名。缓存命中后,通过私有暂存副本核对输入记录、已签名文件摘要、Windows 信任、时间戳和配置的证书,再恢复目标文件;无效缓存项直接失败,不自动修复或回退到硬件签名。完整缓存项原子发布,并发发布冲突时保留已有有效项。
公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。
[主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。
## Alternatives considered
**多文件硬件签名。** 主要运行时决策记录了 SignTool 在首个文件失败后仍继续处理的行为。通过缓存减少重复硬件操作,可以保留现有停止语义。
**分离签名的提取与附加。** 缓存完整文件可以避免给打包流程新增 PE 签名处理代码。其额外磁盘占用可测量,且限于构建存储。
**仅复用整个已签名运行时制品。** 该方案还可以省去解压和复制,但需要标识组装流程及依赖图。逐文件缓存可以在应用代码或单个依赖变化时复用其余未变化的原生依赖。
## Consequences
原生输入未变化的重复构建不再为缓存覆盖的文件调用硬件签名,但仍执行预检和最终制品签名。内容或签名策略变化时缓存未命中。损坏、不完整、不可信或时间戳不合格的缓存项必须失败,不替换目标文件,也不重试硬件。并发发布和进程中断不能暴露不完整缓存项或损坏其他构建的文件。
六轮完整 Windows x64 签名构建覆盖空签名缓存、重复输入、应用变化和单个原生文件的受控内容变化。在测量主机上,热构建将硬件调用从 379 次降至 17 次,耗时从 37:38 降至 12:36–12:42,且最终运行时检查通过。下载缓存保持已填充;受控文件变化验证缓存失效,不代表依赖版本升级。安装器执行和更新验收仍需单独进行。
## Risks
缓存占用本地磁盘,并依赖构建账户控制缓存文件及记录。它不是用于不可信远程缓存的分发格式。签名验证仍依赖 Windows 信任服务;缓存命中不构成清除签名互锁或重试失败令牌操作的授权。即使预检成功,时间戳服务故障仍可能中断冷构建。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-17-windows-signature-completion.md
2026-09-17-windows-signature-completion.md: e9c81531633a41c87ea10c9e4af2999923aa4470
2026-09-17-windows-signature-completion.zh.md: 3142749db309b638676c171d4b1da8bd34729fe4
@@ -0,0 +1,35 @@
# Agent Note: Separate Windows hardware signing from timestamp completion
Status: implemented
English | [中文](2026-09-17-windows-signature-completion.zh.md)
## Problem
A combined SignTool command reports timestamp failure as signing failure. Retrying it repeats hardware access, while treating every timestamp error as an uncertain private-key outcome requires administrator recovery. Public timestamp-only requests can fail independently of the token.
## Decision
Sign a private PE copy once without a timestamp. Release the hardware interlock only after successful execution and Windows verification of the configured primary signature. Hardware or primary-verification failures retain the interlock; no path automatically recovers it. Appended signatures are unsupported.
Timestamp fresh copies of the frozen signed file without signing credentials. Only normal failure or warning exits permit another attempt: at most three calls, with one- and two-second delays. Launch failures, uncertain termination, storage changes and verification failures stop immediately. The preflight's overall 60-second deadline includes these operations and does not guarantee three full subprocess deadlines.
Require Windows trust, timestamp and certificate identity. Remove unsigned signature attributes from private copies with the configured SignTool and compare their complete normalized bytes. A removal warning is accepted only when all bytes remain unchanged. Certificate equality alone cannot exclude different content signed by the same certificate; full-file equality avoids a custom PE or ASN.1 canonicalizer.
SignTool uses short filenames with the original extension in a private temporary directory. Its longest generated path is checked before hardware access, and the journal records the original-to-staged mapping. The target may occupy another volume: copy verified bytes into a private directory beside it, compare hashes, recheck the original and rename within that volume. Verify committed bytes afterward. The build account must own trusted storage and exclusive target access.
Exhausted timestamp attempts stop packaging and queued work without another hardware call. Failure evidence is copied to the supervised run before staging cleanup; evidence or audit failure preserves staging. Final artifact verification and release completion remain mandatory. The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) owns runtime contents and vendor signatures.
## Alternatives considered
**Retry combined signing or classify diagnostic text.** Exit codes and localized text cannot establish the private-key outcome. Separate operations avoid repeated hardware access.
**Reuse failed timestamp output.** A failed process may partially modify its file. Every attempt starts with identical verified primary-signature bytes.
**Stage beside each target for all operations.** Nested dependencies can exceed SignTool's path limit even when the original fits. Short tool paths and same-volume publication address separate requirements.
## Consequences
Fault tests cover lock retention, isolated timestamp retries, queue rejection, credential exclusion, substituted content, changed targets, long paths and evidence failures. Public-only qualification covers EXE, DLL, PYD and NODE files, including failure followed by success and cross-volume publication. A real supervised signing preflight has passed; complete installer comparisons remain separate qualification. This decision does not identify or repair the timestamp service's network failures.
DigiCert [supports separate signing and timestamping](https://knowledge.digicert.com/solution/troubleshooting-timestamping-problems). Microsoft documents [SignTool commands and exit codes](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool). The configured tool and normalization implementation participate in signature-cache identity.
@@ -0,0 +1,35 @@
# Agent Note:分离 Windows 硬件签名与时间戳处理
Status: implemented
[English](2026-09-17-windows-signature-completion.md) | 中文
## 问题
合并的 SignTool 命令把时间戳失败报告为签名失败。重试会重复访问硬件,而把所有时间戳错误都视为私钥操作结果不确定,又需要管理员恢复。公开时间戳请求可以在不使用令牌的情况下独立失败。
## 决策
对私有 PE 副本仅签名一次,不请求时间戳。只有命令成功且 Windows 验证配置证书的主签名后,才释放硬件互锁。硬件或主签名验证失败保留互锁;任何路径都不自动恢复互锁。不支持追加签名。
从固定的已签名文件生成新副本并请求时间戳,不传递签名凭据。只有正常退出但返回失败或警告时才允许再次尝试:最多调用三次,间隔为一秒和两秒。启动失败、终止状态不确定、存储变化和验签失败会立即停止。预检整体 60 秒期限包含这些操作,不保证每次子进程都能获得完整期限。
要求 Windows 信任、时间戳和证书身份均通过验证。使用配置的 SignTool 删除私有副本的未签名属性,再比较完整的规范化字节。删除警告仅在所有字节未变时接受。仅比较证书无法排除由同一证书签署的不同内容;全文件相等检查避免自行实现 PE 或 ASN.1 规范化器。
SignTool 在私有临时目录中使用保留原扩展名的短文件名。访问硬件前检查生成的最长路径,日志记录原文件与暂存文件的对应关系。目标可以位于另一个卷:将已验证字节复制到目标旁的私有目录,比较摘要,重新检查原文件,再在同一卷内重命名。随后验证提交后的字节。构建账户必须拥有受信任的存储和目标独占访问权。
时间戳尝试耗尽后,停止打包和排队任务,不再次调用硬件。清理暂存目录前,将失败证据复制到受监督运行目录;证据保存或审计失败时保留暂存目录。最终产物验签和发布完成检查仍是必需步骤。[第一方运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md)负责运行时内容与上游签名。
## 考虑过的替代方案
**重试合并签名命令或按诊断文本分类。** 退出码和本地化文本无法确定私钥操作结果。分离操作避免重复访问硬件。
**复用失败的时间戳输出。** 失败进程可能已部分修改文件。每次尝试均从相同的已验证主签名字节开始。
**所有操作都在目标旁暂存。** 即使原路径未超限,嵌套依赖的暂存路径也可能超出 SignTool 限制。短工具路径和同卷发布分别满足不同要求。
## 影响
故障测试覆盖互锁保留、独立时间戳重试、队列拒绝、凭据隔离、内容替换、目标变化、长路径和证据保存失败。仅使用公开操作的验收覆盖 EXE、DLL、PYD 和 NODE 文件,包括先失败后成功及跨卷发布。真实受监督签名预检已通过;完整安装包对比仍需单独验收。此决策不确定或修复时间戳服务的网络故障。
DigiCert [支持分开签名与添加时间戳](https://knowledge.digicert.com/solution/troubleshooting-timestamping-problems)。Microsoft 记录了 [SignTool 命令及退出码](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool)。配置的工具和规范化实现均参与签名缓存身份计算。
@@ -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/simplification/2026-09-17-plugin-inventory-phase-dot.md
2026-09-17-plugin-inventory-phase-dot.md: e90d49b6a3d2d8e03f662b3de531ef180d350cad
2026-09-17-plugin-inventory-phase-dot.zh.md: d344ede75c7e0dd77a0d57720e0c1366f22e75b8
2026-09-17-plugin-inventory-phase-dot.md: 5ba614c61a390bbd39a49c17239d0410012b325d
2026-09-17-plugin-inventory-phase-dot.zh.md: a01d172c6b6f3cb461b79ef456b37a17b9c324c8
@@ -32,4 +32,4 @@ Row enablement, tag tones, grouping, search and list behavior are unchanged, as
The package spec pins the rule in both scopes: `loading` and `pending` (global) and `unloading` (preset) keep a dot, while `active`, `failed` and a row without a live fiber render none. `apps/web/tests/settings-chrome.e2e.ts` asserts the assembled global group contains enabled rows and no `role="img"` element named `运行中`.
The remaining dots' palette, halo and animation, and the tag capsule itself, stay owned by [the shared client control decision](../architecture/2026-09-05-shared-client-control-primitives.md).
The remaining dots' palette and animation are owned by [the StateDot visual language decision](../feature/2026-09-17-state-dot-visual-language.md); the tag capsule remains owned by [the shared client control decision](../architecture/2026-09-05-shared-client-control-primitives.md).
@@ -32,4 +32,4 @@ Status: implemented
包级用例在两个作用域钉住规则:`loading` 与 `pending`(全局)、`unloading`(预设)保留圆点,`active`、`failed` 与没有存活 fiber 的条目都不渲染。`apps/web/tests/settings-chrome.e2e.ts` 断言装配后的全局分组含有已启用条目,且没有名为 `运行中` 的 `role="img"` 元素。
其余圆点的配色、光晕与动画,以及标签胶囊本身,仍由[共享客户端控件决策](../architecture/2026-09-05-shared-client-control-primitives.zh.md)拥有。
其余圆点的配色与动画由 [StateDot 视觉语言决策](../feature/2026-09-17-state-dot-visual-language.zh.md)负责;标签胶囊仍由[共享客户端控件决策](../architecture/2026-09-05-shared-client-control-primitives.zh.md)负责。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
"version": "0.1.6-alpha.1",
"version": "0.1.6-alpha.2",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop-host",
"description": "Private Node-mode host process for the Electron desktop application",
"version": "0.1.6-alpha.1",
"version": "0.1.6-alpha.2",
"private": true,
"license": "MIT",
"type": "module",
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: e1045abca7501c7e0db40f164a0d65bf36fd1725
README.zh.md: d3ba41a05dc533c671ad1ce6859f21821d7ebff2
README.md: 123e27d1d03bf218835af310cb529365cdc47b94
README.zh.md: aee40ffa4ceae673a931f076d668debfdf83de2d
+13 -9
View File
@@ -18,7 +18,7 @@ The macOS PNG uses an inset rounded background for legacy ICNS packaging, with r
### Bundled workspace dependencies
Signed Windows packaging preserves valid vendor signatures and signs unsigned PE executables, DLLs, Python extensions and Node addons in the primary runtime before executing its smoke checks. Each new signature must match the configured certificate and carry a timestamp; invalid existing signatures, signing errors and verification errors stop the run without retries. Electron-builder preserves copied runtime executables only after checking their signature and exact equality with the prepared file, preventing repeat signing during resource copying. Checks include decimal, XML, LZMA, UUID, numpy and pandas. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. A passing smoke does not establish compatibility for every extension or enterprise policy.
Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the bounded policy below. Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy.
Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory.
@@ -46,7 +46,7 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09
Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles.
@@ -142,11 +142,11 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t
### Runtime file selection
Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree.
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification.
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`.
Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
@@ -210,7 +210,9 @@ Run `pnpm --dir apps/desktop run test:installer` from the repository root on an
### Windows EV signing
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. A 60-second preflight deadline, signing error or verification failure stops the run without retry. 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.
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).
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.
Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field.
@@ -220,7 +222,7 @@ For this project's SafeNet token, `SignTool Error: No private key is available.`
Windows package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `.desktop-build/packaging-runs/`. Each run retains `run.json`, timestamped `events.jsonl`, redacted `stdout.log` and `stderr.log`, and `result.json`. A signing failure also writes `fatal.json` and notifies the parent through stderr; the supervisor immediately requests termination of its stage process tree and waits for exit. Failed stages cannot start subsequent stages or create a release completion record. Journal failures also stop the run. Termination errors remain failures and require operator inspection; a missing final record means completion was not established.
Hardware signing requires a supervised run. Before invoking the command interpreter, the signer atomically acquires `%USERPROFILE%/.dsh-desktop-signing/attempt.json` and records the attempt. Only successful signing releases that file. Failure, interruption, an existing interlock, or unavailable audit storage prevents further hardware access, including from another signer instance, process, or checkout under the same Windows account. There is no timed recovery or automatic retry. An administrator must inspect the retained evidence and token state before explicitly authorizing interlock recovery; logging into the token or replacing a PIN file does not clear it. The records distinguish signing intent, command-interpreter PID, and completion; they do not measure internal CSP/token authentication attempts. Command arguments, PINs, and credential environments are not recorded. Independent Windows accounts and unrelated signing programs are outside this interlock.
Hardware signing requires a supervised run. Before invoking the command interpreter, the signer atomically acquires `%USERPROFILE%/.dsh-desktop-signing/attempt.json` and records the attempt. Only successful signing followed by verification of the configured primary signature releases that file; timestamps are completed afterward without hardware access. Failure, interruption, an existing interlock, or unavailable audit storage prevents further hardware access, including from another signer instance, process, or checkout under the same Windows account. There is no timed recovery or automatic retry. An administrator must inspect the retained evidence and token state before explicitly authorizing interlock recovery; logging into the token or replacing a PIN file does not clear it. The records distinguish signing intent, command-interpreter PID, and completion; they do not measure internal CSP/token authentication attempts. Command arguments, PINs, and credential environments are not recorded. Independent Windows accounts and unrelated signing programs are outside this interlock.
Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract.
@@ -232,9 +234,11 @@ Fill in `.env.windows` with `DSH_DESKTOP_WINDOWS_CER_FILE` (public EV leaf certi
pnpm run package:desktop:win:x64
```
Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, without a timestamp. The hook then completes a DigiCert SHA-256 RFC 3161 timestamp on isolated copies without signing credentials. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated primary-runtime signing stage and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure.
Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md).
The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure.
Create a runnable application directory instead of an installer by using the matching `:dir` command, such as:
@@ -251,7 +255,7 @@ pnpm run prepare:desktop
This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content.
Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload.
+12 -8
View File
@@ -18,7 +18,7 @@ macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含
### 内置工作区依赖
Windows 签名打包保留有效的上游签名,并在执行冒烟检查前,为第一方运行时中未签名的 PE 可执行文件、DLL、Python 扩展和 Node 插件补签。每个新签名必须匹配配置的证书且带时间戳;已有签名无效、签名错误或验签错误都会停止本轮执行,不自动重试。electron-builder 只有在校验复制后运行时可执行文件的签名、且文件与已准备的源文件逐字节一致后,才保留其签名,避免复制资源时重复签名。检查覆盖 decimal、XML、LZMA、UUID、numpy 和 pandas。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表所有扩展或企业策略都兼容。
Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循下文的有界重试规则。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。
Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。
@@ -46,7 +46,7 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 不提供插件管理 IPC 或独立管理页面。
应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。
产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
@@ -143,11 +143,11 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS
### 运行时文件筛选
Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。
Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。
[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。
Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
@@ -211,7 +211,9 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI
### Windows EV 签名
Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检超过 60 秒、签名报错或验签失败都会停止本轮流程,不重试。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。
运行时签名将完整的已签名文件缓存在 `.desktop-build/targets/win-x64/signature-cache`。缓存项标识原始字节、公钥证书和签名工具链。恢复缓存项时,先检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。缓存要求受信任的本地构建存储,并保留缓存项直到被移除。预检、硬件互锁和最终产物签名仍然执行。[运行时签名缓存决策](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)记录缓存验收要求和设计限制。
Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。
Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。
@@ -221,7 +223,7 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo
Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/packaging-runs/` 下的唯一目录。每次运行保留 `run.json`、带时间戳的 `events.jsonl`、脱敏后的 `stdout.log` 和 `stderr.log`,以及 `result.json`。签名失败还会写入 `fatal.json` 并通过 stderr 通知父进程;监督程序立即请求终止当前阶段的进程树并等待退出。失败阶段不能启动后续阶段或生成发布完成记录。日志写入失败也会停止运行。终止错误仍按失败处理,需要操作者检查;缺少最终记录表示尚未确认完成。
硬件签名必须属于受监督的打包运行。调用命令解释器前,签名器原子获取 `%USERPROFILE%/.dsh-desktop-signing/attempt.json` 并记录本次尝试。只有签名成功才释放该文件。失败、中断、已有锁定文件或审计存储不可用都会阻止再次访问硬件,包括同一 Windows 账户下的另一个签名器实例、进程或代码检出目录。没有定时恢复或自动重试。管理员必须检查保留的证据及令牌状态,再明确授权恢复锁定状态;登录令牌或替换 PIN 文件不会清除它。记录区分签名意图、命令解释器 PID 和完成结果,不计量 CSP/令牌内部的认证次数。不记录命令参数、PIN 或凭据环境。其他 Windows 账户及无关签名程序不在此锁定机制的保护范围内。
硬件签名必须属于受监督的打包运行。调用命令解释器前,签名器原子获取 `%USERPROFILE%/.dsh-desktop-signing/attempt.json` 并记录本次尝试。只有签名成功且配置证书的主签名通过验证后才释放该文件;随后完成时间戳,不再访问硬件。失败、中断、已有锁定文件或审计存储不可用都会阻止再次访问硬件,包括同一 Windows 账户下的另一个签名器实例、进程或代码检出目录。没有定时恢复或自动重试。管理员必须检查保留的证据及令牌状态,再明确授权恢复锁定状态;登录令牌或替换 PIN 文件不会清除它。记录区分签名意图、命令解释器 PID 和完成结果,不计量 CSP/令牌内部的认证次数。不记录命令参数、PIN 或凭据环境。其他 Windows 账户及无关签名程序不在此锁定机制的保护范围内。
Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。
@@ -233,7 +235,9 @@ NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自
pnpm run package:desktop:win:x64
```
打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。
打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`和 SHA-256 文件摘要,不请求时间戳。随后钩子在隔离副本上完成 DigiCert SHA-256 RFC 3161 时间戳,不传递签名凭据。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。
时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。
PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。
@@ -252,7 +256,7 @@ pnpm run prepare:desktop
这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。
每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。
+5 -1
View File
@@ -1,3 +1,5 @@
import type { AfterPackContext, BeforePackContext } from 'app-builder-lib'
/** Electron-builder fields asserted by the Desktop release tests. */
export interface DesktopElectronBuilderConfig {
readonly appId: string
@@ -45,7 +47,9 @@ export interface DesktopElectronBuilderConfig {
readonly installerLanguages: readonly ['en_US', 'zh_CN']
}
readonly beforeBuild: () => Promise<boolean>
readonly beforePack: (context: { readonly appOutDir: string }) => Promise<void>
readonly beforePack: (context: BeforePackContext) => Promise<void>
readonly afterPack: (context: AfterPackContext) => Promise<void>
readonly afterSign: (context: AfterPackContext) => Promise<void>
readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise<void> | undefined
readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop",
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
"version": "0.1.6-alpha.1",
"version": "0.1.6-alpha.2",
"private": true,
"license": "MIT",
"type": "module",
@@ -1,3 +1,5 @@
import { X509Certificate } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFile } from 'node:child_process'
@@ -19,7 +21,9 @@ import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environmen
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs'
import { preserveWindowsRuntimeSignature } from './windows-runtime-signature.mjs'
import { preserveWindowsRuntimeSignature, signWindowsCode } from './windows-runtime-signature.mjs'
import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from './windows-asar-unpack.mjs'
import { recordPackagingEvent } from './packaging-run.mjs'
import {
resolveMacOSAppUpdateFeed,
verifyMacOSAppUpdateConfig,
@@ -57,17 +61,23 @@ export function createElectronBuilderConfig(
if (packagesMacOS) resolveMacOSNotarizationEnvironment(env)
const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
let primaryRuntimeDestination
let dshDestination
let windowsCode = []
const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep/bin/rg']
const windowsSigner = packagesWindows && !unsigned
? createWindowsTokenSigner({
certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE,
signTool: env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
tokenPin: env.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
keyContainer: env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
preserveSignature: async path => primaryRuntimeDestination === undefined ? false : preserveWindowsRuntimeSignature(path, {
sourceRoot: join(buildPaths.runtime, 'primary-runtime'),
destinationRoot: primaryRuntimeDestination,
runDirectory: env.DSH_DESKTOP_PACKAGING_RUN_DIR,
}),
preserveSignature: async path => {
for (const [sourceRoot, destinationRoot] of [[join(buildPaths.runtime, 'primary-runtime'), primaryRuntimeDestination], [buildPaths.dsh, dshDestination]]) {
if (destinationRoot !== undefined && await preserveWindowsRuntimeSignature(path, {
sourceRoot, destinationRoot, runDirectory: env.DSH_DESKTOP_PACKAGING_RUN_DIR,
})) return true
}
return false
},
})
: undefined
if (windowsSigner !== undefined) {
@@ -108,12 +118,7 @@ export function createElectronBuilderConfig(
// electron-builder excludes a source directory's root node_modules.
{ from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] },
],
asarUnpack: [
'**/*.{node,dylib,dll,so,exe}',
'**/*.so.*',
'**/spawn-helper',
'**/@vscode/ripgrep/bin/rg',
],
asarUnpack: unpack,
extraResources: [
{ from: buildPaths.runtime, to: 'runtime' },
{ from: fileURLToPath(new URL('../resources/icon-windows.png', import.meta.url)), to: 'icon.png' },
@@ -134,29 +139,36 @@ export function createElectronBuilderConfig(
writeUpdateInfo: false,
},
beforePack: async context => {
if (windowsSigner !== undefined) primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime')
if (packagesWindows) windowsCode = await prepareWindowsAsarUnpack(context, buildPaths.dsh)
if (windowsSigner !== undefined) {
primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime')
dshDestination = join(context.appOutDir, 'resources', 'app.asar.unpacked', 'dsh')
}
if (policy === undefined) return
const { resolveDesktopPolicyConfig } = await import('../lib/types/mandatory-update-policy.js')
resolveDesktopPolicyConfig(policy)
},
afterPack: async context => {
const { verifyDesktopRuntime, writeDesktopRuntime } = await import('../lib/types/runtime-tree.js')
const { verifyDesktopRuntime } = await import('../lib/types/runtime-tree.js')
const resourcesDir = context.packager.getResourcesDir(context.appOutDir)
if (resolvedPlatform === 'darwin' && update !== undefined) {
await writeMacOSAppUpdateConfig(resourcesDir, resolveMacOSAppUpdateFeed(context.packager.config.publish),
context.packager.appInfo.updaterCacheDirName)
}
if (resolvedPlatform === 'win32' && !unsigned) {
// Windows signs copied executable resources before afterPack runs.
const prepared = await verifyDesktopRuntime(buildPaths.dsh,
context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
writeDesktopRuntime(buildPaths.dsh, prepared.release, prepared.sharedPackages.map(entry => entry.name),
{ platform: resolvedPlatform, arch: resolvedArch })
}
await verifyDesktopRuntime(buildPaths.dsh,
context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
// Unsigned Windows builds skip electron-builder's afterSign hook.
if (packagesWindows && unsigned) await verifyWindowsAsarUnpack(buildPaths.dsh, resourcesDir, windowsCode)
},
afterSign: async context => {
if (windowsSigner !== undefined) {
await signWindowsCode(context.appOutDir, {
thumbprint: new X509Certificate(await readFile(env.DSH_DESKTOP_WINDOWS_CER_FILE)).fingerprint.replaceAll(':', ''),
sign: windowsSigner,
record: event => recordPackagingEvent(env.DSH_DESKTOP_PACKAGING_RUN_DIR, event),
})
await verifyWindowsAsarUnpack(buildPaths.dsh, context.packager.getResourcesDir(context.appOutDir), windowsCode)
}
if (context.electronPlatformName !== 'darwin') return
const appPath = join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
if (update !== undefined) {
@@ -55,7 +55,7 @@ async function inputHashes(manifest: string, version: string, environment: NodeJ
const paths = [manifest, join(run.root, 'application/result.json'), join(run.root, version, 'dsh/desktop-runtime.json'),
join(REPOSITORY, 'pnpm-lock.yaml'), join(APP_ROOT, 'package.json'),
...['electron-builder-config.mjs', 'installed-update-builder.ts', 'build-installed-update-worker.mjs',
'windows-sign.mjs', 'windows-sign.cmd', 'windows-signing-state.mjs', 'windows-directory-installer.mjs',
'windows-sign.mjs', 'windows-sign.cmd', 'windows-signing-state.mjs', 'windows-timestamp.mjs', 'windows-directory-installer.mjs',
'installer.nsh', 'prepare-windows-installer.ps1'].map(path => join(import.meta.dirname, path)),
environment.DSH_DESKTOP_WINDOWS_CER_FILE!, environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!]
const files = []
+3 -1
View File
@@ -361,7 +361,8 @@ export async function packageTarget(
await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], targetEnv)
if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime'], electronBuilderEnv)
await execute(['run', 'prepare:packages'], targetEnv)
await execute(['run', 'prepare:dsh'], targetEnv)
await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], targetEnv)
if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv)
if (invocation.prepareOnly) return
if (target.platform === 'darwin' && !invocation.directory) {
await execute([
@@ -377,6 +378,7 @@ export async function packageTarget(
} else {
await 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)
}
+8 -11
View File
@@ -1,6 +1,6 @@
/** Materialize the complete production runtime before publishing Desktop resources. */
import { spawn, execFile } from 'node:child_process'
import { spawn } from 'node:child_process'
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join, relative, resolve } from 'node:path'
@@ -17,7 +17,8 @@ import {
verifyDesktopCoreLockfile,
} from '../src/core-package-set.ts'
import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
import { smokeDesktopRuntime } from './smoke-runtime.ts'
import { smokePreparedRuntime } from './smoke-prepared-runtime.ts'
import { prepareRuntimeManifests } from './prepare-runtime-manifests.ts'
import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts'
import {
resolveDesktopAppId,
@@ -147,18 +148,14 @@ async function main(): Promise<void> {
await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env))
await signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env))
}
await prepareRuntimeManifests(DSH_OUTPUT_ROOT)
smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))
writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target)
const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
await new Promise<void>((accept, reject) => {
execFile(NODE, ['--expose-internals', join(APP_ROOT, 'tests/fixtures/runtime-payload-smoke.mjs'), DSH_OUTPUT_ROOT],
{ timeout: 120_000, env: desktopNodeEnvironment(NODE, join(RUNTIME_ROOT, 'bin'), { ...process.env, NODE_OPTIONS: '' }) }, (error, stdout, stderr) => {
if (error !== null) reject(new Error(`desktop native payload smoke failed: ${stderr}`, { cause: error }))
else { process.stdout.write(stdout); accept() }
})
})
await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor)
await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
if (!process.argv.includes('--defer-runtime-smoke')) {
await smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor)
await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
}
} catch (error) {
rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
throw error
@@ -0,0 +1,26 @@
/** Apply electron-builder's dependency metadata cleanup before sealing runtime bytes. */
import { readdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createRequire } from 'node:module'
// electron-builder omits declarations for its internal manifest transformer.
const { createTransformer } = createRequire(import.meta.url)('app-builder-lib/out/fileTransformer.js') as {
createTransformer: (root: string, config: object, extraMetadata: undefined) =>
(path: string) => Promise<string | undefined> | null
}
/**
* Materialize the same manifest bytes electron-builder will put in the archive.
* @param root Prepared application dependency tree.
* @returns Resolves after dependency manifests are ready for integrity inventory.
*/
export async function prepareRuntimeManifests(root: string): Promise<void> {
const transform = createTransformer(root, {}, undefined)
const modules = join(root, 'node_modules')
for (const entry of await readdir(modules, { recursive: true, withFileTypes: true })) {
if (!entry.isFile() || entry.name !== 'package.json') continue
const path = join(entry.parentPath, entry.name)
const transformed = await transform(path)
if (transformed != null) await writeFile(path, transformed)
}
}
+53 -75
View File
@@ -1,85 +1,47 @@
/** Sign Windows runtime code before executing it, retaining vendor signatures and fail-stop hardware protection. */
import { X509Certificate } from 'node:crypto'
import { lstat, open, readdir, readFile } from 'node:fs/promises'
import { extname, join, resolve } from 'node:path'
import { readFile, realpath } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { createWindowsTokenSigner } from './windows-sign.mjs'
import { inspectWindowsRuntimeSignature, type WindowsRuntimeSignature } from './windows-runtime-signature.mjs'
import { inspectWindowsRuntimeSignature, signWindowsCode, type WindowsCodeSigningOptions } from './windows-runtime-signature.mjs'
import { createCachedSigner, signatureCacheIdentity } from './windows-signature-cache.mjs'
import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
import { verifyDesktopRuntime, writeDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
import { smokePreparedRuntime } from './smoke-prepared-runtime.ts'
/**
* Enumerate Windows code without following links or treating foreign .node files as PE binaries.
* @param root - Owned, materialized runtime directory.
* @returns Sorted real PE files; rejects links and malformed Windows executable files.
* Sign the bundled interpreters before their smoke checks.
* @param root Materialized primary runtime.
* @param options Supervised signer, certificate identity, audit sink and runtime check.
* @returns Resolves after verified signatures and a successful smoke.
*/
export async function windowsRuntimeCode(root: string): Promise<string[]> {
const rootStat = await lstat(root)
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('primary runtime: expected a real directory')
const files: string[] = []
for (const entry of await readdir(root, { withFileTypes: true })) {
const path = join(root, entry.name)
if (entry.isSymbolicLink()) throw new Error(`primary runtime: directory links are not signable: ${path}`)
if (entry.isDirectory()) { files.push(...await windowsRuntimeCode(path)); continue }
if (!entry.isFile() || !['.exe', '.dll', '.pyd', '.node'].includes(extname(path).toLowerCase())) continue
const file = await open(path, 'r')
let portableExecutable = false
let windowsCandidate = false
try {
const header = Buffer.alloc(64)
const { bytesRead } = await file.read(header, 0, header.length, 0)
windowsCandidate = bytesRead >= 2 && header.readUInt16LE(0) === 0x5a4d
if (bytesRead === 64 && windowsCandidate) {
const signature = Buffer.alloc(4)
const offset = header.readUInt32LE(0x3c)
const read = await file.read(signature, 0, 4, offset)
portableExecutable = offset >= 64 && read.bytesRead === 4 && signature.readUInt32LE(0) === 0x4550
}
} finally { await file.close() }
if (portableExecutable) files.push(path)
else if (windowsCandidate || extname(path).toLowerCase() !== '.node') throw new Error(`primary runtime: invalid PE file: ${path}`)
}
return files.sort()
}
interface RuntimeSigningOptions {
thumbprint: string
sign: ReturnType<typeof createWindowsTokenSigner>
inspect?: (path: string) => Promise<WindowsRuntimeSignature>
record: (event: object) => void
smoke: (root: string) => void
}
/**
* Preserve valid signatures and sign only unsigned PE files before runtime execution.
* @param root - Owned final runtime directory.
* @param options - Supervised signer, certificate identity, audit sink and runtime check.
* @returns Resolves only after sequential signatures, verification and execution; no retries.
*/
export async function signWindowsPrimaryRuntime(root: string, options: RuntimeSigningOptions): Promise<void> {
const inspect = options.inspect ?? inspectWindowsRuntimeSignature
const files = await windowsRuntimeCode(root)
if (files.length === 0) throw new Error('primary runtime: no Windows code found')
const unsigned: string[] = []
for (const path of files) {
const signature = await inspect(path)
options.record({ type: 'primary-runtime-signature', path, ...signature })
if (signature.status === 'NotSigned') unsigned.push(path)
else if (signature.status !== 'Valid') throw new Error(`primary runtime: refusing ${signature.status} signature: ${path}`)
}
options.record({ type: 'primary-runtime-signing-plan', files: files.length, unsigned: unsigned.length })
for (const path of unsigned) {
await options.sign({ path, hash: 'sha256', isNest: false })
const signature = await inspect(path)
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) {
throw new Error(`primary runtime: signing verification failed: ${path}`)
}
options.record({ type: 'primary-runtime-signature-verified', path, ...signature })
}
export async function signWindowsPrimaryRuntime(
root: string, options: WindowsCodeSigningOptions & { smoke: (root: string) => void },
): Promise<void> {
await signWindowsCode(root, options)
options.smoke(root)
options.record({ type: 'primary-runtime-smoke-success' })
}
/**
* Seal application dependency hashes only after all Windows code is signed.
* @param root Materialized production dependency directory.
* @param version Expected release version.
* @param options Supervised signing dependencies and a smoke receiving the signed descriptor.
* @returns Resolves after final inventory verification and smoke; any failure stops the stage.
*/
export async function signWindowsDesktopRuntime(root: string, version: string,
options: WindowsCodeSigningOptions & { smoke: (descriptor: DesktopRuntimeDescriptor) => Promise<void> }): Promise<void> {
const descriptor = await verifyDesktopRuntime(root, version)
await signWindowsCode(root, options)
writeDesktopRuntime(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name))
const signed = await verifyDesktopRuntime(root, version)
await options.smoke(signed)
await verifyDesktopRuntime(root, version)
}
async function main(): Promise<void> {
if (process.platform !== 'win32' || resolveDesktopBuildTarget() !== 'win-x64') throw new Error('primary runtime signing requires Windows x64')
const runDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
@@ -89,13 +51,29 @@ async function main(): Promise<void> {
if (!certificateFile) throw new Error('primary runtime signing requires the configured certificate')
const thumbprint = new X509Certificate(await readFile(certificateFile)).fingerprint.replaceAll(':', '')
try {
await signWindowsPrimaryRuntime(join(resolveDesktopTargetBuildPaths().runtime, 'primary-runtime'), {
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: 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 }),
record: (event) => { recordPackagingEvent(runDirectory, event) },
smoke: smokePrimaryRuntime,
})
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 })
}
} catch (error) {
failPackagingRun(runDirectory, 'primary-runtime-signing-or-smoke-failed')
throw error
@@ -0,0 +1,13 @@
/** Validate the assembled Windows application before recording release completion. */
import { join } from 'node:path'
import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
import { readDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts'
import { verifyWindowsCode } from './windows-runtime-signature.mjs'
import { smokePreparedRuntime } from './smoke-prepared-runtime.ts'
const paths = resolveDesktopTargetBuildPaths()
const application = join(paths.artifacts, 'win-unpacked')
const descriptor = await verifyDesktopRuntime(paths.dsh, readDesktopRuntime(paths.dsh).release.version)
await verifyWindowsCode(application)
await smokePreparedRuntime(join(application, 'resources', 'app.asar', 'dsh'),
join(application, 'DeepSeek Harness.exe'), join(application, 'resources', 'runtime'), descriptor)
@@ -0,0 +1,36 @@
/** Check payloads and Host boot with private native-cache and Harness directories. */
import { execFile } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { desktopNodeEnvironment } from '../src/node-environment.ts'
import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
import { scrubWindowsSigningEnvironment } from './windows-sign.mjs'
import { smokeDesktopRuntime } from './smoke-runtime.ts'
/**
* Exercise real runtime files and Host composition, including an ASAR root when packaged.
* @param root Prepared or archived dsh directory.
* @param node Target Electron executable.
* @param resourcesRuntime External runtime directory beside the archive.
* @param descriptor Runtime descriptor already verified against the selected target, which may differ from the build host.
* @returns Resolves after payload checks, Host readiness and teardown.
*/
export async function smokePreparedRuntime(
root: string, node: string, resourcesRuntime: string, descriptor: DesktopRuntimeDescriptor,
): Promise<void> {
const cache = await mkdtemp(join(tmpdir(), 'desktop-native-smoke-'))
const environment = { ...scrubWindowsSigningEnvironment(process.env), NODE_OPTIONS: '',
NARB_NATIVE_CACHE_DIR: cache, NARB_DISABLE_NATIVE_CACHE: '0' }
try {
const { stdout } = await promisify(execFile)(node, [
'--expose-internals', resolve(import.meta.dirname, '../tests/fixtures/runtime-payload-smoke.mjs'), root, resourcesRuntime,
], { timeout: 120_000, windowsHide: true,
env: desktopNodeEnvironment(node, join(resourcesRuntime, 'bin'), environment) })
process.stdout.write(stdout)
await smokeDesktopRuntime(root, node, descriptor, environment, resourcesRuntime)
} finally {
await rm(cache, { recursive: true, force: true })
}
}
+14 -3
View File
@@ -12,11 +12,18 @@ import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
* @param root - Materialized dsh resources.
* @param node - Prepared target Electron executable.
* @param runtime - Verified resource descriptor.
* @param environment - Credential-scrubbed build environment and private native cache.
* @param resourcesRuntime - Bundled interpreters outside the application archive.
*/
export async function smokeDesktopRuntime(root: string, node: string, runtime: DesktopRuntimeDescriptor): Promise<void> {
export async function smokeDesktopRuntime(
root: string, node: string, runtime: DesktopRuntimeDescriptor, environment: NodeJS.ProcessEnv, resourcesRuntime: string,
): Promise<void> {
const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-'))
const profile = join(home, 'profiles', 'desktop')
const host = new DesktopHostProcess(node, root, profile, undefined, { ...process.env, DSH_HOME: home })
const host = new DesktopHostProcess(node, root, profile, undefined, { ...environment, DSH_HOME: home },
undefined, join(resourcesRuntime, 'primary-runtime'), 'runtime',
{ pnpm: join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.cjs'), nodeBin: join(resourcesRuntime, 'bin') })
let timer: ReturnType<typeof setTimeout> | undefined
try {
createPluginProfile(profile)
const pluginName = 'desktop-runtime-smoke-plugin'
@@ -45,7 +52,10 @@ export function apply(ctx) {
manifest.dsh.profile.bundles.push(pluginName)
writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest))
writeFileSync(join(profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n')
const ready = await host.start()
const ready = await Promise.race([host.start(), new Promise<never>((_, reject) => {
timer = setTimeout(() => { reject(new Error('desktop runtime: Host readiness exceeded 120 seconds')) }, 120_000)
})])
clearTimeout(timer)
const login = await fetch(ready.url, { redirect: 'manual' })
const cookie = login.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
const response = await fetch(new URL('/', ready.url), { headers: { cookie } })
@@ -55,6 +65,7 @@ export function apply(ctx) {
const pluginResponse = await fetch(new URL('/desktop-smoke', ready.url), { headers: { cookie } })
if (await pluginResponse.text() !== 'plugin route ready') throw new Error('desktop runtime: plugin HTTP route failed')
} finally {
clearTimeout(timer)
await host.stop()
rmSync(home, { recursive: true, force: true })
}
@@ -0,0 +1,18 @@
import type { BeforePackContext } from 'app-builder-lib'
/**
* Install source-relative PE patterns in the active builder configuration.
* @param context Active builder configuration and cleanup owner.
* @param sourceRoot Verified prepared dsh directory; external sources receive a build-owned staging copy.
* @returns PE paths relative to the original prepared directory.
*/
export function prepareWindowsAsarUnpack(context: BeforePackContext, sourceRoot: string): Promise<string[]>
/**
* Reject inline, absent, linked, or changed PE files in the assembled application.
* @param sourceRoot Original signed and sealed dsh directory.
* @param resourcesDir Assembled application resources directory.
* @param files PE paths returned by prepareWindowsAsarUnpack.
* @returns Resolves after every PE has an unpacked ASAR entry and identical bytes.
*/
export function verifyWindowsAsarUnpack(sourceRoot: string, resourcesDir: string, files: string[]): Promise<void>
@@ -0,0 +1,70 @@
/** Keep prepared Windows PE files outside ASAR without changing their sealed bytes. */
import { cp, lstat, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
import { readAsar } from 'app-builder-lib/out/asar/asar.js'
import { windowsRuntimeCode } from './windows-runtime-signature.mjs'
function inside(root, file) {
const suffix = relative(root, file)
return suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix)
}
function unpackPattern(path) {
// Builder removes backslash escapes; brace expansion also ignores character classes.
// A brace matches one character, so similarly named neighbors may also be unpacked.
return path.split(sep).join('/').replace(/[\[\]{}()*?+@#]/gu,
character => character === '{' || character === '}' ? '?' : `[${character}]`).replace(/^!/u, '@(!)')
}
/**
* Install source-relative PE patterns in the configuration consumed by electron-builder.
* @param {import('app-builder-lib').BeforePackContext} context Active builder configuration and cleanup owner.
* @param {string} sourceRoot Verified prepared dsh directory; its files remain unchanged.
* @returns {Promise<string[]>} PE paths relative to the original prepared directory.
*/
export async function prepareWindowsAsarUnpack(context, sourceRoot) {
const files = (await windowsRuntimeCode(sourceRoot)).map(file => relative(sourceRoot, file))
const { config, info } = context.packager
const { appDir } = info
let copyRoot = sourceRoot
if (!inside(appDir, sourceRoot)) {
// Builder's source matcher slices the appDir prefix even for external FileSets.
const parent = join(appDir, '.desktop-build')
await mkdir(parent, { recursive: true })
const stage = await mkdtemp(join(parent, 'asar-source-'))
copyRoot = join(stage, 'dsh')
info.disposeOnBuildFinish(() => rm(stage, { recursive: true, force: true }))
await cp(sourceRoot, copyRoot, { recursive: true, force: false, errorOnExist: true })
config.files = config.files.map(file => {
if (typeof file === 'string' || file.from === undefined) return file
const from = resolve(appDir, file.from)
return inside(sourceRoot, from) ? { ...file, from: join(copyRoot, relative(sourceRoot, from)) } : file
})
}
const existing = config.asarUnpack ?? []
config.asarUnpack = [...(typeof existing === 'string' ? [existing] : existing),
...files.map(file => unpackPattern(relative(appDir, join(copyRoot, file))))]
return files
}
/**
* Reject inline, absent, linked, or changed PE files in the assembled application.
* @param {string} sourceRoot Original signed and sealed dsh directory.
* @param {string} resourcesDir Assembled application resources directory.
* @param {string[]} files PE paths returned by prepareWindowsAsarUnpack.
* @returns {Promise<void>} Resolves after every PE has an unpacked ASAR entry and identical bytes.
*/
export async function verifyWindowsAsarUnpack(sourceRoot, resourcesDir, files) {
const archive = await readAsar(join(resourcesDir, 'app.asar'))
for (const file of files) {
const entry = archive.getFile(join('dsh', file), false)
if (entry.unpacked !== true || entry.link !== undefined) {
throw new Error(`Windows ASAR: PE must be unpacked: ${file}`)
}
const copied = join(resourcesDir, 'app.asar.unpacked', 'dsh', file)
const stat = await lstat(copied)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Windows ASAR: expected a real PE file: ${file}`)
const [prepared, packaged] = await Promise.all([readFile(join(sourceRoot, file)), readFile(copied)])
if (!prepared.equals(packaged)) throw new Error(`Windows ASAR: PE bytes changed: ${file}`)
}
}
@@ -24,3 +24,34 @@ export function preserveWindowsRuntimeSignature(path: string, options: {
runDirectory: string
inspect?: typeof inspectWindowsRuntimeSignature
}): Promise<boolean>
/** Supervised signing dependencies shared by all Windows release trees. */
export interface WindowsCodeSigningOptions {
thumbprint: string
sign: ReturnType<typeof import('./windows-sign.mjs').createWindowsTokenSigner>
inspect?: typeof inspectWindowsRuntimeSignature
record: (event: object) => void
}
/**
* Enumerate PE files by content without following links.
* @param root Materialized directory to scan.
* @returns Sorted PE paths; rejects malformed Windows executables and links.
*/
export function windowsRuntimeCode(root: string): Promise<string[]>
/**
* Preserve valid signatures and sign unsigned PE files with a supervised signer.
* @param root Materialized directory to sign.
* @param options Signer, certificate identity and audit sink.
* @returns Resolves after verified sequential signing; rejects without retries.
*/
export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise<void>
/**
* Reject unsigned or invalid PE files in a completed directory.
* @param root Materialized artifact directory.
* @param inspect Public-key verifier.
* @returns Resolves after all PE signatures are valid.
*/
export function verifyWindowsCode(root: string, inspect?: typeof inspectWindowsRuntimeSignature): Promise<void>
@@ -1,10 +1,11 @@
/** Inspect runtime signatures and preserve byte-identical copies made by electron-builder. */
import { execFile } from 'node:child_process'
import { readFile, realpath } from 'node:fs/promises'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
import { lstat, open, readdir, readFile, realpath } from 'node:fs/promises'
import { extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { promisify } from 'node:util'
import { scrubWindowsSigningEnvironment } from './windows-sign.mjs'
import { recordPackagingEvent } from './packaging-run.mjs'
import { inspectSignaturesBatched } from './windows-signature-batch.mjs'
/**
* Read Windows trust, timestamp and signer identity using the engine's bundled modules, without accessing the private key.
@@ -22,13 +23,13 @@ export async function inspectWindowsRuntimeSignature(path) {
if (stderr || typeof value !== 'object' || value === null || typeof value.status !== 'string'
|| typeof value.timestamped !== 'boolean'
|| !(value.thumbprint === null || typeof value.thumbprint === 'string' && /^[A-F\d]{40}$/iu.test(value.thumbprint))) {
throw new Error(`primary runtime: invalid signature inspection: ${path}`)
throw new Error(`Windows code: invalid signature inspection: ${path}`)
}
return { status: value.status, timestamped: value.timestamped, thumbprint: value.thumbprint }
}
/**
* Preserve a copied primary-runtime executable only after signature and exact-byte verification.
* Preserve a copied runtime executable only after signature and exact-byte verification.
* @param {string} path Signing-hook target.
* @param {{sourceRoot: string, destinationRoot: string, runDirectory: string, inspect?: typeof inspectWindowsRuntimeSignature}} options Prepared and copied runtime roots with retained audit directory.
* @returns {Promise<boolean>} True for a verified runtime copy; false for targets outside that directory.
@@ -38,12 +39,105 @@ export async function preserveWindowsRuntimeSignature(path, options) {
if (!suffix || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) return false
const source = join(options.sourceRoot, suffix)
for (const file of [source, path]) {
if (await realpath(file) !== resolve(file)) throw new Error(`primary runtime: linked copy is not signable: ${file}`)
if (await realpath(file) !== resolve(file)) throw new Error(`Windows code: linked copy is not signable: ${file}`)
}
const [prepared, copied] = await Promise.all([readFile(source), readFile(path)])
if (!prepared.equals(copied)) throw new Error(`primary runtime: copied executable changed: ${path}`)
if (!prepared.equals(copied)) throw new Error(`Windows code: copied executable changed: ${path}`)
const signature = await (options.inspect ?? inspectWindowsRuntimeSignature)(path)
if (signature.status !== 'Valid') throw new Error(`primary runtime: copied signature is ${signature.status}: ${path}`)
if (signature.status !== 'Valid') throw new Error(`Windows code: copied signature is ${signature.status}: ${path}`)
recordPackagingEvent(options.runDirectory, { type: 'primary-runtime-copy-verified', path, ...signature })
return true
}
/**
* Enumerate Windows code without following links or treating foreign .node files as PE binaries.
* @param {string} root - Owned, materialized runtime directory.
* @returns {Promise<string[]>} Sorted real PE files; rejects links and malformed Windows executable files.
*/
export async function windowsRuntimeCode(root) {
const rootStat = await lstat(root)
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('Windows code: expected a real directory')
const files = []
for (const entry of await readdir(root, { withFileTypes: true })) {
const path = join(root, entry.name)
if (entry.isSymbolicLink()) throw new Error(`Windows code: directory links are not signable: ${path}`)
if (entry.isDirectory()) { files.push(...await windowsRuntimeCode(path)); continue }
if (!entry.isFile()) continue
const file = await open(path, 'r')
let portableExecutable = false
let windowsCandidate = false
try {
const header = Buffer.alloc(64)
const { bytesRead } = await file.read(header, 0, header.length, 0)
windowsCandidate = bytesRead >= 2 && header.readUInt16LE(0) === 0x5a4d
if (bytesRead === 64 && windowsCandidate) {
const signature = Buffer.alloc(4)
const offset = header.readUInt32LE(0x3c)
const read = await file.read(signature, 0, 4, offset)
portableExecutable = offset >= 64 && read.bytesRead === 4 && signature.readUInt32LE(0) === 0x4550
}
} finally { await file.close() }
if (portableExecutable) files.push(path)
else if (windowsCandidate || ['.exe', '.dll', '.pyd'].includes(extname(path).toLowerCase())) throw new Error(`Windows code: invalid PE file: ${path}`)
}
return files.sort()
}
/** Drain each public-key verification batch before signing or reporting a failure. */
async function inspectWindowsCode(files, inspect) {
if (inspect === inspectWindowsRuntimeSignature) return inspectSignaturesBatched(files)
const signatures = []
for (let offset = 0; offset < files.length; offset += 4) {
const results = await Promise.allSettled(files.slice(offset, offset + 4).map(path => inspect(path)))
for (const result of results) {
if (result.status === 'rejected') throw result.reason
signatures.push(result.value)
}
}
return signatures
}
/**
* Preserve valid signatures and sign unsigned PE files with a supervised signer.
* @param {string} root - Owned final runtime directory.
* @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink.
* @returns {Promise<void>} Resolves only after sequential signatures and verification; no retries.
*/
export async function signWindowsCode(root, options) {
const inspect = options.inspect ?? inspectWindowsRuntimeSignature
const files = await windowsRuntimeCode(root)
if (files.length === 0) throw new Error('Windows code: no Windows code found')
const unsigned = []
const signatures = await inspectWindowsCode(files, inspect)
for (const [index, path] of files.entries()) {
const signature = signatures[index]
options.record({ type: 'windows-code-signature', path, ...signature })
if (signature.status === 'NotSigned') unsigned.push(path)
else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`)
}
options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length })
for (const path of unsigned) {
await options.sign({ path, hash: 'sha256', isNest: false })
const signature = await inspect(path)
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) {
throw new Error(`Windows code: signing verification failed: ${path}`)
}
options.record({ type: 'windows-code-signature-verified', path, ...signature })
}
}
/**
* Reject any unsigned or invalid PE remaining in a completed directory.
* @param {string} root Materialized artifact directory.
* @param {typeof inspectWindowsRuntimeSignature} inspect Public-key verifier.
* @returns {Promise<void>} Resolves after every discovered PE has a valid signature.
*/
export async function verifyWindowsCode(root, inspect = inspectWindowsRuntimeSignature) {
const files = await windowsRuntimeCode(root)
if (files.length === 0) throw new Error('Windows code: no Windows code found')
const signatures = await inspectWindowsCode(files, inspect)
for (const [index, path] of files.entries()) {
const signature = signatures[index]
if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`)
}
}
+1 -1
View File
@@ -13,5 +13,5 @@ set "DSH_DESKTOP_WINDOWS_TOKEN_PIN="
set "DSH_DESKTOP_WINDOWS_KEY_CONTAINER="
set "DSH_DESKTOP_WINDOWS_SIGN_TARGET="
set "DSH_DESKTOP_WINDOWS_SIGN_APPEND="
set "signTool=" & set "certificateFile=" & set "tokenPin=" & set "keyContainer=" & set "targetFile=" & set "appendSignature=" & "%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% /tr http://timestamp.digicert.com /td sha256 "%targetFile%"
set "signTool=" & set "certificateFile=" & set "tokenPin=" & set "keyContainer=" & set "targetFile=" & set "appendSignature=" & "%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% "%targetFile%"
exit /b %errorlevel%
+59 -33
View File
@@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import wineVmModule from 'app-builder-lib/out/vm/WineVm.js'
import { beginWindowsSigningAttempt } from './windows-signing-state.mjs'
import { completeWindowsSignature, normalizeWindowsSignature } from './windows-timestamp.mjs'
import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
const execFileAsync = promisify(execFile)
const { WineVmManager } = wineVmModule
@@ -162,7 +164,7 @@ export function buildWindowsSigningEnvironment(environment, input) {
* @returns {(configuration: { path: string, hash: string, isNest: boolean }) => Promise<void>} The signing hook.
*/
export function createWindowsTokenSigner(options) {
const { path: certificateFile } = resolveCertificateFile(options.certificateFile)
const { path: certificateFile, certificate } = resolveCertificateFile(options.certificateFile)
const signTool = resolveSignTool(options.signTool)
const { keyContainer, tokenPin } = resolveTokenIdentity(options)
const commandInterpreter = options.commandInterpreter
@@ -174,43 +176,67 @@ export function createWindowsTokenSigner(options) {
if (configuration.hash !== 'sha256') {
throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`)
}
if (configuration.isNest) throw new Error('Windows release signing does not support appended signatures')
if (await options.preserveSignature?.(configuration.path)) return
await repairDanglingAuthenticodeDirectory(configuration.path)
const secrets = [tokenPin]
const attempt = beginWindowsSigningAttempt({ runDirectory: options.runDirectory,
stateDirectory: options.stateDirectory, target: configuration.path })
let result
const runDirectory = options.runDirectory ?? process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
if (!runDirectory) throw new Error('Windows hardware signing requires a supervised packaging run')
const { inspectWindowsRuntimeSignature: inspect } = await import('./windows-runtime-signature.mjs')
const thumbprint = certificate.fingerprint.replaceAll(':', '')
try {
const operation = execFileAsync(commandInterpreter, [
'/d',
'/v:off',
'/c',
WINDOWS_SIGN_SCRIPT,
], {
cwd: WINDOWS_SIGN_SCRIPT_DIRECTORY,
env: buildWindowsSigningEnvironment(process.env, {
certificateFile,
signTool,
path: configuration.path,
isNest: configuration.isNest,
tokenPin,
keyContainer,
}),
windowsHide: true,
await completeWindowsSignature(configuration.path, {
thumbprint, inspect, evidenceDirectory: runDirectory,
record: event => recordPackagingEvent(runDirectory, event),
normalize: path => normalizeWindowsSignature(path, signTool, scrubWindowsSigningEnvironment(process.env)),
timestamp: async path => { await execFileAsync(signTool, ['timestamp', '/v', '/tr', 'http://timestamp.digicert.com', '/td', 'sha256', path], {
env: scrubWindowsSigningEnvironment(process.env), windowsHide: true, timeout: 60_000,
}) },
sign: async (path) => {
await repairDanglingAuthenticodeDirectory(path)
const attempt = beginWindowsSigningAttempt({ runDirectory,
stateDirectory: options.stateDirectory, target: path })
let result
try {
const operation = execFileAsync(commandInterpreter, [
'/d',
'/v:off',
'/c',
WINDOWS_SIGN_SCRIPT,
], {
cwd: WINDOWS_SIGN_SCRIPT_DIRECTORY,
env: buildWindowsSigningEnvironment(process.env, {
certificateFile,
signTool,
path,
isNest: false,
tokenPin,
keyContainer,
}),
windowsHide: true,
})
attempt.started(operation.child?.pid ?? null)
result = await operation
const signature = await inspect(path)
if (signature.status !== 'Valid' || signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) {
throw new Error('Windows hardware signing: primary signature verification failed')
}
}
catch (error) {
const failure = createRedactedWindowsSigningError(error, path, secrets)
attempt.failure(typeof error.code === 'number' || typeof error.code === 'string' ? error.code : null, failure.message)
throw failure
}
attempt.success()
const stdout = redactedSigningOutput(result.stdout, secrets)
const stderr = redactedSigningOutput(result.stderr, secrets)
if (stdout !== '') process.stdout.write(stdout)
if (stderr !== '') process.stderr.write(stderr)
},
})
attempt.started(operation.child?.pid ?? null)
result = await operation
} catch (error) {
failPackagingRun(runDirectory, 'signature-completion-failed')
throw error
}
catch (error) {
const failure = createRedactedWindowsSigningError(error, configuration.path, secrets)
attempt.failure(typeof error.code === 'number' || typeof error.code === 'string' ? error.code : null, failure.message)
throw failure
}
attempt.success()
const stdout = redactedSigningOutput(result.stdout, secrets)
const stderr = redactedSigningOutput(result.stderr, secrets)
if (stdout !== '') process.stdout.write(stdout)
if (stderr !== '') process.stderr.write(stderr)
})
return pending
}
@@ -0,0 +1,17 @@
import type { WindowsRuntimeSignature } from './windows-runtime-signature.mjs'
/**
* Validate complete, ordered, per-file inspection results.
* @param stdout Newline-delimited signature records.
* @param stderr Process diagnostics, which must be empty.
* @param files Exact requested file order.
* @returns Validated signatures; rejects missing, duplicate, reordered or malformed rows.
*/
export function parseSignatureRows(stdout: string, stderr: string, files: readonly string[]): WindowsRuntimeSignature[]
/**
* Inspect every file with at most four processes and 32 files per process.
* @param files Materialized PE paths in stable order.
* @returns One signature per file, after all processes in each batch have exited, including on failure.
*/
export function inspectSignaturesBatched(files: readonly string[]): Promise<WindowsRuntimeSignature[]>
@@ -0,0 +1,57 @@
/** Bounded public-key verification batches; every output row must match its requested file. */
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { scrubWindowsSigningEnvironment } from './windows-sign.mjs'
const command = '$ErrorActionPreference="Stop"; [Console]::InputEncoding=[System.Text.UTF8Encoding]::new(); [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new(); Import-Module "$PSHOME/Modules/Microsoft.PowerShell.Security/Microsoft.PowerShell.Security.psd1" -ErrorAction Stop; Import-Module "$PSHOME/Modules/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1" -ErrorAction Stop; $files=[Console]::In.ReadToEnd()|ConvertFrom-Json; foreach($file in $files){ $s=Get-AuthenticodeSignature -LiteralPath $file; [pscustomobject]@{path=$file;status=[string]$s.Status;timestamped=($null -ne $s.TimeStamperCertificate);thumbprint=$s.SignerCertificate.Thumbprint}|ConvertTo-Json -Compress }'
/**
* Reject missing, duplicated, reordered or malformed file results.
* @param {string} stdout Newline-delimited signature records.
* @param {string} stderr Process diagnostics; any content rejects the batch.
* @param {readonly string[]} files Exact requested order.
* @returns {Array<{status: string, timestamped: boolean, thumbprint: string|null}>} Complete signature results.
*/
export function parseSignatureRows(stdout, stderr, files) {
const rows = stdout.trim().split(/\r?\n/u)
if (stderr || rows.length !== files.length) throw new Error('Windows signature batch: incomplete inspection output')
return rows.map((row, index) => {
const value = JSON.parse(row)
if (value === null || typeof value !== 'object' || value.path !== files[index]
|| typeof value.status !== 'string' || typeof value.timestamped !== 'boolean'
|| !(value.thumbprint === null || typeof value.thumbprint === 'string' && /^[A-F\d]{40}$/iu.test(value.thumbprint))) {
throw new Error(`Windows signature batch: invalid inspection output: ${files[index]}`)
}
return { status: value.status, timestamped: value.timestamped, thumbprint: value.thumbprint }
})
}
async function inspectBatch(files) {
const pending = promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], {
env: scrubWindowsSigningEnvironment(process.env), encoding: 'utf8', windowsHide: true,
timeout: 60_000, maxBuffer: 64 * 1024 * files.length,
})
pending.child.stdin.on('error', () => { /* Process failure is reported by the awaited execFile result. */ })
pending.child.stdin.end(JSON.stringify(files))
const { stdout, stderr } = await pending
return parseSignatureRows(stdout, stderr, files)
}
/**
* Inspect every file through at most four processes, draining failures before returning.
* @param {readonly string[]} files Materialized PE paths in stable order.
* @returns {Promise<Array<{status: string, timestamped: boolean, thumbprint: string|null}>>} One result per input.
*/
export async function inspectSignaturesBatched(files) {
const signatures = []
for (let offset = 0; offset < files.length; offset += 128) {
const groups = []
for (let i = offset; i < Math.min(files.length, offset + 128); i += 32) groups.push(files.slice(i, i + 32))
const settled = await Promise.allSettled(groups.map(inspectBatch))
for (const result of settled) {
if (result.status === 'rejected') throw result.reason
signatures.push(...result.value)
}
}
return signatures
}
@@ -0,0 +1,26 @@
import type { WindowsRuntimeSignature } from './windows-runtime-signature.mjs'
import type { createWindowsTokenSigner } from './windows-sign.mjs'
/** File-owned cache policy and existing supervised signing operations. */
export interface WindowsSignatureCacheOptions {
root: string
identity: string
thumbprint: string
sign: ReturnType<typeof createWindowsTokenSigner>
inspect: (path: string) => Promise<WindowsRuntimeSignature>
record: (event: object) => void
}
/**
* Hash the public certificate and files controlling signing output in fixed order.
* @param files Certificate and signing-toolchain paths, excluding credentials.
* @returns Content identity independent of installation paths and PIN values.
*/
export function signatureCacheIdentity(files: readonly string[]): Promise<string>
/**
* 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>
@@ -0,0 +1,97 @@
/** 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 { basename, dirname, join, resolve, toNamespacedPath } from 'node:path'
const digest = bytes => createHash('sha256').update(bytes).digest('hex')
/**
* Include public certificate and every file that controls signing output in the policy identity.
* @param {readonly string[]} files Certificate, SignTool, signing script and signer implementation paths, in fixed order.
* @returns {Promise<string>} Content identity independent of installation paths and PIN values.
*/
export async function signatureCacheIdentity(files) {
return digest(JSON.stringify(await Promise.all(files.map(async path => digest(await regularFile(path))))))
}
async function regularFile(path) {
const stat = await lstat(path)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`signature cache: expected regular file: ${path}`)
return readFile(path)
}
async function directory(path) {
const stat = await lstat(path)
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== resolve(path)) {
throw new Error(`signature cache: expected unlinked directory: ${path}`)
}
}
function validSignature(signature, thumbprint) {
return signature.status === 'Valid' && signature.timestamped
&& signature.thumbprint?.toUpperCase() === thumbprint.toUpperCase()
}
/**
* 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.
*/
export function createCachedSigner(options) {
let pending = Promise.resolve()
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')
return record
}
async function sign(configuration) {
if (configuration.hash !== 'sha256' || configuration.isNest) throw new Error('signature cache: only unsigned SHA-256 runtime files are supported')
const inputDigest = digest(await regularFile(configuration.path))
const key = digest(JSON.stringify({ version: 1, inputDigest, identity: options.identity }))
await mkdir(options.root, { recursive: true })
await directory(options.root)
const entry = join(options.root, key)
let hit = false
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-')))
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 }) }
options.record({ type: 'signature-cache-hit', key, path: configuration.path })
return
}
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 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) }
catch (error) {
if (!['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error.code)) throw error
await readEntry(entry, key, inputDigest)
}
options.record({ type: 'signature-cache-published', key, path: configuration.path })
} finally { await rm(staging, { recursive: true, force: true }) }
}
return configuration => {
pending = pending.then(() => sign(configuration))
return pending
}
}
@@ -30,7 +30,7 @@ async function compileProbe(compiler: string, source: string, output: string, en
/**
* Sign one newly compiled, never-executed probe and verify its timestamp and configured certificate.
* @param options Supervised evidence directory, file-owned settings and isolated test adapters.
* @returns Resolves after one signature and verification; failures retain evidence and never retry or unlock a token.
* @returns Resolves after one signature and verification; failures retain evidence; timestamp attempts never repeat hardware signing.
*/
export async function preflightWindowsSigning(options: SigningPreflightOptions): Promise<void> {
const { environment, runDirectory } = options
@@ -0,0 +1,30 @@
import type { WindowsRuntimeSignature } from './windows-runtime-signature.mjs'
/**
* Remove unsigned signature attributes on a previously verified private copy.
* @param path Single-signature private copy.
* @param signTool Configured SignTool executable.
* @param environment Credential-free subprocess environment.
* @returns Resolves after removal or an unchanged-file warning; other failures reject.
*/
export function normalizeWindowsSignature(path: string, signTool: string, environment: NodeJS.ProcessEnv): Promise<void>
/** Public operations on isolated files; sign resolves only after verified hardware success. */
export interface WindowsTimestampOptions {
sign: (path: string) => Promise<void>
timestamp: (path: string) => Promise<void>
normalize: (path: string) => Promise<void>
inspect: (path: string) => Promise<WindowsRuntimeSignature>
thumbprint: string
evidenceDirectory: string
record: (event: object) => void
wait?: (milliseconds: number) => Promise<unknown>
}
/**
* Complete a single verified hardware signature using bounded, isolated timestamp attempts.
* @param path Exclusively owned build file in trusted storage.
* @param options Verified signer, public operations, certificate and retained audit storage.
* @returns Resolves after a valid timestamp and exact normalized-content verification; failures preserve evidence.
*/
export function completeWindowsSignature(path: string, options: WindowsTimestampOptions): Promise<void>
+107
View File
@@ -0,0 +1,107 @@
/** Finish timestamps on isolated copies without repeating a verified private-key operation. */
import { createHash } from 'node:crypto'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { copyFile, cp, lstat, mkdtemp, readFile, rename, rm } from 'node:fs/promises'
import { dirname, extname, join, toNamespacedPath } from 'node:path'
import { tmpdir } from 'node:os'
import { setTimeout } from 'node:timers/promises'
async function digest(path) {
const stat = await lstat(path)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Windows timestamp: expected a regular file')
return createHash('sha256').update(await readFile(path)).digest('hex')
}
/**
* Remove unsigned signature attributes on a private copy using the configured SignTool.
* @param {string} path Previously verified, single-signature private copy.
* @param {string} signTool Configured SignTool executable.
* @param {NodeJS.ProcessEnv} environment Credential-free subprocess environment.
* @returns {Promise<void>} Resolves after removal, or a warning that left all bytes unchanged.
*/
export async function normalizeWindowsSignature(path, signTool, environment) {
const before = await digest(path)
try {
await promisify(execFile)(signTool, ['remove', '/u', path], { env: environment, windowsHide: true, timeout: 60_000 })
} catch (error) {
// SignTool returns warning status when a verified signature has no unsigned attributes.
if (error.code !== 2 || error.signal || error.killed || await digest(path) !== before) throw error
}
}
/**
* Sign once, timestamp fresh copies at most three times, then atomically replace the unchanged input.
* @param {string} path Exclusively owned build file; storage must be trusted.
* @param {import('./windows-timestamp.mjs').WindowsTimestampOptions} options Verified hardware signer and credential-free public operations.
* @returns {Promise<void>} Resolves only after complete signature, exact normalized bytes and committed-byte verification.
*/
export async function completeWindowsSignature(path, options) {
const original = await digest(path)
const staging = await mkdtemp(join(tmpdir(), 'dsh-sign-'))
const extension = extname(path)
let publication
let completed = false
let retained = false
try {
const raw = join(staging, `raw${extension}`)
const normalized = join(staging, `normalized${extension}`)
// SignTool does not consistently support extended-length Windows paths.
if (normalized.length >= 260) throw new Error('Windows timestamp: temporary SignTool path exceeds MAX_PATH')
await copyFile(path, raw)
if (await digest(raw) !== original) throw new Error('Windows timestamp: input changed during staging')
options.record({ type: 'signature-staging', path, stagedPath: raw })
await options.sign(raw)
const frozen = await digest(raw)
await copyFile(raw, normalized)
await options.normalize(normalized)
const baseline = await digest(normalized)
for (let attempt = 1; attempt <= 3; attempt++) {
if (await digest(raw) !== frozen) throw new Error('Windows timestamp: signed input changed')
const candidate = join(staging, `attempt-${attempt}${extension}`)
await copyFile(raw, candidate)
if (await digest(candidate) !== frozen) throw new Error('Windows timestamp: copy changed')
options.record({ type: 'timestamp-start', path, attempt })
try { await options.timestamp(candidate) }
catch (error) {
options.record({ type: 'timestamp-failure', path, attempt, code: typeof error.code === 'number' ? error.code : null })
if (await digest(raw) !== frozen) throw new Error('Windows timestamp: signed input changed after failure')
if (attempt === 3 || ![1, 2].includes(error.code) || error.signal || error.killed) throw error
await (options.wait ?? setTimeout)(attempt * 1000)
continue
}
const signature = await options.inspect(candidate)
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) {
throw new Error('Windows timestamp: completed signature verification failed')
}
await copyFile(candidate, normalized)
await options.normalize(normalized)
if (await digest(normalized) !== baseline) throw new Error('Windows timestamp: signing content changed')
if (await digest(raw) !== frozen || await digest(path) !== original) throw new Error('Windows timestamp: input changed before publication')
const expected = await digest(candidate)
publication = await mkdtemp(toNamespacedPath(join(dirname(path), '.complete-signature-')))
const ready = join(publication, `ready${extension}`)
await copyFile(candidate, ready)
if (await digest(ready) !== expected || await digest(path) !== original) throw new Error('Windows timestamp: input changed before publication')
await rename(ready, path)
if (await digest(path) !== expected) throw new Error('Windows timestamp: committed bytes changed')
options.record({ type: 'timestamp-success', path, attempt })
completed = true
return
}
} catch (error) {
try {
const evidence = await mkdtemp(join(options.evidenceDirectory, 'signature-failure-'))
await cp(staging, join(evidence, 'files'), { recursive: true, errorOnExist: true, force: false })
options.record({ type: 'signature-failure-evidence', path, directory: evidence })
retained = true
} catch (evidenceError) {
throw new AggregateError([error, evidenceError], 'Windows signature failed and its evidence could not be retained')
}
throw error
} finally {
if (publication) await rm(publication, { recursive: true, force: true })
// Failure staging remains available if evidence publication or its audit record fails.
if (completed || retained) await rm(staging, { recursive: true, force: true })
}
}
+4 -1
View File
@@ -397,6 +397,9 @@ async function main(): Promise<void> {
protocol.handle(SCHEME, (request) => {
const url = new URL(request.url)
// Serves shell URLs declared in update-dialog.ts and mandatory-update-window.ts plus their renderer assets.
// Removing this route leaves those modal windows empty while their parent remains blocked.
if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer'))
if (url.hostname === 'app') {
if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/')
|| ['/favicon.svg', '/manifest.webmanifest'].includes(url.pathname)) {
@@ -707,7 +710,7 @@ async function main(): Promise<void> {
if (quitting) return
event.preventDefault()
quitting = true
mainWindow?.hide()
if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide()
updateSchedule.dispose()
updateDialog.dispose()
mandatoryUI?.dispose()
@@ -43,6 +43,7 @@ export interface MandatoryUpdateWindowOptions {
readonly install: (version: string) => Promise<DesktopUpdateState>
}
// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route.
const page = 'dsh-app://shell/mandatory-update.html'
/** A modal child blocks the product window without cancelling work in the Host. */
+1
View File
@@ -30,6 +30,7 @@ export interface UpdateDialogApi {
respond(index: number): Promise<void>
}
// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route.
const page = 'dsh-app://shell/update-dialog.html'
/** One replaceable confirmation window; aborted checks and mandatory policy cancel ordinary prompts. */
+8 -4
View File
@@ -15,14 +15,16 @@ const descriptor = JSON.parse(readFileSync(join(root, 'desktop-runtime.json'), '
assert.equal(process.versions.node, descriptor.release.nodeVersion, 'Run with the Electron Node runtime version')
assert.equal(process.platform, descriptor.platform)
assert.equal(process.arch, descriptor.arch)
const resourcesRuntime = process.argv[3] ?? join(dirname(root), 'runtime')
const { verifyDesktopRuntime } = await import('../../lib/types/runtime-tree.js')
await verifyDesktopRuntime(root, descriptor.release.version, { platform: process.platform, arch: process.arch })
const requireRuntime = createRequire(join(root, 'package.json'))
const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-'))
/** Run a package script with only the shipped node launcher available on PATH. */
function checkPnpm() {
const resources = dirname(root)
const bin = join(resources, 'runtime', 'bin')
const pnpm = join(resources, 'runtime', 'pnpm', 'bin', 'pnpm.mjs')
const bin = join(resourcesRuntime, 'bin')
const pnpm = join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.mjs')
writeFileSync(join(scratch, 'package.json'), JSON.stringify({
name: 'desktop-node-script-smoke', private: true, scripts: { check: 'node check.cjs' },
}))
@@ -54,7 +56,7 @@ async function checkPty() {
)))
Object.assign(env, { HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch })
env.DSH_DESKTOP_NODE_EXECUTABLE = process.execPath
env.PATH = `${join(dirname(root), 'runtime', 'bin')}${delimiter}${env.PATH ?? env.Path ?? ''}`
env.PATH = `${join(resourcesRuntime, 'bin')}${delimiter}${env.PATH ?? env.Path ?? ''}`
// A Windows GUI executable needs a console-owning shell when launched inside ConPTY.
const executable = process.platform === 'win32' ? process.env.ComSpec : process.execPath
const args = process.platform === 'win32' ? ['/d', '/c', 'node', script] : [script]
@@ -137,6 +139,8 @@ function checkHtml() {
}
try {
const builtin = requireRuntime('node-addon-require-builtin')
assert.equal(typeof builtin.requireBuiltin('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function')
checkPnpm()
checkKoffi()
await checkSharp()
+28 -1
View File
@@ -145,6 +145,7 @@ const harness = await vi.hoisted(async () => {
set publishUpdate(value: (state: DesktopUpdateState) => DesktopUpdateState) { publishUpdate = value },
dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() },
openExternal: vi.fn(),
protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Response | Promise<Response>) => void>(),
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
disableAllPlugins: vi.fn(async () => {
pluginsEnabled = false
@@ -207,7 +208,7 @@ vi.mock('electron', () => ({
},
Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu },
session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } },
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() },
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle },
powerMonitor: harness.powerMonitor,
}))
vi.mock('node:fs/promises', async (importOriginal) => {
@@ -307,6 +308,19 @@ afterEach(async () => {
})
describe('desktop main startup', () => {
it('serves shell dialogs and their assets without forwarding them to the Host', async () => {
await readyForUpdate()
const { serveWebDocument, forwardWebRequest } = await import('../src/web-document.ts')
const handler = harness.protocolHandle.mock.calls[0]![1]
for (const file of ['update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg', 'mandatory-update.html']) {
const request = new Request(`dsh-app://shell/${file}`)
await handler(request)
expect(serveWebDocument).toHaveBeenLastCalledWith(request, join('desktop-test-app', 'renderer'))
}
expect(forwardWebRequest).not.toHaveBeenCalled()
expect((await handler(new Request('dsh-app://unknown/update-dialog.html'))).status).toBe(404)
})
it.each([
['darwin', true, 'en-US'],
['darwin', false, 'zh-CN'],
@@ -717,6 +731,19 @@ describe('desktop main startup', () => {
await harness.quitCompleted.promise
})
it('finishes quitting when the native window is destroyed before its closed listener clears ownership', async () => {
const host = await readyForUpdate()
const window = harness.windows[0]!
window.destroyed = true
window.hide.mockImplementation(() => { throw new TypeError('Object has been destroyed') })
expect(() => { harness.app.quit() }).not.toThrow()
expect(window.hide).not.toHaveBeenCalled()
await host.stopping.promise
window.emit('closed')
host.exited.resolve()
await harness.quitCompleted.promise
})
it('shares the failed-check deadline across focus and resume, while explicit checks reset polling', async () => {
vi.stubEnv('DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS', '1000')
vi.stubEnv('DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS', '4000')
@@ -31,10 +31,18 @@ it('requires one signing preflight before building, then records only the comple
expect(stages.filter(stage => stage === 'preflight:windows-signing')).toHaveLength(1)
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'))
expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts')
for (const call of run.run.mock.calls) {
if (call[0].startsWith('run prepare:') || call[0].includes('smoke-packaged-runtime')) {
expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
}
}
expect(writeFileSync).toHaveBeenCalledOnce()
})
it.each(['preflight:windows-signing', 'run build:official', 'run sign:primary-runtime', 'run prepare:dsh',
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'])
('never continues or records a release after %s fails', async (failure) => {
const { run, stages } = supervisor(failure)
@@ -50,6 +58,7 @@ it.each(['--unsigned', '--prepare-only'])('keeps %s hardware-free and creates no
expect(stages[0]).toBe('run build:official')
expect(stages).not.toContain('preflight:windows-signing')
expect(stages).not.toContain('run sign:primary-runtime')
expect(stages).not.toContain('run sign:primary-runtime --dsh')
for (const call of run.run.mock.calls) expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
expect(writeFileSync).not.toHaveBeenCalled()
})
@@ -0,0 +1,44 @@
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { smokePreparedRuntime } from '../scripts/smoke-prepared-runtime.ts'
import { smokeDesktopRuntime } from '../scripts/smoke-runtime.ts'
import { verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts'
import { runtimeFixture } from './runtime-fixture.ts'
const { payload } = vi.hoisted(() => ({ payload: vi.fn(async (..._args: unknown[]) => ({ stdout: '' })) }))
vi.mock('node:child_process', async (importOriginal) => {
const { promisify } = await import('node:util')
return { ...await importOriginal<typeof import('node:child_process')>(),
execFile: Object.assign(vi.fn(), { [promisify.custom]: payload }) }
})
vi.mock('../scripts/smoke-runtime.ts', () => ({ smokeDesktopRuntime: vi.fn(async () => {}) }))
const roots: string[] = []
const hostArch = Object.getOwnPropertyDescriptor(process, 'arch')!
afterEach(() => {
Object.defineProperty(process, 'arch', hostArch)
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
vi.clearAllMocks()
})
it('smokes an x64 target verified on an arm64 build host without revalidating against the host', async () => {
const root = mkdtempSync(join(tmpdir(), 'prepared-runtime-target-'))
roots.push(root)
const fixture = runtimeFixture(root)
const target = { platform: 'darwin' as const, arch: 'x64' }
writeDesktopRuntime(root, fixture.release, fixture.sharedPackages.map(entry => entry.name), target)
Object.defineProperty(process, 'arch', { ...hostArch, value: 'arm64' })
await expect(verifyDesktopRuntime(root, fixture.release.version, { ...target, arch: process.arch }))
.rejects.toThrow(/incompatible/u)
const descriptor = await verifyDesktopRuntime(root, fixture.release.version, target)
const electron = join(root, 'target-electron')
const resources = join(root, 'runtime')
await smokePreparedRuntime(root, electron, resources, descriptor)
expect(payload.mock.calls[0]![0]).toBe(electron)
expect(payload.mock.calls[0]![1]).toEqual(expect.arrayContaining([root, resources]))
expect(smokeDesktopRuntime).toHaveBeenCalledWith(root, electron, descriptor, expect.any(Object), resources)
const environment = vi.mocked(smokeDesktopRuntime).mock.calls[0]![3]
expect(existsSync(environment.NARB_NATIVE_CACHE_DIR!)).toBe(false)
})
@@ -2,8 +2,10 @@ import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { signWindowsPrimaryRuntime, windowsRuntimeCode } from '../scripts/sign-primary-runtime.ts'
import { inspectWindowsRuntimeSignature, preserveWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs'
import { signWindowsPrimaryRuntime, signWindowsDesktopRuntime } from '../scripts/sign-primary-runtime.ts'
import { inspectWindowsRuntimeSignature, preserveWindowsRuntimeSignature, windowsRuntimeCode, verifyWindowsCode, signWindowsCode } from '../scripts/windows-runtime-signature.mjs'
import { runtimeFixture } from './runtime-fixture.ts'
import { verifyDesktopRuntime } from '../src/runtime-tree.ts'
import { createPackagingRun } from '../scripts/packaging-run.mjs'
const roots: string[] = []
@@ -25,11 +27,11 @@ async function fixture(names = ['a.exe', 'b.pyd', 'vendor.dll']): Promise<string
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
it('selects real PE code, including .node, without signing foreign native modules or data', async () => {
const root = await fixture(['runtime.node', 'python.exe'])
const root = await fixture(['runtime.node', 'python.exe', 'extensionless', 'custom.binary'])
await mkdir(join(root, 'nested'))
await writeFile(join(root, 'nested', 'foreign.node'), Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
await writeFile(join(root, 'readme.txt'), 'text')
expect(await windowsRuntimeCode(root)).toEqual([join(root, 'python.exe'), join(root, 'runtime.node')].sort())
expect(await windowsRuntimeCode(root)).toEqual(['python.exe', 'runtime.node', 'extensionless', 'custom.binary'].map(name => join(root, name)).sort())
})
it('refuses malformed executables and root or nested directory links', async () => {
@@ -150,3 +152,60 @@ it('preserves only identical, valid runtime copies and records verification with
await writeFile(join(sourceRoot, 'linked', 'python.exe'), await readFile(join(sourceRoot, 'python.exe')))
await expect(preserveWindowsRuntimeSignature(join(destinationRoot, 'linked', 'python.exe'), options)).rejects.toThrow('linked copy')
})
it('rejects a newly added unsigned dependency in the final artifact audit', async () => {
const root = await fixture(['new-dependency.node', 'vendor.dll'])
await expect(verifyWindowsCode(root, async path => path.endsWith('.node') ? unsigned : valid)).rejects.toThrow('NotSigned')
await expect(verifyWindowsCode(root, async () => valid)).resolves.toBeUndefined()
})
it('records signed dependency bytes before smoke and rejects changes after smoke', async () => {
const root = await fixture(['dependency.node'])
runtimeFixture(root)
let signed = false
const options = { thumbprint, record: () => {},
inspect: async () => signed ? valid : unsigned,
sign: async ({ path }: { path: string }) => {
await writeFile(path, Buffer.concat([await readFile(path), Buffer.from('signature')]))
signed = true
},
smoke: vi.fn(async () => { await verifyDesktopRuntime(root, '1.0.0') }),
}
await signWindowsDesktopRuntime(root, '1.0.0', options)
expect(options.smoke).toHaveBeenCalledOnce()
await expect(signWindowsDesktopRuntime(root, '1.0.0', { ...options,
smoke: async () => { await writeFile(join(root, 'dependency.node'), 'mutated') },
})).rejects.toThrow('integrity')
})
it('bounds public-key inspection to four files and drains a failed batch before returning', async () => {
const root = await fixture(['a.node', 'b.node', 'c.node', 'd.node', 'e.node'])
const started = Promise.withResolvers<undefined>()
const finish = Promise.withResolvers<undefined>()
let active = 0
let maximum = 0
const calls: string[] = []
const inspect = async (path: string) => {
calls.push(path)
active += 1
maximum = Math.max(maximum, active)
if (active === 4) started.resolve(undefined)
try {
await finish.promise
if (path.endsWith('a.node')) throw new Error('inspection failed')
return unsigned
} finally { active -= 1 }
}
const sign = vi.fn()
const pending = signWindowsCode(root, { thumbprint, inspect, sign, record: () => {} })
const rejected = expect(pending).rejects.toThrow('inspection failed')
try {
await started.promise
expect(calls).toHaveLength(4)
expect(sign).not.toHaveBeenCalled()
} finally { finish.resolve(undefined); await rejected }
expect(maximum).toBe(4)
expect(active).toBe(0)
expect(calls).toHaveLength(4)
expect(sign).not.toHaveBeenCalled()
})
@@ -0,0 +1,27 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { prepareRuntimeManifests } from '../scripts/prepare-runtime-manifests.ts'
import { verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts'
import { runtimeFixture } from './runtime-fixture.ts'
it('seals dependency metadata that remains unchanged by archive preparation', async () => {
const root = mkdtempSync(join(tmpdir(), 'runtime-manifests-'))
const fixture = runtimeFixture(root)
try {
const directory = join(root, 'node_modules/example/node_modules/nested')
mkdirSync(directory, { recursive: true })
const manifest = join(directory, 'package.json')
writeFileSync(manifest, JSON.stringify({ name: 'nested', version: '1.0.0',
scripts: { test: 'vitest' }, keywords: ['example'], bugs: 'https://example.com',
main: 'index.js', dependencies: { runtime: '1.0.0' } }))
await expect(verifyDesktopRuntime(root, fixture.release.version)).rejects.toThrow('integrity')
await prepareRuntimeManifests(root)
expect(JSON.parse(readFileSync(manifest, 'utf8'))).toEqual({ name: 'nested', version: '1.0.0',
main: 'index.js', dependencies: { runtime: '1.0.0' } })
writeDesktopRuntime(root, fixture.release, fixture.sharedPackages.map(entry => entry.name))
await prepareRuntimeManifests(root)
await verifyDesktopRuntime(root, fixture.release.version)
} finally { rmSync(root, { recursive: true, force: true }) }
})
@@ -0,0 +1,185 @@
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import type { Stats } from 'node:fs'
import { Arch, Packager, type BeforePackContext, type Configuration } from 'app-builder-lib'
import { WinPackager } from 'app-builder-lib/out/winPackager.js'
import { getFileMatchers } from 'app-builder-lib/out/fileMatcher.js'
import { readAsar } from 'app-builder-lib/out/asar/asar.js'
import { afterEach, expect, it, vi } from 'vitest'
import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from '../scripts/windows-asar-unpack.mjs'
import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs'
vi.mock('../scripts/windows-sign.mjs', async importOriginal => ({
...await importOriginal<typeof import('../scripts/windows-sign.mjs')>(),
createWindowsTokenSigner: () => async () => {},
installWindowsNsisBootstrapSigner: () => {},
resolveWindowsUpdatePublisher: () => 'Fixture Publisher',
}))
vi.mock('../scripts/windows-runtime-signature.mjs', async importOriginal => ({
...await importOriginal<typeof import('../scripts/windows-runtime-signature.mjs')>(),
signWindowsCode: async () => {},
}))
// Vite's root-relative IDs also resolve mocked build outputs on a clean checkout.
vi.mock('/apps/desktop/lib/types/runtime-tree.js', () => ({ verifyDesktopRuntime: async () => {} }))
vi.mock('/apps/desktop/lib/types/mandatory-update-policy.js', async () => import('../src/mandatory-update-policy.ts'))
vi.mock('node:crypto', async importOriginal => ({
...await importOriginal<typeof import('node:crypto')>(),
X509Certificate: class { fingerprint = 'AA:BB' },
}))
const require = createRequire(import.meta.url)
// The locked builder omits declarations for its AsarPackager implementation.
const { AsarPackager } = require('app-builder-lib/out/asar/asarUtil.js') as {
AsarPackager: new (packager: object, configuration: object) => {
pack(files: { src: string; destination: string; files: string[]; metadata: Map<string, Stats> }[]): Promise<void>
}
}
const roots: string[] = []
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) await cleanup()
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
async function fixture(external: boolean) {
const root = await mkdtemp(join(tmpdir(), 'windows-asar-'))
roots.push(root)
const appDir = join(root, 'app')
const source = external ? join(root, 'private', 'dsh') : join(appDir, '.desktop-build', 'dsh')
const resources = join(root, 'output', 'resources')
await mkdir(resources, { recursive: true })
await mkdir(appDir, { recursive: true })
const pe = Buffer.alloc(128)
pe.writeUInt16LE(0x5a4d, 0)
pe.writeUInt32LE(64, 0x3c)
pe.writeUInt32LE(0x4550, 64)
const names = ['extensionless', 'custom.binary', 'native.node', 'a[0]{x,y}+(z)@!#.binary', '${arch}.binary']
for (const name of names) {
const file = join(source, 'node_modules', 'foo', name)
await mkdir(dirname(file), { recursive: true })
await writeFile(file, pe)
}
await writeFile(join(source, 'node_modules', 'foo', 'companion.json'), '{}')
await writeFile(join(source, 'node_modules', 'foo', '$xarchy.binary'), 'neighbor')
const config = {
files: [{ from: source, to: 'dsh', filter: ['**/*'] },
{ from: join(source, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }],
asarUnpack: ['**/*.node'],
} satisfies Configuration
const info = new Packager({ projectDir: appDir })
Object.defineProperties(info, {
appDir: { value: appDir }, config: { value: config },
metadata: { value: { name: 'fixture', version: '1.0.0', description: 'Fixture' } },
framework: { value: { name: 'electron' } },
})
vi.spyOn(info, 'disposeOnBuildFinish').mockImplementation((cleanup) => { cleanups.push(cleanup) })
const context: BeforePackContext = { packager: new WinPackager(info), arch: Arch.x64,
appOutDir: dirname(resources), outDir: dirname(resources), electronPlatformName: 'win32', targets: [] }
return { root, appDir, source, resources, config, context, names }
}
async function packageFixture(input: Awaited<ReturnType<typeof fixture>>) {
const destination = join(input.resources, 'app')
const matchers = getFileMatchers(input.config, 'asarUnpack', destination, {
defaultSrc: input.appDir, globalOutDir: dirname(input.resources), customBuildOptions: {},
macroExpander: value => value.replaceAll('${arch}', 'x64'),
})!
const mapping = input.config.files[0]!
if (typeof mapping === 'string' || mapping.from === undefined) throw new Error('missing source mapping')
const files: string[] = []
const metadata = new Map<string, Stats>()
async function visit(directory: string): Promise<void> {
for (const name of await readdir(directory)) {
const file = join(directory, name)
const stat = await lstat(file)
if (stat.isDirectory()) await visit(file)
else { files.push(file); metadata.set(file, stat) }
}
}
await visit(mapping.from)
await new AsarPackager({ info: { getWorkspaceRoot: async () => input.root } }, {
defaultDestination: destination, resourcePath: input.resources, options: {}, unpackPattern: matchers[0]!.createFilter(),
}).pack([{ src: mapping.from, destination: join(destination, 'dsh'), files, metadata }])
}
it.each([false, true])('unpacks PE files through builder source patterns (external=%s)', async (external) => {
const input = await fixture(external)
const files = await prepareWindowsAsarUnpack(input.context, input.source)
await packageFixture(input)
await verifyWindowsAsarUnpack(input.source, input.resources, files)
const archive = await readAsar(join(input.resources, 'app.asar'))
expect(archive.getFile(join('dsh', 'node_modules', 'foo', 'companion.json')).unpacked).not.toBe(true)
expect(archive.getFile(join('dsh', 'node_modules', 'foo', '$xarchy.binary')).unpacked).toBe(true)
expect(files).toEqual(input.names.map(name => join('node_modules', 'foo', name)).sort())
const first = input.config.files[0]!
const nested = input.config.files[1]!
if (typeof first === 'string' || typeof nested === 'string') throw new Error('missing source mappings')
expect(first.from === input.source).toBe(!external)
expect(nested.from).toBe(join(first.from, 'node_modules'))
expect(cleanups.length).toBe(external ? 1 : 0)
if (external) {
await cleanups.pop()!()
await expect(lstat(first.from)).rejects.toMatchObject({ code: 'ENOENT' })
expect(await readFile(join(input.source, files[0]!))).toHaveLength(128)
}
})
it('rejects an inline PE even when a neighboring unpacked copy exists', async () => {
const input = await fixture(false)
const files = await prepareWindowsAsarUnpack(input.context, input.source)
input.config.asarUnpack = ['**/*.node']
await packageFixture(input)
const path = join(input.resources, 'app.asar.unpacked', 'dsh', files[0]!)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, await readFile(join(input.source, files[0]!)))
await expect(verifyWindowsAsarUnpack(input.source, input.resources, files)).rejects.toThrow('PE must be unpacked')
})
it.each(['changed', 'missing'] as const)('rejects %s unpacked PE bytes', async (state) => {
const input = await fixture(false)
const files = await prepareWindowsAsarUnpack(input.context, input.source)
await packageFixture(input)
const path = join(input.resources, 'app.asar.unpacked', 'dsh', files[0]!)
if (state === 'changed') await writeFile(path, 'changed')
else await rm(path)
await expect(verifyWindowsAsarUnpack(input.source, input.resources, files)).rejects.toThrow(state === 'changed' ? 'PE bytes changed' : 'ENOENT')
})
it('registers external staging cleanup before a configuration rewrite fails', async () => {
const input = await fixture(true)
const files = input.config.files
// The source still passes PE discovery; a destination mapping setter fails after staging is copied.
Object.defineProperty(input.config, 'files', { get: () => files, set: () => { throw new Error('configuration rejected') } })
await expect(prepareWindowsAsarUnpack(input.context, input.source)).rejects.toThrow('configuration rejected')
const staging = await readdir(join(input.appDir, '.desktop-build'))
expect(staging).toHaveLength(1)
expect(cleanups).toHaveLength(1)
await cleanups.pop()!()
expect(await readdir(join(input.appDir, '.desktop-build'))).toEqual([])
})
it.each([true, false])('validates the real builder hook for unsigned=%s', async (unsigned) => {
const input = await fixture(true)
const certificate = join(input.root, 'certificate.cer')
await writeFile(certificate, 'fixture public certificate')
const config = createElectronBuilderConfig({
DSH_DESKTOP_APP_ID: 'com.example.unpack', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0',
DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com',
}, 'win32', 'x64', input.source)
// Qualification replaces files after creating the base configuration; builder owns a separate config object.
input.config.asarUnpack = [...config.asarUnpack]
await config.beforePack(input.context)
await packageFixture(input)
await config.afterPack(input.context)
if (!unsigned) await config.afterSign(input.context)
const file = join(input.resources, 'app.asar.unpacked', 'dsh', 'node_modules', 'foo', 'custom.binary')
await writeFile(file, 'changed after packaging')
if (unsigned) await expect(config.afterPack(input.context)).rejects.toThrow('PE bytes changed')
else {
await config.afterPack(input.context)
await expect(config.afterSign(input.context)).rejects.toThrow('PE bytes changed')
}
})
+94 -2
View File
@@ -1,4 +1,5 @@
import { execFile } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
@@ -16,13 +17,23 @@ import {
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>()
return { ...actual, execFile: vi.fn() }
const { promisify } = await import('node:util')
const mock = vi.fn()
// Node's execFile promisifier returns both output streams; generic promisify drops stderr.
Object.defineProperty(mock, promisify.custom, { value: (...args: unknown[]) => new Promise((resolve, reject) => {
mock(...args, (error: Error | null, stdout: string, stderr: string) => {
if (error) reject(error)
else resolve({ stdout, stderr })
})
}) })
return { ...actual, execFile: mock }
})
vi.mock('node:crypto', async importOriginal => ({
...await importOriginal<typeof import('node:crypto')>(),
X509Certificate: class {
readonly ca = false
readonly fingerprint = 'A'.repeat(40)
readonly keyUsage = ['1.3.6.1.5.5.7.3.3']
constructor(contents: Buffer) {
@@ -37,6 +48,84 @@ const CERTIFICATE_FILE = 'C:\\release\\server.cer'
const SIGN_SCRIPT = resolve(import.meta.dirname, '../scripts/windows-sign.cmd')
describe('Windows token signing', () => {
it.each(['invalid-primary', 'timestamp-exhausted', 'timestamp-recovered', 'normalization-corrupt'] as const)(
'keeps hardware protection separate from %s', async (mode) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-sign-completion-'))
const certificateFile = join(root, 'server.cer')
const signTool = join(root, 'signtool.exe')
const path = join(root, 'application.exe')
const stateDirectory = join(root, 'state')
await writeFile(certificateFile, 'code-signing-certificate-fixture')
await writeFile(signTool, 'fixture')
await writeFile(path, 'input')
const run = createPackagingRun(join(root, 'records'), {})
let hardware = 0
let timestamps = 0
vi.mocked(execFile).mockImplementation((...args: unknown[]) => {
const command = args[0] as string
const argv = args[1] as string[]
const options = args[2] as { env: NodeJS.ProcessEnv }
const callback = args.at(-1) as (error: Error | null, stdout?: string, stderr?: string) => void
if (argv[0] === '/d') {
hardware++
expect(existsSync(join(stateDirectory, 'attempt.json'))).toBe(true)
writeFileSync(options.env.DSH_DESKTOP_WINDOWS_SIGN_TARGET!, 'signed:input')
callback(null, '', '')
} else {
expect(options.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined()
expect(options.env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER).toBeUndefined()
if (command === 'powershell.exe') {
const timestamped = readFileSync(options.env.DSH_RUNTIME_VERIFY_FILE!, 'utf8').endsWith(':timestamp')
if (!timestamped) expect(existsSync(join(stateDirectory, 'attempt.json'))).toBe(true)
callback(null, JSON.stringify({ status: mode === 'invalid-primary' ? 'NotTrusted' : 'Valid', timestamped, thumbprint: 'A'.repeat(40) }), '')
} else if (argv[0] === 'remove') {
const target = argv.at(-1)!
const content = readFileSync(target, 'utf8')
if (mode === 'normalization-corrupt') writeFileSync(target, 'unexpected modification')
else writeFileSync(target, content.replace(':timestamp', ''))
if (mode === 'normalization-corrupt' || !content.endsWith(':timestamp')) {
callback(Object.assign(new Error('normalization warning'), { code: 2 }))
} else callback(null, '', '')
} else {
expect(argv[0]).toBe('timestamp')
expect(existsSync(join(stateDirectory, 'attempt.json'))).toBe(false)
const target = argv.at(-1)!
expect(readFileSync(target, 'utf8')).toBe('signed:input')
timestamps++
if (mode === 'timestamp-exhausted' || timestamps === 1) {
writeFileSync(target, 'failed partial output')
callback(Object.assign(new Error('timestamp unavailable'), { code: 1, stderr: 'timestamp unavailable' }))
} else {
writeFileSync(target, 'signed:input:timestamp')
callback(null, '', '')
}
}
}
return undefined as unknown as ReturnType<typeof execFile>
})
try {
const sign = createWindowsTokenSigner({ certificateFile, signTool, tokenPin: 'fixture-pin', keyContainer: 'fixture-container',
runDirectory: run.directory, stateDirectory })
const task = { path, hash: 'sha256', isNest: false }
if (mode === 'timestamp-recovered') {
await sign(task)
expect(await readFile(path, 'utf8')).toBe('signed:input:timestamp')
expect(timestamps).toBe(2)
} else {
const results = await Promise.allSettled([sign(task), sign(task)])
expect(results.map(result => result.status)).toEqual(['rejected', 'rejected'])
expect(await readFile(path, 'utf8')).toBe('input')
expect(timestamps).toBe(mode === 'timestamp-exhausted' ? 3 : 0)
expect(existsSync(join(run.directory, 'fatal.json'))).toBe(true)
}
expect(hardware).toBe(1)
expect(existsSync(join(stateDirectory, 'attempt.json'))).toBe(mode === 'invalid-primary')
} finally {
vi.mocked(execFile).mockReset()
await rm(root, { recursive: true, force: true })
}
}, 15_000,
)
it('preserves verified copies without hardware and rejects the entire queue after preservation failure', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-copy-signature-'))
try {
@@ -147,7 +236,7 @@ describe('Windows token signing', () => {
expect(text).toContain('setlocal DisableDelayedExpansion\r\n')
expect(text).toContain('set "DSH_DESKTOP_WINDOWS_CER_FILE="\r\n')
expect(text).toContain('set "DSH_DESKTOP_WINDOWS_TOKEN_PIN="\r\n')
expect(text).toContain('"%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% /tr http://timestamp.digicert.com /td sha256 "%targetFile%"\r\n')
expect(text).toContain('"%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% "%targetFile%"\r\n')
})
it('rejects incomplete signing identities and non-SHA-256 signing tasks', async () => {
@@ -197,6 +286,9 @@ describe('Windows token signing', () => {
hash: 'sha1',
isNest: false,
})).rejects.toThrow(/requires SHA-256/u)
await expect(createWindowsTokenSigner({ certificateFile, signTool, tokenPin: 'fixture-pin', keyContainer: 'fixture-container' })({
path: 'C:\\release\\setup.exe', hash: 'sha256', isNest: true,
})).rejects.toThrow('does not support appended signatures')
}
finally {
await rm(directory, { recursive: true })
@@ -0,0 +1,50 @@
/** Parser fault checks; real-file equivalence is measured separately. */
import { it as test } from 'vitest'
import assert from 'node:assert/strict'
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { parseSignatureRows, inspectSignaturesBatched } from '../scripts/windows-signature-batch.mjs'
import { inspectWindowsRuntimeSignature, signWindowsCode, verifyWindowsCode } from '../scripts/windows-runtime-signature.mjs'
const files = ['one.node', 'two.dll']
const rows = files.map(path => ({ path, status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) }))
const output = (input: readonly unknown[]): string => input.map(value => JSON.stringify(value)).join('\n')
test('each row retains its own signature status', () => {
const input = [rows[0], { ...rows[1], status: 'NotSigned', timestamped: false, thumbprint: null }]
const result = parseSignatureRows(output(input), '', files)
assert.deepEqual(result.map(signature => signature.status), ['Valid', 'NotSigned'])
})
for (const [name, input] of [
['missing', [rows[0]]], ['duplicate', [rows[0], rows[0]]], ['reordered', [...rows].reverse()],
['extra', [...rows, rows[0]]], ['null', [rows[0], null]],
['bad timestamp', [rows[0], { ...rows[1], timestamped: 'true' }]],
['bad certificate', [rows[0], { ...rows[1], thumbprint: 'wrong' }]],
] as const) test(`${name} output is rejected`, () => {
assert.throws(() => parseSignatureRows(output(input), '', files))
})
test('stderr rejects otherwise complete output', () => {
assert.throws(() => parseSignatureRows(output(rows), 'warning', files))
})
test('empty input launches no verifier and returns no rows', async () => {
assert.deepEqual(await inspectSignaturesBatched([]), [])
})
test.skipIf(process.platform !== 'win32')('Unicode paths retain the same Windows trust result as individual inspection', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'signature-batch-'))
t.onTestFinished(() => rm(root, { recursive: true, force: true }))
const path = join(root, "验证 ' 文件.exe")
await copyFile(join(process.env.SystemRoot!, 'System32', 'cmd.exe'), path)
const expected = await inspectWindowsRuntimeSignature(path)
assert.equal(expected.status, 'Valid')
assert.deepEqual(await inspectSignaturesBatched([path]), [expected])
await signWindowsCode(root, {
thumbprint: 'A'.repeat(40),
sign: async () => { throw new Error('vendor-signed files must not access signing hardware') },
record: () => {},
})
await verifyWindowsCode(root)
})
@@ -0,0 +1,168 @@
/** Fault checks for the exploratory cache; these never call signing hardware. */
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'
async function fixture(t: TestContext) {
const root = await mkdtemp(join(import.meta.dirname, 'cache-test-'))
t.onTestFinished(() => rm(root, { recursive: true, force: true }))
const path = join(root, 'module.node')
await writeFile(path, 'original')
const cache = join(root, 'cache')
const state = { hardwareCalls: 0, events: [] as object[] }
const thumbprint = 'A'.repeat(40)
const options: import('../scripts/windows-signature-cache.mjs').WindowsSignatureCacheOptions = {
root: cache, identity: 'test-policy', thumbprint,
sign: async ({ path }) => { state.hardwareCalls++; await writeFile(path, `signed:${await readFile(path, 'utf8')}`) },
inspect: async path => ({ status: (await readFile(path, 'utf8')).startsWith('signed:') ? 'Valid' : 'NotSigned', timestamped: true, thumbprint }),
record: event => state.events.push(event),
}
const request = { path, hash: 'sha256', isNest: false }
return { root, path, cache, state, options, request }
}
test('unchanged bytes restored in another file avoid another hardware call', async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
const second = join(f.root, 'other.node')
await writeFile(second, 'original')
await createCachedSigner(f.options)({ ...f.request, path: second })
assert.equal(f.state.hardwareCalls, 1)
assert.equal(await readFile(second, 'utf8'), 'signed:original')
assert.equal(f.state.events.filter(e => 'type' in e && e.type === 'signature-cache-hit').length, 1)
})
test('restores cached signatures into deeply nested dependency paths', async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
const directory = join(f.root, 'dependency'.repeat(12))
await mkdir(directory)
const path = join(directory, 'module.node')
await writeFile(path, 'original')
await createCachedSigner(f.options)({ ...f.request, path })
assert.equal(await readFile(path, 'utf8'), 'signed:original')
assert.equal(f.state.hardwareCalls, 1)
assert.deepEqual(await readdir(directory), ['module.node'])
})
test('different content or signing policy misses the cache', async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
await writeFile(f.path, 'changed')
await createCachedSigner(f.options)(f.request)
await writeFile(f.path, 'original')
await createCachedSigner({ ...f.options, identity: 'changed-policy' })(f.request)
assert.equal(f.state.hardwareCalls, 3)
})
for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input`, async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
await writeFile(f.path, 'original')
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))
assert.equal(f.state.hardwareCalls, 1)
assert.equal(await readFile(f.path, 'utf8'), 'original')
})
for (const signature of [
{ status: 'NotTrusted', timestamped: true, thumbprint: 'A'.repeat(40) },
{ status: 'Valid', timestamped: false, thumbprint: 'A'.repeat(40) },
{ status: 'Valid', timestamped: true, thumbprint: 'B'.repeat(40) },
]) test(`cache refuses signature ${JSON.stringify(signature)}`, async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
await writeFile(f.path, 'original')
await assert.rejects(createCachedSigner({ ...f.options, inspect: async () => signature })(f.request), /invalid cached signature/u)
assert.equal(f.state.hardwareCalls, 1)
assert.equal(await readFile(f.path, 'utf8'), 'original')
})
test('hardware failure rejects queued work without publishing an entry or retrying', async (t) => {
const f = await fixture(t)
const signer = createCachedSigner({ ...f.options, sign: async () => { f.state.hardwareCalls++; throw new Error('hardware failed') } })
const results = await Promise.allSettled([signer(f.request), signer(f.request)])
assert(results.every(result => result.status === 'rejected'))
assert.equal(f.state.hardwareCalls, 1)
assert.deepEqual(await readdir(f.cache), [])
})
test('a failed new signature verification never publishes a cache entry', async (t) => {
const f = await fixture(t)
await assert.rejects(createCachedSigner({ ...f.options, inspect: async () => ({ status: 'NotTrusted', timestamped: false, thumbprint: null }) })(f.request), /new signature verification failed/u)
assert.deepEqual(await readdir(f.cache), [])
})
test('an incomplete entry is rejected without hardware access', async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
const entry = (await readdir(f.cache))[0]
assert(entry)
await rm(join(f.cache, entry, 'record.json'))
await writeFile(f.path, 'original')
await assert.rejects(createCachedSigner(f.options)(f.request))
assert.equal(f.state.hardwareCalls, 1)
})
test('certificate and toolchain content invalidate policy identity independently of paths', async (t) => {
const f = await fixture(t)
const certificate = join(f.root, 'certificate.cer')
const command = join(f.root, 'sign.cmd')
await writeFile(certificate, 'certificate A')
await writeFile(command, 'signing policy A')
const first = await signatureCacheIdentity([certificate, command])
const relocated = join(f.root, 'relocated.cer')
await writeFile(relocated, 'certificate A')
assert.equal(await signatureCacheIdentity([relocated, command]), first)
await writeFile(certificate, 'certificate B')
assert.notEqual(await signatureCacheIdentity([certificate, command]), first)
await writeFile(command, 'signing policy B')
assert.notEqual(await signatureCacheIdentity([relocated, command]), first)
})
test('two writers publish one complete entry without overwriting another writer', async (t) => {
const f = await fixture(t)
const second = join(f.root, 'second.node')
await writeFile(second, 'original')
const entered = Promise.withResolvers<undefined>()
let arrivals = 0
const options: import('../scripts/windows-signature-cache.mjs').WindowsSignatureCacheOptions = { ...f.options, sign: async (request) => {
if (++arrivals === 2) entered.resolve(undefined)
await entered.promise
await f.options.sign(request)
} }
const results = await Promise.allSettled([
createCachedSigner(options)(f.request),
createCachedSigner(options)({ ...f.request, path: second }),
])
for (const result of results) if (result.status === 'rejected') throw result.reason
assert.equal((await readdir(f.cache)).length, 1)
await writeFile(f.path, 'original')
await createCachedSigner(f.options)(f.request)
assert.equal(f.state.hardwareCalls, 2)
assert.equal(await readFile(f.path, 'utf8'), 'signed:original')
})
test('linked cache directories cannot redirect cache restoration', async (t) => {
const f = await fixture(t)
const elsewhere = join(f.root, 'elsewhere')
await mkdir(elsewhere)
await symlink(elsewhere, f.cache, process.platform === 'win32' ? 'junction' : 'dir')
await assert.rejects(createCachedSigner(f.options)(f.request), /unlinked directory/u)
assert.equal(f.state.hardwareCalls, 0)
})
test('a target changed during verification is never replaced with stale cache bytes', async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
await writeFile(f.path, 'original')
const inspect = async (candidate: string) => { await writeFile(f.path, 'new input'); return f.options.inspect(candidate) }
await assert.rejects(createCachedSigner({ ...f.options, inspect })(f.request), /target changed/u)
assert.equal(await readFile(f.path, 'utf8'), 'new input')
assert.equal(f.state.hardwareCalls, 1)
assert(!(await readdir(f.root)).some(name => name.startsWith('.signature-restore-')))
})
@@ -0,0 +1,139 @@
/** Timestamp retries use fresh signed inputs and cannot publish partial or substituted files. */
import { it, expect, type TestContext } from 'vitest'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { completeWindowsSignature, type WindowsTimestampOptions } from '../scripts/windows-timestamp.mjs'
async function fixture(t: TestContext) {
const root = await mkdtemp(join(tmpdir(), 'dsh-timestamp-'))
t.onTestFinished(() => rm(root, { recursive: true, force: true }))
const path = join(root, 'input.exe')
const evidenceDirectory = join(root, 'evidence')
await mkdir(evidenceDirectory)
await writeFile(path, 'input')
const state = { hardware: 0, requests: [] as string[], events: [] as object[] }
const options: WindowsTimestampOptions = {
evidenceDirectory, thumbprint: 'A'.repeat(40),
sign: async (file) => { state.hardware++; await writeFile(file, 'signed:input') },
timestamp: async (file) => { state.requests.push(await readFile(file, 'utf8')); await writeFile(file, 'signed:input:timestamp') },
normalize: async (file) => { await writeFile(file, (await readFile(file, 'utf8')).replace(':timestamp', '')) },
inspect: async file => ({ status: 'Valid', timestamped: (await readFile(file, 'utf8')).endsWith(':timestamp'), thumbprint: 'A'.repeat(40) }),
record: (event) => { state.events.push(event) },
wait: async () => {},
}
return { root, path, options, state }
}
it('a failed timestamp cannot contaminate the next input or repeat hardware signing', async (t) => {
const f = await fixture(t)
const timestamp = f.options.timestamp
let requests = 0
f.options.timestamp = async (path) => {
if (++requests === 1) {
expect(await readFile(path, 'utf8')).toBe('signed:input')
await writeFile(path, 'damaged by failed timestamp')
throw Object.assign(new Error('timestamp failed'), { code: 1 })
}
await timestamp(path)
}
await completeWindowsSignature(f.path, f.options)
expect(f.state.hardware).toBe(1)
expect(requests).toBe(2)
expect(f.state.requests).toEqual(['signed:input'])
expect(await readFile(f.path, 'utf8')).toBe('signed:input:timestamp')
expect((await readdir(f.root)).filter(name => name.startsWith('.complete-signature-'))).toEqual([])
})
it('exhausted timestamp attempts retain evidence and leave the original untouched', async (t) => {
const f = await fixture(t)
let requests = 0
f.options.timestamp = async () => { requests++; throw Object.assign(new Error('offline'), { code: 1 }) }
await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('offline')
expect(requests).toBe(3)
expect(f.state.hardware).toBe(1)
expect(await readFile(f.path, 'utf8')).toBe('input')
expect(await readdir(f.options.evidenceDirectory)).toHaveLength(1)
expect((await readdir(f.root)).filter(name => name.startsWith('.complete-signature-'))).toEqual([])
})
it('retains staging when failure evidence cannot be stored', async (t) => {
const f = await fixture(t)
f.options.evidenceDirectory = f.path
let staging = ''
f.options.sign = async (path) => { staging = dirname(path); throw new Error('hardware failed') }
t.onTestFinished(async () => { if (staging) await rm(staging, { recursive: true, force: true }) })
await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('evidence could not be retained')
expect(await readdir(staging)).toEqual(['raw.exe'])
expect(await readFile(f.path, 'utf8')).toBe('input')
})
it('uses short tool paths for deeply nested targets and publishes the completed bytes', async (t) => {
const f = await fixture(t)
const directory = join(f.root, 'nested'.repeat(20), 'dependency'.repeat(12))
await mkdir(directory, { recursive: true })
const path = join(directory, 'native-module.node')
await writeFile(path, 'input')
function checked<T>(operation: (file: string) => Promise<T>) {
return async (file: string) => {
expect(file.length).toBeLessThan(260)
expect(file.endsWith('.node')).toBe(true)
expect(dirname(file)).not.toBe(directory)
return operation(file)
}
}
f.options.sign = checked(f.options.sign)
f.options.timestamp = checked(f.options.timestamp)
f.options.normalize = checked(f.options.normalize)
f.options.inspect = checked(f.options.inspect)
await completeWindowsSignature(path, f.options)
expect(await readFile(path, 'utf8')).toBe('signed:input:timestamp')
expect(await readdir(directory)).toEqual(['native-module.node'])
})
for (const failure of [{ code: 'ENOENT' }, { code: 1, killed: true }, { code: 1, signal: 'SIGTERM' }]) {
it(`does not retry uncertain timestamp process failure ${JSON.stringify(failure)}`, async (t) => {
const f = await fixture(t)
let requests = 0
f.options.timestamp = async () => { requests++; throw Object.assign(new Error('process failure'), failure) }
await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('process failure')
expect(requests).toBe(1)
expect(await readFile(f.path, 'utf8')).toBe('input')
})
}
for (const signature of [
{ status: 'Valid', timestamped: false, thumbprint: 'A'.repeat(40) },
{ status: 'Valid', timestamped: true, thumbprint: 'B'.repeat(40) },
{ status: 'HashMismatch', timestamped: true, thumbprint: 'A'.repeat(40) },
]) it(`rejects successful commands with an invalid final signature ${JSON.stringify(signature)}`, async (t) => {
const f = await fixture(t)
f.options.inspect = async () => signature
await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('verification failed')
expect(f.state.requests).toHaveLength(1)
expect(await readFile(f.path, 'utf8')).toBe('input')
})
it('rejects changed contents even when the substituted file has the expected valid certificate', async (t) => {
const f = await fixture(t)
f.options.timestamp = async (path) => { await writeFile(path, 'signed:different-input:timestamp') }
await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('signing content changed')
expect(await readFile(f.path, 'utf8')).toBe('input')
})
it('does not overwrite an input changed while its signature is being completed', async (t) => {
const f = await fixture(t)
const timestamp = f.options.timestamp
f.options.timestamp = async (path) => { await timestamp(path); await writeFile(f.path, 'new input') }
await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('input changed before publication')
expect(await readFile(f.path, 'utf8')).toBe('new input')
})
it('does not retry hardware failures or request timestamps after them', async (t) => {
const f = await fixture(t)
f.options.sign = async () => { f.state.hardware++; throw new Error('hardware failed') }
await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('hardware failed')
expect(f.state.hardware).toBe(1)
expect(f.state.requests).toHaveLength(0)
expect(await readFile(f.path, 'utf8')).toBe('input')
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-web-frontend",
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
"version": "0.1.6-alpha.1",
"version": "0.1.6-alpha.2",
"publishConfig": {
"access": "public"
},
+2 -2
View File
@@ -73,7 +73,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => {
const currentMenuBox = await currentMenu.boundingBox()
expect(currentTriggerBox).not.toBeNull()
expect(currentMenuBox).not.toBeNull()
expect(Math.abs(currentMenuBox!.width - 218)).toBeLessThan(1)
expect(Math.abs(currentMenuBox!.width - 144)).toBeLessThan(1)
expect(currentMenuBox!.width).toBeGreaterThan(currentTriggerBox!.width)
expect(Math.abs(currentTriggerBox!.y - currentMenuBox!.y - currentMenuBox!.height - 4)).toBeLessThan(1)
@@ -183,7 +183,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => {
await menu.waitFor()
await captureAutoReviewState(page, 'experimental-narrow-menu')
const menuBox = (await menu.boundingBox())!
expect(menuBox.width).toBeGreaterThanOrEqual(218)
expect(menuBox.width).toBeGreaterThanOrEqual(144)
expect(menuBox.width).toBeLessThanOrEqual(360)
expect(menuBox.x).toBeGreaterThanOrEqual(0)
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(420)
+7 -1
View File
@@ -91,6 +91,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull()
expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
within(waitingRow).getByText('Waiting for answer')
within(waitingRow).getByText('Answer')
// Opening a session reaches chat content through the RemoteMock transport.
fireEvent.click(waitingTitle)
@@ -103,7 +104,12 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
for (let index = 0; index < 3; index += 1) {
fireEvent.click(await screen.findByRole('button', { name: 'Skip' }))
}
fireEvent.click(await screen.findByRole('button', { name: 'Allow once' }))
const allowOnce = await screen.findByRole('button', { name: 'Allow once' })
await waitFor(() => {
within(waitingRow).getByText('Waiting for approval')
within(waitingRow).getByText('Approval')
})
fireEvent.click(allowOnce)
// The fixture mirrors all three token-meter projections, so the assembled
// ContextMeter reaches its composition panel instead of only the occupancy
@@ -436,8 +436,8 @@ describe('web e2e: clickable links gallery', () => {
// globe in the same seat.
const repoLink = markdown.locator(`a[href="${REPO_URL}"]`)
expect(await repoLink.count()).toBe(1)
const repoMark = await repoLink.locator('svg path').getAttribute('d')
const globeMark = await guideLink.locator('svg path').getAttribute('d')
const repoMark = await repoLink.locator('svg path').first().getAttribute('d')
const globeMark = await guideLink.locator('svg path').first().getAttribute('d')
expect(repoMark).not.toBe(globeMark)
await guideLink.hover()
expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline')
@@ -1,8 +1,6 @@
- dialog "确认启用 Auto review(实验)?":
- heading "确认启用 Auto review(实验)?" [level=2]
- button "关闭":
- img
- img
- button "关闭"
- paragraph: Auto review 不使用沙箱。每次原生工具调用和 PTC 内层调用前,都会由与当前 agent 相同的模型进行审查。此功能仍属实验性,可能误放行或误拒绝,并会消耗额外 token。
- checkbox "我已了解这些风险,并愿意继续"
- text: 我已了解这些风险,并愿意继续
@@ -1,8 +1,6 @@
- menu:
- menuitem "仅可查看"
- menuitem "工作区内修改":
- text: 工作区内修改
- img
- menuitem "工作区内修改"
- menuitem "完全权限"
- menuitem "Auto review EXP":
- text: Auto review
@@ -2,9 +2,7 @@
- /placeholder: 搜索…
- listbox "/permission 匹配项":
- option "仅可查看"
- option "工作区内修改" [selected]:
- text: 工作区内修改
- img
- option "工作区内修改" [selected]
- option "完全权限"
- option "Auto review EXP":
- text: Auto review
@@ -1,8 +1,6 @@
- dialog "确认启用完全权限?":
- heading "确认启用完全权限?" [level=2]
- button "关闭":
- img
- img
- button "关闭"
- paragraph: 启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
- checkbox "我已了解风险,并愿意继续"
- text: 我已了解风险,并愿意继续
@@ -1,7 +1,6 @@
- dialog "复制预设 · 复制自 极简模式":
- heading "复制预设 · 复制自 极简模式" [level=2]
- button "关闭":
- img
- button "关闭"
- paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。
- text: 标识符
- textbox "标识符":
Loaded 100 of 847 files, more files were not shown because too many files have changed in this diff. Show more