Merge remote-tracking branch 'origin/master' into worktree/present-changes-dedup

This commit is contained in:
creatixchu committed 2026-09-18 17:16:34 +08:00
commit ac0a042f08
76 files changed
+2069 -188

No files matched your search

+14 -4
View File
@@ -4,12 +4,22 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness
DSH_DESKTOP_AUTO_UPDATE_ENV=test
DOWNLOAD_TEST_ORIGIN=
# Workspace tarball workers; omit to use 4. Must be a positive integer.
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
# Optional independent proxy origins; empty values preserve inherited networking.
# Downloads: HTTP/HTTPS proxy for Electron, runtime assets, pnpm and builder downloads.
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=
# Apple tools: HTTP proxy temporarily applied to the active macOS network service.
# Requires permission to change system proxies; affects other applications during notarization.
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY=
# The selected mandatory-update origin is required, including unsigned and preparation builds.
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=https://harness-test.deepseek.com
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=https://harness.deepseek.com
# Optional polling and fallback-page options; origin and authentication are selected automatically.
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
# Set approved login origins for test builds; omit allowedAuthOrigins for production.
# Optional polling and fallback-page options belong in the same JSON value.
# The fallback-page allowlist defaults to the selected service origin.
# DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"intervalMs":600000}'
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}'
DSH_DESKTOP_MACOS_SIGNING_IDENTITY=
DSH_DESKTOP_MACOS_TEAM_ID=
+5 -4
View File
@@ -5,11 +5,12 @@ DSH_DESKTOP_AUTO_UPDATE_ENV=test
DOWNLOAD_TEST_ORIGIN=
# The selected mandatory-update origin is required, including unsigned and preparation builds.
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=https://harness-test.deepseek.com
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=https://harness.deepseek.com
# Optional polling and fallback-page options; origin and authentication are selected automatically.
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
# Set approved login origins for test builds; omit allowedAuthOrigins for production.
# Optional polling and fallback-page options belong in the same JSON value.
# The fallback-page allowlist defaults to the selected service origin.
# DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"intervalMs":600000}'
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}'
DSH_DESKTOP_WINDOWS_CER_FILE=
DSH_DESKTOP_WINDOWS_SIGNTOOL=
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: 5de3550de473cb71704025e1d60057611b15987c
README.zh.md: 057094c80e1197e0216f660ea788dabc0d436573
README.md: eb185783e77422d229c476eccb2b2ea817b1fdef
README.zh.md: 95f2c011af23d6dd16da32f8a0c58f8899b24619
+20 -3
View File
@@ -186,7 +186,23 @@ The macOS configuration uses the required release environment instead of accepti
macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements.
Company proxies can accelerate uploads to Apple's notarization service. See the company internal documentation for configuration.
macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets/<target>/signature-cache`. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts.
Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together.
App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log.
Mac packaging reads three tuning fields from `.env.macos`:
| Setting | Default | Scope |
|---|---|---|
| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. |
| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | Empty | HTTP/HTTPS proxy origin for Electron, runtime assets, pnpm installation and builder downloads. |
| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | Empty | HTTP proxy origin for Apple tools through temporary system proxy settings. |
The proxy fields are independent and reject URL credentials, paths, queries and fragments. Empty values preserve inherited networking. An explicit download proxy replaces child-process proxy variables and bypasses local hosts only; it does not change system settings. Windows and standalone `release:pack` retain their existing concurrency defaults. Company proxy addresses belong in the ignored local file; see internal documentation for addresses.
Apple tooling uses the active macOS network service's HTTP/HTTPS proxies. Configured notarization routing checks proxy reachability, saves that service's settings, enables the proxy around both artifact lanes, and restores the saved settings after both lanes settle. An originally disabled proxy with an empty server and port zero is restored by disabling it; the temporary server and port may remain stored but inactive. Directory-only builds apply it to App notarization after the signed directory build. This temporarily affects other applications and requires permission to change system proxies; PAC, auto-discovery, SOCKS and authenticated proxy configurations must be disabled first. Packaging and recovery acquire the same per-user POSIX file lock before reading recovery data or changing proxies; process exit releases ownership, while the lock file remains in place. This prevents overlapping proxy transactions across checkouts; other users and network-setting tools must not change these settings concurrently. SIGINT/SIGTERM wait for active work before restoration. After forced termination or a restoration error, stop any remaining notarization processes and run `pnpm --dir apps/desktop run restore:mac-proxy`; the saved record remains until restoration succeeds. Configuration checks validate URL syntax without changing system settings or contacting the proxy.
### Unsigned Windows test installer
@@ -281,15 +297,16 @@ Confirmed Host exit without successful task teardown displays localized recovery
### Mandatory update policy
The [mandatory client decision](../../.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md) owns policy polling and the blocking window. Packaging reads `.env.windows` or `.env.macos`: `DSH_DESKTOP_AUTO_UPDATE_ENV=test` (the default) selects `DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN`; `production` selects `DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN`. Templates use `https://harness-test.deepseek.com` and `https://harness.deepseek.com`, respectively. The selected origin is required before preparation or signing, including unsigned and preparation-only builds; the unselected origin is optional. These settings never fall back to the parent environment or the other deployment. Packaging embeds the selected policy with the application ID; packaged applications ignore runtime overrides.
The [mandatory client decision](../../.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md) owns policy polling and the blocking window. Packaging reads `.env.windows` or `.env.macos`: `DSH_DESKTOP_AUTO_UPDATE_ENV=test` (the default) selects `DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN`; `production` selects `DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN`. The templates leave both origins blank; fill in the selected deployment origin in the ignored local dotenv file. The selected origin is required before preparation or signing, including unsigned and preparation-only builds; the unselected origin is optional. These settings never fall back to the parent environment or the other deployment. Packaging embeds the selected policy with the application ID; packaged applications ignore runtime overrides.
Optional `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` JSON supplies polling and download-page options; packaging rejects `origin` and `authentication` inside it. The page allowlist defaults to the selected service origin; explicitly allow other approved download-page origins when needed. Test builds select `feishu-test`, and production selects `anonymous`. Policy requests reject redirects; only test authentication carries gateway cookies. Unpackaged development instead reads a complete policy JSON from this variable and requires `DSH_DESKTOP_APP_ID`; absent JSON disables development policy queries, and only anonymous development permits HTTP `127.0.0.1`. A user-initiated ordinary check triggers policy work concurrently but never waits for or reports a policy failure. Only a confirmed blocking decision cancels ordinary dialogs. Test authentication waits until the active ordinary dialog finishes, and cancellation or failure does not discard the updater result.
`DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` JSON supplies test login origins and optional polling and download-page options; packaging rejects `origin` and `authentication` inside it. The page allowlist defaults to the selected service origin; explicitly allow other approved download-page origins when needed. Test builds select `feishu-test` and require `allowedAuthOrigins` in `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG`; production selects `anonymous` and rejects that field. Each login origin must be an HTTPS origin without credentials, path, query, or fragment. The login window permits document navigation only to the selected policy origin and these configured origins. Policy requests reject redirects; only test authentication carries gateway cookies. Unpackaged development instead reads a complete policy JSON from this variable and requires `DSH_DESKTOP_APP_ID`; absent JSON disables development policy queries, and only anonymous development permits HTTP `127.0.0.1`. A user-initiated ordinary check triggers policy work concurrently but never waits for or reports a policy failure. Only a confirmed blocking decision cancels ordinary dialogs. Test authentication waits until the active ordinary dialog finishes, and cancellation or failure does not discard the updater result.
| Resolved policy field | Meaning and default |
|---|---|
| `origin` | Required HTTPS API origin, without credentials, path, query, or fragment; requests use `/api/v0/check_client_update` |
| `allowedPageOrigins` | Nonempty array of exact HTTPS origins; packaging defaults to the selected API origin; subdomains and alternate ports are not implied |
| `authentication` | Packaging selects `feishu-test` for test and `anonymous` for production; unpackaged development defaults to `anonymous` |
| `allowedAuthOrigins` | Nonempty array of exact HTTPS login document origins for test authentication; forbidden for production |
| `intervalMs` | Polling interval; default `600000` |
| `timeoutMs` | Request deadline; default `15000` |
| `maxBackoffMs` | Maximum failed-request interval including jitter; default `3600000`, at least `intervalMs` |
+20 -3
View File
@@ -187,7 +187,23 @@ macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的
macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。
可通过公司代理加速向 Apple 公证服务上传。代理配置参见公司内部文档。
macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets/<target>/signature-cache`。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。
Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。
App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。
Mac 打包从 `.env.macos` 读取三个调优字段:
| 设置 | 默认值 | 作用范围 |
|---|---|---|
| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 |
| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 |
| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 |
两个代理字段互相独立,拒绝 URL 中的凭证、路径、查询参数和片段。空值沿用继承的网络设置。显式下载代理会替换子进程的代理变量,仅绕过本地主机;它不修改系统设置。Windows 与独立 `release:pack` 保持现有并发默认值。公司代理地址仅写入 Git 忽略的本地文件;具体地址参见内部文档。
Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。
### 未签名 Windows 测试安装包
@@ -282,15 +298,16 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
### 强制更新策略
[强更客户端决策](../../.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)负责策略查询和阻塞窗口。打包读取 `.env.windows` 或 `.env.macos`:`DSH_DESKTOP_AUTO_UPDATE_ENV=test`(默认值)选择 `DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN`;`production` 选择 `DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN`。模板分别使用 `https://harness-test.deepseek.com` 和 `https://harness.deepseek.com`。在准备产物或签名前,所选源站必须配置,包括未签名和仅准备构建;未选环境的源站可不填。这些配置不会回退到父进程环境或另一部署环境。打包将选定策略与应用 ID 写入元数据;打包应用忽略运行时覆盖。
[强更客户端决策](../../.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)负责策略查询和阻塞窗口。打包读取 `.env.windows` 或 `.env.macos`:`DSH_DESKTOP_AUTO_UPDATE_ENV=test`(默认值)选择 `DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN`;`production` 选择 `DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN`。模板将两个源站留空;在 Git 忽略的目标 dotenv 文件中填写所选部署的源站。在准备产物或签名前,所选源站必须配置,包括未签名和仅准备构建;未选环境的源站可不填。这些配置不会回退到父进程环境或另一部署环境。打包将选定策略与应用 ID 写入元数据;打包应用忽略运行时覆盖。
可选的 `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` JSON 提供轮询和下载页面选项;打包拒绝其中的 `origin` 和 `authentication`。页面白名单默认只包含所选服务源站;需要其他已批准下载页面源站时应显式配置。测试包选择 `feishu-test`,正式包选择 `anonymous`。策略请求拒绝重定向;仅测试鉴权携带网关 Cookie。未打包开发模式则从此变量读取完整策略 JSON,并要求 `DSH_DESKTOP_APP_ID`;缺少 JSON 会禁用开发模式策略查询,仅匿名开发允许 HTTP `127.0.0.1`。用户发起常规检查时会并发触发策略检查,但不会等待或展示策略失败。只有已确认的强更决定可以关闭常规弹窗。测试环境鉴权会等待当前常规弹窗结束,取消或失败不会丢弃 updater 结果。
`DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` JSON 提供测试登录源站,以及可选的轮询和下载页面选项;打包拒绝其中的 `origin` 和 `authentication`。页面白名单默认只包含所选服务源站;需要其他已批准下载页面源站时应显式配置。测试包选择 `feishu-test`,且必须在 `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` 中配置 `allowedAuthOrigins`;正式包选择 `anonymous`,并拒绝该字段。每个登录源站必须是没有凭据、路径、查询或片段的 HTTPS origin。登录窗口仅允许文档导航到所选策略源站和这些已配置源站。策略请求拒绝重定向;仅测试鉴权携带网关 Cookie。未打包开发模式则从此变量读取完整策略 JSON,并要求 `DSH_DESKTOP_APP_ID`;缺少 JSON 会禁用开发模式策略查询,仅匿名开发允许 HTTP `127.0.0.1`。用户发起常规检查时会并发触发策略检查,但不会等待或展示策略失败。只有已确认的强更决定可以关闭常规弹窗。测试环境鉴权会等待当前常规弹窗结束,取消或失败不会丢弃 updater 结果。
| 解析后的策略字段 | 含义与默认值 |
|---|---|
| `origin` | 必填 HTTPS API 源站,不含凭据、路径、查询或片段;请求使用 `/api/v0/check_client_update` |
| `allowedPageOrigins` | 非空的精确 HTTPS 源站数组;打包时默认只包含所选 API 源站;不隐含子域名或其他端口 |
| `authentication` | 打包时测试环境选择 `feishu-test`,正式环境选择 `anonymous`;未打包开发模式默认为 `anonymous` |
| `allowedAuthOrigins` | 测试鉴权必填的非空 HTTPS 登录文档源站数组;正式环境禁止配置 |
| `intervalMs` | 轮询间隔;默认 `600000` |
| `timeoutMs` | 请求截止时间;默认 `15000` |
| `maxBackoffMs` | 含抖动的失败请求最大间隔;默认 `3600000`,不小于 `intervalMs` |
+2
View File
@@ -22,6 +22,7 @@
"verify:mac-signature": "node scripts/verify-macos-signature.mjs",
"package": "tsx scripts/package-target.ts",
"check:package": "tsx scripts/package-target.ts --check",
"restore:mac-proxy": "tsx scripts/macos-notarization-proxy.ts",
"package:dir": "tsx scripts/package-target.ts --dir",
"package:mac:arm64": "tsx scripts/package-target.ts mac-arm64",
"package:mac:arm64:dir": "tsx scripts/package-target.ts mac-arm64 --dir",
@@ -41,6 +42,7 @@
"devDependencies": {
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/node-addon-system": "workspace:^",
"@electron/get": "^5.1.0",
"@electron/notarize": "2.5.0",
"@types/js-yaml": "^4.0.9",
@@ -8,12 +8,13 @@ import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSS
import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
import { createWindowsTokenSigner } from './windows-sign.mjs'
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
import { resolveMacOSPackageSettings } from './macos-package-settings.mjs'
import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR)$/u
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
@@ -77,6 +78,7 @@ function requireReadableFile(environment, name) {
export function validateDesktopPackageEnvironment(environment, target, options = {}) {
resolveDesktopAppId(environment)
resolveDesktopPolicyEnvironment(environment)
if (target.platform === 'darwin') resolveMacOSPackageSettings(environment)
if (options.unsigned) return
if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
if (target.platform === 'win32') {
@@ -2,6 +2,7 @@
export interface DesktopPolicyEnvironment {
origin: string
allowedPageOrigins: string[]
allowedAuthOrigins?: string[]
authentication: 'anonymous' | 'feishu-test'
[key: string]: unknown
}
@@ -30,7 +30,15 @@ export function resolveDesktopPolicyEnvironment(environment) {
}
const pages = settings.allowedPageOrigins ?? [selected]
if (!Array.isArray(pages) || pages.length === 0) throw new Error('desktop package: allowedPageOrigins must be a nonempty array')
const authOrigins = settings.allowedAuthOrigins
if (deployment === 'test' && (!Array.isArray(authOrigins) || authOrigins.length === 0)) {
throw new Error('desktop package: test policy requires nonempty allowedAuthOrigins')
}
if (deployment === 'production' && authOrigins !== undefined) {
throw new Error('desktop package: production policy must not configure allowedAuthOrigins')
}
return { ...settings, origin: selected,
allowedPageOrigins: pages.map(value => origin(value, 'allowedPageOrigins')),
...(deployment === 'test' ? { allowedAuthOrigins: authOrigins.map(value => origin(value, 'allowedAuthOrigins')) } : {}),
authentication: deployment === 'test' ? 'feishu-test' : 'anonymous' }
}
@@ -0,0 +1,9 @@
/** Separate upload and Apple processing timings without replacing electron-notarize's qualification. */
/**
* Run the requested Apple command, splitting submit --wait into submit --no-wait and wait.
* @param args Arguments from the pinned electron-notarize library.
* @param directory Existing packaging journal.
* @param invoke Apple command executor.
* @returns Final tool output consumed by electron-notarize; upload failure prevents waiting.
*/
export function runLoggedNotarytool(args: readonly string[], directory?: string, invoke?: (args: readonly string[]) => Promise<string>): Promise<string>
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/** Adapt electron-notarize's combined submit/wait into separately timed Apple operations. */
import { execFile } from 'node:child_process'
import { basename, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { recordPackagingEvent, packagingOutputRedactor } from './packaging-run.mjs'
import { packagingStep } from './packaging-step.mjs'
const exec = promisify(execFile)
const AUTH_FLAGS = new Set(['--apple-id', '--password', '--team-id', '--key', '--key-id', '--issuer', '--keychain', '--keychain-profile'])
async function execute(args) {
try {
const { stdout } = await exec('/usr/bin/xcrun', ['notarytool', ...args], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
return stdout
} catch (error) {
// Apple can report rejection with a nonzero exit; electron-notarize needs its JSON ID to retrieve diagnostics.
if (args[0] === 'wait' && typeof error.code === 'number' && typeof error.stdout === 'string' && error.stdout.trim()) return error.stdout
throw error
}
}
/**
* Return only the final JSON to electron-notarize, which owns acceptance checks and stapling retries.
* @param {readonly string[]} args Arguments supplied by the pinned electron-notarize library.
* @param {string | undefined} directory Existing packaging journal.
* @param {(args: readonly string[]) => Promise<string>} invoke Apple command executor.
* @returns {Promise<string>} Tool output; a failed upload never starts waiting.
*/
export async function runLoggedNotarytool(args, directory, invoke = execute) {
const credentials = args.filter((_, index) => AUTH_FLAGS.has(args[index - 1]))
if (args[0] !== 'submit' || !args.includes('--wait')) {
return packagingStep(directory, `notarytool:${args[0]}`, async () => {
const output = await invoke(args)
if (directory && args[0] === 'log') {
let text = ''
const redactor = packagingOutputRedactor(credentials, chunk => { text += chunk })
redactor.write(Buffer.from(output))
redactor.end()
recordPackagingEvent(directory, { type: 'notary-diagnostics', submissionId: args[1], text })
}
return output
}, credentials)
}
const artifact = basename(args[1])
const uploaded = await packagingStep(directory, `notarytool:upload:${artifact}`,
() => invoke(args.filter(arg => arg !== '--wait').concat('--no-wait')), credentials)
const submission = JSON.parse(uploaded)
if (typeof submission.id !== 'string' || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/iu.test(submission.id)) {
throw new Error('desktop notarization: submit returned no valid submission ID')
}
if (directory) recordPackagingEvent(directory, { type: 'notary-submission', artifact, submissionId: submission.id })
const authorization = args.flatMap((arg, index) => AUTH_FLAGS.has(arg) ? [arg, args[index + 1]] : [])
const rejected = new Error('desktop notarization: Apple did not accept the submission')
let rejectedOutput
try {
return await packagingStep(directory, `notarytool:wait:${artifact}`, async () => {
const output = await invoke(['wait', submission.id, ...authorization, '--output-format', 'json'])
const result = JSON.parse(output)
if (directory) recordPackagingEvent(directory, { type: 'notary-result', artifact, submissionId: submission.id, status: result.status })
if (result.id !== submission.id) throw new Error('desktop notarization: wait returned a different submission ID')
if (result.status !== 'Accepted') {
rejectedOutput = output
throw rejected
}
return output
}, credentials)
} catch (error) {
// Preserve rejected JSON for electron-notarize's diagnostic retrieval without marking the wait successful.
if (error === rejected) return rejectedOutput
throw error
}
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
try { process.stdout.write(await runLoggedNotarytool(process.argv.slice(2), process.env.DSH_DESKTOP_PACKAGING_RUN_DIR)) }
catch {
// execFile errors include credential-bearing argv; detailed failures are redacted in the journal.
process.stderr.write('desktop notarization: Apple command failed; see packaging events.jsonl\n')
process.exitCode = 1
}
}
@@ -0,0 +1,61 @@
/** Bind reusable thin Mach-O signatures to the actual certificate, tools, identifier, and entitlements. */
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir, release } from 'node:os'
import { join } from 'node:path'
import { isDeepStrictEqual } from 'node:util'
import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
import { assertMacOSRuntimeSignatureDetails, signMacOSRuntimeCode } from './verify-macos-signature.mjs'
import type { MacOSCachedSigner } from './macos-signature-cache.ts'
function apple(command: string, args: string[], input?: string): { stdout: string; stderr: string } {
const result = spawnSync(command, args, { encoding: 'utf8', input, timeout: 120_000 })
if (result.error || result.status !== 0 || result.signal) throw new Error(`macOS signature cache: ${command} verification failed`)
return result
}
function digest(bytes: Buffer): string { return createHash('sha256').update(bytes).digest('hex') }
function plist(xml: string): unknown {
if (xml.trim() === '') return {}
return JSON.parse(apple('/usr/bin/plutil', ['-convert', 'json', '-o', '-', '-'], xml).stdout) as unknown
}
function inspect(path: string): { certificate: string; details: string; entitlements: unknown } {
const directory = mkdtempSync(join(tmpdir(), 'dsh-signature-policy-'))
try {
const prefix = join(directory, 'certificate')
const result = apple('/usr/bin/codesign', ['--display', '--verbose=4', `--extract-certificates=${prefix}`, '--entitlements', '-', '--xml', path])
return { certificate: digest(readFileSync(`${prefix}0`)), details: result.stderr, entitlements: plist(result.stdout) }
} finally { rmSync(directory, { recursive: true, force: true }) }
}
/**
* Resolve a policy from the keychain's signed probe and current implementation; no credential enters the key.
* @param probe Verified thin executable signed by this invocation's temporary keychain.
* @returns Factory for per-file signing and verification policies.
*/
export function macOSCachePolicy(probe: string):
(identifier: string, expected: MacOSSigningEnvironment, entitlements?: string) => MacOSCachedSigner {
const certificate = inspect(probe).certificate
const tools = ['/usr/bin/codesign', join(import.meta.dirname, 'verify-macos-signature.mjs'),
join(import.meta.dirname, 'macos-cache-policy.ts'), join(import.meta.dirname, 'macos-signature-cache.ts')].map(path => digest(readFileSync(path)))
return (identifier, expected, entitlements) => {
const entitlementBytes = entitlements === undefined ? undefined : readFileSync(entitlements)
const desired = entitlementBytes === undefined ? {} : plist(entitlementBytes.toString('utf8'))
return {
policy: JSON.stringify({ certificate, identifier, expected, entitlements: entitlementBytes?.toString('base64') ?? null, tools, os: release() }),
sign: path => signMacOSRuntimeCode(path, identifier, expected, entitlements),
verify: (path) => {
if (!['cefaedfe', 'cffaedfe', 'feedface', 'feedfacf'].includes(readFileSync(path).subarray(0, 4).toString('hex'))) {
throw new Error('macOS signature cache: only thin Mach-O files can be reused')
}
apple('/usr/bin/codesign', ['--verify', '--strict', '--verbose=2', path])
const actual = inspect(path)
assertMacOSRuntimeSignatureDetails(actual.details, expected)
if (actual.certificate !== certificate || !actual.details.split(/\r?\n/u).includes(`Identifier=${identifier}`)
|| !isDeepStrictEqual(actual.entitlements, desired)) {
throw new Error('macOS signature cache: signature does not match certificate, identifier, or entitlements')
}
},
}
}
}
@@ -0,0 +1,222 @@
/** Temporarily route Apple tools through the active macOS service; retain recovery data before mutation. */
import { execFileSync } from 'node:child_process'
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { connect } from 'node:net'
import { homedir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { tryLockExclusive } from '@deepseek-ai/node-addon-system/flock'
interface ProxyState {
readonly enabled: boolean
readonly server: string
readonly port: number
}
interface ProxyRecord {
readonly pid: number
readonly service: string
readonly http: ProxyState
readonly https: ProxyState
}
interface ProxyOperations {
readonly command: (executable: string, args: readonly string[]) => string
readonly reachable: (url: URL) => Promise<void>
readonly ownerAlive: (pid: number) => boolean
}
const LOCK = join(homedir(), 'Library', 'Caches', 'com.deepseek.harness', 'notarization-proxy')
const RECOVERY = 'pnpm --dir apps/desktop run restore:mac-proxy'
const NETWORKSETUP = '/usr/sbin/networksetup'
const operations: ProxyOperations = {
command(executable, args) {
return execFileSync(executable, [...args], { encoding: 'utf8', env: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'C' }, timeout: 15_000 })
},
reachable(url) {
return new Promise((resolveConnection, reject) => {
const socket = connect({ host: url.hostname.replace(/^\[|\]$/gu, ''), port: Number(url.port || '80') })
let failure: Error | undefined
socket.setTimeout(5000, () => socket.destroy(new Error('desktop package: notarization proxy connection timed out')))
socket.once('error', () => { failure = new Error('desktop package: notarization proxy is unreachable') })
socket.once('connect', () => socket.destroy())
socket.once('close', () => { if (failure) reject(failure); else resolveConnection() })
})
},
ownerAlive(pid) {
try { process.kill(pid, 0); return true } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false
throw error
}
},
}
async function withProxyLock<T>(lock: string, action: () => Promise<T>): Promise<T> {
mkdirSync(dirname(lock), { recursive: true, mode: 0o700 })
// Keep this inode across transactions: unlinking it would allow two independent locks.
const fd = openSync(`${lock}.flock`, 'a', 0o600)
try {
try { await tryLockExclusive(fd) } catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== 'EAGAIN' && code !== 'EWOULDBLOCK') throw error
throw new Error('desktop package: another run or proxy recovery is active; wait for it to finish')
}
return await action()
} finally { closeSync(fd) }
}
function readProxy(service: string, secure: boolean, ops: ProxyOperations): ProxyState {
const output = ops.command(NETWORKSETUP, [secure ? '-getsecurewebproxy' : '-getwebproxy', service])
const enabled = /^Enabled: (Yes|No)$/mu.exec(output)?.[1]
const server = /^Server:[ \t]?(.*)$/mu.exec(output)?.[1]
const port = /^Port: (\d+)$/mu.exec(output)?.[1]
if (enabled === undefined || server === undefined || port === undefined
|| !/^Authenticated Proxy Enabled: 0$/mu.test(output) || Number(port) > 65535) {
throw new Error('desktop package: cannot preserve system proxy settings; authenticated proxies are unsupported')
}
return { enabled: enabled === 'Yes', server, port: Number(port) }
}
function activeService(ops: ProxyOperations): string {
const route = ops.command('/sbin/route', ['-n', 'get', 'default'])
const device = /^\s*interface: (\S+)$/mu.exec(route)?.[1]
const services = ops.command(NETWORKSETUP, ['-listnetworkserviceorder'])
const matches = [...services.matchAll(/^\(\d+\) (.+)\n\(Hardware Port: .+, Device: ([^)]+)\)$/gmu)]
.filter(match => match[2] === device)
if (!device || matches.length !== 1) throw new Error('desktop package: cannot identify one active network service for notarization proxy')
return matches[0]![1]!
}
function setProxy(service: string, secure: boolean, state: ProxyState, ops: ProxyOperations): void {
ops.command(NETWORKSETUP, [secure ? '-setsecurewebproxy' : '-setwebproxy', service, state.server, String(state.port), 'off'])
ops.command(NETWORKSETUP, [secure ? '-setsecurewebproxystate' : '-setwebproxystate', service, state.enabled ? 'on' : 'off'])
const actual = readProxy(service, secure, ops)
if (JSON.stringify(actual) !== JSON.stringify(state)) throw new Error('desktop package: system proxy change did not take effect')
}
function restore(record: ProxyRecord, ops: ProxyOperations): void {
const errors: unknown[] = []
for (const [secure, state] of [[false, record.http], [true, record.https]] as const) {
try {
if (!state.enabled && state.server === '' && state.port === 0) {
ops.command(NETWORKSETUP, [secure ? '-setsecurewebproxystate' : '-setwebproxystate', record.service, 'off'])
if (readProxy(record.service, secure, ops).enabled) throw new Error('desktop package: system proxy disable did not take effect')
} else setProxy(record.service, secure, state, ops)
} catch (error) { errors.push(error) }
}
if (errors.length) throw new AggregateError(errors, `desktop package: proxy restoration failed; retry ${RECOVERY}`)
}
function readRecord(lock: string): ProxyRecord {
const value: unknown = JSON.parse(readFileSync(join(lock, 'original.json'), 'utf8'))
function isState(state: unknown): state is ProxyState {
if (state === null || typeof state !== 'object') return false
const s = state as Partial<ProxyState>
return typeof s.enabled === 'boolean' && typeof s.server === 'string' && !/[\r\n\0]/u.test(s.server)
&& typeof s.port === 'number' && Number.isInteger(s.port) && s.port >= 0 && s.port <= 65535
}
if (value === null || typeof value !== 'object') throw new Error('desktop package: invalid proxy recovery record')
const record = value as Partial<ProxyRecord>
if (!Number.isSafeInteger(record.pid) || record.pid! <= 0 || typeof record.service !== 'string'
|| !record.service || /[\r\n\0]/u.test(record.service) || !isState(record.http) || !isState(record.https)) {
throw new Error('desktop package: invalid proxy recovery record')
}
return record as ProxyRecord
}
/**
* Restore an interrupted run only after its owner exits; active or incomplete records fail closed.
* Originally empty proxies are disabled; their temporary server and port may remain stored.
* @param lock Per-user lock shared across checkouts; tests supply a private directory.
* @param ops Host operations; tests supply isolated system settings.
* @returns Resolves after restoration under the shared file lock; retains the record on failure.
*/
export async function restoreMacOSNotarizationProxy(lock: string = LOCK, ops: ProxyOperations = operations): Promise<void> {
await withProxyLock(lock, async () => {
const record = readRecord(lock)
if (ops.ownerAlive(record.pid)) throw new Error('desktop package: proxy owner is still running; wait for packaging to finish')
restore(record, ops)
rmSync(lock, { recursive: true })
})
}
/**
* Hold the system HTTP/HTTPS proxy until all callback work settles, including after SIGINT/SIGTERM.
* @param proxyUrl Validated HTTP proxy origin, or undefined for unchanged system networking.
* @param action Work that drains every notarization lane before resolving or rejecting.
* @param lock Per-user exclusive recovery directory, shared across checkouts.
* @param ops Host operations; tests supply isolated system settings.
* @param report Reports restoration state to the packaging journal without exposing proxy addresses.
* @returns Callback result after verified restoration; forced termination leaves a recovery record.
*/
export async function withMacOSNotarizationProxy<T>(
proxyUrl: string | undefined,
action: () => Promise<T>,
lock: string = LOCK,
ops: ProxyOperations = operations,
report: (status: 'not-used' | 'restoration-pending' | 'enabled' | 'restored' | 'restore-failed') => void = () => {},
): Promise<T> {
if (proxyUrl === undefined) { report('not-used'); return action() }
const url = new URL(proxyUrl)
await ops.reachable(url)
return withProxyLock(lock, async () => {
try { mkdirSync(lock, { mode: 0o700 }) } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
throw new Error(`desktop package: another run or interrupted proxy record exists; after its owner exits, run ${RECOVERY}`)
}
let original: ProxyRecord | undefined
let saved = false
let workError: unknown
let signal: string | undefined
const interrupted = (received: string) => {
signal = received
process.stderr.write('desktop package: waiting for notarization work to stop before restoring the system proxy\n')
}
process.on('SIGINT', interrupted)
process.on('SIGTERM', interrupted)
try {
const service = activeService(ops)
// PAC, discovery and SOCKS can override explicit proxies; do not silently change those policies.
const pac = ops.command(NETWORKSETUP, ['-getautoproxyurl', service])
const discovery = ops.command(NETWORKSETUP, ['-getproxyautodiscovery', service])
const socks = ops.command(NETWORKSETUP, ['-getsocksfirewallproxy', service])
if (!/^Enabled: No$/mu.test(pac) || !/^Auto Proxy Discovery: Off$/mu.test(discovery) || !/^Enabled: No$/mu.test(socks)) {
throw new Error('desktop package: disable PAC, proxy discovery and SOCKS on the active service before configuring a notarization proxy')
}
original = { pid: process.pid, service, http: readProxy(service, false, ops), https: readProxy(service, true, ops) }
writeFileSync(join(lock, 'original.json'), `${JSON.stringify(original)}\n`, { flag: 'wx', mode: 0o600, flush: true })
saved = true
report('restoration-pending')
const state = { enabled: true, server: url.hostname.replace(/^\[|\]$/gu, ''), port: Number(url.port || '80') }
setProxy(service, false, state, ops)
setProxy(service, true, state, ops)
report('enabled')
const result = await action()
if (signal) throw new Error(`desktop package: interrupted by ${signal}`)
return result
} catch (error) {
workError = error
throw error
} finally {
try {
if (saved && original) {
try { restore(original, ops) } catch (error) { report('restore-failed'); throw error }
report('restored')
}
rmSync(lock, { recursive: true })
} catch (error) {
if (workError !== undefined) {
throw new AggregateError([workError, error], `desktop package: work and proxy cleanup failed; run ${RECOVERY}`)
}
throw error
} finally {
process.off('SIGINT', interrupted)
process.off('SIGTERM', interrupted)
}
}
})
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
if (process.platform !== 'darwin') throw new Error('desktop package: proxy recovery requires macOS')
await restoreMacOSNotarizationProxy()
console.log('desktop package: original system proxy restored')
}
@@ -0,0 +1,23 @@
/** File-owned macOS packaging concurrency and stage-specific proxies. */
/** Validated settings shared by preflight and stage execution. */
export interface MacOSPackageSettings {
readonly packConcurrency: number
readonly downloadProxy?: string
readonly notarizationProxy?: string
}
/**
* Validate tuning before any build or network operation.
* @param environment File-owned packaging settings.
* @returns Resolved settings; empty proxy fields preserve inherited networking.
*/
export function resolveMacOSPackageSettings(environment: NodeJS.ProcessEnv): MacOSPackageSettings
/**
* Override routing only in download-capable subprocesses without mutating the parent.
* @param environment Parent environment.
* @param proxyUrl Validated proxy, or undefined to preserve inherited settings.
* @returns Child environment; explicit routing bypasses only local hosts.
*/
export function macOSDownloadEnvironment(environment: NodeJS.ProcessEnv, proxyUrl?: string): NodeJS.ProcessEnv
@@ -0,0 +1,47 @@
/** Resolve file-owned macOS packaging concurrency and stage-specific proxies. */
function proxy(environment, name, protocols) {
const value = environment[name]?.trim()
if (!value) return undefined
let parsed
try { parsed = new URL(value) } catch { throw new Error(`desktop package: ${name} must be a proxy URL`) }
if (!protocols.includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password
|| parsed.pathname !== '/' || parsed.search || parsed.hash) {
throw new Error(`desktop package: ${name} requires an unauthenticated ${protocols.join('/')} proxy origin`)
}
return parsed.origin
}
/**
* Validate tuning before any build or network operation.
* @param {NodeJS.ProcessEnv} environment File-owned packaging settings.
* @returns {{packConcurrency: number, downloadProxy?: string, notarizationProxy?: string}} Resolved settings; empty proxy fields preserve inherited networking.
*/
export function resolveMacOSPackageSettings(environment) {
const concurrency = environment.DSH_DESKTOP_MACOS_PACK_CONCURRENCY ?? '4'
if (!/^[1-9]\d*$/u.test(concurrency) || !Number.isSafeInteger(Number(concurrency))) {
throw new Error('desktop package: DSH_DESKTOP_MACOS_PACK_CONCURRENCY must be a positive integer')
}
return {
packConcurrency: Number(concurrency),
downloadProxy: proxy(environment, 'DSH_DESKTOP_MACOS_DOWNLOAD_PROXY', ['http:', 'https:']),
notarizationProxy: proxy(environment, 'DSH_DESKTOP_MACOS_NOTARIZATION_PROXY', ['http:']),
}
}
/**
* Override proxy routing only in download-capable subprocesses, including Electron and pnpm.
* @param {NodeJS.ProcessEnv} environment Parent environment, never mutated.
* @param {string | undefined} proxyUrl Validated download proxy, or undefined to preserve inherited settings.
* @returns {NodeJS.ProcessEnv} Child environment; explicit routing bypasses only local hosts.
*/
export function macOSDownloadEnvironment(environment, proxyUrl) {
if (proxyUrl === undefined) return { ...environment }
return {
...Object.fromEntries(Object.entries(environment).filter(([name]) =>
!/^(?:(?:https?|all|no)_proxy|npm_config_(?:https?_proxy|proxy|noproxy)|ELECTRON_GET_USE_PROXY)$/iu.test(name))),
HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, http_proxy: proxyUrl, https_proxy: proxyUrl,
NO_PROXY: 'localhost,127.0.0.1,::1', no_proxy: 'localhost,127.0.0.1,::1',
ELECTRON_GET_USE_PROXY: '1',
}
}
+25 -6
View File
@@ -5,15 +5,17 @@ import { closeSync, openSync, readSync } from 'node:fs'
import { join } from 'node:path'
import { inventoryDesktopRuntime } from '../src/runtime-tree.ts'
import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
import { cachedMacOSSignature, pruneMacOSSignatureCache } from './macos-signature-cache.ts'
import { macOSCachePolicy } from './macos-cache-policy.ts'
import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs'
const MACH_O_MAGICS = new Set(['cafebabe', 'cafebabf', 'cefaedfe', 'cffaedfe', 'feedface', 'feedfacf', 'bebafeca', 'bfbafeca'])
function isMachO(path: string): boolean {
function magic(path: string): string {
const descriptor = openSync(path, 'r')
try {
const header = Buffer.alloc(4)
return readSync(descriptor, header, 0, 4, 0) === 4 && MACH_O_MAGICS.has(header.toString('hex'))
return readSync(descriptor, header, 0, 4, 0) === 4 ? header.toString('hex') : ''
} finally { closeSync(descriptor) }
}
@@ -22,10 +24,16 @@ function isMachO(path: string): boolean {
* @param root - Self-contained production runtime without symlinks.
* @param appId - Release application identifier.
* @param expected - Required signing identity.
* @param cacheDirectory Optional content-addressed cache; requires the keychain-owned signing probe.
* @returns Number of signed native files.
*/
export async function signMacOSRuntime(root: string, appId: string, expected: MacOSSigningEnvironment): Promise<number> {
const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => isMachO(join(root, path)))
export async function signMacOSRuntime(
root: string, appId: string, expected: MacOSSigningEnvironment, cacheDirectory?: string,
): Promise<number> {
const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => MACH_O_MAGICS.has(magic(join(root, path))))
const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '')
let hits = 0
let misses = 0
let next = 0
const workers = Array.from({ length: Math.min(4, files.length) }, async () => {
for (;;) {
@@ -34,12 +42,23 @@ export async function signMacOSRuntime(root: string, appId: string, expected: Ma
const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}`
const entitlements = path === 'dependencies/node/bin/node'
? join(import.meta.dirname, 'node-entitlements.plist') : undefined
await signMacOSRuntimeCode(join(root, path), identifier, expected, entitlements)
verifyMacOSRuntimeCode(join(root, path), expected)
const file = join(root, path)
const thin = ['cefaedfe', 'cffaedfe', 'feedface', 'feedfacf'].includes(magic(file))
if (cacheDirectory !== undefined && policy !== undefined && thin) {
if (await cachedMacOSSignature(file, cacheDirectory, policy(identifier, expected, entitlements))) hits++
else misses++
} else {
await signMacOSRuntimeCode(file, identifier, expected, entitlements)
verifyMacOSRuntimeCode(file, expected)
}
}
})
const results = await Promise.allSettled(workers)
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown)
if (errors.length > 0) throw new AggregateError(errors, 'desktop runtime: native signing failed')
if (cacheDirectory !== undefined) {
pruneMacOSSignatureCache(cacheDirectory)
console.info(`desktop macOS signing cache: ${hits} hits, ${misses} misses, ${files.length - hits - misses} uncached`)
}
return files.length
}
@@ -0,0 +1,121 @@
/** Reuse verified standalone signatures by file content and complete signing policy. */
import { createHash } from 'node:crypto'
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
function digest(bytes: Buffer | string): string { return createHash('sha256').update(bytes).digest('hex') }
function regular(path: string): void {
if (!lstatSync(path).isFile()) throw new Error(`macOS signature cache: expected regular file ${path}`)
}
function directory(path: string): void {
if (!lstatSync(path).isDirectory()) throw new Error(`macOS signature cache: expected directory ${path}`)
}
function ensureDirectory(path: string): void {
if (dirname(path) !== path) ensureDirectory(dirname(path))
if (!existsSync(path)) {
try { mkdirSync(path, { mode: 0o700 }) }
catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error }
}
directory(path)
}
function readEntry(entry: string, key: string): { payload: string; sha256: string } {
directory(entry)
const record = join(entry, 'record.json')
const payload = join(entry, 'code')
regular(record); regular(payload)
const metadata = JSON.parse(readFileSync(record, 'utf8')) as { key?: unknown; sha256?: unknown }
if (metadata.key !== key || typeof metadata.sha256 !== 'string') throw new Error(`macOS signature cache: invalid record ${entry}`)
if (digest(readFileSync(payload)) !== metadata.sha256) throw new Error(`macOS signature cache: damaged payload ${entry}`)
return { payload, sha256: metadata.sha256 }
}
/** A signer whose verifier checks all policy fields represented by its stable key. */
export interface MacOSCachedSigner {
readonly policy: string
/** @param path Private executable copy to sign. @returns Completion after all owned signing activity settles. */
sign: (path: string) => Promise<void>
/** @param path Executable whose signature must satisfy the complete policy. */
verify: (path: string) => void
}
/**
* Sign a private copy or validate a cached copy, then atomically replace an unchanged input.
* @param path Regular standalone executable owned by this build.
* @param cacheRoot Private content-addressed signature storage.
* @param signer Complete signing policy and its signing/verification operations.
* @returns Whether a previously verified signature was reused.
*/
export async function cachedMacOSSignature(path: string, cacheRoot: string, signer: MacOSCachedSigner): Promise<boolean> {
regular(path)
const mode = lstatSync(path).mode & 0o777
const input = digest(readFileSync(path))
const key = digest(JSON.stringify({ schema: 1, input, mode, policy: signer.policy }))
ensureDirectory(cacheRoot)
const entry = join(cacheRoot, key)
const work = mkdtempSync(join(dirname(path), '.dsh-sign-'))
const candidate = join(work, 'code')
const unchanged = (): void => {
regular(path)
if (digest(readFileSync(path)) !== input || (lstatSync(path).mode & 0o777) !== mode) {
throw new Error('macOS signature cache: input changed during signing')
}
}
try {
const hit = existsSync(entry)
if (hit) {
const { payload, sha256 } = readEntry(entry, key)
copyFileSync(payload, candidate)
if (digest(readFileSync(candidate)) !== sha256) throw new Error(`macOS signature cache: damaged payload ${entry}`)
} else {
copyFileSync(path, candidate)
if (digest(readFileSync(candidate)) !== input) throw new Error('macOS signature cache: input changed while copying')
await signer.sign(candidate)
}
chmodSync(candidate, mode)
const signed = digest(readFileSync(candidate))
signer.verify(candidate)
if (digest(readFileSync(candidate)) !== signed) throw new Error('macOS signature cache: candidate changed during verification')
unchanged()
if (!hit) {
const staging = mkdtempSync(join(cacheRoot, '.pending-'))
try {
copyFileSync(candidate, join(staging, 'code'))
if (digest(readFileSync(join(staging, 'code'))) !== signed) throw new Error('macOS signature cache: staged payload changed')
writeFileSync(join(staging, 'record.json'), `${JSON.stringify({ key, sha256: signed })}\n`, { mode: 0o600, flag: 'wx', flush: true })
try { renameSync(staging, entry) }
catch (error) {
if (!(error instanceof Error && 'code' in error && ['ENOTEMPTY', 'EEXIST'].includes(String(error.code)))) throw error
const winner = readEntry(entry, key)
signer.verify(winner.payload)
}
} finally { rmSync(staging, { recursive: true, force: true }) }
}
unchanged()
renameSync(candidate, path)
return hit
} finally { rmSync(work, { recursive: true, force: true }) }
}
/**
* Retain newest complete cache entries within a one-GiB payload budget; active readers fail closed on eviction races.
* @param root Cache directory owned by packaging.
* @returns Nothing.
*/
export function pruneMacOSSignatureCache(root: string): void {
if (!existsSync(root)) return
directory(root)
const entries = readdirSync(root).filter(name => /^[a-f0-9]{64}$/u.test(name)).map((name) => {
const path = join(root, name)
directory(path)
const payload = join(path, 'code')
regular(payload)
const stat = lstatSync(payload)
return { path, bytes: stat.size, time: stat.mtimeMs }
}).sort((a, b) => b.time - a.time)
let bytes = 0
for (const entry of entries) {
bytes += entry.bytes
if (bytes > 1024 ** 3) rmSync(entry.path, { recursive: true })
}
}
@@ -49,7 +49,7 @@ export async function withMacOSSigningKeychain(environment, action, run = execut
run('/bin/cp', ['/usr/bin/true', probe])
run('/usr/bin/codesign', ['--force', '--sign', `Developer ID Application: ${environment.DSH_DESKTOP_MACOS_SIGNING_IDENTITY}`, '--keychain', keychain, '--timestamp', '--options', 'runtime', probe])
run('/usr/bin/codesign', ['--verify', '--strict', probe])
const childEnvironment = { ...environment, CSC_KEYCHAIN: keychain }
const childEnvironment = { ...environment, CSC_KEYCHAIN: keychain, DSH_DESKTOP_MACOS_SIGNING_PROBE: probe }
delete childEnvironment.CSC_LINK
delete childEnvironment.CSC_KEY_PASSWORD
await action(childEnvironment)
@@ -1,6 +1,6 @@
/** Notarize and qualify macOS disk images after electron-builder creates them. */
import { notarize } from '@electron/notarize'
import { notarizeMacOS } from './notarize-macos.mjs'
import { rmSync } from 'node:fs'
import { resolveMacOSNotarizationEnvironment } from './desktop-release-environment.mjs'
import { verifyMacOSDiskImage } from './verify-macos-signature.mjs'
@@ -18,7 +18,7 @@ export async function notarizeMacOSDiskImageArtifact(
artifact,
env,
expected,
submit = notarize,
submit = notarizeMacOS,
verify = verifyMacOSDiskImage,
) {
if (!artifact.file.endsWith('.dmg')) return
@@ -0,0 +1,8 @@
/** Notarize with separate upload and wait timings while preserving signature checks and stapling. */
import type { MacOSNotarizationEnvironment } from './desktop-release-environment.mjs'
/**
* Notarize and staple an App or DMG through the measured notarytool adapter.
* @param options Validated Apple credentials and artifact path.
* @returns Resolves after acceptance and stapling; failures remain in the packaging journal.
*/
export function notarizeMacOS(options: MacOSNotarizationEnvironment & { appPath: string }): Promise<void>
+15
View File
@@ -0,0 +1,15 @@
/** Preserve electron-notarize's signature checks and stapling while recording separate Apple phases. */
import { fileURLToPath } from 'node:url'
import { notarize } from '@electron/notarize'
import { packagingStep } from './packaging-step.mjs'
/**
* Notarize and staple one App or DMG through the measured notarytool adapter.
* @param {object} options Validated credentials and appPath accepted by electron-notarize.
* @returns {Promise<void>} Resolves only after Apple acceptance and stapling.
*/
export async function notarizeMacOS(options) {
const secrets = Object.entries(options).filter(([name]) => /password|key|appleId/iu.test(name)).map(([, value]) => String(value))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'notarize-and-staple',
() => notarize({ ...options, notarytoolPath: fileURLToPath(new URL('./logged-notarytool.mjs', import.meta.url)) }), secrets)
}
+8 -6
View File
@@ -4,7 +4,8 @@ import { execFile } from 'node:child_process'
import { mkdtemp, rename, rm, stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { promisify } from 'node:util'
import { notarize } from '@electron/notarize'
import { notarizeMacOS } from './notarize-macos.mjs'
import { packagingStep } from './packaging-step.mjs'
import {
resolveMacOSNotarizationEnvironment,
resolveMacOSSigningEnvironment,
@@ -45,15 +46,15 @@ const operations: MacOSArtifactOperations = {
async copyApp(source, destination) {
await execute('/usr/bin/ditto', [source, destination])
},
notarize,
notarize: notarizeMacOS,
verifySignature: verifyMacOSSignature,
verifyNotarization: verifyMacOSNotarizedApplication,
}
async function timed(label: string, action: () => Promise<void>): Promise<void> {
async function timed(label: string, action: () => Promise<void>, secrets: readonly string[]): Promise<void> {
const start = performance.now()
process.stdout.write(`desktop macOS packaging: ${label} started at ${new Date().toISOString()}\n`)
await action()
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, label, action, secrets)
process.stdout.write(`desktop macOS packaging: ${label} completed in ${((performance.now() - start) / 1000).toFixed(2)}s\n`)
}
@@ -73,6 +74,7 @@ export async function packageMacOSArtifacts(
apple: MacOSArtifactOperations = operations,
): Promise<void> {
const { arch, version, artifactsRoot, environment } = request
const secrets = Object.entries(environment).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '')
const expected = resolveMacOSSigningEnvironment(environment)
const credentials = resolveMacOSNotarizationEnvironment(environment)
const update = resolveDesktopAutoUpdateConfig(environment, 'darwin', arch)
@@ -95,10 +97,10 @@ export async function packageMacOSArtifacts(
await apple.notarize({ appPath: zipApp, ...credentials })
apple.verifyNotarization(zipApp, expected)
await build({ format: 'zip', appPath: zipApp, output: zipOutput })
}),
}, secrets),
timed('DMG creation and notarization', async () => {
await build({ format: 'dmg', appPath: dmgApp, output: dmgOutput })
}),
}, secrets),
])
const failures = results.filter(result => result.status === 'rejected')
if (failures.length > 0) {
+53 -18
View File
@@ -1,6 +1,6 @@
/** Build one release target with matching Electron and dsh architecture. */
import { spawn } from 'node:child_process'
import { execFileSync, spawn } from 'node:child_process'
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
import { join, resolve } from 'node:path'
@@ -15,6 +15,11 @@ import { createPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
import { withWindowsSigningStage } from './windows-signing-stage.mjs'
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
import { withMacOSSigningKeychain } from './macos-signing-keychain.mjs'
import { macOSDownloadEnvironment, resolveMacOSPackageSettings } from './macos-package-settings.mjs'
import { packagingErrorDetails, packagingStep } from './packaging-step.mjs'
import { notarizeMacOS } from './notarize-macos.mjs'
import { resolveMacOSNotarizationEnvironment } from './desktop-release-environment.mjs'
import { withMacOSNotarizationProxy } from './macos-notarization-proxy.ts'
const APP_ROOT = resolve(import.meta.dirname, '..')
const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
@@ -283,32 +288,51 @@ async function main(): Promise<void> {
const invocation = parseDesktopPackageInvocation(process.argv.slice(2))
const { target } = invocation
const environment = loadDesktopPackageEnvironment(target.platform)
validateDesktopPackageEnvironment(environment, target, invocation)
if (invocation.check) {
validateDesktopPackageEnvironment(environment, target, invocation)
process.stdout.write(`desktop package: ${target.name} local configuration valid; signing and notarization were not attempted\n`)
return
}
const run = target.platform === 'win32'
? createPackagingRun(join(APP_ROOT, '.desktop-build', 'packaging-runs'), {
target: target.name, unsigned: invocation.unsigned, version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
}) : undefined
if (run !== undefined) console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`)
const secrets = Object.entries(environment).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '')
const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'packaging-runs'), {
target: target.name, unsigned: invocation.unsigned, directory: invocation.directory, prepareOnly: invocation.prepareOnly,
version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'), node: process.version,
commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPOSITORY_ROOT, encoding: 'utf8' }).trim(),
dirty: execFileSync('git', ['status', '--porcelain', '--untracked-files=normal'], { cwd: REPOSITORY_ROOT, encoding: 'utf8' }).trim() !== '',
}, { parallel: target.platform === 'darwin', secrets })
console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`)
const previousDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = run.directory
let success = false
try {
await packagingStep(run.directory, 'configuration', async () => { validateDesktopPackageEnvironment(environment, target, invocation) }, secrets)
if (target.platform === 'darwin') {
await withMacOSSigningKeychain(environment, signingEnvironment => packageTarget(invocation, signingEnvironment, run))
const settings = resolveMacOSPackageSettings(environment)
recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency,
downloadProxyConfigured: settings.downloadProxy !== undefined,
notarizationProxyConfigured: settings.notarizationProxy !== undefined })
await packagingStep(run.directory, 'macos-package', () => withMacOSSigningKeychain(environment,
signingEnvironment => packageTarget(invocation, signingEnvironment, run)), secrets)
} else {
await packageTarget(invocation, environment, run)
await packagingStep(run.directory, 'windows-package', () => packageTarget(invocation, environment, run), secrets)
}
success = true
} finally { run?.finish(success) }
} catch (error) {
process.stderr.write(`${packagingErrorDetails(error, secrets)}\n`)
process.stderr.write(`desktop package: failed; see ${run.directory}/events.jsonl\n`)
process.exitCode = 1
} finally {
if (previousDirectory === undefined) delete process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
else process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = previousDirectory
run.finish(success)
}
}
/**
* Prepare one release only after its signing preflight, without publishing from the builder.
* @param invocation Validated host, target and packaging mode.
* @param environment File-owned release configuration.
* @param run Windows stage supervisor; required for signed Windows packaging.
* @param run Persistent stage supervisor; required for signed Windows packaging and enabled for all release commands.
* @returns Resolves after preparation or complete packaging; any failed stage prevents a release record.
*/
export async function packageTarget(
@@ -318,6 +342,10 @@ export async function packageTarget(
): Promise<void> {
const { target } = invocation
const execute = (args: readonly string[], env: NodeJS.ProcessEnv, cwd: string = APP_ROOT) => runPnpm(args, env, cwd, run)
const journal = target.platform === 'darwin' ? process.env.DSH_DESKTOP_PACKAGING_RUN_DIR : undefined
const proxyEvent = (status: string) => { if (journal) recordPackagingEvent(journal, { type: 'notarization-proxy', status }) }
const mac = target.platform === 'darwin' ? resolveMacOSPackageSettings(environment) : undefined
const packArguments = mac === undefined ? [] : ['--concurrency', String(mac.packConcurrency)]
const buildPaths = desktopTargetBuildPaths(target.name)
const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
if (!invocation.prepareOnly && !invocation.unsigned) {
@@ -330,7 +358,8 @@ export async function packageTarget(
DSH_DESKTOP_TARGET_PLATFORM: target.platform,
DSH_DESKTOP_TARGET_ARCH: target.arch,
}
const electronBuilderEnv = desktopElectronBuilderEnvironment(targetEnv, invocation.unsigned)
const downloadEnv = macOSDownloadEnvironment(targetEnv, mac?.downloadProxy)
const electronBuilderEnv = desktopElectronBuilderEnvironment(downloadEnv, invocation.unsigned)
for (const name of WINDOWS_SIGNING_ENV_NAMES) {
if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name]
}
@@ -364,7 +393,7 @@ export async function packageTarget(
})
}
await execute(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
await execute(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
await execute(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh, ...packArguments], buildEnv, REPOSITORY_ROOT)
await execute([
'--dir',
'apps/desktop-host',
@@ -372,7 +401,7 @@ export async function packageTarget(
'--pack-destination',
buildPaths.packedDsh,
], buildEnv, REPOSITORY_ROOT)
await execute(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT)
await execute(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor, ...packArguments], buildEnv, REPOSITORY_ROOT)
rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
mkdirSync(buildPaths.packedLandlock, { recursive: true })
await execute(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
@@ -383,10 +412,10 @@ export async function packageTarget(
'--pack-destination',
buildPaths.packedLandlock,
], buildEnv, REPOSITORY_ROOT)
await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], targetEnv)
await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], downloadEnv)
if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime'], electronBuilderEnv)
await execute(['run', 'prepare:packages'], targetEnv)
await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], targetEnv)
await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], downloadEnv)
if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv)
if (invocation.prepareOnly) return
if (target.platform === 'darwin' && !invocation.directory) {
@@ -394,17 +423,23 @@ export async function packageTarget(
...desktopElectronBuilderArguments(target, true),
'--config.mac.notarize=false',
], electronBuilderEnv)
await packageMacOSArtifacts({
await withMacOSNotarizationProxy(mac?.notarizationProxy, () => packageMacOSArtifacts({
arch: target.arch,
version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
artifactsRoot: buildPaths.artifacts,
environment: electronBuilderEnv,
}, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv))
}, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent)
} else if (target.platform === 'darwin') {
await execute([...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false'], electronBuilderEnv)
const appPath = join(buildPaths.artifacts, target.arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app')
await withMacOSNotarizationProxy(mac?.notarizationProxy,
() => notarizeMacOS({ appPath, ...resolveMacOSNotarizationEnvironment(environment) }), undefined, undefined, proxyEvent)
} else {
await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv))
}
if (signPrimaryRuntime) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv)
if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
if (journal) recordPackagingEvent(journal, { type: 'artifacts', directory: buildPaths.artifacts })
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()
+2 -1
View File
@@ -26,9 +26,10 @@ export function packagingOutputRedactor(secrets: readonly string[], emit: (text:
* Allocate retained evidence and supervise sequential child-process stages.
* @param root Parent directory for retained records.
* @param metadata Public target/version metadata only.
* @param settings Opt-in parallel stages and additional credentials to redact.
* @returns A run that blocks later stages after failure and awaits owned process termination.
*/
export function createPackagingRun(root: string, metadata: object): {
export function createPackagingRun(root: string, metadata: object, settings?: { parallel?: boolean; secrets?: readonly string[] }): {
directory: string
run(stage: string, executable: string, args: readonly string[], options: {
cwd: string
+21 -12
View File
@@ -1,6 +1,7 @@
/** Persist redacted packaging evidence and terminate the owned stage tree on fatal signing failures. */
import { randomUUID } from 'node:crypto'
import { spawn } from 'node:child_process'
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
@@ -59,22 +60,26 @@ export function packagingOutputRedactor(secrets, emit) {
* Allocate a run whose failures never become release completion records.
* @param {string} root Parent for retained packaging records.
* @param {object} metadata Public target/version metadata only.
* @param {{parallel?: boolean, secrets?: readonly string[]}} settings Parallel stages are opt-in; secrets include credentials removed from child environments.
* @returns {{directory: string, run: (stage: string, executable: string, args: readonly string[], options: {cwd: string, env: NodeJS.ProcessEnv, timeoutMs?: number}) => Promise<void>, finish: (success: boolean) => void}} Owned run supervisor; an optional stage deadline records timeout independently of exit status and awaits termination.
*/
export function createPackagingRun(root, metadata) {
export function createPackagingRun(root, metadata, settings = {}) {
const started = performance.now()
mkdirSync(root, { recursive: true })
const directory = realpathSync(mkdtempSync(join(resolve(root), `${new Date().toISOString().replaceAll(':', '-')}-`)))
for (const name of ['events.jsonl', 'stdout.log', 'stderr.log']) {
writeFileSync(join(directory, name), '', { flag: 'wx', mode: 0o600 })
}
writeFileSync(join(directory, 'run.json'), `${JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid, ...metadata })}\n`, { flag: 'wx', flush: true })
writeFileSync(join(directory, 'run.json'), `${JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid, ...metadata })}\n`, { flag: 'wx', mode: 0o600, flush: true })
let failed = false
let active = false
let active = 0
const fatal = join(directory, 'fatal.json')
async function run(stage, executable, args, options) {
if (failed || existsSync(fatal)) throw new Error(`desktop package: run is blocked; see ${directory}`)
if (active) throw new Error('desktop package: supervised stages must run sequentially')
active = true
if (active && !settings.parallel) throw new Error('desktop package: supervised stages must run sequentially')
active++
const stageId = randomUUID()
const started = performance.now()
let child
let fatalObserved = false
let launchError = false
@@ -112,22 +117,23 @@ export function createPackagingRun(root, metadata) {
process.once('SIGINT', interrupted)
process.once('SIGTERM', interrupted)
try {
recordPackagingEvent(directory, { type: 'stage-start', stage })
recordPackagingEvent(directory, { type: 'stage-start', stage, stageId })
child = spawn(executable, [...args], { cwd: options.cwd, env: { ...options.env, DSH_DESKTOP_PACKAGING_RUN_DIR: directory },
windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] })
closed = new Promise(resolveClose => {
child.once('error', () => { launchError = true })
child.once('close', (code, signal) => { stageClosed = true; resolveClose({ code, signal }) })
})
recordPackagingEvent(directory, { type: 'stage-spawn', stage, childPid: child.pid })
recordPackagingEvent(directory, { type: 'stage-spawn', stage, stageId, childPid: child.pid })
if (options.timeoutMs !== undefined) deadline = setTimeout(() => { timedOut = true; stop() }, options.timeoutMs)
const secrets = Object.entries(options.env).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD/iu.test(name)).map(([, value]) => value ?? '')
const secrets = [...(settings.secrets ?? []), ...Object.entries(options.env).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '')]
const streams = [['stdout', child.stdout, process.stdout], ['stderr', child.stderr, process.stderr]]
for (const [name, stream, consoleStream] of streams) {
let notification = ''
const redactor = packagingOutputRedactor(secrets, text => {
try {
appendFileSync(join(directory, `${name}.log`), text, { flush: true })
if (settings.parallel) recordPackagingEvent(directory, { type: 'output', stage, stageId, stream: name, text })
consoleStream.write(text)
} catch { outputError = true; stop() }
notification += text
@@ -142,7 +148,7 @@ export function createPackagingRun(root, metadata) {
const result = await closed
await termination
fatalObserved ||= existsSync(fatal)
recordPackagingEvent(directory, { type: 'stage-end', stage, ...result, timedOut, fatalObserved, launchError, outputError, terminationCode, terminationError })
recordPackagingEvent(directory, { type: 'stage-end', stage, stageId, elapsedMs: performance.now() - started, ...result, timedOut, fatalObserved, launchError, outputError, terminationCode, terminationError })
if (result.code !== 0 || result.signal !== null || fatalObserved || launchError || outputError || terminationError) {
failed = true
throw new Error(`desktop package: ${stage} failed; evidence: ${directory}`)
@@ -157,7 +163,7 @@ export function createPackagingRun(root, metadata) {
clearTimeout(deadline)
process.removeListener('SIGINT', interrupted)
process.removeListener('SIGTERM', interrupted)
active = false
active--
}
}
return {
@@ -165,7 +171,10 @@ export function createPackagingRun(root, metadata) {
run,
finish(success) {
if (active) throw new Error('desktop package: cannot finish an active run')
writeFileSync(join(directory, 'result.json'), `${JSON.stringify({ completedAt: new Date().toISOString(), success: success && !failed && !existsSync(fatal) })}\n`, { flag: 'wx', flush: true })
const events = readFileSync(join(directory, 'events.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
const stages = events.filter(event => event.type === 'stage-end')
const proxy = events.filter(event => event.type === 'notarization-proxy').at(-1)?.status ?? 'not-used'
writeFileSync(join(directory, 'result.json'), `${JSON.stringify({ completedAt: new Date().toISOString(), elapsedMs: performance.now() - started, success: success && !failed && !existsSync(fatal), proxy, artifacts: events.filter(event => event.type === 'artifacts').at(-1)?.directory, stages })}\n`, { flag: 'wx', mode: 0o600, flush: true })
},
}
}
+18
View File
@@ -0,0 +1,18 @@
/** Persist in-process and Apple-tool phases in the packaging journal. */
/**
* Record a phase outcome and redacted nested failure details.
* @param directory Existing run directory; undefined retains unrecorded callers.
* @param stage Public phase label, never credentials.
* @param action Work that settles all owned activity before returning.
* @param secrets Credential values removed from errors.
* @returns Action result; logging failures also reject the phase.
*/
export function packagingStep<T>(directory: string | undefined, stage: string, action: () => Promise<T>, secrets?: readonly string[]): Promise<T>
/**
* Format nested errors with credential values removed for journal and terminal output.
* @param error Failure including optional causes or aggregate errors.
* @param secrets Credential values removed from output.
* @returns Redacted diagnostic including nested recovery instructions.
*/
export function packagingErrorDetails(error: unknown, secrets: readonly string[]): string
+49
View File
@@ -0,0 +1,49 @@
/** Record in-process and Apple-tool phases in the same journal as packaging subprocesses. */
import { randomUUID } from 'node:crypto'
import { recordPackagingEvent, packagingOutputRedactor } from './packaging-run.mjs'
/**
* Persist a phase outcome, including nested failure details, without logging arguments or environments.
* @param {string | undefined} directory Existing run directory; undefined retains unrecorded callers.
* @param {string} stage Public phase label, never credentials.
* @param {() => Promise<unknown>} action Work that settles all owned activity before returning.
* @param {readonly string[]} secrets Credential values removed from errors.
* @returns {Promise<unknown>} Action result; logging failures also reject the phase.
*/
export async function packagingStep(directory, stage, action, secrets = Object.entries(process.env).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '')) {
if (directory === undefined) return action()
const stageId = randomUUID()
const started = performance.now()
recordPackagingEvent(directory, { type: 'stage-start', stage, stageId })
try {
const result = await action()
recordPackagingEvent(directory, { type: 'stage-end', stage, stageId, elapsedMs: performance.now() - started, success: true })
return result
} catch (error) {
const message = packagingErrorDetails(error, secrets)
recordPackagingEvent(directory, { type: 'stage-end', stage, stageId, elapsedMs: performance.now() - started, success: false, error: message })
throw error
}
}
/**
* Format nested errors with credential values removed for journal and terminal output.
* @param {unknown} error Failure including optional causes or aggregate errors.
* @param {readonly string[]} secrets Credential values removed from output.
* @returns {string} Redacted diagnostic including nested recovery instructions.
*/
export function packagingErrorDetails(error, secrets) {
const seen = new Set()
function details(value) {
if (!(value instanceof Error)) return String(value)
if (seen.has(value)) return '[circular error]'
seen.add(value)
return [value.stack ?? value.message, ...(value instanceof AggregateError ? value.errors.map(details) : []),
...(value.cause === undefined ? [] : [details(value.cause)])].join('\n')
}
let message = ''
const redactor = packagingOutputRedactor(secrets, text => { message += text })
redactor.write(Buffer.from(details(error)))
redactor.end()
return message
}
+34 -21
View File
@@ -1,5 +1,6 @@
/** Materialize the complete production runtime before publishing Desktop resources. */
import { packagingStep } from './packaging-step.mjs'
import { spawn } from 'node:child_process'
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -107,20 +108,24 @@ function runPnpm(args: readonly string[]): Promise<void> {
}
async function main(): Promise<void> {
rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
mkdirSync(STORE_ROOT, { recursive: true })
try {
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:reset', async () => {
rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
mkdirSync(STORE_ROOT, { recursive: true })
})
const release = desktopRelease()
copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(BUILD_ROOT, DESKTOP_PACKAGE_SET_FILE))
cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(BUILD_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true })
createRuntimeProjectMetadata(BUILD_ROOT, release)
await runPnpm(['install', '--lockfile-only'])
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:stage-packages', async () => {
copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(BUILD_ROOT, DESKTOP_PACKAGE_SET_FILE))
cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(BUILD_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true })
createRuntimeProjectMetadata(BUILD_ROOT, release)
})
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:lockfile', () => runPnpm(['install', '--lockfile-only']))
verifyDesktopCoreLockfile(
readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'),
readDesktopCorePackageSet(BUILD_ROOT, release.version),
)
await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile'])
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile']))
const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version)
const targetName = resolveDesktopBuildTarget()
const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' }
@@ -128,9 +133,11 @@ async function main(): Promise<void> {
const officeManifest = JSON.parse(readFileSync(join(modules, '@deepseek-ai/libreoffice-kit/package.json'), 'utf8'))
const officeEngine = selectOfficeEngine(officeManifest, target)
mkdirSync(DSH_OUTPUT_ROOT, { recursive: true })
cpSync(modules, join(DSH_OUTPUT_ROOT, 'node_modules'), {
recursive: true, dereference: true,
filter: source => desktopRuntimeFileExclusion(relative(modules, source), target, officeEngine) === undefined,
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:materialize-modules', async () => {
cpSync(modules, join(DSH_OUTPUT_ROOT, 'node_modules'), {
recursive: true, dereference: true,
filter: source => desktopRuntimeFileExclusion(relative(modules, source), target, officeEngine) === undefined,
})
})
writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({
name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module',
@@ -145,23 +152,29 @@ async function main(): Promise<void> {
throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`)
}
if (process.platform === 'darwin') {
await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env))
await signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), join(BUILD_PATHS.root, 'signature-cache')))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), join(BUILD_PATHS.root, 'signature-cache')))
}
await prepareRuntimeManifests(DSH_OUTPUT_ROOT)
smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))
writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target)
const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:write-descriptor', async () => writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target))
const descriptor = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:verify-before-smoke', () => verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target))
if (!process.argv.includes('--defer-runtime-smoke')) {
await smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor)
await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:smoke', () => smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:verify-after-smoke', () => verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target))
}
} catch (error) {
rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
throw error
} finally {
rmSync(BUILD_ROOT, { recursive: true, force: true })
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
let cleaned = false
const cleanup = (): void => {
try { rmSync(BUILD_ROOT, { recursive: true, force: true }) }
finally { rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) }
cleaned = true
}
try { await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:cleanup', async () => cleanup()) }
finally { if (!cleaned) cleanup() }
}
}
+6 -3
View File
@@ -1,5 +1,6 @@
/** Prepare the target Electron distribution and pinned pnpm CLI. */
import { packagingStep } from './packaging-step.mjs'
import { execFileSync } from 'node:child_process'
import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
@@ -32,9 +33,10 @@ async function main(): Promise<void> {
const arch = target.endsWith('arm64') ? 'arm64' : 'x64'
const require = createRequire(import.meta.url)
const { version } = require('electron/package.json') as { version: string }
const archive = await downloadArtifact({ version, platform, arch, artifactName: 'electron', cacheRoot: BUILD_PATHS.downloads })
const archive = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'download:electron',
() => downloadArtifact({ version, platform, arch, artifactName: 'electron', cacheRoot: BUILD_PATHS.downloads }))
rmSync(BUILD_PATHS.electron, { recursive: true, force: true })
await extractZip(archive, { dir: BUILD_PATHS.electron })
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'extract:electron', () => extractZip(archive, { dir: BUILD_PATHS.electron }))
const executable = join(BUILD_PATHS.electron, platform === 'win32' ? 'electron.exe' : 'Electron.app/Contents/MacOS/Electron')
const nodeVersion = execFileSync(executable, ['-p', 'process.versions.node'], {
encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
@@ -49,7 +51,8 @@ async function main(): Promise<void> {
node: nodeVersion,
pnpm: pnpmVersion,
}, undefined, 2)}\n`)
await preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] })
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime',
() => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] }))
}
await main()
@@ -44,7 +44,9 @@ try {
DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64',
DSH_DESKTOP_UNSIGNED: '1', CSC_IDENTITY_AUTO_DISCOVERY: 'false', ELECTRON_BUILDER_7Z_FILTER: 'BCJ',
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: signingEnvironment.DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN
?? 'https://harness-test.deepseek.com',
?? 'https://test.example.com',
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: signingEnvironment.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG
?? JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
...signingRun ? { DSH_DESKTOP_PACKAGING_RUN_DIR: signingRun.directory } : {},
})
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
+2 -1
View File
@@ -735,7 +735,8 @@ async function main(): Promise<void> {
const policyConfig = resolveDesktopPolicyConfig(policyInput, !app.isPackaged)
if (policyConfig !== undefined) {
if (policyConfig.authentication === 'feishu-test') {
policyAuth = new DesktopPolicyTestAuth(policyConfig.origin, locale, () => mandatoryUI?.confirmationWindow ?? mainWindow,
policyAuth = new DesktopPolicyTestAuth(policyConfig.origin, policyConfig.allowedAuthOrigins, locale,
() => mandatoryUI?.confirmationWindow ?? mainWindow,
(event) => { console.info(`desktop policy authentication: ${event}`); updateJournal?.action(`policy-login-${event}`) })
}
const bundleId = app.isPackaged
@@ -16,6 +16,7 @@ export interface DesktopPolicyIdentity {
export interface DesktopPolicyConfig {
readonly origin: string
readonly allowedPageOrigins: readonly string[]
readonly allowedAuthOrigins: readonly string[]
readonly intervalMs: number
readonly timeoutMs: number
readonly maxBackoffMs: number
@@ -74,12 +75,20 @@ export function resolveDesktopPolicyConfig(input: unknown, allowLoopback = false
if (authentication !== 'anonymous' && authentication !== 'feishu-test') {
throw new Error('desktop policy: authentication must be anonymous or feishu-test')
}
const authOrigins = value.allowedAuthOrigins
if (authentication === 'feishu-test' && (!Array.isArray(authOrigins) || authOrigins.length === 0)) {
throw new Error('desktop policy: test authentication requires nonempty allowedAuthOrigins')
}
if (authentication === 'anonymous' && authOrigins !== undefined) {
throw new Error('desktop policy: anonymous policy must not configure allowedAuthOrigins')
}
if (typeof jitter !== 'number' || !Number.isFinite(jitter) || jitter < 0 || jitter > 1 || maxBackoffMs < intervalMs) {
throw new Error('desktop policy: jitter must be in [0, 1] and maxBackoffMs must cover intervalMs')
}
return {
origin: origin(value.origin, authentication === 'anonymous' && allowLoopback),
allowedPageOrigins: value.allowedPageOrigins.map(item => origin(item, false)),
allowedAuthOrigins: authentication === 'feishu-test' ? (authOrigins as unknown[]).map(item => origin(item, false)) : [],
intervalMs, timeoutMs: duration('timeoutMs', 15_000), maxBackoffMs, jitter, authentication,
}
}
+4 -5
View File
@@ -3,9 +3,6 @@ import { randomUUID } from 'node:crypto'
import { BrowserWindow, session } from 'electron'
import type { DesktopLocale } from './locale.ts'
const FEISHU_ORIGINS = ['https://open.feishu.cn', 'https://accounts.feishu.cn',
'https://passport.feishu.cn', 'https://login.feishu.cn']
/** Packaged placeholder document; it is the window's first document and needs no network. */
const LOGIN_LOADING_PAGE = 'renderer/policy-login-loading.html'
@@ -28,11 +25,13 @@ export class DesktopPolicyTestAuth {
/**
* @param origin Validated HTTPS policy origin; login always starts at its root with fresh gateway state.
* @param allowedAuthOrigins Validated HTTPS origins for login document navigation.
* @param locale Shell-owned login title.
* @param parent Current application or mandatory-update window.
* @param record Fixed, nonsecret login outcomes for diagnostic evidence.
*/
constructor(private readonly origin: string, private readonly locale: DesktopLocale,
constructor(private readonly origin: string, private readonly allowedAuthOrigins: readonly string[],
private readonly locale: DesktopLocale,
private readonly parent: () => BrowserWindow | undefined,
private readonly record: (event: 'opened' | DesktopPolicyLoginResult) => void) {
this.browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) })
@@ -144,7 +143,7 @@ export class DesktopPolicyTestAuth {
let url: URL
try { url = new URL(value) } catch { return false }
return url.protocol === 'https:' && url.username === '' && url.password === ''
&& (url.origin === this.origin || FEISHU_ORIGINS.includes(url.origin))
&& (url.origin === this.origin || this.allowedAuthOrigins.includes(url.origin))
}
/**
@@ -6,7 +6,8 @@ import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from
const WINDOWS = { platform: 'win32', arch: 'x64' } as const
const MACOS = { platform: 'darwin', arch: 'arm64' } as const
const POLICY = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com' }
const POLICY = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }) }
const RELEASE = { ...POLICY, DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com' }
const MAC_IDENTITY = { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234' }
@@ -134,3 +135,14 @@ describe('Desktop local packaging configuration', () => {
})
})
})
it('owns macOS tuning in the local file and validates it before signing credentials', async () => {
await withDirectory(async (directory) => {
await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_MACOS_PACK_CONCURRENCY=2\nDSH_DESKTOP_MACOS_DOWNLOAD_PROXY=http://proxy.example:8080\nDSH_DESKTOP_MACOS_NOTARIZATION_PROXY=\n')
const env = loadDesktopPackageEnvironment('darwin', { DSH_DESKTOP_MACOS_PACK_CONCURRENCY: '99' }, directory)
expect(env.DSH_DESKTOP_MACOS_PACK_CONCURRENCY).toBe('2')
expect(env.DSH_DESKTOP_MACOS_NOTARIZATION_PROXY).toBe('')
expect(() =>{ validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_MACOS_PACK_CONCURRENCY: '' }, MACOS) }).toThrow('PACK_CONCURRENCY')
expect(() =>{ validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'socks5://localhost:8080' }, MACOS) }).toThrow('NOTARIZATION_PROXY')
})
})
@@ -3,28 +3,32 @@ import { resolveDesktopPolicyEnvironment } from '../scripts/desktop-policy-envir
import { validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
import { resolveDesktopPolicyConfig } from '../src/mandatory-update-policy.ts'
const origins = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://harness-test.deepseek.com',
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://harness.deepseek.com' }
const origins = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://test.example.com',
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://prod.example.com' }
const auth = { DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }) }
it.each(['test', 'production'] as const)('selects the %s policy and authentication together', (deployment) => {
const policy = resolveDesktopPolicyEnvironment({ ...origins, DSH_DESKTOP_AUTO_UPDATE_ENV: deployment })
const policy = resolveDesktopPolicyEnvironment({ ...origins, ...(deployment === 'test' ? auth : {}), DSH_DESKTOP_AUTO_UPDATE_ENV: deployment })
const origin = deployment === 'test' ? origins.DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN : origins.DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN
expect(policy).toEqual({ origin, allowedPageOrigins: [origin], authentication: deployment === 'test' ? 'feishu-test' : 'anonymous' })
expect(policy).toEqual({ origin, allowedPageOrigins: [origin],
...(deployment === 'test' ? { allowedAuthOrigins: ['https://login.example.com'] } : {}),
authentication: deployment === 'test' ? 'feishu-test' : 'anonymous' })
expect(resolveDesktopPolicyConfig(policy)).toMatchObject(policy)
})
it('requires only the selected origin, defaults to test, and accepts explicit page restrictions', () => {
const policy = resolveDesktopPolicyEnvironment({
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origins.DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN,
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedPageOrigins: ['https://download.deepseek.com'], intervalMs: 5000 }) })
expect(policy).toMatchObject({ authentication: 'feishu-test', intervalMs: 5000, allowedPageOrigins: ['https://download.deepseek.com'] })
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://download.example.com'], intervalMs: 5000 }) })
expect(policy).toMatchObject({ authentication: 'feishu-test', intervalMs: 5000, allowedPageOrigins: ['https://download.example.com'] })
expect(() => resolveDesktopPolicyEnvironment({ ...origins, DSH_DESKTOP_AUTO_UPDATE_ENV: 'prod' })).toThrow('production')
})
it.each([undefined, '', 'http://harness-test.deepseek.com', 'https://user:secret@harness-test.deepseek.com',
'https://harness-test.deepseek.com/api', 'https://harness-test.deepseek.com/?secret=value'])('rejects invalid selected origin %s', (origin) => {
expect(() => resolveDesktopPolicyEnvironment({ DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origin })).toThrow('HTTPS origin')
expect(() => resolveDesktopPolicyEnvironment({ DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origin })).not.toThrow('secret=value')
it.each([undefined, '', 'http://test.example.com', 'https://user:secret@test.example.com',
'https://test.example.com/api', 'https://test.example.com/?secret=value'])('rejects invalid selected origin %s', (origin) => {
expect(() => resolveDesktopPolicyEnvironment({ ...auth, DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origin })).toThrow('HTTPS origin')
expect(() => resolveDesktopPolicyEnvironment({ ...auth, DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origin })).not.toThrow('secret=value')
})
it.each(['{', 'null', '[]', '{"origin":"https://old.example.com"}', '{"authentication":"anonymous"}',
@@ -32,6 +36,17 @@ it.each(['{', 'null', '[]', '{"origin":"https://old.example.com"}', '{"authentic
expect(() => resolveDesktopPolicyEnvironment({ ...origins, DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: options })).toThrow()
})
it.each([undefined, '[]', '["http://login.example.com"]', '["https://login.example.com/path"]',
'["https://user:[email protected]"]'])('rejects missing or invalid test login origins %s', (value) => {
const settings = value === undefined ? {} : { allowedAuthOrigins: JSON.parse(value) as unknown }
expect(() => resolveDesktopPolicyEnvironment({ ...origins, DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify(settings) })).toThrow()
})
it('rejects login origins in production', () => {
expect(() => resolveDesktopPolicyEnvironment({ ...origins, ...auth, DSH_DESKTOP_AUTO_UPDATE_ENV: 'production' }))
.toThrow('must not configure allowedAuthOrigins')
})
it.each([{ unsigned: true }, { prepareOnly: true }, {}])('fails before signing/preparation when policy is absent in %j', (options) => {
for (const platform of ['win32', 'darwin'] as const) {
expect(() => { validateDesktopPackageEnvironment({ DSH_DESKTOP_APP_ID: 'com.example.test' }, { platform, arch: 'x64' }, options) })
+15
View File
@@ -0,0 +1,15 @@
/** Exercise one independent signature-cache writer through native TypeScript stripping. */
import { readFileSync, writeFileSync } from 'node:fs'
import { cachedMacOSSignature } from '../../scripts/macos-signature-cache.ts'
const [file, cache] = process.argv.slice(2)
await cachedMacOSSignature(file, cache, {
policy: 'cross-process-fixture',
sign: async path => {
const ready = new Promise(resolve => process.once('message', resolve))
process.send('ready')
await ready
writeFileSync(path, `${readFileSync(path, 'utf8')}:signed`)
},
verify: path => { if (readFileSync(path, 'utf8') !== 'original:signed') throw Error('invalid signature') },
})
process.disconnect()
@@ -20,6 +20,7 @@ vi.mock('../scripts/windows-sign.mjs', () => ({
const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const
const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.deepseek.com',
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
DOWNLOAD_TEST_COS_BUCKET: 'bj-toc-download-test-1320056602' }
const require = createRequire(import.meta.url)
const { validateConfiguration } = require('app-builder-lib/out/util/config/config.js') as {
@@ -61,7 +61,7 @@ async function fixture(body: (context: {
await writeFile(join(source, 'package.json'), JSON.stringify({ name: `dsh-update-test-${run.id}`, version,
dshDesktopAppId: run.appId, main: 'qualification-bootstrap.mjs', type: 'module',
dshMandatoryUpdatePolicy: { origin: 'https://policy.example.com', allowedPageOrigins: ['https://policy.example.com'],
authentication: 'feishu-test' } }))
authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'] } }))
for (const name of ['electron-updater', 'semver']) {
await mkdir(join(source, 'node_modules', name), { recursive: true })
await cp(require.resolve(`${name}/package.json`), join(source, 'node_modules', name, 'package.json'))
@@ -16,7 +16,7 @@ describe('installer preparation preserves application dependencies', () => {
const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs')
expect(() => createElectronBuilderConfig({ DSH_DESKTOP_APP_ID: 'com.example.installer',
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://harness-test.deepseek.com',
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://test.example.com',
}, platform, 'x64')).toThrow('DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN')
})
it.each(['win32', 'darwin'] as const)('keeps electron-builder responsible for node_modules on %s', async (platform) => {
@@ -24,6 +24,7 @@ describe('installer preparation preserves application dependencies', () => {
const env = {
DSH_DESKTOP_APP_ID: 'com.example.installer',
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
DSH_DESKTOP_TARGET_PLATFORM: platform,
DSH_DESKTOP_TARGET_ARCH: 'x64',
DSH_DESKTOP_UNSIGNED: platform === 'win32' ? '1' : '0',
@@ -0,0 +1,33 @@
import { expect, it, vi } from 'vitest'
import { runLoggedNotarytool } from '../scripts/logged-notarytool.mjs'
const command = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>>())
vi.mock('node:child_process', async (importOriginal) => {
const { promisify } = await import('node:util')
return {
...await importOriginal<typeof import('node:child_process')>(),
execFile: Object.assign(() => {}, { [promisify.custom]: command }),
}
})
const id = '11111111-2222-3333-4444-555555555555'
const args = ['submit', '/fixture/app.zip', '--keychain-profile', 'fixture', '--wait', '--output-format', 'json']
it('passes Apple rejection JSON from a nonzero wait exit back to electron-notarize for diagnostic retrieval', async () => {
command.mockReset()
command.mockResolvedValueOnce({ stdout: JSON.stringify({ id }), stderr: '' })
command.mockRejectedValueOnce(Object.assign(new Error('notarytool exited 65'), {
code: 65, stdout: JSON.stringify({ id, status: 'Invalid' }), stderr: '',
}))
expect(JSON.parse(await runLoggedNotarytool(args))).toEqual({ id, status: 'Invalid' })
expect(command).toHaveBeenCalledTimes(2)
})
it('does not treat failed upload output as a completed submission', async () => {
command.mockReset()
command.mockRejectedValueOnce(Object.assign(new Error('upload failed'), {
code: 1, stdout: JSON.stringify({ id }), stderr: '',
}))
await expect(runLoggedNotarytool(args)).rejects.toThrow('upload failed')
expect(command).toHaveBeenCalledOnce()
})
@@ -0,0 +1,82 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it, vi } from 'vitest'
import { runLoggedNotarytool } from '../scripts/logged-notarytool.mjs'
import { createPackagingRun } from '../scripts/packaging-run.mjs'
const id = '11111111-2222-3333-4444-555555555555'
const args = ['submit', '/fixture/app.zip', '--apple-id', '[email protected]', '--password', 'secret-value', '--team-id', 'TEAM', '--wait', '--output-format', 'json']
async function fixture(action: (directory: string) => Promise<void>) {
const root = await mkdtemp(join(tmpdir(), 'notary-log-'))
try { await action(createPackagingRun(root, {}).directory) } finally { await rm(root, { recursive: true, force: true }) }
}
it('finishes submission before starting wait and records the ID and separate timings', async () => {
await fixture(async (directory) => {
const uploaded = Promise.withResolvers<string>()
const execute = vi.fn(async (command: readonly string[]) => command[0] === 'submit' ? uploaded.promise : JSON.stringify({ id, status: 'Accepted' }))
const work = runLoggedNotarytool(args, directory, execute)
try {
expect(execute).toHaveBeenCalledExactlyOnceWith(args.filter(arg => arg !== '--wait').concat('--no-wait'))
expect(await readFile(join(directory, 'events.jsonl'), 'utf8')).not.toContain('notarytool:wait')
} finally { uploaded.resolve(JSON.stringify({ id })) }
expect(JSON.parse(await work)).toEqual({ id, status: 'Accepted' })
expect(execute.mock.calls[1]![0]).toEqual(['wait', id, '--apple-id', '[email protected]', '--password', 'secret-value', '--team-id', 'TEAM', '--output-format', 'json'])
const log = await readFile(join(directory, 'events.jsonl'), 'utf8')
expect(log).toContain('notary-submission')
expect(log).toContain('notarytool:upload:app.zip')
expect(log).toContain('notarytool:wait:app.zip')
expect(log).toContain('elapsedMs')
expect(log).not.toContain('[email protected]')
expect(log).not.toContain('secret-value')
})
})
it('records a redacted upload error without starting wait', async () => {
await fixture(async (directory) => {
const execute = vi.fn(async () => { throw new Error('rejected secret-value [email protected]') })
await expect(runLoggedNotarytool(args, directory, execute)).rejects.toThrow('rejected')
expect(execute).toHaveBeenCalledOnce()
const log = await readFile(join(directory, 'events.jsonl'), 'utf8')
expect(log).toContain('"success":false')
expect(log).not.toContain('secret-value')
expect(log).not.toContain('[email protected]')
})
})
it.each(['Invalid', 'Rejected'])('preserves %s for electron-notarize rejection handling', async (status) => {
const execute = vi.fn(async (command: readonly string[]) => JSON.stringify(command[0] === 'submit' ? { id } : { id, status }))
await fixture(async (directory) => {
expect(JSON.parse(await runLoggedNotarytool(args, directory, execute))).toEqual({ id, status })
const log = await readFile(join(directory, 'events.jsonl'), 'utf8')
expect(log).toContain('\"success\":false')
expect(log).toContain(status)
})
})
it('rejects missing submission IDs and mismatched wait results', async () => {
await expect(runLoggedNotarytool(args, undefined, async () => '{}')).rejects.toThrow('submission ID')
await expect(runLoggedNotarytool(args, undefined, async command => JSON.stringify(command[0] === 'submit' ? { id } : { id: 'other', status: 'Accepted' }))).rejects.toThrow('different submission ID')
})
it.each([
['--key', '/private/AuthKey.p8', '--key-id', 'KEY123', '--issuer', id],
['--keychain-profile', 'release', '--keychain', '/private/release.keychain'],
])('preserves API-key and keychain authentication when waiting: %j', async (...auth) => {
const execute = vi.fn(async () => JSON.stringify({ id, status: 'Accepted' }))
await runLoggedNotarytool(['submit', '/fixture/release.dmg', ...auth, '--wait', '--output-format', 'json'], undefined, execute)
expect(execute.mock.calls[1]).toEqual([['wait', id, ...auth, '--output-format', 'json']])
})
it('retains redacted Apple diagnostics and passes the original result to electron-notarize', async () => {
await fixture(async (directory) => {
const output = 'diagnostic for [email protected]'
const command = ['log', id, '--apple-id', '[email protected]']
expect(await runLoggedNotarytool(command, directory, async () => output)).toBe(output)
const log = await readFile(join(directory, 'events.jsonl'), 'utf8')
expect(log).toContain('notary-diagnostics')
expect(log).toContain('[REDACTED]')
expect(log).not.toContain('[email protected]')
})
})
@@ -0,0 +1,57 @@
import { spawnSync } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { macOSCachePolicy } from '../scripts/macos-cache-policy.ts'
vi.mock('node:child_process', async importOriginal => ({ ...await importOriginal<typeof import('node:child_process')>(), spawnSync: vi.fn() }))
vi.mock('node:fs', async (importOriginal) => {
const fs = await importOriginal<typeof import('node:fs')>()
return { ...fs, readFileSync: (...args: Parameters<typeof fs.readFileSync>) => args[0] === '/usr/bin/codesign' ? Buffer.from('codesign-tool') : args[0] === 'candidate' ? Buffer.from(header, 'hex') : fs.readFileSync(...args) }
})
const identity = { signingIdentity: 'Example (TEAM123456)', teamId: 'TEAM123456' }
const expectedDetails = 'Authority=Developer ID Application: Example (TEAM123456)\nTeamIdentifier=TEAM123456\nTimestamp=today\nCodeDirectory v=20500 flags=0x10000(runtime)\nIdentifier=com.example.file\n'
let header = 'cffaedfe'
let details = expectedDetails
let certificate = 'leaf-certificate'
let entitlements = ''
let rejectVerify = false
beforeEach(() => {
header = 'cffaedfe'; details = expectedDetails; certificate = 'leaf-certificate'; entitlements = ''; rejectVerify = false
vi.mocked(spawnSync).mockImplementation((command, args, options) => {
const argv = args as string[]
if (command === '/usr/bin/plutil') {
const input = (options as { input: string }).input
return { pid: 1, output: [], status: 0, signal: null, stdout: JSON.stringify(input.includes('allow-jit') ? { 'com.apple.security.cs.allow-jit': true } : {}), stderr: '' }
}
const extraction = argv.find(arg => arg.startsWith('--extract-certificates='))
if (extraction) writeFileSync(`${extraction.slice('--extract-certificates='.length)}0`, certificate)
return { pid: 1, output: [], status: rejectVerify && argv.includes('--verify') ? 1 : 0, signal: null, stdout: entitlements, stderr: details }
})
})
afterEach(() => { vi.resetAllMocks() })
it('accepts only a signature matching the resolved policy', () => {
const signer = macOSCachePolicy('probe')('com.example.file', identity)
expect(() => { signer.verify('candidate') }).not.toThrow()
})
it.each(['certificate', 'identifier', 'team', 'authority', 'timestamp', 'runtime', 'entitlements', 'strict', 'universal'])('rejects mismatched %s', (field) => {
const signer = macOSCachePolicy('probe')('com.example.file', identity)
if (field === 'certificate') certificate = 'another-certificate'
if (field === 'identifier') details = details.replace('com.example.file', 'com.other.file')
if (field === 'team') details = details.replace('TeamIdentifier=TEAM123456', 'TeamIdentifier=OTHER12345')
if (field === 'authority') details = details.replace('Authority=Developer', 'Authority=Other')
if (field === 'timestamp') details = details.replace('Timestamp=today', '')
if (field === 'runtime') details = details.replace('flags=0x10000(runtime)', 'flags=0x0(none)')
if (field === 'entitlements') entitlements = '<key>com.apple.security.cs.allow-jit</key>'
if (field === 'strict') rejectVerify = true
if (field === 'universal') header = 'cafebabe'
expect(() => { signer.verify('candidate') }).toThrow()
})
it('includes certificate, identifier and entitlement contents in the cache policy', () => {
const create = macOSCachePolicy('probe')
const original = create('com.example.file', identity).policy
expect(create('com.other.file', identity).policy).not.toBe(original)
expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/node-entitlements.plist')).policy).not.toBe(original)
certificate = 'renewed-certificate'
expect(macOSCachePolicy('probe')('com.example.file', identity).policy).not.toBe(original)
})
@@ -0,0 +1,273 @@
import { existsSync, readFileSync } from 'node:fs'
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it as test, vi } from 'vitest'
import { restoreMacOSNotarizationProxy, withMacOSNotarizationProxy } from '../scripts/macos-notarization-proxy.ts'
// System proxy transactions use POSIX flock, which is unavailable on Windows.
const it = test.skipIf(process.platform === 'win32')
async function fixture(action: (fixture: ReturnType<typeof fakeSystem>, lock: string) => Promise<void>): Promise<void> {
const root = await mkdtemp(join(tmpdir(), 'mac-proxy-test-'))
try { await action(fakeSystem(), join(root, 'lock')) } finally { await rm(root, { recursive: true, force: true }) }
}
function fakeSystem() {
const states = {
http: { enabled: false, server: 'old-http', port: 3128 },
https: { enabled: true, server: 'old-https', port: 8080 },
}
const original = structuredClone(states)
const control = { failSet: 0, setCount: 0, pac: false, authenticated: false, serverSpace: true, ignoreDisable: false }
const ops = {
reachable: vi.fn(async (_url: URL) => {}),
ownerAlive: vi.fn((_pid: number) => false),
command: vi.fn((executable: string, args: readonly string[]) => {
if (executable === '/sbin/route') return ' interface: en9\n'
if (args[0] === '-listnetworkserviceorder') return '(1) Ethernet\n(Hardware Port: USB LAN, Device: en9)\n\n(2) Wi-Fi\n(Hardware Port: Wi-Fi, Device: en0)\n'
expect(args[1]).toBe('Ethernet')
if (args[0] === '-getautoproxyurl') return `URL: http://pac\nEnabled: ${control.pac ? 'Yes' : 'No'}\n`
if (args[0] === '-getproxyautodiscovery') return 'Auto Proxy Discovery: Off\n'
if (args[0] === '-getsocksfirewallproxy') return 'Enabled: No\n'
const state = args[0]!.includes('secure') ? states.https : states.http
if (args[0]!.startsWith('-get')) return `Enabled: ${state.enabled ? 'Yes' : 'No'}\nServer:${control.serverSpace ? ' ' : ''}${state.server}\nPort: ${state.port}\nAuthenticated Proxy Enabled: ${control.authenticated ? '1' : '0'}\n`
control.setCount++
if (control.setCount === control.failSet) throw new Error('system write failed')
if (args[0]!.endsWith('state')) {
if (!(control.ignoreDisable && args[2] === 'off')) state.enabled = args[2] === 'on'
}
else { state.server = args[2]!; state.port = Number(args[3]); state.enabled = true }
return ''
}),
}
return { ops, states, original, control }
}
it('preserves the network when no Apple proxy is configured', async () => {
await fixture(async ({ ops }, lock) => {
await expect(withMacOSNotarizationProxy(undefined, async () => 42, lock, ops)).resolves.toBe(42)
expect(ops.command).not.toHaveBeenCalled()
expect(ops.reachable).not.toHaveBeenCalled()
expect(existsSync(lock)).toBe(false)
})
})
it.each([false, true])('restores both original proxies after settled work (failure=%s)', async (fail) => {
await fixture(async ({ ops, states, original }, lock) => {
const statuses: string[] = []
const work = withMacOSNotarizationProxy('http://localhost:8888', async () => {
expect(states.http).toEqual({ enabled: true, server: 'localhost', port: 8888 })
expect(states.https).toEqual(states.http)
const record = JSON.parse(readFileSync(join(lock, 'original.json'), 'utf8')) as { service: string }
expect(record.service).toBe('Ethernet')
if (fail) throw new Error('Apple refused')
return 42
}, lock, ops, (status) => { statuses.push(status) })
if (fail) await expect(work).rejects.toThrow('Apple refused')
else await expect(work).resolves.toBe(42)
expect(statuses).toEqual(['restoration-pending', 'enabled', 'restored'])
expect(states).toEqual(original)
expect(existsSync(lock)).toBe(false)
})
})
it.each([false, true])('disables originally empty proxies without writing empty endpoints (space=%s)', async (space) => {
await fixture(async ({ ops, states, control }, lock) => {
control.serverSpace = space
states.http = { enabled: false, server: '', port: 0 }
states.https = { enabled: false, server: '', port: 0 }
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {
expect(states.http.enabled).toBe(true)
expect(states.https.enabled).toBe(true)
throw new Error('Apple refused')
}, lock, ops)).rejects.toThrow('Apple refused')
expect(states.http).toEqual({ enabled: false, server: 'localhost', port: 8888 })
expect(states.https).toEqual(states.http)
const setters = ops.command.mock.calls.filter(([, args]) => ['-setwebproxy', '-setsecurewebproxy'].includes(args[0]!))
expect(setters).toHaveLength(2)
expect(setters.every(([, args]) => args[2] === 'localhost')).toBe(true)
expect(existsSync(lock)).toBe(false)
})
})
it('retains an empty-proxy recovery record after failed disabling and retries it', async () => {
await fixture(async ({ ops, states, control }, lock) => {
states.http = { enabled: false, server: '', port: 0 }
states.https = { enabled: false, server: '', port: 0 }
control.failSet = 5
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {}, lock, ops)).rejects.toThrow('restoration failed')
expect(existsSync(lock)).toBe(true)
expect(states.http.enabled).toBe(true)
expect(states.https.enabled).toBe(false)
await restoreMacOSNotarizationProxy(lock, ops)
expect(states.http.enabled).toBe(false)
expect(states.https.enabled).toBe(false)
expect(existsSync(lock)).toBe(false)
})
})
it('keeps the recovery record when disabling an originally empty proxy has no effect', async () => {
await fixture(async ({ ops, states, control }, lock) => {
states.http = { enabled: false, server: '', port: 0 }
control.ignoreDisable = true
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {}, lock, ops)).rejects.toThrow('restoration failed')
expect(states.http.enabled).toBe(true)
expect(existsSync(lock)).toBe(true)
control.ignoreDisable = false
await restoreMacOSNotarizationProxy(lock, ops)
expect(states.http.enabled).toBe(false)
expect(existsSync(lock)).toBe(false)
})
})
it('does not steal a live lock and holds the proxy until concurrent work drains', async () => {
await fixture(async ({ ops, states, original }, lock) => {
const entered = Promise.withResolvers<undefined>()
const finish = Promise.withResolvers<undefined>()
const running = withMacOSNotarizationProxy('http://localhost:8888', async () => { entered.resolve(undefined); await finish.promise }, lock, ops)
try {
await entered.promise
await expect(withMacOSNotarizationProxy('http://localhost:8889', async () => {}, lock, ops)).rejects.toThrow('another run')
await expect(restoreMacOSNotarizationProxy(lock, ops)).rejects.toThrow('another run')
expect(ops.ownerAlive).not.toHaveBeenCalled()
expect(states.http.port).toBe(8888)
expect(existsSync(lock)).toBe(true)
} finally { finish.resolve(undefined); await running }
expect(states).toEqual(original)
})
})
it('excludes both operations across processes and releases ownership after forced exit', async () => {
await fixture(async ({ ops, states, original, control }, lock) => {
control.failSet = 5
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {}, lock, ops)).rejects.toThrow('restoration failed')
const saved = readFileSync(join(lock, 'original.json'), 'utf8')
// Coverage builds the addon, but does not emit the package's JavaScript entry.
const flockSource = new URL('../../../native/system/packages/entry/src/flock.ts', import.meta.url).href
const child = spawn(process.execPath, ['--input-type=module', '-e', `
import { openSync } from 'node:fs';
import { tryLockExclusive } from ${JSON.stringify(flockSource)};
const fd = openSync(process.argv[1], 'a', 0o600);
await tryLockExclusive(fd);
process.send('locked');
process.stdin.resume();
`, `${lock}.flock`], { stdio: ['pipe', 'ignore', 'pipe', 'ipc'], env: { PATH: process.env.PATH } })
const exited = once(child, 'exit')
const closed = once(child, 'close')
let stderr = ''
child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr = (stderr + chunk).slice(-8192) })
try {
await Promise.race([
once(child, 'message').then(([message]) => { expect(message).toBe('locked') }),
closed.then(([code, signal]) => { throw new Error(`lock holder exited before readiness: ${code}/${signal}\n${stderr}`) }),
])
ops.command.mockClear()
ops.ownerAlive.mockClear()
await expect(restoreMacOSNotarizationProxy(lock, ops)).rejects.toThrow('another run')
await expect(withMacOSNotarizationProxy('http://localhost:8889', async () => {}, lock, ops)).rejects.toThrow('another run')
expect(ops.command).not.toHaveBeenCalled()
expect(ops.ownerAlive).not.toHaveBeenCalled()
expect(readFileSync(join(lock, 'original.json'), 'utf8')).toBe(saved)
} finally {
child.kill('SIGKILL')
await closed
}
expect(await exited).toEqual([null, 'SIGKILL'])
await restoreMacOSNotarizationProxy(lock, ops)
expect(states).toEqual(original)
expect(existsSync(lock)).toBe(false)
expect(existsSync(`${lock}.flock`)).toBe(true)
await withMacOSNotarizationProxy('http://localhost:8889', async () => {}, lock, ops)
expect(states).toEqual(original)
})
})
it('restores partial setup and never calls Apple after a system write failure', async () => {
await fixture(async ({ ops, states, original, control }, lock) => {
control.failSet = 3
const action = vi.fn(async () => {})
await expect(withMacOSNotarizationProxy('http://localhost:8888', action, lock, ops)).rejects.toThrow('system write failed')
expect(action).not.toHaveBeenCalled()
expect(states).toEqual(original)
expect(existsSync(lock)).toBe(false)
})
})
it.each(['pac', 'authenticated'] as const)('rejects unpreservable %s settings before mutation', async (kind) => {
await fixture(async ({ ops, states, original, control }, lock) => {
control[kind] = true
const action = vi.fn(async () => {})
await expect(withMacOSNotarizationProxy('http://localhost:8888', action, lock, ops)).rejects.toThrow(/proxy|proxies/u)
expect(control.setCount).toBe(0)
expect(action).not.toHaveBeenCalled()
expect(states).toEqual(original)
})
})
it('fails unreachable proxies before acquiring a lock or changing settings', async () => {
await fixture(async ({ ops }, lock) => {
ops.reachable.mockRejectedValueOnce(new Error('unreachable'))
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {}, lock, ops)).rejects.toThrow('unreachable')
expect(ops.command).not.toHaveBeenCalled()
expect(existsSync(lock)).toBe(false)
})
})
it('retains recovery data after failed restoration, rejects live owners, then recovers after owner exit', async () => {
await fixture(async ({ ops, states, original, control }, lock) => {
control.failSet = 5
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {}, lock, ops)).rejects.toThrow('restoration failed')
expect(existsSync(join(lock, 'original.json'))).toBe(true)
ops.ownerAlive.mockReturnValueOnce(true)
await expect(restoreMacOSNotarizationProxy(lock, ops)).rejects.toThrow('still running')
await restoreMacOSNotarizationProxy(lock, ops)
expect(states).toEqual(original)
expect(existsSync(lock)).toBe(false)
})
})
it('rejects malformed saved state without invoking host commands', async () => {
await fixture(async ({ ops, control }, lock) => {
control.failSet = 5
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {}, lock, ops)).rejects.toThrow('restoration failed')
await writeFile(join(lock, 'original.json'), '{"pid":-1}')
ops.command.mockClear()
await expect(restoreMacOSNotarizationProxy(lock, ops)).rejects.toThrow('invalid proxy recovery record')
expect(ops.command).not.toHaveBeenCalled()
})
})
it('defers interruption until work settles and removes its signal listeners after restoration', async () => {
await fixture(async ({ ops, states, original }, lock) => {
const before = process.listenerCount('SIGTERM')
const entered = Promise.withResolvers<undefined>()
const finish = Promise.withResolvers<undefined>()
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
const running = withMacOSNotarizationProxy('http://localhost:8888', async () => { entered.resolve(undefined); await finish.promise }, lock, ops)
const result = expect(running).rejects.toThrow('interrupted by SIGTERM')
try {
await entered.promise
// Deliver only the listener owned by this invocation; never signal the test runner or other suites.
const listener = process.listeners('SIGTERM').at(-1)!
listener('SIGTERM')
expect(states.http.port).toBe(8888)
expect(existsSync(lock)).toBe(true)
} finally { finish.resolve(undefined); await result; stderr.mockRestore() }
expect(states).toEqual(original)
expect(process.listenerCount('SIGTERM')).toBe(before)
})
})
it('retains both the Apple failure and restoration failure', async () => {
await fixture(async ({ ops, control }, lock) => {
control.failSet = 5
const appleError = new Error('Apple refused')
const result = withMacOSNotarizationProxy('http://localhost:8888', async () => { throw appleError }, lock, ops)
await expect(result).rejects.toMatchObject({ errors: [appleError, expect.any(AggregateError)] })
expect(existsSync(join(lock, 'original.json'))).toBe(true)
await restoreMacOSNotarizationProxy(lock, ops)
})
})
@@ -0,0 +1,34 @@
import { expect, it } from 'vitest'
import { macOSDownloadEnvironment, resolveMacOSPackageSettings } from '../scripts/macos-package-settings.mjs'
it('defaults to four workers and keeps download and Apple proxies independent', () => {
expect(resolveMacOSPackageSettings({})).toEqual({ packConcurrency: 4 })
expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_PACK_CONCURRENCY: '2',
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: 'https://download.example:8080',
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'http://apple.example:8081',
})).toEqual({ packConcurrency: 2, downloadProxy: 'https://download.example:8080', notarizationProxy: 'http://apple.example:8081' })
expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: ' ', DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: '' })).toEqual({ packConcurrency: 4 })
})
it.each(['', '0', '-1', '1.5', '1e2', 'Infinity', '9007199254740992'])('rejects invalid concurrency %j', (value) => {
expect(() => resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_PACK_CONCURRENCY: value })).toThrow('PACK_CONCURRENCY')
})
it.each(['socks5://host:8080', 'http://user:secret@host', 'http://host/path', 'http://host?query', 'http://host#fragment', 'invalid'])
('rejects unsupported proxy input without echoing it: %s', (value) => {
expect(() => resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: value })).toThrow('NOTARIZATION_PROXY')
expect(() => resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: value })).not.toThrow(value)
})
it('overrides conflicting child proxy variables while preserving the parent and unrelated settings', () => {
const parent = { PATH: 'tools', HTTPS_PROXY: 'http://old', http_proxy: 'http://lower', ALL_PROXY: 'socks5://old',
NO_PROXY: '*', npm_config_proxy: 'http://npm', ELECTRON_GET_USE_PROXY: '0' }
expect(macOSDownloadEnvironment(parent)).toEqual(parent)
const child = macOSDownloadEnvironment(parent, 'http://new:8080')
expect(child).toMatchObject({ PATH: 'tools', HTTP_PROXY: 'http://new:8080', HTTPS_PROXY: 'http://new:8080',
http_proxy: 'http://new:8080', https_proxy: 'http://new:8080', ELECTRON_GET_USE_PROXY: '1', NO_PROXY: 'localhost,127.0.0.1,::1' })
expect(child).not.toHaveProperty('ALL_PROXY')
expect(child).not.toHaveProperty('npm_config_proxy')
expect(parent.HTTPS_PROXY).toBe('http://old')
expect(parent.NO_PROXY).toBe('*')
})
@@ -0,0 +1,139 @@
import { fork } from 'node:child_process'
import { once } from 'node:events'
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { cachedMacOSSignature, type MacOSCachedSigner } from '../scripts/macos-signature-cache.ts'
const roots: string[] = []
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
function fixture(): { root: string; file: string; cache: string; signer: MacOSCachedSigner } {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-signature-cache-')))
roots.push(root)
const file = join(root, 'code')
writeFileSync(file, 'original', { mode: 0o755 })
return { root, file, cache: join(root, 'cache'), signer: {
policy: 'certificate+identifier+entitlements+tools',
sign: vi.fn(async (path: string) => { writeFileSync(path, `${readFileSync(path, 'utf8')}:signed`) }),
verify: vi.fn((path: string) => { if (!readFileSync(path, 'utf8').endsWith(':signed')) throw Error('invalid signature') }),
} }
}
it('reuses verified bytes without sharing mutable cache storage', async () => {
const { file, cache, signer } = fixture()
expect(await cachedMacOSSignature(file, cache, signer)).toBe(false)
writeFileSync(file, 'original')
expect(await cachedMacOSSignature(file, cache, signer)).toBe(true)
expect(signer.sign).toHaveBeenCalledTimes(1)
expect(signer.verify).toHaveBeenCalledTimes(2)
expect(readFileSync(file, 'utf8')).toBe('original:signed')
if (process.platform !== 'win32') expect(lstatSync(file).mode & 0o777).toBe(0o755)
writeFileSync(file, 'later mutation')
writeFileSync(file, 'original')
expect(await cachedMacOSSignature(file, cache, signer)).toBe(true)
})
it.each(['input', 'policy', 'mode'])('invalidates changed %s without consulting git', async (change) => {
const { file, cache, signer } = fixture()
await cachedMacOSSignature(file, cache, signer)
writeFileSync(file, change === 'input' ? 'uncommitted' : 'original')
if (change === 'mode') {
if (process.platform === 'win32') return
chmodSync(file, 0o644)
}
expect(await cachedMacOSSignature(file, cache, change === 'policy' ? { ...signer, policy: 'new-policy' } : signer)).toBe(false)
expect(signer.sign).toHaveBeenCalledTimes(2)
})
it.each(['payload', 'record', 'signature'])('rejects corrupted %s without replacing the input', async (damage) => {
const { file, cache, signer } = fixture()
await cachedMacOSSignature(file, cache, signer)
writeFileSync(file, 'original')
const entry = join(cache, readdirSync(cache)[0]!)
if (damage === 'payload') writeFileSync(join(entry, 'code'), 'truncated')
if (damage === 'record') writeFileSync(join(entry, 'record.json'), '{"key":"wrong"}')
if (damage === 'signature') signer.verify = () => { throw Error('invalid signature') }
await expect(cachedMacOSSignature(file, cache, signer)).rejects.toThrow()
expect(readFileSync(file, 'utf8')).toBe('original')
})
it('never publishes or replaces input after signing fails', async () => {
const { file, cache, signer } = fixture()
signer.sign = async () => { throw Error('interrupted signing') }
await expect(cachedMacOSSignature(file, cache, signer)).rejects.toThrow('interrupted')
expect(readFileSync(file, 'utf8')).toBe('original')
expect(readdirSync(cache)).toEqual([])
})
it('rejects a concurrently changed target', async () => {
const { file, cache, signer } = fixture()
const sign = signer.sign
signer.sign = async (candidate) => { await sign(candidate); writeFileSync(file, 'concurrent edit') }
await expect(cachedMacOSSignature(file, cache, signer)).rejects.toThrow('input changed')
expect(readFileSync(file, 'utf8')).toBe('concurrent edit')
expect(readdirSync(cache)).toEqual([])
})
it.skipIf(process.platform === 'win32')('allows competing writers to publish only complete entries', async () => {
const { root, file, cache, signer } = fixture()
const second = join(root, 'other')
writeFileSync(second, 'original', { mode: 0o755 })
let ready!: () => void
const both = new Promise<void>((resolve) => { ready = resolve })
let count = 0
const sign = signer.sign
signer.sign = async (path) => { if (++count === 2) ready(); await both; await sign(path) }
await Promise.all([cachedMacOSSignature(file, cache, signer), cachedMacOSSignature(second, cache, signer)])
expect(readdirSync(cache)).toHaveLength(1)
expect(readFileSync(file, 'utf8')).toBe('original:signed')
expect(readFileSync(second, 'utf8')).toBe('original:signed')
writeFileSync(file, 'original')
expect(await cachedMacOSSignature(file, cache, signer)).toBe(true)
})
it.skipIf(process.platform === 'win32')('rejects links in the target and cache ancestry', async () => {
const { root, file, cache, signer } = fixture()
const link = join(root, 'link')
symlinkSync(file, link)
await expect(cachedMacOSSignature(link, cache, signer)).rejects.toThrow('regular file')
const real = join(root, 'real')
mkdirSync(real)
symlinkSync(real, cache)
await expect(cachedMacOSSignature(file, join(cache, 'nested'), signer)).rejects.toThrow('directory')
expect(existsSync(join(real, 'nested'))).toBe(false)
})
it.skipIf(process.platform === 'win32')('publishes reusable entries across independent processes', async () => {
const { root, file, cache } = fixture()
const second = join(root, 'second')
writeFileSync(second, 'original', { mode: 0o755 })
// Windows directory replacement uses different errors; this cache is owned by macOS packaging.
const children: ReturnType<typeof fork>[] = []
const exits: Promise<[number | null, NodeJS.Signals | null]>[] = []
try {
for (const path of [file, second]) {
const child = fork(join(import.meta.dirname, 'fixtures/macos-cache-writer.mjs'), [path, cache], {
execArgv: [], stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
})
children.push(child)
exits.push(new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => { resolve([code, signal]) })
}))
}
await Promise.all(children.map((child, index) => Promise.race([once(child, 'message'), exits[index]!.then(() => { throw Error('cache writer exited before ready') })])))
for (const child of children) child.send('continue')
for (const [code, signal] of await Promise.all(exits)) {
expect(signal).toBeNull()
expect(code).toBe(0)
}
expect(readdirSync(cache)).toHaveLength(1)
expect(readFileSync(file, 'utf8')).toBe('original:signed')
expect(readFileSync(second, 'utf8')).toBe('original:signed')
} finally {
for (const child of children) if (child.exitCode === null && child.signalCode === null) child.kill()
await Promise.allSettled(exits)
}
})
it('rejects candidate mutation during verification', async () => {
const { file, cache, signer } = fixture()
signer.verify = (candidate) => { writeFileSync(candidate, 'mutated') }
await expect(cachedMacOSSignature(file, cache, signer)).rejects.toThrow('candidate changed')
expect(readFileSync(file, 'utf8')).toBe('original')
expect(readdirSync(cache)).toEqual([])
})
@@ -14,6 +14,7 @@ import {
const RELEASE_ENVIRONMENT = {
DSH_DESKTOP_APP_ID: 'com.example.desktop',
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
DSH_DESKTOP_TARGET_PLATFORM: 'darwin',
DSH_DESKTOP_TARGET_ARCH: 'arm64',
DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)',
@@ -97,6 +98,7 @@ describe('desktop macOS release signature', () => {
expect(() => createElectronBuilderConfig({
DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID,
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
DSH_DESKTOP_TARGET_PLATFORM: 'win32',
}, 'win32')).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u)
})
@@ -106,6 +108,7 @@ describe('desktop macOS release signature', () => {
const config = createElectronBuilderConfig({
DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID,
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
DSH_DESKTOP_TARGET_PLATFORM: 'win32',
DSH_DESKTOP_UNSIGNED: '1',
}, 'win32', 'x64')
+6 -6
View File
@@ -340,7 +340,7 @@ describe('desktop main startup', () => {
})
it('shows one explained startup login before Host readiness and joins concurrent checks without reopening it', async () => {
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () =>
Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })))
@@ -383,7 +383,7 @@ describe('desktop main startup', () => {
})
it.each(['returned', 'cancelled', 'failed'] as const)('requires explicit test login and handles %s without downloading', async (outcome) => {
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 }
const request = vi.fn<typeof fetch>().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
vi.stubGlobal('fetch', request)
@@ -410,7 +410,7 @@ describe('desktop main startup', () => {
})
it('does not open Feishu when the user declines test login', async () => {
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'] }
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })))
await readyForUpdate()
@@ -420,7 +420,7 @@ describe('desktop main startup', () => {
})
it('does not require gateway login to download an already available ordinary update', async () => {
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'] }
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () =>
Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })))
@@ -433,7 +433,7 @@ describe('desktop main startup', () => {
})
it('retains the same blocking window and running Host after expired test login is cancelled', async () => {
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
const request = vi.fn<typeof fetch>().mockImplementation(async () =>
Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
@@ -937,7 +937,7 @@ describe('desktop main startup', () => {
})
it('queues policy authentication until the ordinary result dialog closes', async () => {
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 }
const request = vi.fn<typeof fetch>()
.mockResolvedValueOnce(Response.json({ code: 0, data: { biz_code: 0, biz_data: null } }))
@@ -13,7 +13,7 @@ const instances: DesktopMandatoryUpdatePolicy[] = []
function fixture(authentication: 'anonymous' | 'feishu-test' = 'anonymous') {
const request = vi.fn<typeof fetch>().mockResolvedValue(Response.json(clear))
const publish = vi.fn<(state: DesktopPolicyState) => void>()
const policy = new DesktopMandatoryUpdatePolicy(resolveDesktopPolicyConfig({ ...deployment, authentication })!,
const policy = new DesktopMandatoryUpdatePolicy(resolveDesktopPolicyConfig({ ...deployment, authentication, ...(authentication === 'feishu-test' ? { allowedAuthOrigins: ['https://login.example.com'] } : {}) })!,
identity, publish, request)
instances.push(policy)
return { policy, request, publish }
@@ -162,6 +162,15 @@ describe('mandatory update policy', () => {
})
describe('policy deployment and page validation', () => {
it('requires validated login origins only for test authentication', () => {
expect(() => resolveDesktopPolicyConfig({ ...deployment, authentication: 'feishu-test' })).toThrow('allowedAuthOrigins')
expect(() => resolveDesktopPolicyConfig({ ...deployment, allowedAuthOrigins: ['https://login.example.com'] })).toThrow('anonymous')
expect(() => resolveDesktopPolicyConfig({ ...deployment, authentication: 'feishu-test',
allowedAuthOrigins: ['https://login.example.com/path'] })).toThrow('HTTPS origin')
expect(resolveDesktopPolicyConfig({ ...deployment, authentication: 'feishu-test',
allowedAuthOrigins: ['https://login.example.com'] })?.allowedAuthOrigins).toEqual(['https://login.example.com'])
})
it('requires explicit production origins and confines HTTP to an explicit local policy fixture', () => {
expect(resolveDesktopPolicyConfig(undefined)).toBeUndefined()
expect(() => resolveDesktopPolicyConfig({ ...deployment, origin: 'http://127.0.0.1:19001' })).toThrow('HTTPS')
@@ -0,0 +1,69 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({ root: '', directory: '' }))
vi.mock('../scripts/desktop-package-environment.mjs', () => ({
loadDesktopPackageEnvironment: () => ({ DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'credential-sentinel' }),
validateDesktopPackageEnvironment: () => {},
}))
vi.mock('../scripts/macos-signing-keychain.mjs', () => ({
withMacOSSigningKeychain: async (_environment: object, action: (environment: object) => Promise<unknown>) => action({}),
}))
vi.mock('node:fs', async importOriginal => ({
...await importOriginal<typeof import('node:fs')>(),
rmSync: () => { throw new AggregateError([new Error('credential-sentinel')], 'restore:mac-proxy recovery required') },
}))
vi.mock('../scripts/packaging-run.mjs', async (importOriginal) => {
const original = await importOriginal<typeof import('../scripts/packaging-run.mjs')>()
return { ...original, createPackagingRun: (...args: Parameters<typeof original.createPackagingRun>) => {
const run = original.createPackagingRun(state.root, args[1], args[2])
state.directory = run.directory
return run
} }
})
it.each(['win32', 'darwin'] as const)('records and prints redacted parent failures on %s', async (platform) => {
state.root = await mkdtemp(join(tmpdir(), 'package-errors-'))
const savedPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
const savedArch = Object.getOwnPropertyDescriptor(process, 'arch')!
const savedArgv = process.argv
const savedExit = process.exitCode
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const savedDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
try {
Object.defineProperty(process, 'platform', { ...savedPlatform, value: platform })
Object.defineProperty(process, 'arch', { ...savedArch, value: 'x64' })
process.argv = [process.execPath, fileURLToPath(new URL('../scripts/package-target.ts', import.meta.url)), platform === 'win32' ? 'win-x64' : 'mac-x64']
vi.resetModules()
await import('../scripts/package-target.ts')
expect(process.exitCode).toBe(1)
const events = await readFile(join(state.directory, 'events.jsonl'), 'utf8')
const output = stderr.mock.calls.map(([text]) => String(text)).join('')
for (const diagnostic of [events, output]) {
expect(diagnostic).toContain('restore:mac-proxy recovery required')
expect(diagnostic).toContain('[REDACTED]')
expect(diagnostic).not.toContain('credential-sentinel')
}
expect(JSON.parse(await readFile(join(state.directory, 'result.json'), 'utf8'))).toMatchObject({
success: false, stages: [
{ stage: 'configuration', success: true },
{ stage: platform === 'win32' ? 'windows-package' : 'macos-package', success: false },
],
})
expect(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR).toBe(savedDirectory)
} finally {
Object.defineProperty(process, 'platform', savedPlatform)
Object.defineProperty(process, 'arch', savedArch)
process.argv = savedArgv
process.exitCode = savedExit
if (savedDirectory === undefined) delete process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
else process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = savedDirectory
stderr.mockRestore()
consoleLog.mockRestore()
await rm(state.root, { recursive: true, force: true })
}
})
@@ -1,9 +1,17 @@
import { writeFileSync } from 'node:fs'
import { afterEach, expect, it, vi } from 'vitest'
import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts'
import { withMacOSNotarizationProxy } from '../scripts/macos-notarization-proxy.ts'
import { packageMacOSArtifacts } from '../scripts/package-macos.ts'
import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs'
import { prepareWindowsSignatureCacheDirectory } from '../scripts/windows-signature-cache-directory.mjs'
vi.mock('../scripts/macos-notarization-proxy.ts', () => ({
withMacOSNotarizationProxy: vi.fn(async (_proxy: string | undefined, action: () => Promise<void>) => action()),
}))
vi.mock('../scripts/notarize-macos.mjs', () => ({ notarizeMacOS: vi.fn(async () => {}) }))
vi.mock('../scripts/package-macos.ts', () => ({ packageMacOSArtifacts: vi.fn(async () => {}) }))
vi.mock('../scripts/windows-signing-stage.mjs', () => ({
withWindowsSigningStage: vi.fn(async (_options: object, operation: () => Promise<void>) => operation()),
}))
@@ -85,3 +93,42 @@ it.each(['--unsigned', '--prepare-only'])('keeps %s hardware-free and creates no
for (const call of run.run.mock.calls) expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
expect(writeFileSync).not.toHaveBeenCalled()
})
it.each([undefined, '2'])('passes macOS pack concurrency %s only to workspace packing and download routing only to download stages', async (concurrency) => {
const { run } = supervisor()
await packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--prepare-only'], 'darwin', 'arm64'), {
...environment, DSH_DESKTOP_MACOS_PACK_CONCURRENCY: concurrency,
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: 'http://downloads.example:8080',
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'http://apple.example:8081',
}, run)
const calls = run.run.mock.calls
const packs = calls.filter(call => call[0].startsWith('run release:pack'))
expect(packs).toHaveLength(2)
for (const call of packs) expect(call[2].slice(-2)).toEqual(['--concurrency', concurrency ?? '4'])
for (const call of calls) {
expect(call[3].env.HTTP_PROXY).toBe(/^run prepare:(?:runtime|dsh)$/u.test(call[0]) ? 'http://downloads.example:8080' : undefined)
}
expect(withMacOSNotarizationProxy).not.toHaveBeenCalled()
})
it.each([false, true])('scopes the Apple proxy around notarization (directory=%s)', async (directory) => {
const { run } = supervisor()
await packageTarget(parseDesktopPackageInvocation(['mac-arm64', ...(directory ? ['--dir'] : [])], 'darwin', 'arm64'), {
...environment, APPLE_KEYCHAIN_PROFILE: 'fixture', DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'http://apple.example:8081',
}, run)
expect(withMacOSNotarizationProxy).toHaveBeenCalledExactlyOnceWith('http://apple.example:8081', expect.any(Function), undefined, undefined, expect.any(Function))
expect(packageMacOSArtifacts).toHaveBeenCalledTimes(directory ? 0 : 1)
if (!directory) {
const signing = run.run.mock.calls.findIndex(call => call[0].includes('--config.mac.notarize=false'))
expect(signing).toBeGreaterThan(-1)
expect(run.run.mock.invocationCallOrder[signing]).toBeLessThan(vi.mocked(withMacOSNotarizationProxy).mock.invocationCallOrder[0]!)
}
})
it('does not write a release completion record when Apple proxy cleanup fails', async () => {
const { run } = supervisor()
vi.mocked(withMacOSNotarizationProxy).mockRejectedValueOnce(new Error('proxy restoration failed'))
await expect(packageTarget(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64'), environment, run))
.rejects.toThrow('proxy restoration failed')
expect(writeFileSync).not.toHaveBeenCalled()
})
+26
View File
@@ -94,3 +94,29 @@ describe('packaging run records', () => {
} finally { await rm(root, { recursive: true, force: true }) }
})
})
it('allows parallel Mac stages, attributes their output, and refuses finish until both settle', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-parallel-log-'))
try {
const run = createPackagingRun(root, { target: 'mac-fixture' }, { parallel: true, secrets: ['removed-p12-password'] })
const script = "process.stdout.write(process.argv[1]+' removed-p12-password')"
const first = run.run('app', process.execPath, ['-e', script, 'app'], { cwd: root, env: environment })
const second = run.run('dmg', process.execPath, ['-e', script, 'dmg'], { cwd: root, env: environment })
const settled = Promise.allSettled([first, second])
try { expect(() => { run.finish(true) }).toThrow('active run') } finally { await settled }
const results = await settled
expect(results.map(result => result.status)).toEqual(['fulfilled', 'fulfilled'])
run.finish(true)
const log = await readFile(join(run.directory, 'events.jsonl'), 'utf8')
const events = log.trim().split('\n').map(line => JSON.parse(line) as { type: string; stage: string; text?: string })
expect(events.filter(event => event.type === 'stage-start' || event.type === 'stage-end').slice(0, 2).map(event => event.type))
.toEqual(['stage-start', 'stage-start'])
expect(events.filter(event => event.type === 'output').map(event => event.stage).sort()).toEqual(['app', 'dmg'])
expect(log).not.toContain('removed-p12-password')
expect(events.filter(event => event.type === 'output').every(event => event.text?.includes('[REDACTED]'))).toBe(true)
const summary = JSON.parse(await readFile(join(run.directory, 'result.json'), 'utf8')) as { stages: unknown[]; elapsedMs: number }
expect(summary).toMatchObject({ success: true, proxy: 'not-used' })
expect(typeof summary.elapsedMs).toBe('number')
expect(summary.stages).toHaveLength(2)
} finally { await rm(root, { recursive: true, force: true }) }
})
+39
View File
@@ -0,0 +1,39 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { createPackagingRun, recordPackagingEvent } from '../scripts/packaging-run.mjs'
import { packagingStep } from '../scripts/packaging-step.mjs'
it('retains nested failures without credentials and includes the restored proxy in the final summary', async () => {
const root = await mkdtemp(join(tmpdir(), 'packaging-step-'))
try {
const run = createPackagingRun(root, { target: 'fixture' })
const work = packagingStep(run.directory, 'notarization', async () => {
throw new AggregateError([new Error('secret-sentinel'), new Error('outer', { cause: new Error('secret-sentinel') })], 'Apple refused')
}, ['secret-sentinel'])
await expect(work).rejects.toThrow('Apple refused')
recordPackagingEvent(run.directory, { type: 'notarization-proxy', status: 'restored' })
run.finish(false)
const events = await readFile(join(run.directory, 'events.jsonl'), 'utf8')
const summary = await readFile(join(run.directory, 'result.json'), 'utf8')
expect(events).toContain('[REDACTED]')
expect(events).toContain('outer')
expect(events).not.toContain('secret-sentinel')
expect(summary).not.toContain('secret-sentinel')
expect(JSON.parse(summary)).toMatchObject({ success: false, proxy: 'restored', stages: [{ stage: 'notarization', success: false }] })
} finally { await rm(root, { recursive: true, force: true }) }
})
it('preserves unrecorded callers and records successful return values without persisting them', async () => {
const root = await mkdtemp(join(tmpdir(), 'packaging-step-result-'))
try {
const run = createPackagingRun(root, {})
expect(await packagingStep(undefined, 'plain', async () => 42)).toBe(42)
expect(await packagingStep(run.directory, 'logged', async () => 'private-return-value')).toBe('private-return-value')
run.finish(true)
const events = await readFile(join(run.directory, 'events.jsonl'), 'utf8')
expect(events).toContain('"success":true')
expect(events).not.toContain('private-return-value')
} finally { await rm(root, { recursive: true, force: true }) }
})
+6 -6
View File
@@ -35,7 +35,7 @@ beforeEach(() => {
vi.clearAllMocks()
browserSession = makeSession(); window = makeWindow()
native.partition.mockReturnValue(browserSession); native.create.mockReturnValue(window)
auth = new DesktopPolicyTestAuth('https://policy.example.com', resolveDesktopLocale('zh'), () => undefined, record)
auth = new DesktopPolicyTestAuth('https://policy.example.com', ['https://login.example.com'], resolveDesktopLocale('zh'), () => undefined, record)
})
afterEach(async () => { await auth.dispose() })
@@ -64,7 +64,7 @@ it('opens a sandboxed window on explicit action and coalesces logins without aut
expect(window.loadURL).not.toHaveBeenCalled()
await vi.waitFor(() => { expect(window.loadURL).toHaveBeenCalledWith('https://policy.example.com/') })
expect(window.loadFile).toHaveBeenCalledTimes(1)
window.webContents.emit('did-navigate', {}, 'https://accounts.feishu.cn/open-apis/authen/v1/index?state=secret')
window.webContents.emit('did-navigate', {}, 'https://login.example.com/open-apis/authen/v1/index?state=secret')
expect(window.isDestroyed()).toBe(false)
window.webContents.emit('did-navigate', {}, 'https://policy.example.com/')
expect(await pending).toBe('returned')
@@ -121,7 +121,7 @@ it('cancels a login closed while the placeholder is still loading, without start
it.each(['will-navigate', 'will-redirect'])('refuses unapproved %s targets without logging OAuth data', async (eventName) => {
const pending = auth.login()
const event = { preventDefault: vi.fn() }
window.webContents.emit(eventName, event, 'https://accounts.feishu.cn.evil.example/?code=secret')
window.webContents.emit(eventName, event, 'https://login.example.com.evil.example/?code=secret')
expect(event.preventDefault).toHaveBeenCalledOnce()
expect(await pending).toBe('failed')
expect(record.mock.calls).toEqual([['opened'], ['failed']])
@@ -144,8 +144,8 @@ it('rejects child-frame escapes, permissions, downloads, new windows and HTTP au
expect(await pending).toBe('failed')
})
it.each(['https://open.feishu.cn/', 'https://accounts.feishu.cn/', 'https://passport.feishu.cn/',
'https://login.feishu.cn/'])('allows reviewed login documents: %s', (url) => {
it.each(['https://login.example.com/', 'https://login.example.com/', 'https://login.example.com/',
'https://login.example.com/'])('allows reviewed login documents: %s', (url) => {
const callback = vi.fn()
browserSession.webRequest.onBeforeRequest.mock.calls[0]![0]({ resourceType: 'mainFrame', url }, callback)
expect(callback).toHaveBeenCalledWith({ cancel: false })
@@ -157,7 +157,7 @@ it('keeps manual login open until close, reports cancellation once, and permits
expect(await pending).toBe('cancelled')
window = makeWindow(); native.create.mockReturnValue(window)
const retry = auth.login()
window.webContents.emit('did-fail-load', {}, -105, 'raw URL error', 'https://accounts.feishu.cn/?state=secret', true)
window.webContents.emit('did-fail-load', {}, -105, 'raw URL error', 'https://login.example.com/?state=secret', true)
expect(await retry).toBe('failed')
expect(record.mock.calls).toEqual([['opened'], ['cancelled'], ['opened'], ['failed']])
})
@@ -166,6 +166,7 @@ it.each([true, false])('validates the real builder hook for unsigned=%s', async
await writeFile(certificate, 'fixture public certificate')
const config = createElectronBuilderConfig({
DSH_DESKTOP_APP_ID: 'com.example.unpack', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0',
DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com',
}, 'win32', 'x64', input.source)
+1
View File
@@ -161,6 +161,7 @@ describe('Windows token signing', () => {
validateDesktopPackageEnvironment({
DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com',
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
DSH_DESKTOP_WINDOWS_CER_FILE: certificateFile, DSH_DESKTOP_WINDOWS_SIGNTOOL: signTool,
DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin', DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'fixture-container',
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: 'C:\\fixture\\signature-cache',
@@ -51,6 +51,7 @@ describe('Windows update publisher', () => {
beforeAll(() => {
vi.stubEnv('DSH_DESKTOP_APP_ID', 'com.example.publisher-test')
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN', 'https://policy.example.com')
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }))
vi.stubEnv('DSH_DESKTOP_TARGET_PLATFORM', 'win32')
vi.stubEnv('DSH_DESKTOP_UNSIGNED', '1')
})
@@ -0,0 +1,10 @@
- button "返回插件列表": 插件列表
- switch "启用 missing-bundle" [checked]
- heading "missing-bundle" [level=3]
- text: 异常
- paragraph:
- code: "@fixture/missing-bundle"
- paragraph: 暂无描述。
- status: "原因: dsh: cannot resolve profile bundle \"@fixture/missing-bundle\" from the dsh installation or {{home}}/profiles/scaffold; run 'dsh plugin --profile scaffold install' if its dependency is not installed"
- heading "包含的组件" [level=4]
- paragraph: 这个插件包不包含任何组件。
+25 -2
View File
@@ -30,7 +30,7 @@ describe('web e2e: plugin manager', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] },
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }], bundles: ['@fixture/missing-bundle'] },
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
@@ -66,6 +66,29 @@ describe('web e2e: plugin manager', () => {
return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '')
}
it('starts with an unavailable selected bundle and lets the user clear its error', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle'))
const panel = await openPluginsPanel()
await panel.getByRole('button', { name: '查看 missing-bundle', exact: true }).click()
const toggle = panel.getByRole('switch', { name: '启用 missing-bundle', exact: true })
expect(await toggle.getAttribute('aria-checked')).toBe('true')
expect(await toggle.isDisabled()).toBe(false)
await panel.getByText(/cannot resolve profile bundle/).waitFor({ timeout: 10_000 })
expect((await scaffold.ctx.pluginManager.listBundles()).find(row => row.name === '@fixture/missing-bundle'))
.toMatchObject({ enabled: true, error: { code: 'operation-error' }, rows: [] })
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'missing-bundle.expected.md'),
await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[scaffold.harnessHome, '{{home}}']],
}), MODE)
await toggle.click()
await expect.poll(async () => (JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as {
dsh: { profile: { bundles: string[] } }
}).dsh.profile.bundles, { timeout: 10_000 }).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
await expect.poll(() => panel.getByText(/cannot resolve profile bundle/).count(), { timeout: 10_000 }).toBe(0)
expect((await scaffold.ctx.pluginManager.listBundles()).some(row => row.name === '@fixture/missing-bundle')).toBe(false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('lists the installed bundles with their switches and leaves the installation\'s own to Settings', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list'))
const panel = await openPluginsPanel()
@@ -258,7 +281,7 @@ describe('web e2e: plugin manager', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'live-enabled.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md'])
})
})
+6 -3
View File
@@ -334,6 +334,8 @@ export interface LaunchOptions {
profile?: {
hmr?: boolean
packages: { dir: string; enabled?: boolean }[]
/** Additional selected names, including bundles unavailable after an upgrade. */
bundles?: readonly string[]
}
/**
* Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
@@ -455,7 +457,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
requireDist()
const {
auditStartupEntries, composeEntries, createProfileResolutionGeneration, healProfilesModuleFallback, initProfile,
mountRootInclude, readProfileManifest, readProfilePatches, loadOverlayPatches, PluginPackages,
mountRootInclude, readProfileManifest, readProfilePatches, loadProfileDirectory, loadOverlayPatches, PluginPackages,
} = appBoot()
const mode = webSnapshotMode()
const replayFixture = options.replayFixture === undefined
@@ -731,7 +733,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// A real profile: the shipped web bundles plus each fixture package,
// installed the way `dsh plugin add` leaves them.
const dependencies: Record<string, string> = {}
const bundles = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
const bundles = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', ...options.profile.bundles ?? []]
for (const entry of options.profile.packages) {
const manifest = JSON.parse(await readFile(join(entry.dir, 'package.json'), 'utf8')) as { name: string }
dependencies[manifest.name] = `file:${entry.dir}`
@@ -746,7 +748,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
await writeFile(join(profileDir, 'package.json'), JSON.stringify(manifest, null, 2) + '\n')
profileContext = {
name: 'scaffold', dir: profileDir, patchPath: profile.patchPath, installAnchor: INSTALL_ANCHOR,
cwd: workspaceCwd, home: harnessHome, startedBundles: bundles,
cwd: workspaceCwd, home: harnessHome,
startedBundles: loadProfileDirectory('dsh', profileDir, INSTALL_ANCHOR).layers.map(layer => layer.packageName),
overlays: overlayPatches, telemetryDisabledEnv: undefined,
}
// HMR gates file-driven reloads on application readiness, which the
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: fcf95b4268cc41b84099af351f1d6fdb07c80fc4
event-producer-consumer.zh.md: bd466a2e39f4a268e4a8b1567ae5a5f22d250977
event-producer-consumer.md: e57d974de1e35d26a709a3d978dc7cd2c3c29ed7
event-producer-consumer.zh.md: 62abea80c12cc64b017c3b15787ad3b17c054146
+3 -3
View File
@@ -51,9 +51,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:188`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:194`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:200`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:189`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:195`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:201`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+3 -3
View File
@@ -53,9 +53,9 @@
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:188`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:194`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:200`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:189`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:195`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:201`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
README.md: 507445c13953d5b38fcc780f0c0e9adaac7a2d9d
README.zh.md: 11ab4b9df5df5ca16addda7d58513fdd7869a1b0
README.md: 9d9b98ad4669931c32886a73937f4c33ab746b74
README.zh.md: f7b092ad2f52f51404b02177059e8e4b0121b7c3
+1 -1
View File
@@ -47,7 +47,7 @@ With that entry point, startup keeps every plugin that can activate. An enabled
Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot adapts `DshPackageManifest` to `ProfileManifest` with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data.
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. A missing bundle or one without a patch declaration fails startup loudly. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures print a diagnostic and skip that bundle without changing its selection. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
Your machine-local preferences also live in the Harness home:
+1 -1
View File
@@ -47,7 +47,7 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
Profile 与组合包的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 将 `DshPackageManifest` 适配为包身份可选的 `ProfileManifest`,因为本地 profile 无需发布版本。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。缺失组合包或未声明 patch 的组合包会让启动明确失败。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。组合包解析、manifest 读取或 patch 加载失败时会输出诊断并跳过该组合包,不改变其选择状态。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
你的机器本地偏好同样位于 harness home 中:
+52 -25
View File
@@ -467,7 +467,7 @@ function profileDependencyNames(manifest: ProfileManifest): string[] {
/** Resolve the installation generation that every profile must find through the fallback directory. */
function resolveModuleFallbackEntries(
installAnchor: string, materialize = true,
installAnchor: string, materialize = true, skippedBundles: ReadonlySet<string> = new Set(),
): {
entries: ModuleFallbackEntry[]
packageNames: ReadonlySet<string>
@@ -499,10 +499,16 @@ function resolveModuleFallbackEntries(
// A declared-but-uninstalled dependency cannot be a loader-visible
// plugin; skip it rather than fail the whole boot.
if (dir === undefined) continue
const manifestPath = join(dir, 'package.json')
let manifest: ProfileManifest
try {
manifest = skippedBundles.has(dep) ? readProfileManifest('dsh', dir) : readModuleFallbackManifest(manifestPath)
} catch (error) {
if (!skippedBundles.has(dep)) throw error
continue
}
links.set(dep, dir)
declarers.set(dep, next.anchor)
const manifestPath = join(dir, 'package.json')
const manifest = readModuleFallbackManifest(manifestPath)
versions.set(dep, manifest.version)
queue.push({ anchor: manifestPath, manifest })
}
@@ -574,7 +580,10 @@ export async function healProfilesModuleFallback(
const profilesDir = join(home, PROFILES_DIR)
const modulesDir = join(profilesDir, 'node_modules')
if (materialize) mkdirSync(modulesDir, { recursive: true })
const { entries, packageNames, packageDirs, declarers, versions } = resolveModuleFallbackEntries(installAnchor, materialize)
const manifest = readOptionalProfileManifest(profile)
const { entries, packageNames, packageDirs, declarers, versions } = resolveModuleFallbackEntries(
installAnchor, materialize, skippedProfileBundles(profile, manifest),
)
if (materialize && !moduleFallbackCurrent(modulesDir, entries)) {
await withFileLock(modulesDir, () => {
if (!moduleFallbackCurrent(modulesDir, entries)) healProfilesModuleFallbackLocked(entries, modulesDir)
@@ -583,7 +592,7 @@ export async function healProfilesModuleFallback(
}
const profileDeclarers = new Map<string, string>()
const profileVersions = new Map<string, string | undefined>()
const localPackageNames = profile === undefined ? [] : installedProfilePackageNames(profile)
const localPackageNames = profile === undefined ? [] : installedProfilePackageNames(profile, manifest)
const profilePackages: ReadonlyMap<string, string> = profile === undefined
? new Map<string, string>()
: healProfileModuleFallback(profile, packageNames, materialize, profileDeclarers, profileVersions)
@@ -604,16 +613,27 @@ export async function healProfilesModuleFallback(
})
}
/** Return installed direct dependencies that Node resolves before profile fallback. */
function installedProfilePackageNames(profile: Profile): string[] {
let manifest: ProfileManifest
/** Synthetic profiles used by direct callers may have no on-disk manifest. */
function readOptionalProfileManifest(profile: Profile | undefined): ProfileManifest | undefined {
if (profile === undefined) return undefined
try {
manifest = readModuleFallbackManifest(join(profile.dir, 'package.json'))
return readModuleFallbackManifest(join(profile.dir, 'package.json'))
} catch (error) {
// Direct helper callers can supply a synthetic Profile without its on-disk manifest.
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
throw error
}
}
/** Broken selected manifests must not fail again during installation dependency traversal. */
function skippedProfileBundles(profile: Profile | undefined, manifest: ProfileManifest | undefined): ReadonlySet<string> {
const selected = manifest?.dsh?.profile?.bundles ?? []
const loaded = new Set(profile?.layers.map(layer => layer.packageName))
return new Set(selected.filter(name => !loaded.has(name)))
}
/** Return installed direct dependencies that Node resolves before profile fallback. */
function installedProfilePackageNames(profile: Profile, manifest: ProfileManifest | undefined): string[] {
if (manifest === undefined) return []
return profileDependencyNames(manifest).filter((name) => {
const candidate = join(profile.dir, 'node_modules', name)
if (!existsSync(join(candidate, 'package.json'))) return false
@@ -639,7 +659,9 @@ export function createProfileResolutionGeneration(
* @param options - owning installation package.json and the loaded application profile.
*/
export function healIsolatedProfileModuleFallback(options: { installAnchor: string; profile: Profile }): void {
const installationLinks = resolveModuleFallbackEntries(options.installAnchor, false).packageDirs
const installationLinks = resolveModuleFallbackEntries(
options.installAnchor, false, skippedProfileBundles(options.profile, readOptionalProfileManifest(options.profile)),
).packageDirs
healProfileModuleFallback(options.profile, new Set(installationLinks.keys()), true, undefined, undefined, installationLinks)
}
@@ -869,11 +891,12 @@ export function resolveBundleDir(
* Load an already initialized profile directory without resolving it through
* the shared Harness home. This is used by application-owned profiles whose
* package project and lifecycle belong to that application.
* Unreadable bundles are reported on stderr and skipped without changing the manifest.
* @param binName - the diagnostic prefix on thrown errors.
* @param dir - absolute profile package directory.
* @param installAnchor - absolute path of the owning dsh app's package.json.
* @param options - `userLayer: false` skips reading `cordis.patch.yml`.
* @returns the resolved bundle layers and optional user patch layer.
* @returns the successfully loaded bundle layers and optional user patch layer.
*/
export function loadProfileDirectory(
binName: string,
@@ -883,16 +906,21 @@ export function loadProfileDirectory(
): Profile {
const manifest = readProfileManifest(binName, dir)
const bundles = manifest.dsh?.profile?.bundles ?? []
const layers = bundles.map((packageName): ProfileLayer => {
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest
const declared = bundleManifest.dsh?.bundle?.patch
if (declared === undefined) {
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
const layers: ProfileLayer[] = []
for (const packageName of bundles) {
try {
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
const bundleManifest = readProfileManifest(binName, packageDir)
const declared = bundleManifest.dsh?.bundle?.patch
if (declared === undefined) {
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
}
const patchPath = join(packageDir, declared)
layers.push({ packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) })
} catch (error) {
process.stderr.write(`${binName}: skipping profile bundle ${JSON.stringify(packageName)}: ${String(error)}\n`)
}
const patchPath = join(packageDir, declared)
return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
})
}
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
const patches = options.userLayer !== false && existsSync(patchPath)
? loadOverlayPatches(binName, patchPath)
@@ -902,9 +930,8 @@ export function loadProfileDirectory(
/**
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch
* layer and parse the profile's own patch file. A listed bundle without a
* `dsh.bundle` manifest fails loud — naming a bundle-less package as a layer
* is a misconfiguration, not "no patches".
* layer and parse the profile's own patch file. Unreadable bundles are reported
* on stderr and skipped; profile manifest and user patch errors still throw.
* @param binName - the diagnostic prefix on thrown errors.
* @param name - the profile name.
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
+55 -15
View File
@@ -12,9 +12,10 @@ import { tmpdir } from 'node:os'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
import { afterAll, describe, expect, it } from 'vitest'
import { afterAll, describe, expect, it, onTestFinished, vi } from 'vitest'
import {
composeEntries,
createProfileResolutionGeneration,
healProfilesModuleFallback,
healIsolatedProfileModuleFallback,
initProfile,
@@ -308,9 +309,6 @@ describe('loadProfile', () => {
const home = tmp()
expect(() => loadProfile('t', 'custom', anchor, home))
.toThrow('profile "custom" does not exist')
// The web template auto-initializes on first load. Bundle resolution
// cannot be asserted to fail here: the source-plane test runner resolves
// @deepseek-ai/* through tsconfig paths regardless of the staged anchor.
expect(PROFILE_TEMPLATES.web?.bundles).toContain('@deepseek-ai/dsh-base')
expect(PROFILE_TEMPLATES.acp).toEqual({
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'],
@@ -321,11 +319,7 @@ describe('loadProfile', () => {
expect(PROFILE_TEMPLATES['sdk-minimal']).toEqual({
bundles: ['@deepseek-ai/dsh-sdk-minimal'],
})
try {
loadProfile('t', 'web', anchor, home)
} catch {
// Resolution failure is the plain-Node outcome for this empty anchor.
}
loadProfile('t', 'web', anchor, home)
expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles)
.toEqual([...PROFILE_TEMPLATES.web?.bundles ?? []])
})
@@ -360,12 +354,58 @@ describe('loadProfile', () => {
])
})
it('fails loud when a listed bundle declares no dsh.bundle', () => {
const anchor = stageInstallation({ 'not-a-bundle': {} })
const home = tmp()
const dir = resolveProfileDir('demo', home)
initProfile(dir, ['not-a-bundle'])
expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.bundle')
it.each(['missing package', 'invalid manifest', 'not a bundle', 'missing patch', 'invalid patch'])(
'skips a bundle with %s, retains selections, and retries it on reread', async (failure) => {
const anchor = stageInstallation({
before: { patch: '- insert: [{ id: a, name: pkg-a }]\n' },
broken: { patch: '[]\n' },
after: { patch: '- id: a\n config: { value: after }\n' },
})
const home = tmp()
const dir = resolveProfileDir('demo', home)
initProfile(dir, ['before', 'broken', 'after'])
const bundleDir = join(anchor, '..', 'node_modules', 'broken')
const manifestPath = join(bundleDir, 'package.json')
const patchPath = join(bundleDir, 'cordis.patch.yml')
const original = readFileSync(manifestPath, 'utf8')
if (failure === 'missing package') rmSync(bundleDir, { recursive: true })
if (failure === 'invalid manifest') writeFileSync(manifestPath, '{')
if (failure === 'not a bundle') writeFileSync(manifestPath, '{}')
if (failure === 'missing patch') rmSync(patchPath)
if (failure === 'invalid patch') writeFileSync(patchPath, '[invalid')
const saved = readFileSync(join(dir, 'package.json'), 'utf8')
const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
onTestFinished(() => { warn.mockRestore() })
const profile = loadProfile('t', 'demo', anchor, home)
expect(profile.layers.map(layer => layer.packageName)).toEqual(['before', 'after'])
expect(composeEntries(profile.layers.map(layer => layer.patches)))
.toEqual([{ id: 'a', name: 'pkg-a', config: { value: 'after' } }])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipping profile bundle "broken":'))
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(saved)
const generation = await createProfileResolutionGeneration({ installAnchor: anchor, profile, home })
const unavailable = failure === 'missing package' || failure === 'invalid manifest'
expect(generation.entries.map(entry => entry.name))
.toEqual(['dsh-app', 'before', ...unavailable ? [] : ['broken'], 'after'])
healIsolatedProfileModuleFallback({ installAnchor: anchor, profile })
expect(existsSync(join(dir, 'node_modules', 'broken'))).toBe(!unavailable)
mkdirSync(bundleDir, { recursive: true })
writeFileSync(manifestPath, original)
writeFileSync(patchPath, '[]\n')
expect(loadProfileDirectory('t', dir, anchor).layers.map(layer => layer.packageName))
.toEqual(['before', 'broken', 'after'])
},
)
it('still rejects invalid profile manifests and user patches', () => {
const anchor = stageInstallation({})
const dir = tmp()
initProfile(dir, [])
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '[invalid')
expect(() => loadProfileDirectory('t', dir, anchor)).toThrow()
expect(loadProfileDirectory('t', dir, anchor, { userLayer: false }).layers).toEqual([])
writeFileSync(join(dir, 'package.json'), '{')
expect(() => loadProfileDirectory('t', dir, anchor)).toThrow()
})
})
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
README.md: 46b5447a8b3548b548bf844e1d753296861b17da
README.zh.md: d4c285b72f8d31357a12f222d741323e9b4658e6
README.md: d0c78bf23655d14e8df475c67fb4cda6399a7fa9
README.zh.md: 46bd8d5fdb62331993adc0d7a0261d9a808878a1
+2
View File
@@ -39,6 +39,8 @@ For a deployment without agent presets, enable the tool in the profile patch. Pr
A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority.
A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable.
`inspect(spec)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same registry and proxy settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form. The answer carries the name, version, description, and whether the package declares a bundle, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`. A caller's `signal` or `inspectTimeoutMs` ends the lookup.
`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`. `cancelInstall(requestId)` stops the run and answers `cancelled` only after pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies a failed run from its exit and output, and `bundle` names the package a finished run added. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read.
@@ -39,6 +39,8 @@ kind: "package-reference"
插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。
已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。
`inspect(spec)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的注册表与代理设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式。答复携带名称、版本、描述以及该包是否声明组合包,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。
`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`。`cancelInstall(requestId)` 停止运行,只在 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对失败运行分类,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。
+21 -6
View File
@@ -136,6 +136,8 @@ export class PluginManager extends TypertRemoteService {
lockWaitMs: z.number().step(1).min(0).default(120000),
inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
})
/** Management bundles remain protected if their files become unreadable. */
private readonly managementBundles = new Set<string>()
private readonly ownerEntryId: string | undefined
private readonly packageOperations = new Set<Promise<unknown>>()
private readonly profile: ProfileContext
@@ -153,6 +155,7 @@ export class PluginManager extends TypertRemoteService {
this.ownerEntryId = ctx.fiber.entry?.id
this.ownerContext = ctx
this.profile = ctx.profileContext
for (const name of this.profile.startedBundles) this.protectsManager(name)
this.outputBytes = (config as Required<Config>).outputBytes
this.lockWaitMs = (config as Required<Config>).lockWaitMs
this.inspectTimeoutMs = (config as Required<Config>).inspectTimeoutMs
@@ -203,13 +206,14 @@ export class PluginManager extends TypertRemoteService {
const optional = OPTIONAL_BUNDLES.includes(name)
const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name)
const enabled = selected.includes(name)
const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined
try {
const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
if (info === undefined) {
if (enabled) bundles.push({ name, enabled, installed, optional, removable, error: { code: 'not-bundle' }, rows: [], overrides: [] })
if (enabled) bundles.push({ name, enabled, installed, optional, removable: removable && readOnlyReason === undefined,
...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] })
continue
}
const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined
bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
enabled, installed, optional, removable: removable && readOnlyReason === undefined,
@@ -217,7 +221,8 @@ export class PluginManager extends TypertRemoteService {
...this.declaredRows(name, info) })
} catch (error) {
if (enabled || installed) {
bundles.push({ name, enabled, installed, optional, removable, error: managementError(error), rows: [], overrides: [] })
bundles.push({ name, enabled, installed, optional, removable: removable && readOnlyReason === undefined,
...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: managementError(error), rows: [], overrides: [] })
}
}
}
@@ -424,8 +429,8 @@ export class PluginManager extends TypertRemoteService {
const bundle = (await this.listBundles()).find(item => item.name === name)
if (bundle === undefined || !bundle.removable) throw new ManagementFailure('not-removable')
if (this.ownerContext.get('hmr') === undefined && (this.profile.startedBundles.includes(name)
|| this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
.some(entry => entry.options.id === row.id && entry.fiber !== undefined)))) {
|| (bundle.error === undefined && this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
.some(entry => entry.options.id === row.id && entry.fiber !== undefined))))) {
throw new ManagementFailure('stop-profile')
}
const contributions = bundle.error === undefined ? this.bundleRows(name) : []
@@ -523,6 +528,7 @@ export class PluginManager extends TypertRemoteService {
if ((enabled || !previous.includes(name)) && bundleManifest(name, this.profile.dir, this.profile.installAnchor) === undefined) {
throw new ManagementFailure('not-bundle')
}
if (enabled) this.bundleRows(name)
if (!enabled && previous.includes(name)) {
if (this.protectsManager(name)) throw new ManagementFailure('management-required')
}
@@ -530,6 +536,7 @@ export class PluginManager extends TypertRemoteService {
if (JSON.stringify(previous) === JSON.stringify(bundles)) return
manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } }
await saveManifest(this.profile.dir, manifest)
if (enabled) this.protectsManager(name)
}
private bundleRows(name: string): EntryOptions[] {
@@ -540,7 +547,15 @@ export class PluginManager extends TypertRemoteService {
}
private protectsManager(name: string): boolean {
return this.bundleRows(name).some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)
if (this.managementBundles.has(name)) return true
let rows: EntryOptions[]
try { rows = this.bundleRows(name) } catch (_error) {
// Unreadable bundles contribute no new rows; listBundles reports their diagnostics.
return false
}
const protectedBundle = rows.some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)
if (protectedBundle) this.managementBundles.add(name)
return protectedBundle
}
private configure<T>(operation: () => Promise<T>): Promise<T> {
@@ -35,6 +35,7 @@ export interface BundleInfo {
version?: string
/** `description` of the package manifest. */
description?: string
/** Selected in the profile manifest; a load error means its layer was skipped. */
enabled: boolean
/** Whether the profile's own dependencies hold the package; false for a bundle the dsh installation supplies. */
installed: boolean
@@ -7,7 +7,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import { expect, it, onTestFinished, vi } from 'vitest'
import {
boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, reconcileProfilePatches, OPTIONAL_BUNDLES,
boot, composeEntries, initProfile, loadProfileDirectory, readProfilePatches, readProfileManifest,
reconcileProfilePatches, OPTIONAL_BUNDLES,
type ProfileContext,
} from '@deepseek-ai/dsh-app-boot'
import PluginManager, { type Config, type PluginChange, type PluginInstallLogChunk, type PluginInstallProgress, type PluginInstallRequestId } from '../src/index.ts'
@@ -18,7 +19,7 @@ import { Group } from '@deepseek-ai/cordis-plugin-loader'
import * as operations from '../src/operations.ts'
import { parse, parseDocument } from 'yaml'
async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager']) {
async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager'], prepareFiles?: (dir: string) => void) {
// pnpm resolves workspace roots through native realpath, including Windows 8.3 aliases.
const home = await realpath(mkdtempSync(join(tmpdir(), 'plugin-manager-')))
const dir = join(home, 'profiles', 'test')
@@ -38,11 +39,12 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
manifest.dependencies = { extra: '1.0.0' }
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
prepareFiles?.(dir)
const overlays: PatchOptions[] = overlay ? [{ id: 'managed', disabled: true }] : []
const profile: ProfileContext = {
name: 'test',
...(packageManager === undefined ? {} : { packageManager }),
startedBundles: ['core', 'extra'],
startedBundles: loadProfileDirectory('test', dir, anchor).layers.map(layer => layer.packageName),
dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: anchor, cwd: home, home,
overlays, telemetryDisabledEnv: undefined,
}
@@ -63,6 +65,66 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays }
}
it.each(['missing package', 'invalid manifest', 'not a bundle', 'missing patch', 'invalid patch'])(
'boots with %s, lists its error, and permits deselection without enabling the broken bundle', async (failure) => {
const { manager, dir, ctx } = await fixture('live', false, undefined, {}, undefined, (dir) => {
const path = join(dir, 'node_modules', 'extra')
if (failure === 'missing package') rmSync(path, { recursive: true })
if (failure === 'invalid manifest') writeFileSync(join(path, 'package.json'), '{')
if (failure === 'not a bundle') writeFileSync(join(path, 'package.json'), '{}')
if (failure === 'missing patch') rmSync(join(path, 'cordis.patch.yml'))
if (failure === 'invalid patch') writeFileSync(join(path, 'cordis.patch.yml'), '[invalid')
})
expect([...ctx.loader.entries()].some(entry => entry.id === 'include:managed')).toBe(false)
expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({
enabled: true, error: { code: failure === 'not a bundle' ? 'not-bundle' : 'operation-error' }, rows: [],
})
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core'])
expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' })
},
)
it.each(['missing files', 'missing declaration'])('keeps a running management bundle protected with %s', async (failure) => {
const { manager, dir } = await fixture()
if (failure === 'missing files') rmSync(join(dir, 'node_modules', 'core'), { recursive: true })
else writeFileSync(join(dir, 'node_modules', 'core', 'package.json'), '{}')
expect((await manager.listBundles()).find(row => row.name === 'core')).toMatchObject({
enabled: true, readOnlyReason: 'management-required', removable: false, error: { code: failure === 'missing files' ? 'operation-error' : 'not-bundle' },
})
expect(await manager.setBundleEnabled('core', false)).toMatchObject({
changed: false, application: 'failed', error: { code: 'management-required' },
})
})
it.each(['live', 'startup'] as const)('removes a bundle skipped at startup in a %s profile', async (mode) => {
const { manager, dir } = await fixture(mode, false, undefined, {}, undefined, (dir) => {
rmSync(join(dir, 'node_modules', 'extra'), { recursive: true })
})
const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
const manifest = readProfileManifest('test', dir)
expect(manifest.dsh?.profile?.bundles).toEqual(['core'])
delete manifest.dependencies?.extra
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
return { exitCode: 0, output: 'removed', truncated: false, logPath: join(dir, 'pnpm.log') }
})
onTestFinished(() => { remove.mockRestore() })
expect(await manager.removeBundle('extra')).toMatchObject({ changed: true, application: mode === 'live' ? 'applied' : 'restart-required' })
expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
})
it('recomposes the remaining layers while another bundle is broken and mounts it after repair', async () => {
const { manager, dir, bundle, ctx } = await fixture()
writeFileSync(join(dir, 'node_modules', 'extra', 'cordis.patch.yml'), '[invalid')
bundle('another', [])
expect(await manager.setBundleEnabled('another', true)).toMatchObject({ application: 'applied' })
expect([...ctx.loader.entries()].some(entry => entry.id === 'include:managed')).toBe(false)
bundle('extra', [{ id: 'managed', name: './plugin.mjs' }])
expect(await manager.setBundleEnabled('another', false)).toMatchObject({ application: 'applied' })
expect([...ctx.loader.entries()].find(entry => entry.id === 'include:managed')?.fiber?.state).toBe(2)
expect((await manager.listBundles()).find(row => row.name === 'extra')?.error).toBeUndefined()
})
it('lists bundle versions and current-profile plugin targets', async () => {
const { manager, dir } = await fixture()
const plugins = await manager.listPlugins()
@@ -51,6 +51,33 @@ it.each(['read-only', 'workspace-write'] as const)('denies every management acti
for (const method of Object.values(manager)) expect(method).not.toHaveBeenCalled()
})
it('preserves selected bundle load errors in the agent list result', async () => {
const { manager, call } = await fixture()
const bundles = [{
name: 'bundle', enabled: true,
error: { code: 'operation-error', diagnostic: 'bundle patch is unreadable' }, rows: [], overrides: [],
}]
manager.listBundles.mockResolvedValue(bundles)
expect(JSON.parse(resultText(await call({ action: 'list_bundles' })))).toMatchInlineSnapshot(`
{
"entries": [
{
"enabled": true,
"error": {
"code": "operation-error",
"diagnostic": "bundle patch is unreadable",
},
"name": "bundle",
"overrides": [],
"rows": [],
},
],
"nextOffset": null,
"total": 1,
}
`)
})
it('checks the calling session on each execution, including after permission is revoked', async () => {
const { call, manager } = await fixture()
const id = SessionId('manager-permissions')
+3
View File
@@ -539,6 +539,9 @@ importers:
'@deepseek-ai/dsh-home-paths':
specifier: workspace:^
version: link:../../packages/util/home-paths
'@deepseek-ai/node-addon-system':
specifier: workspace:^
version: link:../../native/system/packages/entry
'@electron/get':
specifier: ^5.1.0
version: 5.1.0([email protected])
@@ -32,6 +32,7 @@ const MANIFEST_BIN_ALLOWLIST = new Map<string, ManifestBin>([
/** Every JavaScript executable in an application or packaging workspace has one explicit role. */
const EXECUTABLE_SOURCE_ALLOWLIST = new Map<string, string>([
['apps/cli/src/bin.ts', 'supported dsh application launcher'],
['apps/desktop/scripts/logged-notarytool.mjs', 'build-only notarization logging wrapper'],
['packages/context/time-context/tests/fixtures/driver.ts', 'test-only subprocess driver'],
['packages/experimental/webworker-packer/bin.js', 'build-only wrapper'],
['packages/experimental/webworker-packer/src/bin.ts', 'build-only implementation'],