diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index 3c02109a3f..bdb99e9fab 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: 7b01ba80e31667d851642e57cbb025dfdce7b463 -2026-08-25-electron-desktop-packaging-and-updates.zh.md: a0db068b13f64ce5ef343d77a07e621304ae4d35 +2026-08-25-electron-desktop-packaging-and-updates.md: 1f3107a289f33dea36bb6b22e0240df1688216d2 +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 4f340d3d6d06d127cd6a979dacc3e3178132df3f diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index 7b01ba80e3..1f3107a289 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -68,9 +68,9 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory, applies an exact dependency change with the bundled pnpm, performs a full health check, stops the backend, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. `pending.json` journals the filesystem moves so startup can complete or reverse an interrupted replacement. -The packaged seed is an offline installation kit, not an executable dsh tree. It contains the release identity, initial desktop-project manifest, a descriptor and immutable tarballs for the first-party package closure rooted at dsh, lockfile, integrity inventory, and required store subset. The release build requires the Electron package and root dsh package to have the same version, creates final npm tarballs from the official source build, selects the reachable dsh and vendored packages plus the Landlock entry, and verifies the dsh tarball's `lib/desktop-host.js` entry and `config/desktop.cordis.patch.yml` overlay. The overlay is the only CLI configuration file published specifically for Desktop; example configurations remain outside the tarball. These tarballs remain the official `pnpm pack` results governed by each package's `files` manifest; Desktop does not remove published declarations or otherwise create a second package-content policy. The manifest lists every selected package as a local direct dependency, automatic peer installation is disabled, and the workspace file overrides every selected first-party name to its local tarball. The build rejects any lockfile that resolves one of those names by registry version. Bundled pnpm fetches external production dependencies from npm, performs an offline installation, checks both Desktop Host files, and removes `node_modules` before final store preparation. Requiring both files before copying the package set and after offline installation prevents a release whose Host entry loads but cannot compose its required overlay from reaching application signing. +The packaged seed is an offline installation kit, not an executable dsh tree. It contains the release identity, initial desktop-project manifest, a descriptor and immutable tarballs for the first-party package closure rooted at dsh, lockfile, integrity inventory, and required store subset. The release build requires the Electron package and root dsh package to have the same version, creates final npm tarballs from the official source build, selects the reachable dsh and vendored packages plus the Landlock entry, and verifies the dsh tarball's `lib/desktop-host.js` entry and `config/desktop.cordis.patch.yml` overlay. The overlay is the only CLI configuration file published specifically for Desktop; example configurations remain outside the tarball. These tarballs remain the official `pnpm pack` results governed by each package's `files` manifest; Desktop does not remove published declarations or otherwise create a second package-content policy. The manifest lists every selected package as a local direct dependency, automatic peer installation is disabled, and the workspace file overrides every selected first-party name to its local tarball. The build rejects any lockfile that resolves one of those names by registry version. Bundled pnpm disables its global virtual store, materializes external production dependencies from npm without lifecycle scripts, deletes `node_modules` and every temporary pnpm cache, config, and state directory, then performs a clean offline installation from the final store alone and checks both Desktop Host files. Inventory generation follows removal of that second `node_modules` tree and temporary pnpm project registrations. Requiring both files before copying the package set and after offline installation prevents a release whose Host entry loads but cannot compose its required overlay from reaching application signing. -The seed stores pnpm content in 16 deterministic uncompressed tar shards selected by normalized store path. Apple notarization inspects Mach-O code inside those archives, so macOS seed preparation signs every Mach-O content-addressed object with the release Developer ID, a secure timestamp, and hardened runtime before sharding. Signing changes the bytes: preparation writes each object at its new SHA-512 path and transactionally rewrites every base and side-effects file reference in pnpm's MessagePack SQLite index. A second offline installation proves that pnpm resolves the rewritten store; preparation then shards it, extracts the final archives, and repeats signature verification. Package paths and non-native bytes remain unchanged, and the seed retains bundled architecture variants because removing files would create a Desktop-specific package file set. Seed integrity covers the shard manifest and every archive before extraction. Startup validates archive paths, entry types, uniqueness, and counts, extracts every shard into a unique Desktop-owned staging directory, and only then merges the complete extraction into `.dsh/desktop/pnpm/store`. An interrupted merge may leave valid immutable cache content, but profile installation and activation still require pnpm integrity and the complete health check. +The seed stores pnpm content in 16 deterministic uncompressed tar shards selected by normalized store path. Apple notarization inspects Mach-O code inside those archives, so macOS seed preparation signs every Mach-O content-addressed object with the release Developer ID, a secure timestamp, and hardened runtime before sharding. Signing changes the bytes: preparation writes each object at its new SHA-512 path and transactionally rewrites every base and side-effects file reference in pnpm's MessagePack SQLite index. A second offline installation proves that pnpm resolves the rewritten store; preparation then shards it, extracts the final archives, and repeats signature verification. Package paths and non-native bytes remain unchanged, and the seed retains bundled architecture variants because removing files would create a Desktop-specific package file set. Seed integrity covers the shard manifest and every archive before extraction. Startup validates archive paths, entry types, uniqueness, and counts, extracts every shard into a unique Desktop-owned staging directory, replaces matching immutable store files, and transactionally merges each pnpm store version's SQLite `package_index` into `.dsh/desktop/pnpm/store`. Seed records replace matching keys while records downloaded for Desktop plugins remain. An interrupted file merge may leave valid immutable cache content, but each SQLite merge is atomic, and profile installation and activation still require pnpm integrity and the complete health check. Startup requires the packaged release identity to equal Electron's application version, then compares `.dsh/profiles/desktop/desktop-release.json` and the installed dsh package with that release before launching the backend. It installs the new seed manifest and lockfile with `pnpm install --offline --frozen-lockfile --trust-lockfile` in staging. After Electron replacement, it restores every plugin bundle recorded in the active profile at its exact installed version through one offline pnpm add from the existing desktop store and metadata cache. The complete graph must pass the same health check before activation. @@ -92,6 +92,8 @@ Core dsh comes only from integrity-recorded local npm tarballs inside the signed Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Seed preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and seed-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. + Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, seed, or active project. The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the seed store subset. Architecture-specific builds must report actual component-level size deltas. @@ -103,7 +105,7 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr | Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, project transactions, the plugin GUI, update coordination, and electron-builder configuration. | | Installed runtime | `@deepseek-ai/dsh/desktop-host` boots the portless desktop composition from the active project and streams API and asset responses over validated framed byte pipes. | | Package state | The release seed and every later mutation run through bundled Node.js and pnpm with desktop-owned store, config, cache, state, and home paths; core packages resolve from release tarballs while plugins resolve from the fixed npm registry. | -| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native seed object after final archive extraction, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows signing, update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. | +| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native seed object after final archive extraction, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. | `dev:desktop` builds the current workspace, projects the built CLI package and its dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, seed installation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation. @@ -123,10 +125,14 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr **Remove non-target Mach-O files from registry packages.** Architecture pruning saves a small amount of seed space, but packages can deliberately ship several architecture variants and callers can observe their installed file set. Signing every shipped Mach-O object satisfies notarization without inventing a Desktop-specific package layout. +**Export the Windows EV private key in a PFX file.** The externally supplied public leaf certificate lets SignTool construct the signature while `/csp` and `/kc` locate the hardware key. The EV private key remains non-exportable on the token. + +**Commit a credential-bearing signing script or persist the Token Password.** A credential-bearing CMD file, `.env`, or Windows user or system environment variable leaves the Token Password recoverable at rest. The checked-in CMD contains only environment-variable references, and the packaging step accepts the password as an ephemeral runner secret. + ## Consequences - A clean offline machine with no system Node.js or pnpm installs the seed into `.dsh/profiles/desktop` and starts a working dsh session. -- The signed application inventories a fixed small set of seed store shards instead of every pnpm cache file; every Mach-O object inside the macOS shards has the release Developer ID, secure timestamp, and hardened runtime, while the installed private store retains the ordinary pnpm layout. +- The signed application inventories a fixed small set of seed store shards instead of every pnpm cache file; every Mach-O object inside the macOS shards has the release Developer ID, secure timestamp, and hardened runtime, every Windows artifact has the configured hardware-backed EV signature, and the installed private store retains the ordinary pnpm layout. - `.dsh/profiles/desktop/node_modules` contains and resolves the desktop dsh package and every GUI-installed desktop plugin. - Every desktop pnpm operation uses the bundled executable and `.dsh/desktop/pnpm/store`; none reads user `PATH`, config, store, or profile `node_modules`. - The Electron-only GUI installs, removes, and updates ordinary npm plugin packages without exposing raw pnpm arguments. @@ -138,6 +144,7 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr - Shared `.dsh` data rejects incompatible readers before migration or mutation. - No loopback listener is opened, and the sandboxed renderer cannot access arbitrary filesystem or Electron APIs. - Workspace development runs current built code without downloading release resources, while unpacked-package verification retains the production installation path. +- Windows release packaging requires the validated SignTool, EV token, matching public leaf certificate, Token Password, and explicit key container; it never falls back to an unsigned artifact or an exportable key file. - Signed installed artifacts update successfully from the previous supported release on each release-blocking platform. ## Review decisions diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index a0db068b13..4f340d3d6d 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -68,9 +68,9 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse 安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,使用内置 pnpm 应用精确依赖变更,执行完整健康检查,停止后端,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。`pending.json` 记录文件系统移动,使启动过程可以完成或反转中断的替换。 -打包种子是离线安装包,而不是可执行 dsh 目录。它包含发布身份、初始桌面项目 manifest、以 dsh 为根的第一方包闭包描述文件及不可变 tarball、lockfile、完整性清单和所需 store 子集。发布构建要求 Electron 包与根 dsh 包使用相同版本,从正式源码构建生成最终 npm tarball,选择可达的 dsh 与 vendored 包以及 Landlock 入口,并验证 dsh tarball 中的 `lib/desktop-host.js` 入口与 `config/desktop.cordis.patch.yml` overlay。该 overlay 是唯一为了 Desktop 而发布的 CLI 配置文件;示例配置仍留在 tarball 之外。这些 tarball 保持为由各包 `files` manifest 决定内容的正式 `pnpm pack` 结果;Desktop 不删除已发布的声明文件,也不建立第二套包内容策略。manifest 把每个选中的包列为本地直接依赖,关闭对等依赖自动安装,workspace 文件再把每个选中的第一方包 override 到对应本地 tarball。构建会拒绝任何通过 registry 版本解析这些包名的 lockfile。内置 pnpm 从 npm 拉取外部生产依赖,执行离线安装,检查两个 Desktop Host 文件,并在最终准备 store 前删除 `node_modules`。在复制 package set 前与离线安装后都要求两个文件,可防止 Host 入口本身能够加载、却无法组合所需 overlay 的发布进入应用签名阶段。 +打包种子是离线安装包,而不是可执行 dsh 目录。它包含发布身份、初始桌面项目 manifest、以 dsh 为根的第一方包闭包描述文件及不可变 tarball、lockfile、完整性清单和所需 store 子集。发布构建要求 Electron 包与根 dsh 包使用相同版本,从正式源码构建生成最终 npm tarball,选择可达的 dsh 与 vendored 包以及 Landlock 入口,并验证 dsh tarball 中的 `lib/desktop-host.js` 入口与 `config/desktop.cordis.patch.yml` overlay。该 overlay 是唯一为了 Desktop 而发布的 CLI 配置文件;示例配置仍留在 tarball 之外。这些 tarball 保持为由各包 `files` manifest 决定内容的正式 `pnpm pack` 结果;Desktop 不删除已发布的声明文件,也不建立第二套包内容策略。manifest 把每个选中的包列为本地直接依赖,关闭对等依赖自动安装,workspace 文件再把每个选中的第一方包 override 到对应本地 tarball。构建会拒绝任何通过 registry 版本解析这些包名的 lockfile。内置 pnpm 关闭全局 virtual store,在禁用生命周期脚本的情况下从 npm 物化外部生产依赖,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 执行一次干净的离线安装,并检查两个 Desktop Host 文件。生成清单前会删除第二次生成的 `node_modules` 和临时 pnpm 项目注册。在复制 package set 前与离线安装后都要求两个文件,可防止 Host 入口本身能够加载、却无法组合所需 overlay 的发布进入应用签名阶段。 -种子根据规范化 store 路径,把 pnpm 内容放入 16 个确定性的未压缩 tar 分片。Apple 公证会检查这些归档内的 Mach-O 代码,因此 macOS seed 在分片前会用发布 Developer ID、安全时间戳与 hardened runtime 签署每个内容寻址 Mach-O 对象。签名会改变字节:准备过程把每个对象写到新的 SHA-512 路径,并以事务方式重写 pnpm MessagePack SQLite 索引内全部基础文件和 side-effects 文件引用。第二次离线安装证明 pnpm 可以解析重写后的 store;准备过程随后完成分片、解包最终归档并再次验证签名。包路径和非原生字节保持不变;种子保留包内附带的架构变体,因为删除文件会创建 Desktop 专属的包文件集。种子完整性覆盖分片 manifest 和解包前的每个归档。启动时验证归档路径、条目类型、唯一性和数量,把所有分片解包到唯一且由 Desktop 拥有的 staging 目录,然后才把完整结果合并进 `.dsh/desktop/pnpm/store`。中断的合并可能留下有效的不可变缓存内容,但 profile 安装与激活仍必须通过 pnpm 完整性与完整健康检查。 +种子根据规范化 store 路径,把 pnpm 内容放入 16 个确定性的未压缩 tar 分片。Apple 公证会检查这些归档内的 Mach-O 代码,因此 macOS seed 在分片前会用发布 Developer ID、安全时间戳与 hardened runtime 签署每个内容寻址 Mach-O 对象。签名会改变字节:准备过程把每个对象写到新的 SHA-512 路径,并以事务方式重写 pnpm MessagePack SQLite 索引内全部基础文件和 side-effects 文件引用。第二次离线安装证明 pnpm 可以解析重写后的 store;准备过程随后完成分片、解包最终归档并再次验证签名。包路径和非原生字节保持不变;种子保留包内附带的架构变体,因为删除文件会创建 Desktop 专属的包文件集。种子完整性覆盖分片 manifest 和解包前的每个归档。启动时验证归档路径、条目类型、唯一性和数量,把所有分片解包到唯一且由 Desktop 拥有的 staging 目录,替换匹配的不可变 store 文件,并以事务方式把各 pnpm store 版本的 SQLite `package_index` 合并进 `.dsh/desktop/pnpm/store`。Seed 记录替换匹配的键,为 Desktop 插件下载的记录继续保留。中断的文件合并可能留下有效的不可变缓存内容,但每次 SQLite 合并都是原子的,profile 安装与激活仍必须通过 pnpm 完整性与完整健康检查。 启动过程先要求安装包内的发布身份等于 Electron 应用版本,再在启动后端前比较 `.dsh/profiles/desktop/desktop-release.json`、已安装 dsh 包与该发布版本。它在 staging 中通过 `pnpm install --offline --frozen-lockfile --trust-lockfile` 安装新的种子 manifest 与 lockfile。Electron 替换后,启动过程再通过一次离线 pnpm add,从桌面端现有 store 与元数据缓存恢复活跃 profile 记录的每个插件 bundle 精确版本。完整依赖图必须通过同一套健康检查才能激活。 @@ -92,6 +92,8 @@ generic 更新服务必须一起发布元数据、安装包和 blockmap。NSIS Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。Seed 准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 seed 准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 + 打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、seed 或活跃项目。 在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。 @@ -103,7 +105,7 @@ Electron 产物必须签名;macOS 产物必须公证。发布自动化必须 | 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 | | 已安装运行时 | `@deepseek-ai/dsh/desktop-host` 从活跃项目启动无端口桌面组合,并通过经过验证的分帧字节管道流式传输 API 与资源响应。 | | 包状态 | 发布种子和后续每次修改都通过内置 Node.js 与 pnpm 执行,并使用桌面端拥有的 store、config、cache、state 和 home 路径;核心包从发布 tarball 解析,插件从固定 npm registry 解析。 | -| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在解包最终归档后验证每个原生 seed 对象,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 签名、更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 | +| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在解包最终归档后验证每个原生 seed 对象,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 | `dev:desktop` 会构建当前 workspace,把已构建 CLI 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、seed 安装和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。 @@ -123,10 +125,14 @@ Electron 产物必须签名;macOS 产物必须公证。发布自动化必须 **从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 seed 空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。 +**把 Windows EV 私钥导出到 PFX 文件。** 外部提供的公开叶证书让 SignTool 构造签名,`/csp` 与 `/kc` 则定位硬件密钥。EV 私钥保持不可导出,并留在 Token 上。 + +**提交包含凭据的签名脚本或持久保存 Token Password。** 包含凭据的 CMD 文件、`.env` 或 Windows 用户/系统环境变量都会让 Token Password 以静态形式被读取。已提交的 CMD 只包含环境变量引用,打包步骤则把密码作为 runner 临时 secret 接收。 + ## 结果 - 没有系统 Node.js 或 pnpm 的干净离线机器把种子安装进 `.dsh/profiles/desktop`,并启动可工作的 dsh 会话。 -- 已签名应用记录固定少量的 seed store 分片,而不是记录每个 pnpm 缓存文件;macOS 分片内每个 Mach-O 对象都带有发布 Developer ID、安全时间戳与 hardened runtime,安装后的私有 store 仍保持普通 pnpm 布局。 +- 已签名应用记录固定少量的 seed store 分片,而不是记录每个 pnpm 缓存文件;macOS 分片内每个 Mach-O 对象都带有发布 Developer ID、安全时间戳与 hardened runtime,每个 Windows 产物都带有配置的硬件支持 EV 签名,安装后的私有 store 仍保持普通 pnpm 布局。 - `.dsh/profiles/desktop/node_modules` 包含并解析桌面 dsh 包和每个 GUI 安装的桌面插件。 - 每个桌面 pnpm 操作都使用内置可执行文件和 `.dsh/desktop/pnpm/store`;不读取用户 `PATH`、配置、store 或 profile `node_modules`。 - Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。 @@ -138,6 +144,7 @@ Electron 产物必须签名;macOS 产物必须公证。发布自动化必须 - 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。 - 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。 - Workspace 开发无需下载发布资源即可运行当前已构建代码,未封装安装器的应用验证仍保留生产安装路径。 +- Windows 发布打包要求已验证的 SignTool、EV Token、匹配的公开叶证书、Token Password 和明确的密钥容器,绝不会回退到未签名产物或可导出的密钥文件。 - 每个发布阻断平台上的签名已安装产物均能从上一个受支持版本成功更新。 ## 评审决策 diff --git a/.gitattributes b/.gitattributes index e0a432111d..ac01e65042 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,10 +1,10 @@ # The repo's canonical text form is LF, enforced at checkout too: no smudge # boundary between working tree and repo, so byte-level gates (verify-* # comparisons, blob hashing, coverage offsets) see one form on every host. -# If a file class ever genuinely needs CRLF in the working tree (.bat/.cmd -# for cmd.exe), add a `*.bat text eol=crlf` override AFTER this line — the -# in-repo form stays LF; CRLF becomes checkout-time presentation only. +# Windows command scripts require CRLF in the working tree for cmd.exe; the +# override after the default keeps the in-repo form normalized as text. * text=auto eol=lf +*.cmd text eol=crlf *.pdf binary diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 8b94302303..4c220991b2 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: d3c796a2b23caa789c3e511a8d8af046ca8b784a -README.zh.md: 130173b8656225141e40463c3de958b031d7283b +README.md: aa35c3fca95f3dd9d9409ca8b2446dbcdc58eab2 +README.zh.md: 27848b17fe1f8aa4b0afbec106a323eb354eb4d5 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d3c796a2b2..aa35c3fca9 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -27,12 +27,12 @@ The main dsh renderer receives only the desktop protocol marker. The separate pl ### Seed installation -The packaged seed is an installation kit, not a ready-to-run `node_modules` tree. Packaging creates the lockfile, fetches the production graph, and proves one complete offline installation with the matching Desktop Host entry. A macOS build then Developer ID signs every Mach-O object in pnpm's content-addressed store, updates every affected SHA-512 index record, and proves the rewritten store with another offline install before deleting `node_modules`. The signed seed retains the release identity, local first-party tarballs and their descriptor, project metadata, lockfile, integrity inventory, and pnpm store content required to repeat that installation on the user's machine. +The packaged seed is an installation kit, not a ready-to-run `node_modules` tree. Packaging creates the lockfile, materializes the production graph online with lifecycle scripts disabled, deletes `node_modules` and every temporary pnpm cache, config, and state directory, and proves one complete installation offline from the final store alone with both Desktop Host files. A macOS build then Developer ID signs every Mach-O object in pnpm's content-addressed store, updates every affected SHA-512 index record, and proves the rewritten store with another offline install before deleting `node_modules`. The signed seed retains the release identity, local first-party tarballs and their descriptor, project metadata, lockfile, integrity inventory, and pnpm store content required to repeat that installation on the user's machine. | Seed content | Writable destination or use | |---|---| | `integrity.json` and `desktop-packages.json` | Verify every inventoried seed file, local tarball hash, and bound dsh version before package state changes. | -| `store-archives.json` and `store-archives/*.tar` | Validate the deterministic uncompressed shards, extract them into a unique Desktop staging directory, and merge the result into `$DSH_HOME/desktop/pnpm/store` without removing packages already downloaded for Desktop plugins. | +| `store-archives.json` and `store-archives/*.tar` | Validate the deterministic uncompressed shards, extract them into a unique Desktop staging directory, replace matching immutable store files, and transactionally merge pnpm's versioned SQLite package index into `$DSH_HOME/desktop/pnpm/store` without removing packages already downloaded for Desktop plugins. | | Project metadata and `desktop-packages/` | Copy into a unique `$DSH_HOME/desktop/staging//profile` project. | | Lockfile and local package mappings | Drive the bundled pnpm installation without resolving a packaged core name from npm. | @@ -40,7 +40,7 @@ Startup installs or reconciles the seed as one serialized transaction: 1. Recover an interrupted activation journal, verify the complete seed inventory and local package set, and require the seed version to equal Electron's application version. 2. If the active profile already contains that release and dsh version, verify its local package set and reuse it without reinstalling. -3. Otherwise validate every archive entry, extract all store shards into a temporary Desktop-owned staging directory, merge that complete extraction into the private store, create a staging profile, and run `pnpm install --offline --frozen-lockfile --trust-lockfile` through the bundled Node.js and pnpm. +3. Otherwise validate every archive entry, extract all store shards into a temporary Desktop-owned staging directory, merge the package files and SQLite package-index records into the private store, create a staging profile, and run `pnpm install --offline --frozen-lockfile --trust-lockfile` through the bundled Node.js and pnpm. Seed records replace matching index keys while plugin-only records remain available. 4. During an Electron upgrade, read every plugin name and exact version from the old active profile and add those versions to staging with `--offline` from existing Desktop pnpm state. A first installation has no plugin-restore step. 5. Boot the complete staged backend as a health check. Installation or plugin incompatibility before activation deletes staging and leaves the active profile unchanged. 6. Journal the directory replacement, move the active profile to `$DSH_HOME/desktop/rollback/profile`, and move staging into `$DSH_HOME/profiles/desktop`. A failed replacement restores the old profile immediately; the next launch recovers an interrupted replacement from the journal. @@ -96,6 +96,22 @@ The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on In The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Seed preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. Electron-builder notarizes and staples the application before packaging and signs the DMG. The DMG artifact-completion hook then notarizes and staples it before requiring its exact identity, ticket, and Gatekeeper acceptance; only after the hook succeeds can electron-builder publish the file. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. +### Windows EV signing + +Windows release packaging requires `DSH_DESKTOP_WINDOWS_CER_FILE` to identify the public GlobalSign EV leaf certificate, `DSH_DESKTOP_WINDOWS_SIGNTOOL` to identify the SafeNet-compatible SignTool executable, `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` to identify the matching private-key container, and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` to contain the SafeNet Token Password. The certificate file remains outside source control, and the matching private key stays on the USB token. Set the four inputs before running the fixed Windows target: + +```powershell +$env:DSH_DESKTOP_WINDOWS_CER_FILE = 'C:\path\to\server.cer' +$env:DSH_DESKTOP_WINDOWS_SIGNTOOL = 'C:\path\to\the\validated\signtool.exe' +$env:DSH_DESKTOP_WINDOWS_KEY_CONTAINER = '' +$env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '' +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 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 seed-preparation subprocesses, gives 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. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable. + Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: ```sh @@ -111,7 +127,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 seed content. -Every package command performs the official repository build, packs the dsh and vendored package families, and packs the Landlock entry before preparing release resources. `prepare:packages` selects the first-party production closure rooted at `@deepseek-ai/dsh`, verifies that its tarball contains both `lib/desktop-host.js` and `config/desktop.cordis.patch.yml`, copies the selected tarballs into the seed input, and records their sizes and SHA-512 integrity. The overlay is the only CLI configuration file published specifically for Desktop; example configurations remain outside the tarball. These are the official `pnpm pack` outputs, so each package's `files` manifest controls its published contents: Desktop adds no second filter, retains published declarations such as `lib/types`, and neither strips nor adds source maps independently. Registry resolution, package paths, manifests, and non-native bytes remain npm-owned. For macOS, `prepare:seed` replaces each Mach-O CAS object with the company Developer ID signed bytes, writes them at their new SHA-512 paths, and transactionally rewrites every base or side-effects index reference; it preserves the package file set, including bundled architecture variants. The root dsh package and Electron package must have the same version, but dsh does not need to be published to npm before the Desktop application is built. `prepare:runtime` downloads Node.js 24.17.0 from the official Node.js release service, verifies its SHA-256 entry before extraction, and executes the prepared binary on a compatible build host to verify its reported version. It copies the pnpm version declared by the desktop package and records both runtime versions in the release seed. `prepare:seed` generates local core-package mappings, uses bundled pnpm to fetch external production dependencies from npm, proves the graph installs offline and contains both Desktop Host files, performs the macOS rewrite when applicable, proves the rewritten store with another offline installation, removes temporary pnpm project registrations, and replaces the loose store with 16 deterministic uncompressed tar shards. It extracts those final shards and verifies every embedded macOS signature before inventory generation. Later GUI plugin operations retain the local core mappings while resolving plugin packages and their external dependencies from the fixed Desktop npm registry. `electron-builder` emits platform artifacts under `apps/desktop/.desktop-build/artifacts`. +Every package command performs the official repository build, packs the dsh and vendored package families, and packs the Landlock entry before preparing release resources. `prepare:packages` selects the first-party production closure rooted at `@deepseek-ai/dsh`, verifies that its tarball contains both `lib/desktop-host.js` and `config/desktop.cordis.patch.yml`, copies the selected tarballs into the seed input, and records their sizes and SHA-512 integrity. The overlay is the only CLI configuration file published specifically for Desktop; example configurations remain outside the tarball. These are the official `pnpm pack` outputs, so each package's `files` manifest controls its published contents: Desktop adds no second filter, retains published declarations such as `lib/types`, and neither strips nor adds source maps independently. Registry packages likewise retain their published package bytes in pnpm's content-addressed store. The root dsh package and Electron package must have the same version, but dsh does not need to be published to npm before the Desktop application is built. `prepare:runtime` downloads Node.js 24.17.0 from the official Node.js release service, verifies its SHA-256 entry before extraction, and executes the prepared binary on a compatible build host to verify its reported version. It copies the pnpm version declared by the desktop package and records both runtime versions in the release seed. `prepare:seed` generates local core-package mappings, uses bundled pnpm with the global virtual store disabled to materialize external production dependencies from npm without lifecycle scripts, deletes `node_modules` and all temporary pnpm cache, config, and state, proves the complete graph installs offline with both Desktop Host files, performs the macOS rewrite when applicable, proves the rewritten store with another offline installation, removes temporary pnpm project registrations, and replaces the loose store with 16 deterministic uncompressed tar shards. It extracts those final shards and verifies every embedded macOS signature before inventory generation. Later GUI plugin operations retain the local core mappings while resolving plugin packages and their external dependencies from the fixed Desktop npm registry. `electron-builder` emits platform artifacts under `apps/desktop/.desktop-build/artifacts`. An unpacked artifact contains four independent size contributors: Electron, the offline seed store shards and local dsh tarballs, the upstream Node.js and pnpm runtime, and the small shell application. The shards are uncompressed so the outer DMG, ZIP, or NSIS compressor and differential updater can operate on stable ranges. Filesystem size is not installer download size, so measure both separately. First packaged startup also extracts the seed store into `$DSH_HOME/desktop/pnpm/store` before installing the writable profile, so release qualification must measure both application and Harness-home disk use. @@ -119,7 +135,7 @@ An unpacked artifact contains four independent size contributors: Electron, the A packaged application checks its configured release stream ten seconds after the main window opens; the **检查更新…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch reconciles the version-bound seed before reopening the product window. A build without updater configuration performs no network update request and reports that it is current. -Release builds set `DSH_DESKTOP_SHELL_UPDATE_URL` to the generic update server used by electron-updater. With this setting, electron-builder emits the channel metadata that must be published with the update blockmaps and installers; an unconfigured local build omits that metadata. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The seed and shell still form one signed Desktop release. Windows signing and macOS notarization credentials use electron-builder's standard environment; the required Desktop release environment selects the application and macOS signature identities that the build verifies. +Release builds set `DSH_DESKTOP_SHELL_UPDATE_URL` to the generic update server used by electron-updater. With this setting, electron-builder emits the channel metadata that must be published with the update blockmaps and installers; an unconfigured local build omits that metadata. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The seed and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies. ## Low-level development overrides diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 130173b865..27848b17fe 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -27,12 +27,12 @@ dsh 主渲染进程只获得桌面协议标记。独立插件窗口获得结构 ### Seed 安装 -安装包内的 seed 是安装工具包,不是可以直接运行的 `node_modules` 目录。打包过程会生成锁文件、拉取生产依赖图,并用匹配的 Desktop Host 入口完成一次完整离线安装验证。macOS 构建随后用 Developer ID 签署 pnpm 内容寻址 store 中的每个 Mach-O 对象,更新所有受影响的 SHA-512 索引记录,再用一次离线安装证明重写后的 store,最后删除 `node_modules`。签名 seed 保留发布身份、本地第一方 tarball 及其描述文件、项目元数据、锁文件、完整性清单,以及在用户机器上重复该安装所需的 pnpm store 内容。 +安装包内的 seed 是安装工具包,不是可以直接运行的 `node_modules` 目录。打包过程会生成锁文件,在禁用生命周期脚本的情况下在线物化生产依赖图,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 完成一次完整离线安装,并验证两个 Desktop Host 文件。macOS 构建随后用 Developer ID 签署 pnpm 内容寻址 store 中的每个 Mach-O 对象,更新所有受影响的 SHA-512 索引记录,再用一次离线安装证明重写后的 store,最后删除 `node_modules`。签名 seed 保留发布身份、本地第一方 tarball 及其描述文件、项目元数据、锁文件、完整性清单,以及在用户机器上重复该安装所需的 pnpm store 内容。 | Seed 内容 | 可写目标或用途 | |---|---| | `integrity.json` 与 `desktop-packages.json` | 在修改包状态前验证清单记录的每个 seed 文件、本地 tarball 哈希和绑定的 dsh 版本。 | -| `store-archives.json` 与 `store-archives/*.tar` | 验证确定性的未压缩分片,把它们解包到唯一的 Desktop staging 目录,再把完整结果合并到 `$DSH_HOME/desktop/pnpm/store`,且不移除已经为 Desktop 插件下载的包。 | +| `store-archives.json` 与 `store-archives/*.tar` | 验证确定性的未压缩分片,把它们解包到唯一的 Desktop staging 目录,替换匹配的不可变 store 文件,并以事务方式把 pnpm 的版本化 SQLite 包索引合并进 `$DSH_HOME/desktop/pnpm/store`,且不移除已经为 Desktop 插件下载的包。 | | 项目元数据与 `desktop-packages/` | 复制到唯一的 `$DSH_HOME/desktop/staging//profile` 项目。 | | 锁文件与本地包映射 | 驱动内置 pnpm 完成安装,且不会从 npm 解析已打包的核心包名。 | @@ -40,7 +40,7 @@ dsh 主渲染进程只获得桌面协议标记。独立插件窗口获得结构 1. 恢复中断的激活事务日志,验证完整 seed 清单与本地包集,并要求 seed 版本等于 Electron 应用版本。 2. 如果活跃 profile 已包含该发布与 dsh 版本,则验证其中的本地包集并直接复用,不重新安装。 -3. 否则验证每个归档条目,把全部 store 分片解包到 Desktop 拥有的临时 staging 目录,把完整解包结果合并进私有 store,再创建 staging profile,并通过内置 Node.js 与 pnpm 执行 `pnpm install --offline --frozen-lockfile --trust-lockfile`。 +3. 否则验证每个归档条目,把全部 store 分片解包到 Desktop 拥有的临时 staging 目录,将包文件与 SQLite 包索引记录合并进私有 store,再创建 staging profile,并通过内置 Node.js 与 pnpm 执行 `pnpm install --offline --frozen-lockfile --trust-lockfile`。Seed 记录替换匹配的索引键,插件专属记录继续保留。 4. Electron 升级时,从旧活跃 profile 读取每个插件的名称和精确版本,再通过现有 Desktop pnpm 状态以 `--offline` 把这些版本加入 staging。首次安装不执行插件恢复。 5. 启动完整的 staging 后端执行健康检查。在激活前发生安装错误或插件不兼容时,删除 staging 并保持活跃 profile 不变。 6. 记录目录替换事务,把活跃 profile 移到 `$DSH_HOME/desktop/rollback/profile`,再把 staging 移到 `$DSH_HOME/profiles/desktop`。替换失败时立即恢复旧 profile;替换中断时,下次启动会根据事务日志恢复。 @@ -96,6 +96,22 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。Seed 准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion hook 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有 hook 成功,electron-builder 才能发布该文件。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 +### Windows EV 签名 + +Windows 发布打包要求 `DSH_DESKTOP_WINDOWS_CER_FILE` 标识公开的 GlobalSign EV 叶证书,要求 `DSH_DESKTOP_WINDOWS_SIGNTOOL` 标识与 SafeNet 兼容的 SignTool 可执行文件,要求 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥容器,并要求 `DSH_DESKTOP_WINDOWS_TOKEN_PIN` 包含 SafeNet Token Password。证书文件保留在源码仓库之外,匹配的私钥仍位于 USB Token。运行固定 Windows 目标前设置这四个输入: + +```powershell +$env:DSH_DESKTOP_WINDOWS_CER_FILE = 'C:\path\to\server.cer' +$env:DSH_DESKTOP_WINDOWS_SIGNTOOL = 'C:\path\to\the\validated\signtool.exe' +$env:DSH_DESKTOP_WINDOWS_KEY_CONTAINER = '' +$env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '' +pnpm run package:desktop:win:x64 +``` + +打包前插入并解锁 Token。electron-builder hook 把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 打包会失败,不会生成未签名产物。 + +PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 seed 准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量。 + 使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如: ```sh @@ -111,7 +127,7 @@ pnpm run prepare:desktop 这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 seed 内容。 -每条打包命令都会先执行仓库的正式构建,打包 dsh 与 vendored 包族,并打包 Landlock 入口,然后再准备发布资源。`prepare:packages` 选择以 `@deepseek-ai/dsh` 为根的第一方生产依赖闭包,验证 dsh tarball 同时包含 `lib/desktop-host.js` 与 `config/desktop.cordis.patch.yml`,把选中的 tarball 复制到种子输入,并记录其大小与 SHA-512 完整性。该 overlay 是唯一为了 Desktop 而发布的 CLI 配置文件;示例配置仍留在 tarball 之外。这些 tarball 是正式的 `pnpm pack` 输出,因此各包的 `files` manifest 决定发布内容:Desktop 不增加第二套过滤规则,会保留 `lib/types` 等已发布声明,也不会独立删除或增加 source map。Registry 解析、包路径、manifest 和非原生字节仍由 npm 管理。在 macOS 上,`prepare:seed` 会用公司 Developer ID 签名字节替换每个 Mach-O CAS 对象,把它们写到新的 SHA-512 路径,并以事务方式重写所有基础或 side-effects 索引引用;它保留完整包文件集,包括包内附带的架构变体。根 dsh 包与 Electron 包必须使用同一版本,但构建 Desktop 应用前不再要求 dsh 已发布到 npm。`prepare:runtime` 从 Node.js 官方发行服务下载 Node.js 24.17.0,在解压前验证其 SHA-256 条目,并在兼容的构建宿主上执行准备完成的二进制文件以验证其报告版本。它复制桌面包声明的 pnpm 版本,并把两个运行时版本记录进发布种子。`prepare:seed` 生成本地核心包映射,使用内置 pnpm 从 npm 拉取外部生产依赖,证明依赖图可以离线安装且包含两个 Desktop Host 文件,在适用时执行 macOS 重写,再通过一次离线安装证明重写后的 store,删除临时 pnpm 项目注册,然后把松散 store 替换为 16 个确定性的未压缩 tar 分片。它会解包这些最终分片,并在生成清单前验证每个内嵌 macOS 签名。后续 GUI 插件操作保留本地核心包映射,同时从固定的 Desktop npm registry 解析插件包及其外部依赖。`electron-builder` 把平台产物写到 `apps/desktop/.desktop-build/artifacts`。 +每条打包命令都会先执行仓库的正式构建,打包 dsh 与 vendored 包族,并打包 Landlock 入口,然后再准备发布资源。`prepare:packages` 选择以 `@deepseek-ai/dsh` 为根的第一方生产依赖闭包,验证 dsh tarball 同时包含 `lib/desktop-host.js` 与 `config/desktop.cordis.patch.yml`,把选中的 tarball 复制到种子输入,并记录其大小与 SHA-512 完整性。该 overlay 是唯一为了 Desktop 而发布的 CLI 配置文件;示例配置仍留在 tarball 之外。这些 tarball 是正式的 `pnpm pack` 输出,因此各包的 `files` manifest 决定发布内容:Desktop 不增加第二套过滤规则,会保留 `lib/types` 等已发布声明,也不会独立删除或增加 source map。Registry 包同样在 pnpm 内容寻址 store 中保留其发布的包字节。根 dsh 包与 Electron 包必须使用同一版本,但构建 Desktop 应用前不再要求 dsh 已发布到 npm。`prepare:runtime` 从 Node.js 官方发行服务下载 Node.js 24.17.0,在解压前验证其 SHA-256 条目,并在兼容的构建宿主上执行准备完成的二进制文件以验证其报告版本。它复制桌面包声明的 pnpm 版本,并把两个运行时版本记录进发布种子。`prepare:seed` 生成本地核心包映射,使用禁用全局 virtual store 的内置 pnpm 从 npm 物化外部生产依赖并禁用生命周期脚本,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state,证明完整依赖图可以离线安装并包含两个 Desktop Host 文件,在适用时执行 macOS 重写,再通过一次离线安装证明重写后的 store,删除临时 pnpm 项目注册,然后把松散 store 替换为 16 个确定性的未压缩 tar 分片。它会解包这些最终分片,并在生成清单前验证每个内嵌 macOS 签名。后续 GUI 插件操作保留本地核心包映射,同时从固定的 Desktop npm registry 解析插件包及其外部依赖。`electron-builder` 把平台产物写到 `apps/desktop/.desktop-build/artifacts`。 未压缩产物包含四块相互独立的体积:Electron、离线 seed store 分片与本地 dsh tarball、上游 Node.js 与 pnpm 运行时,以及很小的桌面壳应用。分片不压缩,使外层 DMG、ZIP 或 NSIS 压缩器与差分更新器可以处理稳定的数据区间。文件系统占用不等于安装包下载大小,因此必须分别测量。打包应用首次启动时还会先把 seed store 解包到 `$DSH_HOME/desktop/pnpm/store`,再安装可写 profile,因此发布验证必须同时测量应用与 Harness home 的磁盘占用。 @@ -119,7 +135,7 @@ pnpm run prepare:desktop 打包应用会在主窗口打开十秒后检查已配置的发布流;**检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的 seed,再重新打开产品窗口。没有 updater 配置的构建不会发起网络更新请求,并会报告当前已是最新版本。 -发布构建通过 `DSH_DESKTOP_SHELL_UPDATE_URL` 配置 electron-updater 使用的 generic 更新服务。设置该变量后,electron-builder 会生成需要与更新 blockmap 和安装包一起发布的频道元数据;未配置的本地构建不会生成该元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。Seed 与桌面壳仍属于同一个签名 Desktop 发布。Windows 签名和 macOS 公证凭据使用 electron-builder 的标准环境变量;必填 Desktop 发布环境选择构建所验证的应用身份与 macOS 签名身份。 +发布构建通过 `DSH_DESKTOP_SHELL_UPDATE_URL` 配置 electron-updater 使用的 generic 更新服务。设置该变量后,electron-builder 会生成需要与更新 blockmap 和安装包一起发布的频道元数据;未配置的本地构建不会生成该元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。Seed 与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。 ## 底层开发覆盖项 diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index e0856e7271..379472659a 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -5,6 +5,10 @@ import { } from './scripts/desktop-release-environment.mjs' import { notarizeMacOSDiskImageArtifact } from './scripts/notarize-macos-disk-images.mjs' import { verifyMacOSSignatureAfterSign } from './scripts/verify-macos-signature.mjs' +import { + createWindowsTokenSigner, + installWindowsNsisBootstrapSigner, +} from './scripts/windows-sign.mjs' /** * Create electron-builder configuration from one release environment. @@ -16,8 +20,20 @@ export function createElectronBuilderConfig(env = process.env, hostPlatform = pr const appId = resolveDesktopAppId(env) const targetPlatform = env.DSH_DESKTOP_TARGET_PLATFORM const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin') + const packagesWindows = targetPlatform === 'win32' const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined if (packagesMacOS) resolveMacOSNotarizationEnvironment(env) + const windowsSigner = packagesWindows + ? 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, + }) + : undefined + if (windowsSigner !== undefined) { + installWindowsNsisBootstrapSigner({ sign: windowsSigner }) + } const publishUrl = env.DSH_DESKTOP_SHELL_UPDATE_URL return { appId, @@ -60,6 +76,11 @@ export function createElectronBuilderConfig(env = process.env, hostPlatform = pr ) }, win: { + forceCodeSigning: true, + signtoolOptions: { + sign: windowsSigner, + signingHashAlgorithms: ['sha256'], + }, target: ['nsis'], }, linux: { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5798dc4b64..2b0b344689 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -33,6 +33,7 @@ "@electron/notarize": "2.5.0", "@types/node": "^22.20.0", "@types/semver": "^7.8.0", + "app-builder-lib": "26.15.3", "electron": "^44.0.0", "electron-builder": "^26.15.3", "extract-zip": "^2.0.1", diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 9548f35449..8e1cb98f3f 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -10,6 +10,13 @@ const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') const DSH_PACK_ROOT = join(REPOSITORY_ROOT, 'dist', 'npm') const VENDOR_PACK_ROOT = join(REPOSITORY_ROOT, 'dist', 'npm-vendor') const LANDLOCK_PACK_ROOT = join(REPOSITORY_ROOT, 'dist', 'npm-landlock') +const WINDOWS_SIGNING_ENV_PREFIX = 'DSH_DESKTOP_WINDOWS_' +const WINDOWS_SIGNING_ENV_NAMES = [ + 'DSH_DESKTOP_WINDOWS_CER_FILE', + 'DSH_DESKTOP_WINDOWS_KEY_CONTAINER', + 'DSH_DESKTOP_WINDOWS_SIGNTOOL', + 'DSH_DESKTOP_WINDOWS_TOKEN_PIN', +] as const /** Fixed platform and architecture identifiers exposed by package scripts. */ export type DesktopPackageTargetName = 'mac-arm64' | 'mac-x64' | 'win-x64' @@ -47,6 +54,16 @@ const TARGETS: Record = { }, } +/** + * Remove Windows signing configuration from package preparation subprocesses. + * @param environment - Packaging command environment. + * @returns A copy without Windows signing fields. + */ +export function withoutWindowsSigningEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(environment) + .filter(([name]) => !name.startsWith(WINDOWS_SIGNING_ENV_PREFIX))) +} + function isTargetName(value: string): value is DesktopPackageTargetName { return Object.hasOwn(TARGETS, value) } @@ -149,24 +166,29 @@ function runPnpm( async function main(): Promise { const invocation = parseDesktopPackageInvocation(process.argv.slice(2)) const { target } = invocation + const buildEnv = withoutWindowsSigningEnvironment(process.env) const targetEnv: NodeJS.ProcessEnv = { - ...process.env, + ...buildEnv, DSH_DESKTOP_TARGET_PLATFORM: target.platform, DSH_DESKTOP_TARGET_ARCH: target.arch, } - await runPnpm(['run', 'build:official'], process.env, REPOSITORY_ROOT) - await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', DSH_PACK_ROOT], process.env, REPOSITORY_ROOT) - await runPnpm(['run', 'release:pack', '--family', 'vendor', '--out', VENDOR_PACK_ROOT], process.env, REPOSITORY_ROOT) + const electronBuilderEnv = { ...targetEnv } + for (const name of WINDOWS_SIGNING_ENV_NAMES) { + if (process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name] + } + await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT) + await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', DSH_PACK_ROOT], buildEnv, REPOSITORY_ROOT) + await runPnpm(['run', 'release:pack', '--family', 'vendor', '--out', VENDOR_PACK_ROOT], buildEnv, REPOSITORY_ROOT) rmSync(LANDLOCK_PACK_ROOT, { recursive: true, force: true }) mkdirSync(LANDLOCK_PACK_ROOT, { recursive: true }) - await runPnpm(['--dir', 'native/landlock-run', 'run', 'build:ts'], process.env, REPOSITORY_ROOT) + await runPnpm(['--dir', 'native/landlock-run', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT) await runPnpm([ '--dir', 'native/landlock-run/packages/entry', 'pack', '--pack-destination', LANDLOCK_PACK_ROOT, - ], process.env, REPOSITORY_ROOT) + ], buildEnv, REPOSITORY_ROOT) await runPnpm(['run', 'prepare:runtime'], targetEnv) await runPnpm(['run', 'prepare:packages'], targetEnv) await runPnpm(['run', 'prepare:seed'], targetEnv) @@ -179,7 +201,7 @@ async function main(): Promise { target.builderPlatform, target.builderArch, ...(invocation.directory ? ['--dir'] : []), - ], targetEnv) + ], electronBuilderEnv) } if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main() diff --git a/apps/desktop/scripts/prepare-seed.ts b/apps/desktop/scripts/prepare-seed.ts index 370d69ad3d..514aa50109 100644 --- a/apps/desktop/scripts/prepare-seed.ts +++ b/apps/desktop/scripts/prepare-seed.ts @@ -74,6 +74,7 @@ function runPnpm(args: readonly string[]): Promise { PNPM, '--config.registry=https://registry.npmjs.org/', `--config.store-dir=${STORE_ROOT}`, + '--config.enable-global-virtual-store=false', `--config.userconfig=${userConfig}`, command, ...commandArgs, @@ -151,7 +152,10 @@ async function main(): Promise { readFileSync(join(SEED_ROOT, 'pnpm-lock.yaml'), 'utf8'), readDesktopCorePackageSet(SEED_ROOT, release.version), ) - await runPnpm(['fetch', '--prod', '--frozen-lockfile']) + const installedModules = join(SEED_ROOT, 'node_modules') + await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile', '--ignore-scripts']) + rmSync(installedModules, { recursive: true, force: true }) + rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) await verifyOfflineInstallation(release) const targetPlatform = process.env.DSH_DESKTOP_TARGET_PLATFORM ?? process.platform let signedMachOFiles: number | undefined diff --git a/apps/desktop/scripts/windows-sign.cmd b/apps/desktop/scripts/windows-sign.cmd new file mode 100644 index 0000000000..366f787f57 --- /dev/null +++ b/apps/desktop/scripts/windows-sign.cmd @@ -0,0 +1,17 @@ +@echo off +setlocal DisableDelayedExpansion +set "signTool=%DSH_DESKTOP_WINDOWS_SIGNTOOL%" +set "certificateFile=%DSH_DESKTOP_WINDOWS_CER_FILE%" +set "tokenPin=%DSH_DESKTOP_WINDOWS_TOKEN_PIN%" +set "keyContainer=%DSH_DESKTOP_WINDOWS_KEY_CONTAINER%" +set "targetFile=%DSH_DESKTOP_WINDOWS_SIGN_TARGET%" +set "appendSignature=" +if "%DSH_DESKTOP_WINDOWS_SIGN_APPEND%"=="1" set "appendSignature=/as" +set "DSH_DESKTOP_WINDOWS_SIGNTOOL=" +set "DSH_DESKTOP_WINDOWS_CER_FILE=" +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%" +exit /b %errorlevel% diff --git a/apps/desktop/scripts/windows-sign.d.mts b/apps/desktop/scripts/windows-sign.d.mts new file mode 100644 index 0000000000..4532f32099 --- /dev/null +++ b/apps/desktop/scripts/windows-sign.d.mts @@ -0,0 +1,91 @@ +/** + * Build the minimal CMD environment for one Electron artifact. + * + * @param environment Parent environment. + * @param input Validated signing identity and task. + * @returns Scrubbed environment plus the fields consumed and cleared by the signing CMD. + */ +export function buildWindowsSigningEnvironment(environment: NodeJS.ProcessEnv, input: { + certificateFile: string + signTool: string + path: string + isNest: boolean + tokenPin: string + keyContainer: string +}): NodeJS.ProcessEnv + +/** + * Create the electron-builder hook for a hardware-backed Windows code-signing certificate. + * + * @param options Release signing configuration. + * @returns The signing hook. + */ +export function createWindowsTokenSigner(options: { + certificateFile?: string | undefined + signTool?: string | undefined + tokenPin?: string | undefined + keyContainer?: string | undefined + commandInterpreter?: string | undefined +}): ( + configuration: { + path: string + hash: string + isNest: boolean + }, +) => Promise + +/** + * Remove inherited credentials before starting a signing-related subprocess. + * + * @param environment Parent environment. + * @returns Environment without credential-shaped names. + */ +export function scrubWindowsSigningEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv + +/** + * Replace a SignTool failure with a diagnostic that cannot retain its command line. + * + * @param error SignTool process failure. + * @param path Artifact that failed signing. + * @param secrets Values that must not appear in the diagnostic. + * @returns Sanitized signing failure without the original error as its cause. + */ +export function createRedactedWindowsSigningError( + error: unknown, + path: string, + secrets: readonly string[], +): Error + +/** + * Clear a certificate-table entry that points beyond the end of a generated executable. + * + * @param path Executable to inspect. + * @returns Whether an invalid certificate-table entry was cleared. + */ +export function repairDanglingAuthenticodeDirectory(path: string): Promise + +/** + * Sign electron-builder's temporary NSIS executable before enterprise code integrity evaluates it. + * + * @param options Signing hook and injectable host values. + * @returns Nothing. + */ +export function installWindowsNsisBootstrapSigner(options: { + sign: (configuration: { + path: string + hash: string + isNest: boolean + }) => Promise + wineVmManager?: { + prototype: { + exec: ( + file: string, + args: string[], + options?: { env?: NodeJS.ProcessEnv }, + isLogOutIfDebug?: boolean, + ) => unknown + } + } + platform?: NodeJS.Platform + environment?: NodeJS.ProcessEnv +}): void diff --git a/apps/desktop/scripts/windows-sign.mjs b/apps/desktop/scripts/windows-sign.mjs new file mode 100644 index 0000000000..ac38a35601 --- /dev/null +++ b/apps/desktop/scripts/windows-sign.mjs @@ -0,0 +1,266 @@ +import { execFile } from 'node:child_process' +import { X509Certificate } from 'node:crypto' +import { readFileSync, realpathSync, statSync } from 'node:fs' +import { open } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import wineVmModule from 'app-builder-lib/out/vm/WineVm.js' + +const execFileAsync = promisify(execFile) +const { WineVmManager } = wineVmModule +const CODE_SIGNING_EKU = '1.3.6.1.5.5.7.3.3' +const NSIS_RUN_AS_INVOKER = 'RunAsInvoker' +const NSIS_BOOTSTRAP_PATCH = Symbol.for('@deepseek-ai/dsh-desktop/nsis-bootstrap-signing') +const WINDOWS_SIGN_SCRIPT = 'windows-sign.cmd' +const WINDOWS_SIGN_SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)) +const PE_HEADER_READ_SIZE = 4096 +const PE32_MAGIC = 0x10B +const PE32_PLUS_MAGIC = 0x20B +const SENSITIVE_ENVIRONMENT_NAME = /(?:KEY|SECRET|TOKEN|PASSWORD)/iu +const WINDOWS_SIGNING_ENVIRONMENT_PREFIX = 'DSH_DESKTOP_WINDOWS_' + +/** + * Remove inherited credentials before starting a signing-related subprocess. + * + * @param {NodeJS.ProcessEnv} environment Parent environment. + * @returns {NodeJS.ProcessEnv} Environment without credential-shaped names. + */ +export function scrubWindowsSigningEnvironment(environment) { + return Object.fromEntries(Object.entries(environment) + .filter(([name]) => !SENSITIVE_ENVIRONMENT_NAME.test(name) + && !name.startsWith(WINDOWS_SIGNING_ENVIRONMENT_PREFIX))) +} + +function resolveTokenIdentity(input) { + const keyContainer = input.keyContainer?.trim() + if (!keyContainer) { + throw new Error('DSH_DESKTOP_WINDOWS_KEY_CONTAINER must contain the SafeNet private-key container name') + } + if (/["\r\n]/u.test(keyContainer)) { + throw new Error('DSH_DESKTOP_WINDOWS_KEY_CONTAINER cannot contain quotes or line breaks') + } + const tokenPin = input.tokenPin + if (tokenPin === undefined || tokenPin.length === 0) { + throw new Error('DSH_DESKTOP_WINDOWS_TOKEN_PIN must contain the SafeNet Token Password') + } + if (/[\]"\r\n]/u.test(tokenPin)) { + throw new Error('DSH_DESKTOP_WINDOWS_TOKEN_PIN cannot contain "]", quotes, or line breaks because the SafeNet key-container syntax uses them as delimiters') + } + return { keyContainer, tokenPin } +} + +function resolveCertificateFile(value) { + const candidate = value?.trim() + if (!candidate) { + throw new Error('DSH_DESKTOP_WINDOWS_CER_FILE must identify the public X.509 leaf certificate file') + } + let path + let certificate + try { + path = realpathSync(candidate) + certificate = new X509Certificate(readFileSync(path)) + } + catch { + throw new Error(`Windows code-signing certificate file is missing or invalid: ${candidate}`) + } + if (certificate.ca || !certificate.keyUsage?.includes(CODE_SIGNING_EKU)) { + throw new Error(`Windows code-signing certificate file must contain a non-CA Code Signing certificate: ${path}`) + } + return path +} + +function resolveSignTool(value) { + const candidate = value?.trim() + if (!candidate) { + throw new Error('DSH_DESKTOP_WINDOWS_SIGNTOOL must identify the SafeNet-compatible SignTool executable') + } + let path + try { + path = realpathSync(candidate) + if (!statSync(path).isFile() || !path.toLowerCase().endsWith('.exe')) throw new Error('not an executable file') + } + catch { + throw new Error(`DSH_DESKTOP_WINDOWS_SIGNTOOL is missing or is not an executable file: ${candidate}`) + } + return path +} + +function redactedSigningOutput(value, secrets) { + let output = Buffer.isBuffer(value) ? value.toString('utf8') : typeof value === 'string' ? value : '' + for (const secret of secrets) { + if (secret !== '') output = output.replaceAll(secret, '') + } + return output +} + +/** + * Replace a SignTool failure with a diagnostic that cannot retain its command line. + * + * @param {unknown} error SignTool process failure. + * @param {string} path Artifact that failed signing. + * @param {readonly string[]} secrets Values that must not appear in the diagnostic. + * @returns {Error} Sanitized signing failure without the original error as its cause. + */ +export function createRedactedWindowsSigningError(error, path, secrets) { + const record = error !== null && typeof error === 'object' ? error : undefined + const code = record !== undefined && 'code' in record + && (typeof record.code === 'number' || typeof record.code === 'string') + ? ` (exit ${String(record.code)})` + : '' + const stderr = record !== undefined && 'stderr' in record + ? redactedSigningOutput(record.stderr, secrets).trim() + : '' + return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}`) +} + +/** + * Build the minimal CMD environment for one Electron artifact. + * + * @param {NodeJS.ProcessEnv} environment Parent environment. + * @param {{ certificateFile: string, signTool: string, path: string, isNest: boolean, tokenPin: string, keyContainer: string }} input Validated signing identity and task. + * @returns {NodeJS.ProcessEnv} Scrubbed environment plus fields consumed and cleared by the signing CMD. + */ +export function buildWindowsSigningEnvironment(environment, input) { + return { + ...scrubWindowsSigningEnvironment(environment), + DSH_DESKTOP_WINDOWS_SIGNTOOL: input.signTool, + DSH_DESKTOP_WINDOWS_CER_FILE: input.certificateFile, + DSH_DESKTOP_WINDOWS_TOKEN_PIN: input.tokenPin, + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: input.keyContainer, + DSH_DESKTOP_WINDOWS_SIGN_TARGET: input.path, + DSH_DESKTOP_WINDOWS_SIGN_APPEND: input.isNest ? '1' : '', + } +} + +/** + * Create the electron-builder hook for a SafeNet-backed Windows code-signing certificate. + * + * @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string }} options Release signing configuration. + * @returns {(configuration: { path: string, hash: string, isNest: boolean }) => Promise} The signing hook. + */ +export function createWindowsTokenSigner(options) { + const certificateFile = resolveCertificateFile(options.certificateFile) + const signTool = resolveSignTool(options.signTool) + const { keyContainer, tokenPin } = resolveTokenIdentity(options) + const commandInterpreter = options.commandInterpreter + ?? process.env.ComSpec + ?? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe') + return async (configuration) => { + if (configuration.hash !== 'sha256') { + throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`) + } + await repairDanglingAuthenticodeDirectory(configuration.path) + const secrets = [tokenPin] + let result + try { + result = await 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: false, + }) + } + catch (error) { + throw createRedactedWindowsSigningError(error, configuration.path, secrets) + } + const stdout = redactedSigningOutput(result.stdout, secrets) + const stderr = redactedSigningOutput(result.stderr, secrets) + if (stdout !== '') process.stdout.write(stdout) + if (stderr !== '') process.stderr.write(stderr) + } +} + +/** + * Clear a certificate-table entry that points beyond the end of a generated executable. + * + * @param {string} path Executable to inspect. + * @returns {Promise} Whether an invalid certificate-table entry was cleared. + */ +export async function repairDanglingAuthenticodeDirectory(path) { + const file = await open(path, 'r+') + try { + const { size } = await file.stat() + const header = Buffer.alloc(Math.min(PE_HEADER_READ_SIZE, size)) + await file.read(header, 0, header.length, 0) + const directoryOffset = findDanglingAuthenticodeDirectory(header, size) + if (directoryOffset === undefined) return false + await file.write(Buffer.alloc(8), 0, 8, directoryOffset) + return true + } + finally { + await file.close() + } +} + +/** + * Locate an Authenticode certificate-table entry whose declared bytes are outside the file. + * + * @param {Buffer} header Initial executable bytes. + * @param {number} fileSize Complete file size. + * @returns {number | undefined} File offset of the invalid data-directory entry. + */ +function findDanglingAuthenticodeDirectory(header, fileSize) { + if (header.length < 64 || header.toString('ascii', 0, 2) !== 'MZ') return undefined + const peOffset = header.readUInt32LE(60) + const optionalHeaderOffset = peOffset + 24 + if (optionalHeaderOffset + 2 > header.length + || header.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') return undefined + const magic = header.readUInt16LE(optionalHeaderOffset) + const dataDirectoryOffset = magic === PE32_MAGIC + ? optionalHeaderOffset + 96 + : magic === PE32_PLUS_MAGIC + ? optionalHeaderOffset + 112 + : undefined + if (dataDirectoryOffset === undefined) return undefined + const certificateDirectoryOffset = dataDirectoryOffset + (4 * 8) + if (certificateDirectoryOffset + 8 > header.length) return undefined + const certificateOffset = header.readUInt32LE(certificateDirectoryOffset) + const certificateSize = header.readUInt32LE(certificateDirectoryOffset + 4) + if (certificateOffset === 0 && certificateSize === 0) return undefined + return certificateOffset > 0 + && certificateSize > 0 + && certificateOffset + certificateSize <= fileSize + ? undefined + : certificateDirectoryOffset +} + +/** + * Sign electron-builder's temporary NSIS executable before enterprise code integrity evaluates it. + * + * @param {{ sign: (configuration: { path: string, hash: string, isNest: boolean }) => Promise, wineVmManager?: typeof WineVmManager, platform?: NodeJS.Platform, environment?: NodeJS.ProcessEnv }} options Signing hook and injectable host values. + * @returns {void} + */ +export function installWindowsNsisBootstrapSigner(options) { + if ((options.platform ?? process.platform) !== 'win32') return + const prototype = (options.wineVmManager ?? WineVmManager).prototype + if (prototype[NSIS_BOOTSTRAP_PATCH] === true) return + const originalExec = prototype.exec + prototype.exec = async function (file, args, execOptions, isLogOutIfDebug) { + const isNsisBootstrap = file.toLowerCase().endsWith('.exe') + && execOptions?.env?.__COMPAT_LAYER === NSIS_RUN_AS_INVOKER + if (!isNsisBootstrap) { + return originalExec.call(this, file, args, execOptions, isLogOutIfDebug) + } + await options.sign({ path: file, hash: 'sha256', isNest: false }) + return originalExec.call(this, file, args, { + ...execOptions, + env: scrubWindowsSigningEnvironment({ + ...(options.environment ?? process.env), + ...execOptions.env, + }), + }, isLogOutIfDebug) + } + Object.defineProperty(prototype, NSIS_BOOTSTRAP_PATCH, { value: true }) +} diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index 83901f3e36..757ae16618 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -29,7 +29,7 @@ import { } from './core-package-set.ts' import type { DesktopPaths } from './paths.ts' import { parseDesktopRelease, type DesktopRelease } from './release.ts' -import { extractPnpmStoreArchives } from './seed-store.ts' +import { extractPnpmStoreArchives, mergePnpmStore } from './seed-store.ts' /** Files the package transaction copies between active and staging projects. */ const DESKTOP_PROJECT_FILES = [ @@ -510,8 +510,7 @@ export class DesktopProjectManager { const extractedStore = join(transactionRoot, 'store') try { extractPnpmStoreArchives(seedDir, extractedStore) - mkdirSync(this.paths.pnpm.store, { recursive: true, mode: 0o700 }) - cpSync(extractedStore, this.paths.pnpm.store, { recursive: true, force: false }) + mergePnpmStore(extractedStore, this.paths.pnpm.store) } finally { removeOwnedDirectory(transactionRoot) } @@ -566,6 +565,7 @@ export class DesktopProjectManager { this.runtime.pnpm, `--config.registry=${DESKTOP_REGISTRY}`, `--config.store-dir=${this.paths.pnpm.store}`, + '--config.enable-global-virtual-store=false', `--config.userconfig=${npmrc}`, command, ...commandArgs, diff --git a/apps/desktop/src/seed-store.ts b/apps/desktop/src/seed-store.ts index dce4156051..e4b4759fd1 100644 --- a/apps/desktop/src/seed-store.ts +++ b/apps/desktop/src/seed-store.ts @@ -3,6 +3,8 @@ import { createHash } from 'node:crypto' import { chmodSync, + copyFileSync, + cpSync, existsSync, mkdirSync, readdirSync, @@ -11,6 +13,7 @@ import { writeFileSync, } from 'node:fs' import { join, relative, sep } from 'node:path' +import { DatabaseSync } from 'node:sqlite' import { create, extract, list } from 'tar' /** Directory containing the seed's uncompressed pnpm store archives. */ @@ -21,6 +24,7 @@ export const SEED_STORE_ARCHIVE_MANIFEST = 'store-archives.json' const DEFAULT_SHARD_COUNT = 16 const ARCHIVE_NAME_PATTERN = /^store-[0-9a-f]{2}\.tar$/u +const STORE_VERSION_PATTERN = /^v\d+$/u interface SeedStoreArchiveRecord { readonly file: string @@ -113,6 +117,54 @@ export function removePnpmProjectRegistrations(storeRoot: string): void { } } +function mergeStoreIndex(source: string, destination: string): void { + if (!existsSync(destination)) { + copyFileSync(source, destination) + return + } + const database = new DatabaseSync(destination) + let attached = false + try { + database.exec('PRAGMA busy_timeout=5000') + database.prepare('ATTACH DATABASE ? AS seed').run(source) + attached = true + database.exec('BEGIN IMMEDIATE') + let committed = false + try { + database.exec('INSERT OR REPLACE INTO package_index (key, data) SELECT key, data FROM seed.package_index') + database.exec('COMMIT') + committed = true + } finally { + if (!committed) database.exec('ROLLBACK') + } + } finally { + if (attached) database.exec('DETACH DATABASE seed') + database.close() + } +} + +/** + * Merge a completely extracted seed store into Desktop's persistent pnpm store. + * @param source - Verified temporary store extraction. + * @param destination - Desktop-owned persistent pnpm store. + */ +export function mergePnpmStore(source: string, destination: string): void { + mkdirSync(destination, { recursive: true, mode: 0o700 }) + const indexPaths = readdirSync(source, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && STORE_VERSION_PATTERN.test(entry.name) + && existsSync(join(source, entry.name, 'index.db'))) + .map(entry => `${entry.name}/index.db`) + const indexes = new Set(indexPaths) + cpSync(source, destination, { + recursive: true, + force: true, + filter: path => !indexes.has(relative(source, path).split(sep).join('/')), + }) + for (const path of indexPaths) { + mergeStoreIndex(join(source, ...path.split('/')), join(destination, ...path.split('/'))) + } +} + /** * Replace a prepared loose pnpm store with deterministic uncompressed archive shards. * @param seedRoot - seed directory that owns the archive output. diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index dcaa67b86c..2aa1ae0127 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -47,15 +47,12 @@ describe('desktop macOS release signature', () => { expect(typeof config.artifactBuildCompleted).toBe('function') }) - it('does not require macOS identifiers for a Windows target', async () => { + it('validates Windows signing without requiring macOS identifiers for a Windows target', async () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') - expect(createElectronBuilderConfig({ + expect(() => createElectronBuilderConfig({ DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, DSH_DESKTOP_TARGET_PLATFORM: 'win32', - }, 'win32').mac).toMatchObject({ - identity: undefined, - forceCodeSigning: true, - }) + }, 'win32')).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u) }) it('accepts the configured authority and team', () => { diff --git a/apps/desktop/tests/package-target.spec.ts b/apps/desktop/tests/package-target.spec.ts index 6080a4eb8f..04394deaf8 100644 --- a/apps/desktop/tests/package-target.spec.ts +++ b/apps/desktop/tests/package-target.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { parseDesktopPackageInvocation, resolveDesktopPackageTarget, + withoutWindowsSigningEnvironment, } from '../scripts/package-target.ts' describe('desktop package target', () => { @@ -37,4 +38,14 @@ describe('desktop package target', () => { expect(() => parseDesktopPackageInvocation(['mac-arm64', 'mac-x64'], 'darwin', 'arm64')) .toThrow(/at most one target/u) }) + + it('keeps Windows signing fields out of build and seed preparation subprocesses', () => { + expect(withoutWindowsSigningEnvironment({ + DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'container', + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe', + DSH_DESKTOP_SHELL_UPDATE_URL: 'https://updates.example.test', + })).toEqual({ DSH_DESKTOP_SHELL_UPDATE_URL: 'https://updates.example.test' }) + }) }) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index efe1947e62..f6c7ede8fc 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -200,6 +200,7 @@ describe('desktop project transactions', () => { expect(invocation.args).toContain('--offline') expect(invocation.args).toContain('--trust-lockfile') expect(invocation.args).toContain(`--config.store-dir=${paths.pnpm.store}`) + expect(invocation.args).toContain('--config.enable-global-virtual-store=false') expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/') expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/') expect(invocation.env.NPM_CONFIG_STORE_DIR).toBe(paths.pnpm.store) diff --git a/apps/desktop/tests/seed-store.spec.ts b/apps/desktop/tests/seed-store.spec.ts index e7ec63ec47..b2f5fc614a 100644 --- a/apps/desktop/tests/seed-store.spec.ts +++ b/apps/desktop/tests/seed-store.spec.ts @@ -12,10 +12,12 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' import { afterEach, describe, expect, it } from 'vitest' import { archivePnpmStore, extractPnpmStoreArchives, + mergePnpmStore, removePnpmProjectRegistrations, SEED_STORE_ARCHIVE_DIR, SEED_STORE_ARCHIVE_MANIFEST, @@ -60,6 +62,47 @@ describe('desktop seed store cleanup', () => { }) }) +describe('desktop seed store merge', () => { + it('preserves installed package records while the verified seed replaces matching records and files', () => { + const root = temporaryRoot() + const source = join(root, 'source') + const destination = join(root, 'destination') + for (const store of [source, destination]) { + mkdirSync(join(store, 'v11', 'files'), { recursive: true }) + const database = new DatabaseSync(join(store, 'v11', 'index.db')) + database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') + const insert = database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') + if (store === source) { + insert.run('seed-only', Buffer.from('seed')) + insert.run('shared', Buffer.from('new')) + } else { + insert.run('plugin-only', Buffer.from('plugin')) + insert.run('shared', Buffer.from('old')) + } + database.close() + } + writeFileSync(join(source, 'v11', 'files', 'shared'), 'new') + writeFileSync(join(destination, 'v11', 'files', 'shared'), 'old') + writeFileSync(join(destination, 'v11', 'files', 'plugin'), 'plugin') + + mergePnpmStore(source, destination) + + const database = new DatabaseSync(join(destination, 'v11', 'index.db'), { readOnly: true }) + const records = database.prepare('SELECT key, data FROM package_index ORDER BY key').all() as { + key: string + data: Uint8Array + }[] + database.close() + expect(records.map(record => [record.key, Buffer.from(record.data).toString()])).toEqual([ + ['plugin-only', 'plugin'], + ['seed-only', 'seed'], + ['shared', 'new'], + ]) + expect(readFileSync(join(destination, 'v11', 'files', 'shared'), 'utf8')).toBe('new') + expect(readFileSync(join(destination, 'v11', 'files', 'plugin'), 'utf8')).toBe('plugin') + }) +}) + describe('desktop seed store archives', () => { it('extracts package bytes and executable modes without retaining loose seed files', () => { const root = temporaryRoot() @@ -77,7 +120,9 @@ describe('desktop seed store archives', () => { expect(existsSync(store)).toBe(false) expect(readFileSync(join(destination, 'v10', 'files', 'package-data'), 'utf8')).toBe('package') - expect(statSync(join(destination, 'v10', 'files', 'native-addon')).mode & 0o111).toBe(0o111) + if (process.platform !== 'win32') { + expect(statSync(join(destination, 'v10', 'files', 'native-addon')).mode & 0o111).toBe(0o111) + } }) it('produces identical shards for identical paths, bytes, and modes', () => { diff --git a/apps/desktop/tests/windows-sign.spec.ts b/apps/desktop/tests/windows-sign.spec.ts new file mode 100644 index 0000000000..9005d24f6f --- /dev/null +++ b/apps/desktop/tests/windows-sign.spec.ts @@ -0,0 +1,226 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + buildWindowsSigningEnvironment, + createRedactedWindowsSigningError, + createWindowsTokenSigner, + installWindowsNsisBootstrapSigner, + repairDanglingAuthenticodeDirectory, + scrubWindowsSigningEnvironment, +} from '../scripts/windows-sign.mjs' + +vi.mock('node:crypto', () => ({ + X509Certificate: class { + readonly ca = false + readonly keyUsage = ['1.3.6.1.5.5.7.3.3'] + + constructor(contents: Buffer) { + if (contents.toString('utf8') !== 'code-signing-certificate-fixture') { + throw new Error('invalid test certificate') + } + } + }, +})) + +const CERTIFICATE_FILE = 'C:\\release\\server.cer' +const SIGN_SCRIPT = resolve(import.meta.dirname, '../scripts/windows-sign.cmd') + +describe('Windows token signing', () => { + it('passes only the validated BAT fields to the signing command interpreter', () => { + expect(buildWindowsSigningEnvironment({ + SystemRoot: 'C:\\Windows', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'inherited-token-secret', + DEEPSEEK_API_KEY: 'api-secret', + BUILD_PASSWORD: 'build-secret', + }, { + certificateFile: CERTIFICATE_FILE, + signTool: 'C:\\tools\\signtool.exe', + path: 'C:\\release\\DeepSeek Harness.exe', + isNest: false, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + })).toEqual({ + SystemRoot: 'C:\\Windows', + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe', + DSH_DESKTOP_WINDOWS_CER_FILE: CERTIFICATE_FILE, + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret!', + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'te-container', + DSH_DESKTOP_WINDOWS_SIGN_TARGET: 'C:\\release\\DeepSeek Harness.exe', + DSH_DESKTOP_WINDOWS_SIGN_APPEND: '', + }) + }) + + it('requests an appended signature only for an electron-builder nested task', () => { + expect(buildWindowsSigningEnvironment({}, { + certificateFile: CERTIFICATE_FILE, + signTool: 'C:\\tools\\signtool.exe', + path: 'C:\\release\\setup.exe', + isNest: true, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + }).DSH_DESKTOP_WINDOWS_SIGN_APPEND).toBe('1') + }) + + it('keeps the verified SafeNet command in an ASCII CRLF CMD file', async () => { + const contents = await readFile(SIGN_SCRIPT) + const text = contents.toString('ascii') + expect(contents.every(byte => byte <= 0x7F)).toBe(true) + expect(text).toContain('\r\n') + expect(text.replaceAll('\r\n', '')).not.toContain('\n') + 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') + }) + + it('rejects incomplete signing identities and non-SHA-256 signing tasks', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-sign-tool-')) + const certificateFile = join(directory, 'server.cer') + const signTool = join(directory, 'signtool.exe') + await writeFile(certificateFile, 'code-signing-certificate-fixture') + await writeFile(signTool, 'fixture') + expect(() => createWindowsTokenSigner({ + certificateFile: undefined, + signTool, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + })).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u) + expect(() => createWindowsTokenSigner({ + certificateFile, + signTool: undefined, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + })).toThrow(/DSH_DESKTOP_WINDOWS_SIGNTOOL/u) + const signer = createWindowsTokenSigner({ + certificateFile, + signTool, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + }) + try { + expect(() => createWindowsTokenSigner({ + certificateFile, + signTool, + tokenPin: 'token-secret!', + })).toThrow(/DSH_DESKTOP_WINDOWS_KEY_CONTAINER/u) + expect(() => createWindowsTokenSigner({ + certificateFile, + signTool, + tokenPin: '', + keyContainer: 'te-container', + })).toThrow(/DSH_DESKTOP_WINDOWS_TOKEN_PIN/u) + expect(() => createWindowsTokenSigner({ + certificateFile, + signTool, + tokenPin: 'token]secret', + keyContainer: 'te-container', + })).toThrow(/cannot contain/u) + await expect(signer({ + path: 'C:\\release\\setup.exe', + hash: 'sha1', + isNest: false, + })).rejects.toThrow(/requires SHA-256/u) + } + finally { + await rm(directory, { recursive: true }) + } + }) + + it('removes inherited credentials and redacts SignTool process failures', () => { + expect(scrubWindowsSigningEnvironment({ + SystemRoot: 'C:\\Windows', + DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer', + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', + DEEPSEEK_API_KEY: 'api-secret', + BUILD_PASSWORD: 'build-secret', + })).toEqual({ SystemRoot: 'C:\\Windows' }) + + const processError = Object.assign(new Error('failed'), { + code: 1, + cmd: 'signtool /kc [{{token-secret}}]=te-container', + stderr: 'provider rejected token-secret', + }) + const failure = createRedactedWindowsSigningError( + processError, + 'C:\\release\\setup.exe', + ['token-secret'], + ) + expect(failure.message).toBe('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected ') + expect(failure.message).not.toContain('token-secret') + expect(failure).not.toHaveProperty('cause') + expect(failure).not.toHaveProperty('cmd') + }) + + it('signs the temporary NSIS executable before enterprise policy evaluates it', async () => { + const events: string[] = [] + let receivedEnvironment: NodeJS.ProcessEnv | undefined + class FakeWineVmManager { + async exec( + file: string, + _args: string[], + options?: { env?: NodeJS.ProcessEnv }, + ): Promise { + events.push(`exec:${file}`) + receivedEnvironment = options?.env + return 'executed' + } + } + installWindowsNsisBootstrapSigner({ + sign: async (configuration) => { + events.push(`sign:${configuration.path}:${configuration.hash}:${String(configuration.isNest)}`) + }, + wineVmManager: FakeWineVmManager, + platform: 'win32', + environment: { + SystemRoot: 'C:\\Windows', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', + }, + }) + + const result = await new FakeWineVmManager().exec('C:\\release\\setup.exe', [], { + env: { + __COMPAT_LAYER: 'RunAsInvoker', + BUILD_PASSWORD: 'build-secret', + }, + }) + + expect(result).toBe('executed') + expect(events).toEqual([ + 'sign:C:\\release\\setup.exe:sha256:false', + 'exec:C:\\release\\setup.exe', + ]) + expect(receivedEnvironment).toEqual({ + SystemRoot: 'C:\\Windows', + __COMPAT_LAYER: 'RunAsInvoker', + }) + }) + + it('clears a certificate table inherited beyond the generated uninstaller', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-sign-')) + const path = join(directory, 'uninstaller.exe') + const executable = Buffer.alloc(512) + const peOffset = 216 + const optionalHeaderOffset = peOffset + 24 + const certificateDirectoryOffset = optionalHeaderOffset + 96 + (4 * 8) + executable.write('MZ', 0, 'ascii') + executable.writeUInt32LE(peOffset, 60) + executable.write('PE\0\0', peOffset, 'ascii') + executable.writeUInt16LE(0x10B, optionalHeaderOffset) + executable.writeUInt32LE(600, certificateDirectoryOffset) + executable.writeUInt32LE(100, certificateDirectoryOffset + 4) + await writeFile(path, executable) + try { + await expect(repairDanglingAuthenticodeDirectory(path)).resolves.toBe(true) + const repaired = await readFile(path) + expect(repaired.readUInt32LE(certificateDirectoryOffset)).toBe(0) + expect(repaired.readUInt32LE(certificateDirectoryOffset + 4)).toBe(0) + await expect(repairDanglingAuthenticodeDirectory(path)).resolves.toBe(false) + } + finally { + await rm(directory, { recursive: true }) + } + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51cc583651..2d68d83bd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,7 +80,7 @@ importers: version: 1.32.0 mdast-util-from-markdown: specifier: ^2.0.3 - version: 2.0.3 + version: 2.0.3(supports-color@9.4.0) mdast-util-gfm: specifier: ^3.1.0 version: 3.1.0 @@ -116,7 +116,7 @@ importers: version: 6.0.3 vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 6.1.1(supports-color@9.4.0)(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) vitest: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.6)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -503,9 +503,12 @@ importers: '@types/semver': specifier: ^7.8.0 version: 7.8.0 + app-builder-lib: + specifier: 26.15.3 + version: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) electron: specifier: ^44.0.0 - version: 44.0.0 + version: 44.0.0(supports-color@9.4.0) electron-builder: specifier: ^26.15.3 version: 26.15.3(electron-builder-squirrel-windows@26.15.3) @@ -571,13 +574,13 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^4.0.0 - version: 4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.7.0(supports-color@9.4.0)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) fflate: specifier: ^0.8.2 version: 0.8.3 http-server: specifier: ^14.1.1 - version: 14.1.1 + version: 14.1.1(supports-color@9.4.0) playwright: specifier: ^1.49.0 version: 1.61.1 @@ -2967,7 +2970,7 @@ importers: version: 0.16.47 mdast-util-from-markdown: specifier: ^2.0.3 - version: 2.0.3 + version: 2.0.3(supports-color@9.4.0) mdast-util-gfm: specifier: ^3.1.0 version: 3.1.0 @@ -6147,7 +6150,7 @@ importers: version: link:../../../vendor/schemastery compression: specifier: ^1.8.1 - version: 1.8.1 + version: 1.8.1(supports-color@9.4.0) negotiator: specifier: ^1.0.0 version: 1.0.0 @@ -10875,7 +10878,7 @@ importers: version: 1.11.21 debug: specifier: 4.4.3 - version: 4.4.3 + version: 4.4.3(supports-color@9.4.0) mermaid: specifier: 11.16.0 version: 11.16.0 @@ -17788,20 +17791,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@9.4.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@9.4.0)) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@9.4.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -17837,17 +17840,17 @@ snapshots: '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@9.4.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@9.4.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@9.4.0) '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -17876,14 +17879,14 @@ snapshots: dependencies: '@babel/types': 8.0.0-rc.6 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@9.4.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@9.4.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@9.4.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@9.4.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/runtime@7.29.7': {} @@ -17894,7 +17897,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.7(supports-color@9.4.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -17902,7 +17905,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -18031,7 +18034,7 @@ snapshots: '@electron/get@3.1.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) env-paths: 2.2.1 fs-extra: 8.1.0 got: 11.8.6 @@ -18043,9 +18046,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/get@5.1.0': + '@electron/get@5.1.0(supports-color@9.4.0)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) env-paths: 3.0.0 graceful-fs: 4.2.11 progress: 2.0.3 @@ -18058,7 +18061,7 @@ snapshots: '@electron/notarize@2.5.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) fs-extra: 9.1.0 promise-retry: 2.0.1 transitivePeerDependencies: @@ -18067,7 +18070,7 @@ snapshots: '@electron/osx-sign@1.3.3': dependencies: compare-version: 0.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 @@ -18078,7 +18081,7 @@ snapshots: '@electron/rebuild@4.2.0': dependencies: '@malept/cross-spawn-promise': 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) node-abi: 4.34.0 node-api-version: 0.2.1 node-gyp: 12.4.0 @@ -18090,7 +18093,7 @@ snapshots: dependencies: '@electron/asar': 3.4.1 '@malept/cross-spawn-promise': 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) dir-compare: 4.2.0 fs-extra: 11.4.0 minimatch: 9.0.9 @@ -18101,7 +18104,7 @@ snapshots: '@electron/windows-sign@1.2.2': dependencies: cross-dirname: 0.1.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) fs-extra: 11.4.0 minimist: 1.2.8 postject: 1.0.0-alpha.6 @@ -18381,7 +18384,7 @@ snapshots: '@eslint/config-array@0.23.5': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -18748,7 +18751,7 @@ snapshots: '@malept/flatpak-bundler@0.4.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) fs-extra: 9.1.0 lodash: 4.18.1 tmp-promise: 3.0.3 @@ -19866,11 +19869,11 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitejs/plugin-react@4.7.0(supports-color@9.4.0)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@9.4.0) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@9.4.0)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@9.4.0)) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 @@ -20207,7 +20210,7 @@ snapshots: builder-util-runtime: 9.7.0 chromium-pickle-js: 0.2.0 ci-info: 4.3.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) dotenv: 16.6.1 dotenv-expand: 11.0.7 @@ -20299,7 +20302,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 @@ -20352,7 +20355,7 @@ snapshots: builder-util-runtime@9.7.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) sax: 1.6.1 transitivePeerDependencies: - supports-color @@ -20363,7 +20366,7 @@ snapshots: builder-util-runtime: 9.7.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) fs-extra: 10.1.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -20488,11 +20491,11 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.8.1(supports-color@9.4.0): dependencies: bytes: 3.1.2 compressible: 2.0.18 - debug: 2.6.9 + debug: 2.6.9(supports-color@9.4.0) negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 @@ -20746,13 +20749,17 @@ snapshots: dayjs@1.11.21: {} - debug@2.6.9: + debug@2.6.9(supports-color@9.4.0): dependencies: ms: 2.0.0 + optionalDependencies: + supports-color: 9.4.0 - debug@4.4.3: + debug@4.4.3(supports-color@9.4.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 9.4.0 decimal.js@10.6.0: {} @@ -20945,7 +20952,7 @@ snapshots: electron-winstaller@5.4.0: dependencies: '@electron/asar': 3.4.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) fs-extra: 7.0.1 lodash: 4.18.1 temp: 0.9.4 @@ -20954,10 +20961,10 @@ snapshots: transitivePeerDependencies: - supports-color - electron@44.0.0: + electron@44.0.0(supports-color@9.4.0): dependencies: '@electron-internal/extract-zip': 1.0.5 - '@electron/get': 5.1.0 + '@electron/get': 5.1.0(supports-color@9.4.0) '@types/node': 24.13.3 transitivePeerDependencies: - supports-color @@ -21144,7 +21151,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -21244,7 +21251,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -21273,7 +21280,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -21334,7 +21341,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -21653,7 +21660,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -21665,7 +21672,7 @@ snapshots: transitivePeerDependencies: - debug - http-server@14.1.1: + http-server@14.1.1(supports-color@9.4.0): dependencies: basic-auth: 2.0.1 chalk: 4.1.2 @@ -21676,7 +21683,7 @@ snapshots: mime: 1.6.0 minimist: 1.2.8 opener: 1.5.2 - portfinder: 1.0.38 + portfinder: 1.0.38(supports-color@9.4.0) secure-compare: 3.0.1 union: 0.5.0 url-join: 4.0.1 @@ -21692,7 +21699,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -22148,14 +22155,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3: + mdast-util-from-markdown@2.0.3(supports-color@9.4.0): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@9.4.0) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -22177,7 +22184,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: @@ -22186,7 +22193,7 @@ snapshots: mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -22196,7 +22203,7 @@ snapshots: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -22205,14 +22212,14 @@ snapshots: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-gfm-autolink-literal: 2.0.1 mdast-util-gfm-footnote: 2.1.0 mdast-util-gfm-strikethrough: 2.0.0 @@ -22228,7 +22235,7 @@ snapshots: '@types/mdast': 4.0.4 devlop: 1.1.0 longest-streak: 3.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 unist-util-remove-position: 5.0.0 transitivePeerDependencies: @@ -22476,10 +22483,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2: + micromark@4.0.2(supports-color@9.4.0): dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -22909,10 +22916,10 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - portfinder@1.0.38: + portfinder@1.0.38(supports-color@9.4.0): dependencies: async: 3.2.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -23039,7 +23046,7 @@ snapshots: read-binary-file-arch@1.0.6: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -23224,7 +23231,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -23285,7 +23292,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -23494,7 +23501,7 @@ snapshots: sumchecker@3.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -23751,9 +23758,9 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vite-tsconfig-paths@6.1.1(supports-color@9.4.0)(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.3) vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)